diff --git a/Cargo.lock b/Cargo.lock index f8fddd5..7ac437a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,7 +1015,7 @@ dependencies = [ [[package]] name = "chatd" -version = "0.2.12" +version = "0.2.13" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -4938,7 +4938,7 @@ dependencies = [ [[package]] name = "login" -version = "0.2.12" +version = "0.2.13" dependencies = [ "block2 0.6.2", "embed-resource", @@ -5098,7 +5098,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "mediad" -version = "0.2.12" +version = "0.2.13" dependencies = [ "aes-gcm", "chacha20poly1305", @@ -7017,7 +7017,7 @@ dependencies = [ [[package]] name = "protocol" -version = "0.2.12" +version = "0.2.13" dependencies = [ "interprocess", "serde", @@ -9081,7 +9081,7 @@ dependencies = [ [[package]] name = "tinyclient" -version = "0.2.12" +version = "0.2.13" dependencies = [ "embed-resource", "gpui", diff --git a/Cargo.toml b/Cargo.toml index 3369cef..91d6fca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.2.12" +version = "0.2.13" edition = "2021" license = "MIT" publish = false diff --git a/README.md b/README.md index e3cd482..fb92915 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Sizes of a macOS arm64 release build, 2026-10-14: ## What works today -Current release: [v0.2.12](https://github.com/itz4blitz/tinyclient/releases/tag/v0.2.12). This is what `main` does. The goal is full parity with each official app. Every gap is a place to help. +Current release: [v0.2.13](https://github.com/itz4blitz/tinyclient/releases/tag/v0.2.13). This is what `main` does. The goal is full parity with each official app. Every gap is a place to help. | Network | Sign-in | Reading and media | Writing | Calls | | --- | --- | --- | --- | --- | diff --git a/crates/chatd/src/adapters/tiny.rs b/crates/chatd/src/adapters/tiny.rs index bc922a0..6439d8c 100644 --- a/crates/chatd/src/adapters/tiny.rs +++ b/crates/chatd/src/adapters/tiny.rs @@ -1,11 +1,16 @@ //! A Tiny server the owner already runs. One account lives in this network's keychain item. //! Sign-in is the sheet's (`signin`), never `tinyclient-login`. This slice has no conversations, -//! calls, or media, and does not load a WebRTC stack. +//! calls, or media, and does not load a WebRTC stack. A signed-in account can list members, +//! invite, change a role, remove a member, and read the audit. pub mod signin; use super::{serve_commands, Command, Context, Signed}; -use protocol::Link; +use crate::session; +use protocol::{ + tiny_can, AccountKey, ErrorCode, Link, Network, Response, TinyAudit, TinyCode, TinyCommunity, + TinyInvite, TinyMember, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::mpsc::Receiver; @@ -16,7 +21,7 @@ use ureq::tls::{TlsConfig, TlsProvider}; const TIMEOUT: Duration = Duration::from_secs(10); #[derive(Clone, Serialize, Deserialize)] -struct Saved { +pub(crate) struct Saved { base_url: String, token: String, user_id: String, @@ -38,6 +43,8 @@ enum Fault { /// The server answered, and the answer was a failure of its own. Unavailable, BadAnswer, + /// Words for the sheet, already chosen. Not a password failure. + Words(&'static str), } fn safe_id(id: &str) -> bool { @@ -49,7 +56,7 @@ fn safe_id(id: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) } -fn parse(session: &str) -> Result { +pub(crate) fn parse(session: &str) -> Result { let saved: Saved = serde_json::from_str(session) .map_err(|_| "the Tiny server session is unreadable".to_owned())?; if saved.base_url.is_empty() || saved.token.is_empty() || !safe_id(&saved.user_id) { @@ -131,6 +138,16 @@ fn call( None => request.send_empty(), } } + "PATCH" => { + let mut request = agent.patch(url); + if let Some(bearer) = bearer.as_deref() { + request = request.header("Authorization", bearer); + } + match body { + Some(body) => request.send_json(body), + None => request.send_empty(), + } + } _ => return Err(Fault::Unreachable), }; let mut response = sent.map_err(|_| Fault::Unreachable)?; @@ -158,12 +175,15 @@ fn post_session(base: &str, email: &str, password: &str) -> Result Some(serde_json::json!({ "email": email, "password": password })), )?; match status { - 200 => {} - 401 => return Err(Fault::Unauthorized), - 429 => return Err(Fault::SlowDown), - 500..=599 => return Err(Fault::Unavailable), - _ => return Err(Fault::Unreachable), + 200 => saved_from(base, body), + 401 => Err(Fault::Unauthorized), + 429 => Err(Fault::SlowDown), + 500..=599 => Err(Fault::Unavailable), + _ => Err(Fault::Unreachable), } +} + +fn saved_from(base: &str, body: Value) -> Result { let answer: SessionAnswer = serde_json::from_value(body).map_err(|_| Fault::BadAnswer)?; if answer.token.is_empty() || !safe_id(&answer.user_id) @@ -181,6 +201,34 @@ fn post_session(base: &str, email: &str, password: &str) -> Result }) } +/// Joining with a code. The same session shape as `post_session`. No bearer is sent. +fn post_accept( + base: &str, + token: &str, + email: &str, + password: &str, + display_name: &str, +) -> Result { + let (status, body) = call( + "POST", + &endpoint(base, "/v1/invites/accept"), + None, + Some(serde_json::json!({ + "token": token, + "email": email, + "password": password, + "display_name": display_name, + })), + )?; + match status { + 200 => saved_from(base, body), + 401 => Err(Fault::Unauthorized), + 429 => Err(Fault::SlowDown), + 500..=599 => Err(Fault::Unavailable), + _ => Err(Fault::Words(explain(status, &body, Missing::Code))), + } +} + #[derive(Deserialize)] struct SessionAnswer { token: String, @@ -227,6 +275,248 @@ fn delete_session(saved: &Saved) { ); } +const CHECK: &str = "That didn't work. Check it and try again."; +const UNREADABLE: &str = "The server's answer could not be read."; +const UNREACHABLE: &str = "Couldn't reach that server. Check the address and your connection."; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Missing { + Member, + Code, + Plain, +} + +fn error(code: ErrorCode, detail: impl ToString) -> Response { + Response::Error { + code, + detail: detail.to_string(), + } +} + +fn refused(detail: &str) -> Response { + error(ErrorCode::Refused, detail) +} + +fn explain(status: u16, body: &Value, missing: Missing) -> &'static str { + let code = body.get("error").and_then(Value::as_str).unwrap_or(""); + match (status, code) { + (401, _) => "Tiny server signed you out.", + (403, _) => "You can't do that.", + (409, "last_owner") => "This server needs an owner. Make someone else the owner first.", + (409, "already_member") => "That person is already on this server.", + (409, "email_taken") => "That email is already on this server.", + (404, _) if missing == Missing::Member => "That person is no longer on this server.", + (404, _) if missing == Missing::Code => { + "That code doesn't work. It may have expired or already been used." + } + (400, _) => CHECK, + (429, _) => "Too many tries. Wait a minute, then try again.", + (500..=599, _) => "The server had a problem. Try again in a moment.", + _ => UNREACHABLE, + } +} + +/// The signed-in session for `account`, or a refusal. Nothing is asked of the server here. +fn open(account: &AccountKey) -> Result { + if account.network != Network::Tiny { + return Err(refused("You can't do that.")); + } + let session = match session::read(Network::Tiny) { + session::Stored::Found(session) => session, + session::Stored::Absent => return Err(refused("You can't do that.")), + session::Stored::Unreadable(why) => { + return Err(error( + ErrorCode::Internal, + format!("The session could not be read from {}: {why}", session::STORE), + )); + } + }; + let saved = match parse(&session) { + Ok(saved) => saved, + Err(_) => return Err(refused("You can't do that.")), + }; + if saved.user_id != account.account { + return Err(refused("You can't do that.")); + } + Ok(saved) +} + +fn asked(method: &str, saved: &Saved, path: &str, body: Option) -> Result<(u16, Value), &'static str> { + match call(method, &endpoint(&saved.base_url, path), Some(&saved.token), body) { + Ok(answer) => Ok(answer), + Err(_) => Err(UNREACHABLE), + } +} + +enum Listed { + Rows(T), + /// A 403: the caller may not see this list. The load still succeeds. + Empty, + Failed(&'static str), +} + +fn listed(saved: &Saved, path: &str, parse: impl FnOnce(Value) -> Result) -> Listed { + match asked("GET", saved, path, None) { + Err(detail) => Listed::Failed(detail), + Ok((403, _)) => Listed::Empty, + Ok((200, body)) => match parse(body) { + Ok(rows) => Listed::Rows(rows), + Err(()) => Listed::Failed(UNREADABLE), + }, + Ok((status, body)) => Listed::Failed(explain(status, &body, Missing::Plain)), + } +} + +fn members_of(saved: &Saved) -> Result, &'static str> { + match asked("GET", saved, "/v1/members", None) { + Err(detail) => Err(detail), + Ok((200, body)) => serde_json::from_value(body).map_err(|_| UNREADABLE), + Ok((status, body)) => Err(explain(status, &body, Missing::Plain)), + } +} + +fn invites_of(saved: &Saved) -> Listed> { + listed(saved, "/v1/invites", |body| serde_json::from_value(body).map_err(|_| ())) +} + +#[derive(Deserialize)] +struct AuditWire { + id: i64, + #[serde(default)] + actor_id: Option, + action: String, + subject: String, + #[serde(default)] + detail: Option, + created_at: String, +} + +fn detail_text(detail: Option) -> String { + match detail { + None | Some(Value::Null) => String::new(), + Some(value) => serde_json::to_string(&value).unwrap_or_default(), + } +} + +fn audit_of(saved: &Saved) -> Listed> { + listed(saved, "/v1/audit?limit=50", |body| { + let rows: Vec = serde_json::from_value(body).map_err(|_| ())?; + Ok(rows + .into_iter() + .map(|row| TinyAudit { + id: row.id, + actor_id: row.actor_id, + action: row.action, + subject: row.subject, + detail: detail_text(row.detail), + created_at: row.created_at, + }) + .collect()) + }) +} + +pub(crate) fn community(account: AccountKey) -> Response { + let saved = match open(&account) { + Ok(saved) => saved, + Err(error) => return error, + }; + let members = match members_of(&saved) { + Ok(members) => members, + Err(detail) => return refused(detail), + }; + // A 403 on invites or the audit is an empty list. Any other failure fails the load. + // The role is the one that was saved, not a role from the member list. + let invites = if tiny_can(&saved.role, "community.invites.read") { + match invites_of(&saved) { + Listed::Rows(rows) => rows, + Listed::Empty => Vec::new(), + Listed::Failed(detail) => return refused(detail), + } + } else { + Vec::new() + }; + let audit = if tiny_can(&saved.role, "community.audit.read") { + match audit_of(&saved) { + Listed::Rows(rows) => rows, + Listed::Empty => Vec::new(), + Listed::Failed(detail) => return refused(detail), + } + } else { + Vec::new() + }; + Response::TinyCommunity { + community: TinyCommunity { + role: saved.role, + members, + invites, + audit, + }, + } +} + +pub(crate) fn invite(account: AccountKey, role: String) -> Response { + let saved = match open(&account) { + Ok(saved) => saved, + Err(error) => return error, + }; + if role != "admin" && role != "member" { + return refused(CHECK); + } + match asked( + "POST", + &saved, + "/v1/invites", + Some(serde_json::json!({ "role": role })), + ) { + Err(detail) => refused(detail), + Ok((201, body)) => match serde_json::from_value::(body) { + Ok(code) => Response::TinyCode { code }, + Err(_) => refused(UNREADABLE), + }, + Ok((status, body)) => refused(explain(status, &body, Missing::Plain)), + } +} + +pub(crate) fn set_role(account: AccountKey, user_id: String, role: String) -> Response { + let saved = match open(&account) { + Ok(saved) => saved, + Err(error) => return error, + }; + if !safe_id(&user_id) || !matches!(role.as_str(), "owner" | "admin" | "member") { + return refused(CHECK); + } + let path = format!("/v1/members/{user_id}"); + match asked( + "PATCH", + &saved, + &path, + Some(serde_json::json!({ "role": role })), + ) { + Err(detail) => refused(detail), + Ok((200, body)) => match body.get("role").and_then(Value::as_str) { + Some(role) if matches!(role, "owner" | "admin" | "member") => Response::Ok, + _ => refused(UNREADABLE), + }, + Ok((status, body)) => refused(explain(status, &body, Missing::Member)), + } +} + +pub(crate) fn remove(account: AccountKey, user_id: String) -> Response { + let saved = match open(&account) { + Ok(saved) => saved, + Err(error) => return error, + }; + if !safe_id(&user_id) { + return refused(CHECK); + } + let path = format!("/v1/members/{user_id}"); + match asked("DELETE", &saved, &path, None) { + Err(detail) => refused(detail), + Ok((204, _)) => Response::Ok, + Ok((status, body)) => refused(explain(status, &body, Missing::Member)), + } +} + pub fn start(ctx: Context, session: &str, inbox: Receiver) -> Result<(), String> { let saved = parse(session)?; if saved.user_id != ctx.account.account { @@ -272,7 +562,7 @@ pub fn start(ctx: Context, session: &str, inbox: Receiver) -> Result<() } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; fn session() -> &'static str { @@ -403,7 +693,231 @@ mod tests { assert!(matches!(get_me(&saved), Err(Fault::Unauthorized))); } - fn http(status: u16, reason: &str, body: &str) -> Vec { + fn with_session(base: &str, role: &str, run: impl FnOnce(AccountKey)) { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _lock = LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let name = format!("TINYCLIENT_SESSION_{}", Network::Tiny.label().to_uppercase()); + let user_id = "11111111-1111-1111-1111-111111111111"; + let session = format!( + r#"{{"base_url":{base:?},"token":"t","user_id":{user_id:?},"email":"ada@example.com","display_name":"Ada","role":{role:?}}}"# + ); + // Not the temp root. A file there is one the user picked, and anything under + // TINYCLIENT_DIR is refused. These tests share a process with that check. + let scratch = std::env::temp_dir().join(format!("tinyclient-tiny-{}", std::process::id())); + let _ = std::fs::create_dir_all(&scratch); + let _dir = Restore::set("TINYCLIENT_DIR", &scratch); + let _session = Restore::set(&name, &session); + match crate::session::read(Network::Tiny) { + crate::session::Stored::Found(_) => {} + crate::session::Stored::Absent => panic!("tiny session was absent ({name})"), + crate::session::Stored::Unreadable(why) => panic!("tiny session was unreadable: {why}"), + } + run(AccountKey { + network: Network::Tiny, + account: user_id.to_owned(), + }); + } + + /// Puts one environment variable back when the test ends, including on a panic. + struct Restore { + key: String, + previous: Option, + } + + impl Restore { + fn set(key: &str, value: impl AsRef) -> Restore { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Restore { key: key.to_owned(), previous } + } + } + + impl Drop for Restore { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(&self.key, value), + None => std::env::remove_var(&self.key), + } + } + } + + #[test] + fn a_session_scratch_does_not_hide_a_file_the_user_picked() { + let picked = std::env::temp_dir().join(format!("tinyclient-beside-{}.txt", std::process::id())); + std::fs::write(&picked, b"hello").unwrap(); + with_session("http://127.0.0.1:9", "member", |_| { + let answer = crate::blobs::upload::allowed(&picked.to_string_lossy()); + assert!(answer.is_ok(), "{answer:?}"); + }); + let _ = std::fs::remove_file(&picked); + } + + #[test] + fn a_member_loads_members_and_does_not_ask_for_invites_or_the_audit() { + let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = log.clone(); + let base = serve(move |request| { + seen.lock().unwrap().push(request.to_owned()); + let first = request.lines().next().unwrap_or(""); + if first.starts_with("GET /v1/members ") { + return http( + 200, + "OK", + r#"[{"user_id":"11111111-1111-1111-1111-111111111111","email":"ada@example.com","display_name":"Ada","role":"owner","created_at":"2020-01-01T00:00:00Z"}]"#, + ); + } + http(500, "Internal Server Error", r#"{"error":"no"}"#) + }); + with_session(&base, "member", |account| match community(account) { + Response::TinyCommunity { community } => { + assert_eq!(community.role, "member"); + assert_eq!(community.members.len(), 1); + assert_eq!(community.members[0].display_name, "Ada"); + assert_eq!(community.members[0].email, "ada@example.com"); + assert_eq!(community.members[0].role, "owner"); + assert!(community.invites.is_empty()); + assert!(community.audit.is_empty()); + } + other => panic!("load: {other:?}"), + }); + let lines = log.lock().unwrap(); + assert!(lines.iter().any(|line| line.contains("GET /v1/members "))); + assert!(lines + .iter() + .all(|line| !line.contains("/v1/invites") && !line.contains("/v1/audit"))); + } + + #[test] + fn a_forbidden_invite_list_still_returns_the_members() { + let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = log.clone(); + let base = serve(move |request| { + seen.lock().unwrap().push(request.to_owned()); + let first = request.lines().next().unwrap_or(""); + if first.starts_with("GET /v1/members ") { + return http( + 200, + "OK", + r#"[{"user_id":"11111111-1111-1111-1111-111111111111","email":"ada@example.com","display_name":"Ada","role":"member","created_at":"2020-01-01T00:00:00Z"}]"#, + ); + } + if first.starts_with("GET /v1/invites ") { + return http(403, "Forbidden", r#"{"error":"forbidden"}"#); + } + if first.starts_with("GET /v1/audit") { + return http( + 200, + "OK", + r#"[{"id":7,"actor_id":"11111111-1111-1111-1111-111111111111","action":"member.role","subject":"22222222-2222-2222-2222-222222222222","detail":{"to":"admin"},"created_at":"2020-01-02T00:00:00Z"},{"id":8,"actor_id":null,"action":"member.remove","subject":"x","detail":null,"created_at":"2020-01-03T00:00:00Z"}]"#, + ); + } + http(500, "Internal Server Error", r#"{"error":"no"}"#) + }); + with_session(&base, "admin", |account| match community(account) { + Response::TinyCommunity { community } => { + assert_eq!(community.role, "admin"); + assert_eq!(community.members.len(), 1); + assert_eq!(community.members[0].role, "member"); + assert!(community.invites.is_empty()); + assert_eq!(community.audit.len(), 2); + assert_eq!(community.audit[0].id, 7); + assert_eq!(community.audit[0].detail, r#"{"to":"admin"}"#); + assert_eq!(community.audit[1].detail, ""); + assert_eq!(community.audit[1].actor_id, None); + } + other => panic!("load: {other:?}"), + }); + let lines = log.lock().unwrap(); + assert!(lines.iter().any(|line| line.contains("GET /v1/members "))); + assert!(lines.iter().any(|line| line.contains("GET /v1/invites "))); + assert!(lines.iter().any(|line| line.contains("GET /v1/audit?limit=50"))); + } + + #[test] + fn an_invite_a_role_and_a_removal_use_the_member_routes() { + let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = log.clone(); + let base = serve(move |request| { + seen.lock().unwrap().push(request.to_owned()); + let first = request.lines().next().unwrap_or(""); + if first.starts_with("POST /v1/invites ") { + return http( + 201, + "Created", + r#"{"id":"inv1","token":"invite-token","role":"member","expires_at":"2026-01-01T00:00:00Z"}"#, + ); + } + if first.starts_with("PATCH /v1/members/last-owner ") { + return http(409, "Conflict", r#"{"error":"last_owner"}"#); + } + if first.starts_with("PATCH /v1/members/") { + return http(200, "OK", r#"{"role":"admin"}"#); + } + if first.starts_with("DELETE /v1/members/") { + return http(204, "No Content", ""); + } + http(404, "Not Found", r#"{"error":"missing"}"#) + }); + let refused = |detail: &str| Response::Error { + code: ErrorCode::Refused, + detail: detail.to_owned(), + }; + with_session(&base, "owner", |account| { + let quiet = || log.lock().unwrap().len(); + let before = quiet(); + assert_eq!( + set_role(account.clone(), "not a user".into(), "admin".into()), + refused("That didn't work. Check it and try again.") + ); + assert_eq!(quiet(), before); + assert_eq!( + invite(account.clone(), "owner".into()), + refused("That didn't work. Check it and try again.") + ); + assert_eq!(quiet(), before); + match invite(account.clone(), "member".into()) { + Response::TinyCode { code } => { + assert_eq!(code.token, "invite-token"); + assert_eq!(code.id, "inv1"); + assert_eq!(code.role, "member"); + assert_eq!(code.expires_at, "2026-01-01T00:00:00Z"); + } + other => panic!("invite: {other:?}"), + } + let posted = log.lock().unwrap().last().cloned().unwrap_or_default(); + assert!(posted.lines().next().unwrap_or("").starts_with("POST /v1/invites ")); + assert_eq!(json_body(&posted)["role"], "member"); + let id = "22222222-2222-2222-2222-222222222222"; + assert_eq!(set_role(account.clone(), id.into(), "admin".into()), Response::Ok); + let patched = log.lock().unwrap().last().cloned().unwrap_or_default(); + assert!(patched + .lines() + .next() + .unwrap_or("") + .starts_with(&format!("PATCH /v1/members/{id} "))); + assert_eq!(json_body(&patched)["role"], "admin"); + assert_eq!( + set_role(account.clone(), "last-owner".into(), "member".into()), + refused("This server needs an owner. Make someone else the owner first.") + ); + assert_eq!(remove(account.clone(), id.into()), Response::Ok); + let deleted = log.lock().unwrap().last().cloned().unwrap_or_default(); + assert!(deleted.contains(&format!("DELETE /v1/members/{id} ")), "{deleted}"); + let before = quiet(); + assert_eq!( + remove(account, "a/b".into()), + refused("That didn't work. Check it and try again.") + ); + assert_eq!(quiet(), before); + }); + } + + fn json_body(request: &str) -> serde_json::Value { + let body = request.split("\r\n\r\n").nth(1).unwrap_or(""); + serde_json::from_str(body).unwrap_or_else(|err| panic!("body {err}")) + } + + pub(crate) fn http(status: u16, reason: &str, body: &str) -> Vec { format!( "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() @@ -411,7 +925,24 @@ mod tests { .into_bytes() } - fn serve(handler: impl Fn(&str) -> Vec + Send + 'static) -> String { + /// One read. A timeout is not the end of the request: the body can arrive in a later packet. + fn read_some(stream: &mut std::net::TcpStream, got: &mut Vec, deadline: std::time::Instant) -> bool { + let mut buf = [0u8; 2048]; + while std::time::Instant::now() < deadline { + match std::io::Read::read(stream, &mut buf) { + Ok(0) => return false, + Err(err) if err.kind() == std::io::ErrorKind::TimedOut || err.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(_) => return false, + Ok(read) => { + got.extend_from_slice(&buf[..read]); + return true; + } + } + } + false + } + + pub(crate) fn serve(handler: impl Fn(&str) -> Vec + Send + 'static) -> String { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); let port = listener.local_addr().expect("addr").port(); std::thread::spawn(move || { @@ -423,11 +954,26 @@ mod tests { let _ = stream.set_nonblocking(false); let _ = stream.set_read_timeout(Some(Duration::from_millis(300))); let mut got = Vec::new(); - let mut buf = [0u8; 2048]; - while got.windows(4).all(|window| window != b"\r\n\r\n") { - match std::io::Read::read(&mut stream, &mut buf) { - Ok(0) | Err(_) => break, - Ok(read) => got.extend_from_slice(&buf[..read]), + let header_end = loop { + if let Some(at) = got.windows(4).position(|window| window == b"\r\n\r\n") { + break Some(at + 4); + } + if !read_some(&mut stream, &mut got, deadline) { + break None; + } + }; + if let Some(header_end) = header_end { + let head = String::from_utf8_lossy(&got[..header_end]); + let length = head.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }); + if let Some(length) = length { + while got.len() < header_end + length && read_some(&mut stream, &mut got, deadline) {} } } let request = String::from_utf8_lossy(&got).into_owned(); diff --git a/crates/chatd/src/adapters/tiny/signin.rs b/crates/chatd/src/adapters/tiny/signin.rs index 6f21f20..1621dda 100644 --- a/crates/chatd/src/adapters/tiny/signin.rs +++ b/crates/chatd/src/adapters/tiny/signin.rs @@ -2,7 +2,7 @@ //! password. Nothing is kept until `POST /v1/session` answers 200. The password and the bearer //! are not logged. One sign-in at a time, in memory; closing the sheet drops it. -use super::{post_session, Fault}; +use super::{post_accept, post_session, Fault}; use crate::hub::Hub; use protocol::{AccountKey, Network, SignInInput, SignInProblem, SignInStep}; use std::sync::{Arc, Mutex, MutexGuard}; @@ -13,6 +13,9 @@ pub type Adopt = Box Result, String> + Send + Sy struct Flow { server: String, email: String, + code: String, + display_name: String, + joining: bool, step: SignInStep, adopt: Adopt, } @@ -131,6 +134,9 @@ pub fn step( *held = Some(Flow { server: String::new(), email: String::new(), + code: String::new(), + display_name: String::new(), + joining: false, step: SignInStep::Server { problem: None }, adopt, }); @@ -159,10 +165,12 @@ pub fn cancel() { impl Flow { fn answer(&mut self, input: SignInInput) -> SignInStep { + let joining = self.joining; let next = match (&self.step, input) { (SignInStep::Server { .. }, SignInInput::Server { url }) => match base_url(&url) { Some(server) => { self.server = server.clone(); + self.joining = false; SignInStep::Email { server, problem: None, @@ -172,6 +180,58 @@ impl Flow { problem: Some(SignInProblem::UrlInvalid), }, }, + (SignInStep::Server { .. }, SignInInput::HaveCode { url }) => match base_url(&url) { + Some(server) => { + self.server = server.clone(); + self.joining = true; + SignInStep::Invite { + server, + problem: None, + } + } + None => SignInStep::Server { + problem: Some(SignInProblem::UrlInvalid), + }, + }, + (SignInStep::Invite { .. }, SignInInput::Invite { code }) => { + let code = code.trim(); + if code.is_empty() { + SignInStep::Invite { + server: self.server.clone(), + problem: Some(SignInProblem::Other { + reason: "Paste the code you were given.".to_owned(), + }), + } + } else { + self.code = code.to_owned(); + SignInStep::Name { + server: self.server.clone(), + problem: None, + } + } + } + (SignInStep::Invite { .. }, SignInInput::Back) => SignInStep::Server { problem: None }, + (SignInStep::Name { .. }, SignInInput::Name { name }) => { + let name = name.trim(); + if name.is_empty() || name.chars().count() > 80 { + SignInStep::Name { + server: self.server.clone(), + problem: Some(SignInProblem::Other { + reason: "Type the name people should see.".to_owned(), + }), + } + } else { + self.display_name = name.to_owned(); + SignInStep::Email { + server: self.server.clone(), + problem: None, + } + } + } + (SignInStep::Name { .. }, SignInInput::Back) => SignInStep::Invite { + server: self.server.clone(), + problem: None, + }, (SignInStep::Email { .. }, SignInInput::Email { email }) => { if email_ok(&email) { self.email = email.trim().to_owned(); @@ -186,13 +246,21 @@ impl Flow { } } } + (SignInStep::Email { .. }, SignInInput::Back) if joining => SignInStep::Name { + server: self.server.clone(), + problem: None, + }, (SignInStep::Email { .. }, SignInInput::Back) => SignInStep::Server { problem: None }, (SignInStep::Password { .. }, SignInInput::Back) => SignInStep::Email { server: self.server.clone(), problem: None, }, (SignInStep::Password { .. }, SignInInput::Password { password }) => { - self.submit(&password) + if joining { + self.accept(&password) + } else { + self.submit(&password) + } } _ => self.step.clone(), }; @@ -206,28 +274,7 @@ impl Flow { problem: Some(problem), }; match post_session(&self.server, &self.email, password) { - Ok(saved) => { - let account = saved.user_id.clone(); - let session = match serde_json::to_string(&saved) { - Ok(session) => session, - Err(_) => { - return SignInStep::Failed { - reason: "The server's answer could not be kept.".to_owned(), - } - } - }; - match (self.adopt)(&session) { - Ok(_) => SignInStep::Done { - account: Some(AccountKey { - network: Network::Tiny, - account, - }), - }, - Err(_) => SignInStep::Failed { - reason: "The sign-in could not be kept.".to_owned(), - }, - } - } + Ok(saved) => self.kept(saved), Err(fault) => match password_outcome(fault) { Ok(problem) => again(problem), Err(reason) => SignInStep::Failed { @@ -236,6 +283,57 @@ impl Flow { }, } } + + fn accept(&self, password: &str) -> SignInStep { + let again = |problem| SignInStep::Password { + hint: String::new(), + problem: Some(problem), + }; + let length = password.chars().count(); + if !(12..=128).contains(&length) { + return again(SignInProblem::Other { + reason: "Use 12 to 128 characters.".to_owned(), + }); + } + match post_accept( + &self.server, + &self.code, + &self.email, + password, + &self.display_name, + ) { + Ok(saved) => self.kept(saved), + Err(fault) => match password_outcome(fault) { + Ok(problem) => again(problem), + Err(reason) => SignInStep::Failed { + reason: reason.to_owned(), + }, + }, + } + } + + fn kept(&self, saved: super::Saved) -> SignInStep { + let account = saved.user_id.clone(); + let session = match serde_json::to_string(&saved) { + Ok(session) => session, + Err(_) => { + return SignInStep::Failed { + reason: "The server's answer could not be kept.".to_owned(), + } + } + }; + match (self.adopt)(&session) { + Ok(_) => SignInStep::Done { + account: Some(AccountKey { + network: Network::Tiny, + account, + }), + }, + Err(_) => SignInStep::Failed { + reason: "The sign-in could not be kept.".to_owned(), + }, + } + } } /// A failed password step stays on that step. `Err` ends the sheet: the answer could not be read. @@ -250,6 +348,9 @@ fn password_outcome(fault: Fault) -> Result { }), Fault::Unreachable => Ok(SignInProblem::Unreachable), Fault::BadAnswer => Err("The server's answer could not be read."), + Fault::Words(reason) => Ok(SignInProblem::Other { + reason: reason.to_owned(), + }), } } @@ -274,8 +375,14 @@ mod tests { Box::new(|_session| Ok(Vec::new())) } + fn one_sheet() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + #[test] fn a_bad_address_stays_on_the_server_step_and_a_bad_email_stays_on_email() { + let _one = one_sheet(); cancel(); let hub = hub(); assert_eq!( @@ -459,5 +566,220 @@ mod tests { } ) .contains("hunter2")); + assert!(!format!( + "{:?}", + SignInInput::Invite { + code: "secret-code".into() + } + ) + .contains("secret")); + assert!(!format!( + "{:?}", + SignInInput::Name { + name: "Ada Lovelace".into() + } + ) + .contains("Ada")); + } + + #[test] + fn a_code_joins_from_the_same_sheet() { + let _one = one_sheet(); + cancel(); + let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = hits.clone(); + let base = super::super::tests::serve(move |request| { + seen.lock().unwrap().push(request.to_owned()); + if request.lines().next().unwrap_or("").starts_with("POST /v1/invites/accept ") { + return super::super::tests::http( + 200, + "OK", + r#"{"token":"tok_join","user_id":"22222222-2222-2222-2222-222222222222","email":"ada@example.com","display_name":"Ada","role":"member"}"#, + ); + } + super::super::tests::http(500, "Internal Server Error", r#"{"error":"no"}"#) + }); + let kept = std::sync::Arc::new(std::sync::Mutex::new(None)); + let kept_step = kept.clone(); + let hub = hub(); + assert_eq!( + step( + &hub, + None, + Box::new(move |session| { + *kept_step.lock().unwrap() = Some(session.to_owned()); + Ok(Vec::new()) + }), + ) + .unwrap(), + SignInStep::Server { problem: None } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::HaveCode { + url: "not a url".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Server { + problem: Some(SignInProblem::UrlInvalid) + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::HaveCode { url: base.clone() }), + adopt() + ) + .unwrap(), + SignInStep::Invite { + server: base.clone(), + problem: None + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Invite { + code: " ".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Invite { + server: base.clone(), + problem: Some(SignInProblem::Other { + reason: "Paste the code you were given.".into() + }) + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Invite { + code: " join-code ".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Name { + server: base.clone(), + problem: None + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Name { + name: "n".repeat(81) + }), + adopt() + ) + .unwrap(), + SignInStep::Name { + server: base.clone(), + problem: Some(SignInProblem::Other { + reason: "Type the name people should see.".into() + }) + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Name { + name: " Ada ".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Email { + server: base.clone(), + problem: None + } + ); + assert_eq!( + step(&hub, Some(SignInInput::Back), adopt()).unwrap(), + SignInStep::Name { + server: base.clone(), + problem: None + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Name { name: "Ada".into() }), + adopt() + ) + .unwrap(), + SignInStep::Email { + server: base.clone(), + problem: None + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Email { + email: "ada@example.com".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Password { + hint: String::new(), + problem: None + } + ); + assert_eq!(hits.lock().unwrap().len(), 0); + assert_eq!( + step( + &hub, + Some(SignInInput::Password { + password: "short".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Password { + hint: String::new(), + problem: Some(SignInProblem::Other { + reason: "Use 12 to 128 characters.".into() + }) + } + ); + assert_eq!(hits.lock().unwrap().len(), 0); + assert_eq!( + step( + &hub, + Some(SignInInput::Password { + password: "correct-horse".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Done { + account: Some(AccountKey { + network: Network::Tiny, + account: "22222222-2222-2222-2222-222222222222".into(), + }) + } + ); + let sent = hits.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert!(sent[0].lines().next().unwrap_or("").starts_with("POST /v1/invites/accept ")); + let body: serde_json::Value = serde_json::from_str(sent[0].split("\r\n\r\n").nth(1).unwrap_or("")).unwrap(); + assert_eq!(body["token"], "join-code"); + assert_eq!(body["email"], "ada@example.com"); + assert_eq!(body["display_name"], "Ada"); + assert_eq!(body["password"], "correct-horse"); + let session = kept.lock().unwrap().clone().expect("adopted"); + let saved: serde_json::Value = serde_json::from_str(&session).unwrap(); + assert_eq!(saved["user_id"], "22222222-2222-2222-2222-222222222222"); + assert_eq!(saved["role"], "member"); + assert_eq!(saved["base_url"], base); + assert_eq!(saved["display_name"], "Ada"); + cancel(); } } diff --git a/crates/chatd/src/server.rs b/crates/chatd/src/server.rs index 9da16c8..deeb4b2 100644 --- a/crates/chatd/src/server.rs +++ b/crates/chatd/src/server.rs @@ -1723,6 +1723,10 @@ impl Chat { Response::Ok } Request::SignOut { account } => self.sign_out(account), + Request::TinyCommunity { account } => adapters::tiny::community(account), + Request::TinyInvite { account, role } => adapters::tiny::invite(account, role), + Request::TinyRole { account, user_id, role } => adapters::tiny::set_role(account, user_id, role), + Request::TinyRemove { account, user_id } => adapters::tiny::remove(account, user_id), Request::CallStart { conversation } => self.call_start(conversation), Request::CallAccept => match self.hub.calls.accept(&self.hub) { diff --git a/crates/chatd/tests/playback.rs b/crates/chatd/tests/playback.rs index bfa3fec..4181c90 100644 --- a/crates/chatd/tests/playback.rs +++ b/crates/chatd/tests/playback.rs @@ -108,12 +108,9 @@ fn start(path: &Path, at: Option, rate: u16) -> PlayRequest { fn mediads() -> usize { #[cfg(windows)] { - let script = format!( - "(Get-CimInstance Win32_Process -Filter \"Name = 'mediad.exe'\" | Where-Object {{ $_.ParentProcessId -eq {} }}).ProcessId", - std::process::id() - ); - let out = Command::new("powershell").args(["-NoProfile", "-Command", &script]).output().unwrap(); - String::from_utf8(out.stdout).unwrap().lines().filter(|line| !line.trim().is_empty()).count() + // PowerShell's process list takes longer than this one-second clip, so it reported + // none while mediad was still playing. The snapshot is the process list itself. + windows_mediads() } #[cfg(not(windows))] { @@ -122,6 +119,55 @@ fn mediads() -> usize { } } +/// `mediad.exe` whose parent is this test. A toolhelp snapshot, not PowerShell. +#[cfg(windows)] +fn windows_mediads() -> usize { + use std::ffi::c_void; + #[repr(C)] + struct Entry { + size: u32, + usage: u32, + pid: u32, + heap: usize, + module: u32, + threads: u32, + parent: u32, + priority: i32, + flags: u32, + exe: [u16; 260], + } + #[link(name = "kernel32")] + extern "system" { + fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> *mut c_void; + fn Process32FirstW(snap: *mut c_void, entry: *mut Entry) -> i32; + fn Process32NextW(snap: *mut c_void, entry: *mut Entry) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + const SNAPPROCESS: u32 = 2; + unsafe { + let snap = CreateToolhelp32Snapshot(SNAPPROCESS, 0); + assert_ne!(snap as isize, -1, "could not list processes"); + let mut entry: Entry = std::mem::zeroed(); + entry.size = std::mem::size_of::() as u32; + let mut count = 0usize; + let parent = std::process::id(); + if Process32FirstW(snap, &mut entry) != 0 { + loop { + let end = entry.exe.iter().position(|unit| *unit == 0).unwrap_or(entry.exe.len()); + let name = String::from_utf16_lossy(&entry.exe[..end]); + if entry.parent == parent && name.eq_ignore_ascii_case("mediad.exe") { + count += 1; + } + if Process32NextW(snap, &mut entry) == 0 { + break; + } + } + } + CloseHandle(snap); + count + } +} + fn until_no_mediad() { let deadline = Instant::now() + PATIENCE; while mediads() > 0 { @@ -206,9 +252,8 @@ fn a_second_file_replaces_the_first_and_speed_and_seek_reach_mediad() { world.ask(second).unwrap(); let playing = world.until_state(PlayState::Playing); assert_eq!(playing.file, "d6101"); - // One file at a time: the first one's mediad went. Listing processes on Windows takes - // PowerShell a second or more, long enough for the second file to end and its mediad to go - // too, so none left counts as well. + // One file at a time: the first one's mediad went. The second file is a second long, so + // it may have ended before this looks. None left is that. world.until("at most one mediad", |_| mediads() <= 1); world.ask(PlayRequest::Stop).unwrap(); until_no_mediad(); diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 0d47c8a..68654d0 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -21,6 +21,7 @@ mod play; mod react; pub mod settings; mod signin; +mod tiny; pub use call::*; pub use crash::redact; @@ -30,6 +31,7 @@ pub use message::*; pub use play::*; pub use react::*; pub use signin::*; +pub use tiny::*; pub const CHAT_SOCK_NAME: &str = "chat.sock"; @@ -1347,6 +1349,26 @@ pub enum Request { }, /// Plays the test sound on the call speaker (V2): in the call's mediad, or one started for it. TestSound, + /// Who is on this Tiny server. An owner or an admin also gets the invites and the audit. + TinyCommunity { + account: AccountKey, + }, + /// Creates an invite for `role` (`admin` or `member`). The token is in the answer, once. + TinyInvite { + account: AccountKey, + role: String, + }, + /// Changes one member's role (`owner`, `admin`, or `member`). + TinyRole { + account: AccountKey, + user_id: String, + role: String, + }, + /// Removes one member. + TinyRemove { + account: AccountKey, + user_id: String, + }, } /// One thing that changed. The shell asks again for exactly that; the ephemeral kinds carry their @@ -1614,6 +1636,14 @@ pub enum Response { messages: u64, bytes: u64, }, + /// Who is on this Tiny server, plus the invites and the audit when this account may see them. + TinyCommunity { + community: TinyCommunity, + }, + /// An invite that was just created. `code.token` is not kept. + TinyCode { + code: TinyCode, + }, } /// `TINYCLIENT_DIR` moves the socket and database, for tests and the demo seed. diff --git a/crates/protocol/src/signin.rs b/crates/protocol/src/signin.rs index 40c4201..c816f54 100644 --- a/crates/protocol/src/signin.rs +++ b/crates/protocol/src/signin.rs @@ -97,6 +97,18 @@ pub enum SignInStep { #[serde(default, skip_serializing_if = "Option::is_none")] problem: Option, }, + /// Ask for an invite code for `server`. + Invite { + server: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + problem: Option, + }, + /// Ask for the name people should see. + Name { + server: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + problem: Option, + }, /// Signed in. The account is in `Status` from now on. Done { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -121,6 +133,12 @@ pub enum SignInInput { Server { url: String }, /// The email on that server, as typed. Email { email: String }, + /// A Tiny server's address, to join it with a code. + HaveCode { url: String }, + /// The invite code, as pasted. + Invite { code: String }, + /// The name people should see. + Name { name: String }, /// Send the code again, the way `SignInStep::Code::next` says. Resend, /// One step back: from the code to the number, from the number to the QR code. @@ -138,6 +156,9 @@ impl fmt::Debug for SignInInput { SignInInput::Password { .. } => "Password { .. }", SignInInput::Server { .. } => "Server { .. }", SignInInput::Email { .. } => "Email { .. }", + SignInInput::HaveCode { .. } => "HaveCode { .. }", + SignInInput::Invite { .. } => "Invite { .. }", + SignInInput::Name { .. } => "Name { .. }", SignInInput::Resend => "Resend", SignInInput::Back => "Back", }) @@ -160,6 +181,13 @@ mod tests { assert!(!format!("{url:?}").contains("127.0.0.1")); assert!(!format!("{email:?}").contains("ada@")); assert!(!format!("{email:?}").contains("example.com")); + let invite = SignInInput::Invite { code: "secret-code".into() }; + let name = SignInInput::Name { name: "Ada Lovelace".into() }; + assert_eq!(format!("{invite:?}"), "Invite { .. }"); + assert_eq!(format!("{name:?}"), "Name { .. }"); + assert!(!format!("{invite:?}").contains("secret-code")); + assert!(!format!("{name:?}").contains("Ada")); + assert!(!format!("{name:?}").contains("Lovelace")); // A step with nothing but its name still reads: every other field is optional. assert_eq!(serde_json::from_str::(r#"{"step":"server"}"#).unwrap(), SignInStep::Server { problem: None }); let email_step = SignInStep::Email { server: "http://127.0.0.1:8080".into(), problem: None }; diff --git a/crates/protocol/src/tiny.rs b/crates/protocol/src/tiny.rs new file mode 100644 index 0000000..e734377 --- /dev/null +++ b/crates/protocol/src/tiny.rs @@ -0,0 +1,109 @@ +//! A Tiny server's members, invites, and audit. No rooms and no messages. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TinyMember { + pub user_id: String, + pub email: String, + pub display_name: String, + pub role: String, + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TinyInvite { + pub id: String, + pub role: String, + pub created_by: String, + pub expires_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accepted_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accepted_by: Option, +} + +/// The token is only in the answer to creating an invite. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TinyCode { + pub id: String, + pub token: String, + pub role: String, + pub expires_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TinyAudit { + pub id: i64, + pub actor_id: Option, + pub action: String, + pub subject: String, + /// The server's detail object, as JSON text. Empty when the server sent none. + pub detail: String, + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TinyCommunity { + pub role: String, + pub members: Vec, + pub invites: Vec, + pub audit: Vec, +} + +/// Whether `role` may do `id`. An unknown id is refused. +pub fn tiny_can(role: &str, id: &str) -> bool { + match id { + "community.members.read" => matches!(role, "owner" | "admin" | "member"), + "community.invites.read" + | "community.invites.create" + | "community.members.role" + | "community.members.remove" + | "community.audit.read" => matches!(role, "owner" | "admin"), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tiny_can_matrix() { + assert!(tiny_can("member", "community.members.read")); + assert!(tiny_can("admin", "community.members.read")); + assert!(tiny_can("owner", "community.members.read")); + assert!(!tiny_can("guest", "community.members.read")); + for id in [ + "community.invites.read", + "community.invites.create", + "community.members.role", + "community.members.remove", + "community.audit.read", + ] { + assert!(!tiny_can("member", id), "{id}"); + assert!(tiny_can("admin", id), "{id}"); + assert!(tiny_can("owner", id), "{id}"); + assert!(!tiny_can("someone", id), "{id}"); + } + assert!(!tiny_can("owner", "community.nope")); + assert!(!tiny_can("admin", "")); + assert!(!tiny_can("member", "community.invites.create")); + } + + #[test] + fn an_invite_omits_an_acceptance_it_does_not_have() { + let invite = TinyInvite { + id: "1".into(), + role: "member".into(), + created_by: "a".into(), + expires_at: "t".into(), + accepted_at: None, + accepted_by: None, + }; + let json = serde_json::to_string(&invite).unwrap(); + assert!(!json.contains("accepted")); + let back: TinyInvite = serde_json::from_str(r#"{"id":"1","role":"member","created_by":"a","expires_at":"t"}"#).unwrap(); + assert_eq!(back, invite); + } +} diff --git a/crates/tinyclient/src/gui/layout.rs b/crates/tinyclient/src/gui/layout.rs index effcfca..499bd8b 100644 --- a/crates/tinyclient/src/gui/layout.rs +++ b/crates/tinyclient/src/gui/layout.rs @@ -825,6 +825,7 @@ impl Render for Shell { self.note_place(window, cx); self.follow_changes(cx); self.refresh_menus(cx); + self.note_tiny_page(cx); // Before the first answer the window is still drawn: rail, strips, an empty list, and // what it waits on. Never a blank canvas, never a developer message. let placeholder; @@ -872,6 +873,7 @@ impl Render for Shell { let pane = match (&snapshot.open, self.keychain_state(window, cx)) { (_, Some(waiting)) => waiting, (Some(_), None) => self.conversation(snapshot, window, cx), + (None, None) if self.tiny_open() => self.tiny_page(window, cx), (None, None) => self.nothing_open(window, cx), }; root.child(div().flex_1().min_w_0().h_full().child(pane)) diff --git a/crates/tinyclient/src/gui/mod.rs b/crates/tinyclient/src/gui/mod.rs index 55b0b1f..a7082e8 100644 --- a/crates/tinyclient/src/gui/mod.rs +++ b/crates/tinyclient/src/gui/mod.rs @@ -23,6 +23,7 @@ mod signin; mod shortcuts; mod state; mod theme; +mod tiny; mod time; use gpui::{point, prelude::*, px, size, App, Bounds, Context, Div, KeyBinding, TitlebarOptions, WindowBounds, WindowOptions}; diff --git a/crates/tinyclient/src/gui/sidebar.rs b/crates/tinyclient/src/gui/sidebar.rs index 94d89ce..4c705aa 100644 --- a/crates/tinyclient/src/gui/sidebar.rs +++ b/crates/tinyclient/src/gui/sidebar.rs @@ -1371,6 +1371,27 @@ impl Shell { match &state.mode { Mode::People => div().into_any_element(), Mode::Inbox => self.inbox_list(window, cx), + // No rooms. People on this server are drawn in the window, not as channels. + Mode::Network(Network::Tiny) => match snapshot.link(Network::Tiny) { + Link::Connected => div() + .size_full() + .px(px(24.)) + .flex() + .items_center() + .justify_center() + .child( + div() + .max_w(px(220.)) + .text_center() + .whitespace_normal() + .type_ui() + .text_color(theme::text_soft()) + .child("People on this server are in the window."), + ) + .into_any_element(), + Link::Offline => empty("icons/wifi-off.svg", "Tiny server is offline", "It shows up here once it connects."), + _ => empty("icons/users.svg", "Tiny server", "Sign in to see who's here."), + }, _ if state.listed() => self.home_list(snapshot, window, cx), Mode::Network(network) if state.rows.is_empty() && state.spaces_loaded => { let name = network.label(); @@ -1400,6 +1421,7 @@ impl Shell { pub(super) fn sidebar(&self, snapshot: &Snapshot, width: f32, window: &mut Window, cx: &mut Context) -> Div { let people = self.sidebar_state.mode == Mode::People; + let tiny = matches!(self.sidebar_state.mode, Mode::Network(Network::Tiny)); if !matches!(&self.sidebar_state.mode, Mode::Space(space) if space.network == Network::Discord) { self.sidebar_state.width.set(width); } @@ -1422,7 +1444,7 @@ impl Shell { .border_r_1() .border_color(theme::hairline()) .child(self.title_strip(&self.sidebar_state.mode, cx)) - .when(!people, |sidebar| sidebar.child(self.jump_strip(cx))) + .when(!people && !tiny, |sidebar| sidebar.child(self.jump_strip(cx))) .children(tabs) .when(!banners.is_empty(), |sidebar| sidebar.child(div().flex_none().px(px(theme::INSET)).pb(px(8.)).flex().flex_col().gap(px(4.)).children(banners))) .child(div().id("sidebar-list").flex_1().min_h_0().track_focus(&self.sidebar_state.focus).key_context("Sidebar").child(body)) diff --git a/crates/tinyclient/src/gui/signin.rs b/crates/tinyclient/src/gui/signin.rs index 405f503..97a23f2 100644 --- a/crates/tinyclient/src/gui/signin.rs +++ b/crates/tinyclient/src/gui/signin.rs @@ -3,7 +3,8 @@ //! scan, or a phone number, the code Telegram sends and, for accounts that have one, the two-step //! password. Signal: only the code to scan from the phone, which links this computer as one more //! device; a number typed here would take the account from the phone, so none is asked. A Tiny -//! server asks for its address, then an email, then the password for that server. +//! server asks for its address, then an email, then a password of 12 to 128 characters. +//! A code from someone already on that server is its own step, then the name people there will see. //! //! chatd owns the sign-in. The sheet asks it to begin, draws the step it is told //! (`protocol::SignInStep`) and answers it; while the sheet is open its own loop asks `Wait` and @@ -89,6 +90,10 @@ pub struct Sheet { url: Entity, /// The email on that server. email: Entity, + /// An invite code for that server. + invite: Entity, + /// The name people on that server will see. + name: Entity, /// Has the keys when no field does, so Escape still closes the sheet. focus: FocusHandle, /// The step changed: put the keys where typing goes, on the next frame. @@ -227,7 +232,9 @@ fn step_problem(step: &SignInStep) -> Option<&SignInProblem> { | SignInStep::Code { problem, .. } | SignInStep::Password { problem, .. } | SignInStep::Server { problem } - | SignInStep::Email { problem, .. } => problem.as_ref(), + | SignInStep::Email { problem, .. } + | SignInStep::Invite { problem, .. } + | SignInStep::Name { problem, .. } => problem.as_ref(), SignInStep::Done { .. } | SignInStep::Failed { .. } => None, } } @@ -266,6 +273,8 @@ impl Sheet { let password = cx.new(|cx| TextInput::new("", cx)); let url = cx.new(|cx| TextInput::new("tiny.example.com", cx)); let email = cx.new(|cx| TextInput::new("Email", cx)); + let invite = cx.new(|cx| TextInput::new("Invite code", cx)); + let name = cx.new(|cx| TextInput::new("Name", cx)); for input in [&country, &number] { cx.subscribe(input, |this, _, event: &InputEvent, cx| match event { InputEvent::Submit => this.send_phone(cx), @@ -306,8 +315,24 @@ impl Sheet { } }) .detach(); + cx.subscribe(&invite, |this, _, event: &InputEvent, cx| match event { + InputEvent::Submit => this.send_invite(cx), + InputEvent::Changed => { + this.dismiss(); + cx.notify(); + } + }) + .detach(); + cx.subscribe(&name, |this, _, event: &InputEvent, cx| match event { + InputEvent::Submit => this.send_name(cx), + InputEvent::Changed => { + this.dismiss(); + cx.notify(); + } + }) + .detach(); // Escape in a field is the field's key; it hands it on. - for input in [&country, &number, &digits, &password, &url, &email] { + for input in [&country, &number, &digits, &password, &url, &email, &invite, &name] { cx.subscribe(input, |this, _, event: &InputKey, cx| { if matches!(event, InputKey::Escape) { this.close(true, cx); @@ -330,6 +355,8 @@ impl Sheet { password, url, email, + invite, + name, focus: cx.focus_handle(), refocus: false, before: None, @@ -408,6 +435,8 @@ impl Sheet { self.country.update(cx, |input, cx| input.set_text("+1", cx)); self.url.update(cx, |input, cx| input.set_text("", cx)); self.email.update(cx, |input, cx| input.set_text("", cx)); + self.invite.update(cx, |input, cx| input.set_text("", cx)); + self.name.update(cx, |input, cx| input.set_text("", cx)); } /// Drops a typed code and password. They live nowhere else. @@ -426,6 +455,8 @@ impl Sheet { SignInStep::Phone { problem } | SignInStep::Server { problem } | SignInStep::Email { problem, .. } + | SignInStep::Invite { problem, .. } + | SignInStep::Name { problem, .. } | SignInStep::Password { problem, .. } => *problem = None, _ => {} } @@ -707,6 +738,36 @@ impl Sheet { self.answer(SignInInput::Email { email }, cx); } + fn send_have_code(&mut self, cx: &mut Context) { + if self.busy { + return; + } + let url = self.url.read(cx).text().to_owned(); + self.dismiss(); + self.answer(SignInInput::HaveCode { url }, cx); + } + + fn send_invite(&mut self, cx: &mut Context) { + if self.busy { + return; + } + let code = self.invite.read(cx).text().to_owned(); + // A code is not kept in the field after it is sent. An empty one still goes to chatd, + // which stays on this step and says to paste the code. + self.invite.update(cx, |input, cx| input.set_text("", cx)); + self.dismiss(); + self.answer(SignInInput::Invite { code }, cx); + } + + fn send_name(&mut self, cx: &mut Context) { + if self.busy { + return; + } + let name = self.name.read(cx).text().to_owned(); + self.dismiss(); + self.answer(SignInInput::Name { name }, cx); + } + /// Where typing goes on the step on show. fn target(&self, cx: &App) -> FocusHandle { match &self.face { @@ -715,6 +776,8 @@ impl Sheet { Some(Face::Step(SignInStep::Password { .. })) => self.password.focus_handle(cx), Some(Face::Step(SignInStep::Server { .. })) => self.url.focus_handle(cx), Some(Face::Step(SignInStep::Email { .. })) => self.email.focus_handle(cx), + Some(Face::Step(SignInStep::Invite { .. })) => self.invite.focus_handle(cx), + Some(Face::Step(SignInStep::Name { .. })) => self.name.focus_handle(cx), _ => self.focus.clone(), } } @@ -962,7 +1025,7 @@ impl Sheet { let tiny = self.network == Network::Tiny; let heading = if tiny { self.header("Password", true, cx) } else { self.header("Two-step verification password", true, cx) }; let aside = if tiny { - div().type_ui().text_color(theme::text_soft()).child("The password for that server.") + div().type_ui().text_color(theme::text_soft()).child("A password of 12 to 128 characters.") } else { // Words in a link's color, not a link: the reset happens in Telegram's own app. div().type_ui().text_color(theme::accent_text()).child("Forgot it? Reset it in the Telegram app.") @@ -989,6 +1052,12 @@ impl Sheet { .child(input) .children(problem.map(|problem| error_line(problem_words(self.network, problem)).mt(px(6.)))) .child(footer(22.).child(div()).child(buttons().child(self.cancel("Cancel", cx)).child(self.primary("Continue", Sheet::send_url, cx)))) + .child(div().mt(px(12.)).child(self.have_code(cx))) + } + + /// "I have a code" reads the address already typed. `answer_link` cannot see the field. + fn have_code(&self, cx: &Context) -> Stateful
{ + link("signin-have-code", "I have a code").on_click(cx.listener(|this, _, _, cx| this.send_have_code(cx))) } fn email_step(&self, server: &str, problem: Option<&SignInProblem>, window: &mut Window, cx: &Context) -> Div { @@ -1004,6 +1073,32 @@ impl Sheet { .child(footer(22.).child(div()).child(buttons().child(self.cancel("Cancel", cx)).child(self.primary("Continue", Sheet::send_email, cx)))) } + fn invite_step(&self, server: &str, problem: Option<&SignInProblem>, window: &mut Window, cx: &Context) -> Div { + let focused = self.invite.focus_handle(cx).is_focused(window); + let input = tall_field(focused, problem.is_some()).child(self.invite.clone()); + div() + .flex() + .flex_col() + .child(self.header("Code", true, cx)) + .child(div().mt(px(6.)).mb(px(16.)).text_color(theme::text_soft()).child(server.to_owned())) + .child(input) + .children(problem.map(|problem| error_line(problem_words(self.network, problem)).mt(px(6.)))) + .child(footer(22.).child(div()).child(buttons().child(self.cancel("Cancel", cx)).child(self.primary("Continue", Sheet::send_invite, cx)))) + } + + fn name_step(&self, problem: Option<&SignInProblem>, window: &mut Window, cx: &Context) -> Div { + let focused = self.name.focus_handle(cx).is_focused(window); + let input = tall_field(focused, problem.is_some()).child(self.name.clone()); + div() + .flex() + .flex_col() + .child(self.header("Name", true, cx)) + .child(div().mt(px(6.)).mb(px(16.)).text_color(theme::text_soft()).child("The name people on that server will see.")) + .child(input) + .children(problem.map(|problem| error_line(problem_words(self.network, problem)).mt(px(6.)))) + .child(footer(22.).child(div()).child(buttons().child(self.cancel("Cancel", cx)).child(self.primary("Continue", Sheet::send_name, cx)))) + } + fn title(&self) -> &'static str { match self.network { Network::Telegram => "Sign in to Telegram", @@ -1219,6 +1314,8 @@ impl Render for Sheet { SignInStep::Password { hint, .. } => self.password_step(hint, problem, window, cx), SignInStep::Server { .. } => self.server_step(problem, window, cx), SignInStep::Email { server, .. } => self.email_step(server, problem, window, cx), + SignInStep::Invite { server, .. } => self.invite_step(server, problem, window, cx), + SignInStep::Name { .. } => self.name_step(problem, window, cx), SignInStep::Failed { reason } => self.failed_step(reason, cx), SignInStep::Done { .. } => div(), } diff --git a/crates/tinyclient/src/gui/state.rs b/crates/tinyclient/src/gui/state.rs index 066208c..9ebdd5b 100644 --- a/crates/tinyclient/src/gui/state.rs +++ b/crates/tinyclient/src/gui/state.rs @@ -218,6 +218,8 @@ pub struct Shell { pub(super) call_state: call::State, /// The voice message or audio file playing, across conversations. pub(super) playing_state: super::playing::State, + /// The Tiny server on show. + pub(super) tiny_state: super::tiny::State, /// chatd as the shell sees it: starting, live, reconnecting, given up; the keychain. pub(super) service: Service, @@ -281,6 +283,7 @@ impl Shell { composer_state: composer::State::default(), call_state: call::State::default(), playing_state: super::playing::State::default(), + tiny_state: super::tiny::State::default(), service: Service::new(!cfg!(feature = "gui-shot") || std::env::args().any(|arg| arg == "--start-chatd")), crashed: app::crash::relaunched_after(), restore: Some((prefs.open.clone(), prefs.mode.clone())), diff --git a/crates/tinyclient/src/gui/tiny.rs b/crates/tinyclient/src/gui/tiny.rs new file mode 100644 index 0000000..307be16 --- /dev/null +++ b/crates/tinyclient/src/gui/tiny.rs @@ -0,0 +1,652 @@ +//! The Tiny server window: who is on it, and an invite for the next person. +//! A control the account cannot use is absent, never disabled. + +use super::components::{button, button_disabled, section_header, segment, segmented, ButtonKind}; +use super::layout::{drags_window, TOP_BAR}; +use super::sidebar::Mode; +use super::state::{Shell, Snapshot}; +use super::theme::{self, Type}; +use crate::client::ask; +use gpui::{div, prelude::*, px, AnyElement, ClipboardItem, Context, Div, Window}; +use protocol::{AccountKey, Link, Network, Request, Response}; +use std::io; + +/// What the server window is holding. The invite token stays until Done and is never logged. +pub struct State { + seen: bool, + account: Option, + load_epoch: u64, + action_epoch: u64, + loading: bool, + community: Option, + error: Option, + /// `"member"` or `"admin"`. Never owner. + invite_role: String, + token: Option, + busy: bool, + confirm: Option, +} + +impl Default for State { + fn default() -> State { + State { + seen: false, + account: None, + load_epoch: 0, + action_epoch: 0, + loading: false, + community: None, + error: None, + invite_role: "member".into(), + token: None, + busy: false, + confirm: None, + } + } +} + +struct Member { + user_id: String, + name: String, + email: String, + role: String, + make_admin: bool, + make_member: bool, + make_owner: bool, + remove: bool, +} + +struct OpenInvite { + role: String, + expires: String, +} + +struct Ready { + title: &'static str, + line: Option<&'static str>, + error: Option, + token: Option, + invite_role: String, + can_invite: bool, + busy: bool, + confirm: Option, + members: Vec, + invites: Vec, + /// `None` when this account cannot read the audit. + audit: Option>, +} + +enum Page { + Loading, + Failed(String), + Ready(Ready), +} + +fn connected(snapshot: &Snapshot) -> Option { + snapshot + .accounts + .iter() + .find(|status| status.key.network == Network::Tiny && status.link == Link::Connected) + .map(|status| status.key.clone()) +} + +fn same_role(role: &str, name: &str) -> bool { + role.eq_ignore_ascii_case(name) +} + +fn known_role(role: &str) -> bool { + same_role(role, "owner") || same_role(role, "admin") || same_role(role, "member") +} + +/// A role button is drawn only when that change would be legal. +fn role_change_legal(caller: &str, target: &str, next: &str, owners: usize) -> bool { + if same_role(target, next) || !known_role(next) { + return false; + } + // The only owner cannot be demoted. + if same_role(target, "owner") && owners <= 1 { + return false; + } + // Changing an owner, or granting owner, is the owner's act alone. + if same_role(target, "owner") || same_role(next, "owner") { + return same_role(caller, "owner"); + } + if same_role(caller, "owner") { + return true; + } + // An admin may promote a member, and does not demote another admin. + same_role(caller, "admin") && same_role(target, "member") && same_role(next, "admin") +} + +fn remove_legal(caller: &str, target: &str, owners: usize) -> bool { + if same_role(target, "owner") && owners <= 1 { + return false; + } + same_role(caller, "owner") || (same_role(caller, "admin") && same_role(target, "member")) +} + +fn can_make(caller: &str, target: &str, next: &str, owners: usize) -> bool { + protocol::tiny_can(caller, "community.members.role") && role_change_legal(caller, target, next, owners) +} + +fn can_remove(caller: &str, target: &str, owners: usize) -> bool { + protocol::tiny_can(caller, "community.members.remove") && remove_legal(caller, target, owners) +} + +fn role_word(role: &str) -> String { + if same_role(role, "owner") { + "Owner".to_owned() + } else if same_role(role, "admin") { + "Admin".to_owned() + } else if same_role(role, "member") { + "Member".to_owned() + } else { + role.to_owned() + } +} + +fn person_name(display: &str, email: &str) -> (String, String) { + if !display.is_empty() { + (display.to_owned(), email.to_owned()) + } else if !email.is_empty() { + (email.to_owned(), String::new()) + } else { + ("Someone".to_owned(), String::new()) + } +} + +impl Shell { + /// Loads the server when the window is actually showing it, and again the next time it is. + pub(super) fn note_tiny_page(&mut self, cx: &mut Context) { + let Some(account) = self.tiny_shown() else { + self.tiny_state.seen = false; + return; + }; + if self.tiny_state.seen && self.tiny_state.account.as_ref() == Some(&account) { + return; + } + self.tiny_state.seen = true; + self.fetch(account, None, cx); + } + + pub(super) fn tiny_open(&self) -> bool { + self.tiny_state.seen + } + + fn tiny_shown(&self) -> Option { + if self.shown().empty_state().is_some() { + return None; + } + if !matches!(self.sidebar_state.mode, Mode::Network(Network::Tiny)) { + return None; + } + let snapshot = self.snapshot.as_ref()?; + if snapshot.open.is_some() { + return None; + } + connected(snapshot) + } + + pub(super) fn tiny_page(&self, window: &Window, cx: &mut Context) -> Div { + let page = self.page(); + let body = match &page { + Page::Loading => note_line("Loading this server.", false).into_any_element(), + Page::Failed(detail) => note_line(detail, true).into_any_element(), + Page::Ready(ready) => ready_view(ready, cx), + }; + div() + .size_full() + .flex() + .flex_col() + .child( + drags_window(div()) + .h(px(TOP_BAR)) + .flex_none() + .px(px(theme::PANE_PAD)) + .flex() + .items_center() + .child(self.strip_controls(window, cx)), + ) + .child(body) + } + + fn page(&self) -> Page { + let state = &self.tiny_state; + if state.loading && state.community.is_none() { + return Page::Loading; + } + let Some(community) = state.community.as_ref() else { + return match state.error.clone() { + Some(detail) => Page::Failed(detail), + None => Page::Loading, + }; + }; + let caller = community.role.as_str(); + let owners = community.members.iter().filter(|member| same_role(&member.role, "owner")).count(); + let can_invite = protocol::tiny_can(caller, "community.invites.create"); + let alone = community.members.len() == 1; + let members = if protocol::tiny_can(caller, "community.members.read") { + community + .members + .iter() + .map(|member| { + let (name, email) = person_name(&member.display_name, &member.email); + let role = member.role.as_str(); + Member { + user_id: member.user_id.clone(), + name, + email, + role: role_word(role), + make_admin: can_make(caller, role, "admin", owners), + make_member: can_make(caller, role, "member", owners), + make_owner: can_make(caller, role, "owner", owners), + remove: can_remove(caller, role, owners), + } + }) + .collect() + } else { + Vec::new() + }; + let invites = if protocol::tiny_can(caller, "community.invites.read") { + community + .invites + .iter() + .filter(|invite| invite.accepted_at.is_none()) + .map(|invite| OpenInvite { role: role_word(&invite.role), expires: invite.expires_at.to_string() }) + .collect() + } else { + Vec::new() + }; + let audit = protocol::tiny_can(caller, "community.audit.read").then(|| { + community.audit.iter().map(|row| format!("{} {}", row.action, row.subject)).collect() + }); + Page::Ready(Ready { + title: if alone && can_invite { "Just you so far" } else { "Tiny server" }, + line: (alone && can_invite && state.token.is_none()) + .then_some("Invite someone. They paste the code into their own tinyclient."), + error: state.error.clone().filter(|detail| !detail.is_empty()), + token: state.token.clone(), + invite_role: state.invite_role.clone(), + can_invite, + busy: state.busy, + confirm: state.confirm.clone(), + members, + invites, + audit, + }) + } + + /// `clear_busy` is the action whose button stays quiet until this read returns. + fn fetch(&mut self, account: AccountKey, clear_busy: Option, cx: &mut Context) { + let switched = self.tiny_state.account.as_ref() != Some(&account); + if switched { + self.tiny_state.community = None; + self.tiny_state.token = None; + self.tiny_state.confirm = None; + self.tiny_state.error = None; + self.tiny_state.invite_role = "member".into(); + self.tiny_state.action_epoch += 1; + self.tiny_state.busy = false; + } + self.tiny_state.account = Some(account.clone()); + if self.tiny_state.community.is_none() { + self.tiny_state.loading = true; + self.tiny_state.error = None; + } + self.tiny_state.load_epoch += 1; + let epoch = self.tiny_state.load_epoch; + self.off_thread(cx, move || ask(&Request::TinyCommunity { account }), move |this, answer, cx| { + if this.tiny_state.load_epoch != epoch { + return; + } + this.tiny_state.loading = false; + if let Some(action) = clear_busy { + if this.tiny_state.action_epoch == action { + this.tiny_state.busy = false; + } + } + match answer { + Ok(Response::TinyCommunity { community }) => { + let confirm = this.tiny_state.confirm.clone(); + if confirm.as_ref().is_some_and(|id| community.members.iter().all(|member| &member.user_id != id)) { + this.tiny_state.confirm = None; + } + this.tiny_state.error = None; + this.tiny_state.community = Some(community); + } + Ok(Response::Error { detail, .. }) => this.tiny_state.error = Some(detail), + Ok(_) => this.tiny_state.error = Some(crate::client::OTHER_ANSWER.to_owned()), + Err(err) => this.tiny_state.error = Some(crate::client::trouble_words(&err)), + } + cx.notify(); + }); + } + + fn act(&mut self, request: Request, cx: &mut Context) { + if self.tiny_state.busy { + return; + } + let Some(account) = self.tiny_state.account.clone() else { + return; + }; + self.tiny_state.busy = true; + self.tiny_state.error = None; + self.tiny_state.action_epoch += 1; + let epoch = self.tiny_state.action_epoch; + cx.notify(); + self.off_thread(cx, move || ask(&request), move |this, answer, cx| this.acted(epoch, account, answer, cx)); + } + + fn acted(&mut self, epoch: u64, account: AccountKey, answer: io::Result, cx: &mut Context) { + if self.tiny_state.action_epoch != epoch { + return; + } + if self.tiny_state.account.as_ref() != Some(&account) { + self.tiny_state.busy = false; + cx.notify(); + return; + } + match answer { + Ok(Response::Ok) => { + self.tiny_state.confirm = None; + self.tiny_state.error = None; + self.fetch(account, Some(epoch), cx); + cx.notify(); + } + Ok(Response::TinyCode { code }) => { + self.tiny_state.token = Some(code.token); + self.tiny_state.error = None; + self.fetch(account, Some(epoch), cx); + cx.notify(); + } + Ok(Response::Error { detail, .. }) => { + self.tiny_state.busy = false; + self.tiny_state.error = Some(detail); + cx.notify(); + } + Ok(_) => { + self.tiny_state.busy = false; + self.tiny_state.error = Some(crate::client::OTHER_ANSWER.to_owned()); + cx.notify(); + } + Err(err) => { + self.tiny_state.busy = false; + self.tiny_state.error = Some(crate::client::trouble_words(&err)); + cx.notify(); + } + } + } + + fn send_invite(&mut self, cx: &mut Context) { + let Some(account) = self.tiny_state.account.clone() else { + return; + }; + let role = if self.tiny_state.invite_role == "admin" { "admin" } else { "member" }; + self.act(Request::TinyInvite { account, role: role.to_owned() }, cx); + } + + fn pick_role(&mut self, role: &'static str, cx: &mut Context) { + if self.tiny_state.busy || (role != "member" && role != "admin") { + return; + } + self.tiny_state.invite_role = role.to_owned(); + cx.notify(); + } + + fn send_role(&mut self, user_id: String, role: &'static str, cx: &mut Context) { + let Some(account) = self.tiny_state.account.clone() else { + return; + }; + self.act(Request::TinyRole { account, user_id, role: role.to_owned() }, cx); + } + + fn ask_remove(&mut self, user_id: String, cx: &mut Context) { + if self.tiny_state.busy { + return; + } + self.tiny_state.confirm = Some(user_id); + cx.notify(); + } + + fn cancel_remove(&mut self, cx: &mut Context) { + self.tiny_state.confirm = None; + cx.notify(); + } + + fn send_remove(&mut self, user_id: String, cx: &mut Context) { + let Some(account) = self.tiny_state.account.clone() else { + return; + }; + self.tiny_state.confirm = None; + self.act(Request::TinyRemove { account, user_id }, cx); + } + + fn copy_code(&mut self, cx: &mut Context) { + let Some(token) = self.tiny_state.token.clone() else { + return; + }; + cx.write_to_clipboard(ClipboardItem::new_string(token)); + } + + fn done_code(&mut self, cx: &mut Context) { + self.tiny_state.token = None; + cx.notify(); + } +} + +fn note_line(words: &str, bad: bool) -> Div { + let line = div().flex_1().px(px(theme::PANE_PAD)).pt(px(8.)); + if bad { + line.type_small().text_color(theme::danger()).child(words.to_owned()) + } else { + line.type_ui().text_color(theme::text_soft()).child(words.to_owned()) + } +} + +fn ready_view(ready: &Ready, cx: &mut Context) -> AnyElement { + let mut column = div().w_full().pt(px(8.)).pb(px(24.)).flex().flex_col().child( + div() + .px(px(theme::PANE_PAD)) + .flex() + .flex_col() + .gap(px(8.)) + .child(div().type_title().text_color(theme::text()).child(ready.title)) + .children(ready.line.map(|line| div().max_w(px(360.)).whitespace_normal().type_ui().text_color(theme::text_soft()).child(line))) + .children(ready.error.as_ref().map(|detail| div().type_small().text_color(theme::danger()).child(detail.clone()))) + .children(ready.token.as_ref().map(|token| code_panel(token, cx))) + .when(ready.token.is_none() && ready.can_invite, |column| column.child(invite_row(&ready.invite_role, ready.busy, cx))), + ); + if !ready.members.is_empty() { + column = column.child(div().mt(px(8.)).flex().flex_col().children(ready.members.iter().map(|member| member_row(member, ready.confirm.as_deref(), cx)))); + } + if !ready.invites.is_empty() { + column = column.child(invite_section(&ready.invites)); + } + if let Some(rows) = &ready.audit { + column = column.child(audit_section(rows)); + } + div().id("tiny-page").flex_1().min_h_0().overflow_y_scroll().child(column).into_any_element() +} + +fn invite_row(role: &str, busy: bool, cx: &mut Context) -> Div { + let invite = if busy { + button_disabled("Invite someone").into_any_element() + } else { + button("tiny-invite", ButtonKind::Primary, "Invite someone") + .on_click(cx.listener(|this, _, _, cx| this.send_invite(cx))) + .into_any_element() + }; + div() + .flex() + .flex_wrap() + .items_center() + .gap(px(8.)) + .child( + segmented() + .child(segment("tiny-as-member", "Member", role != "admin").on_click(cx.listener(|this, _, _, cx| this.pick_role("member", cx)))) + .child(segment("tiny-as-admin", "Admin", role == "admin").on_click(cx.listener(|this, _, _, cx| this.pick_role("admin", cx)))), + ) + .child(invite) +} + +fn code_panel(token: &str, cx: &mut Context) -> Div { + div() + .flex() + .flex_col() + .gap(px(8.)) + .child(div().whitespace_normal().type_ui().text_color(theme::text_soft()).child("Copy this code. It works for 7 days and will not be shown again.")) + .child( + div() + .px(px(12.)) + .py(px(8.)) + .rounded(px(theme::R_CONTROL)) + .bg(theme::field()) + .border_1() + .border_color(theme::border()) + .whitespace_normal() + .type_code() + .child(token.to_owned()), + ) + .child( + div() + .flex() + .flex_wrap() + .gap(px(8.)) + .child(button("tiny-copy", ButtonKind::Primary, "Copy code").on_click(cx.listener(|this, _, _, cx| this.copy_code(cx)))) + .child(button("tiny-done", ButtonKind::Secondary, "Done").on_click(cx.listener(|this, _, _, cx| this.done_code(cx)))), + ) +} + +fn member_row(member: &Member, confirm: Option<&str>, cx: &mut Context) -> Div { + let confirming = confirm == Some(member.user_id.as_str()); + let identity = div().flex_1().min_w(px(120.)).flex().flex_col().child( + div() + .min_w_0() + .flex() + .items_center() + .gap(px(6.)) + .child(div().min_w_0().truncate().type_ui().text_color(theme::text()).child(member.name.clone())) + .child(div().flex_none().type_small().text_color(theme::text_soft()).child(member.role.clone())), + ) + .when(!member.email.is_empty(), |identity| { + identity.child(div().min_w_0().truncate().type_small().text_color(theme::text_faint()).child(member.email.clone())) + }); + div().w_full().px(px(theme::PANE_PAD)).py(px(6.)).flex().flex_col().gap(px(6.)).child( + div() + .flex() + .flex_wrap() + .items_center() + .gap(px(8.)) + .child(identity) + .when(!confirming, |row| row.child(member_actions(member, cx))) + .when(confirming, |row| row.child(confirm_remove(&member.name, &member.user_id, cx))), + ) +} + +fn member_actions(member: &Member, cx: &mut Context) -> Div { + let mut actions = div().flex_none().flex().flex_wrap().items_center().gap(px(4.)); + if member.make_admin { + let user_id = member.user_id.clone(); + actions = actions.child( + button(format!("tiny-admin-{}", member.user_id), ButtonKind::Secondary, "Make admin") + .on_click(cx.listener(move |this, _, _, cx| this.send_role(user_id.clone(), "admin", cx))), + ); + } + if member.make_member { + let user_id = member.user_id.clone(); + actions = actions.child( + button(format!("tiny-member-{}", member.user_id), ButtonKind::Secondary, "Make member") + .on_click(cx.listener(move |this, _, _, cx| this.send_role(user_id.clone(), "member", cx))), + ); + } + if member.make_owner { + let user_id = member.user_id.clone(); + actions = actions.child( + button(format!("tiny-owner-{}", member.user_id), ButtonKind::Secondary, "Make owner") + .on_click(cx.listener(move |this, _, _, cx| this.send_role(user_id.clone(), "owner", cx))), + ); + } + if member.remove { + let user_id = member.user_id.clone(); + actions = actions.child( + button(format!("tiny-remove-{}", member.user_id), ButtonKind::Danger, "Remove") + .on_click(cx.listener(move |this, _, _, cx| this.ask_remove(user_id.clone(), cx))), + ); + } + actions +} + +fn confirm_remove(name: &str, user_id: &str, cx: &mut Context) -> Div { + let remove_id = user_id.to_owned(); + div() + .flex() + .flex_wrap() + .items_center() + .gap(px(8.)) + .child(div().whitespace_normal().type_ui().text_color(theme::text()).child(format!("Remove {name} from this server?"))) + .child( + button(format!("tiny-remove-yes-{user_id}"), ButtonKind::Danger, "Remove") + .on_click(cx.listener(move |this, _, _, cx| this.send_remove(remove_id.clone(), cx))), + ) + .child(button(format!("tiny-remove-no-{user_id}"), ButtonKind::Secondary, "Cancel").on_click(cx.listener(|this, _, _, cx| this.cancel_remove(cx)))) +} + +fn invite_section(invites: &[OpenInvite]) -> Div { + div().mt(px(12.)).flex().flex_col().child(section_header("Invites", true)).children(invites.iter().map(|invite| { + div() + .px(px(theme::PANE_PAD)) + .h(px(theme::ROW_ONE)) + .flex() + .items_center() + .gap(px(8.)) + .child(div().type_ui().text_color(theme::text()).child(invite.role.clone())) + .child(div().type_small().text_color(theme::text_soft()).child(format!("Expires {}", invite.expires))) + })) +} + +fn audit_section(rows: &[String]) -> Div { + let mut section = div().mt(px(12.)).flex().flex_col().child(section_header("Activity", true)); + if rows.is_empty() { + section = section.child(div().px(px(theme::PANE_PAD)).type_ui().text_color(theme::text_soft()).child("Nothing has been recorded.")); + } else { + section = section.children(rows.iter().map(|row| { + div().px(px(theme::PANE_PAD)).h(px(theme::ROW_ONE)).flex().items_center().min_w_0().truncate().type_ui().text_color(theme::text()).child(row.clone()) + })); + } + section +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn role_buttons_follow_who_may_change_whom() { + let owners = 1; + assert!(role_change_legal("owner", "member", "admin", owners)); + assert!(role_change_legal("owner", "member", "owner", owners)); + assert!(role_change_legal("admin", "member", "admin", owners)); + assert!(!role_change_legal("admin", "member", "owner", owners)); + assert!(!role_change_legal("admin", "admin", "member", owners)); + assert!(!role_change_legal("admin", "owner", "admin", owners)); + assert!(!role_change_legal("admin", "owner", "member", owners)); + assert!(!role_change_legal("owner", "owner", "admin", owners)); + assert!(!role_change_legal("owner", "owner", "member", owners)); + assert!(!role_change_legal("member", "member", "admin", owners)); + assert!(role_change_legal("owner", "owner", "admin", 2)); + assert!(role_change_legal("owner", "admin", "member", owners)); + assert!(!role_change_legal("owner", "admin", "admin", owners)); + } + + #[test] + fn remove_spares_the_only_owner_and_an_admin_does_not_touch_one() { + assert!(!remove_legal("owner", "owner", 1)); + assert!(remove_legal("owner", "owner", 2)); + assert!(remove_legal("owner", "admin", 1)); + assert!(remove_legal("owner", "member", 1)); + assert!(remove_legal("admin", "member", 1)); + assert!(!remove_legal("admin", "admin", 1)); + assert!(!remove_legal("admin", "owner", 2)); + assert!(!remove_legal("member", "member", 1)); + } +}