From fa0365470e6029b03335eadff7985f968960a0ed Mon Sep 17 00:00:00 2001 From: HaSh3003 <64496882+HaSh3003@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:31:58 +0300 Subject: [PATCH 1/4] M9: remove someone from a live session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server. The hub closes their connection as the end of the session for them (close 1001: they keep their copy and don't reconnect) and bars their address from that file while it runs, so the code or link they hold no longer gets them in; everyone else stays connected. Protocol addition within version 2: `Admin::RemovePeer {file, client}` on an open hosting connection with the file's owner token (or the server key). Older servers say they can't. Tests: loopback (removed, can't rejoin, others stay), engine (host can, guest can't, bad ids refused), codec round trip; the collab junk sweep covers the new command. --- apps/figcraft-server/src/store.rs | 4 + crates/collab-net/src/host.rs | 11 +++ crates/collab-net/src/hub.rs | 101 +++++++++++++++++++++++- crates/collab-net/src/link.rs | 14 ++++ crates/collab-net/src/loopback_tests.rs | 35 +++++++- crates/collab/src/codec_tests.rs | 3 + crates/collab/src/message.rs | 6 ++ crates/engine/src/live.rs | 71 +++++++++++++++++ crates/engine/src/live_tests.rs | 49 ++++++++++++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/live.rs | 23 +++++- docs/collab-protocol.md | 12 ++- docs/collaboration-parity.md | 5 +- docs/mcp.md | 7 ++ 14 files changed, 335 insertions(+), 7 deletions(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index e5e56b8f..352e3086 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1438,6 +1438,10 @@ impl Backend for Store { Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), Admin::Owned { files } => self.owned(&files).into(), + // start feat: remove-from-session + // The hub removes people itself (it knows who is connected); it never asks the store. + Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), + // end feat: remove-from-session Admin::Remove { file } => { if !self.files.contains_key(&file) { return failed("this file isn't on the server (it may have been removed)".into()); diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs index 0d51735e..a198ab5c 100644 --- a/crates/collab-net/src/host.rs +++ b/crates/collab-net/src/host.rs @@ -14,6 +14,9 @@ use figcraft_engine::live::{Event, PEOPLE}; use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local}; use crate::inbox::Inbox; pub use crate::join::ENDED; +// start feat: remove-from-session +pub use crate::hub::REMOVED; +// end feat: remove-from-session use crate::net::NetLimits; /// What a hosted session needs. @@ -129,6 +132,14 @@ impl Host { self.hosting.send(msg); } + // start feat: remove-from-session + /// Remove client `id` from the session: they are disconnected and can't join again from + /// their address while it runs. The host can't remove themselves. + pub fn remove(&self, id: u32) { + self.hosting.remove(id); + } + // end feat: remove-from-session + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 24474211..69910754 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -21,7 +21,7 @@ //! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is //! answered `200 ok` for uptime checks. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::{self, Read, Write}; use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -413,6 +413,10 @@ enum HubIn { }, Local(Presence), LocalMsg(ClientMsg), + // start feat: remove-from-session + /// The person hosting removes client `id` from their file (app sessions). + Remove(u32), + // end feat: remove-from-session /// Cut every connection without a word (tests: a network failure). #[cfg(test)] Cut, @@ -461,7 +465,15 @@ impl Hosting { pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { let addr = listener.local_addr()?; let in_flight = InFlight(Arc::default()); - let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new(), in_flight: in_flight.clone() }; + let mut hub = Hub { + backend, + rooms: HashMap::new(), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + in_flight: in_flight.clone(), + barred: HashMap::new(), + }; if let Some(l) = local { let mut room = Room::new(l.server); room.local = Some((l.id, l.inbox)); @@ -551,6 +563,14 @@ impl Hosting { let _ = self.hub.send(HubIn::LocalMsg(msg)); } + // start feat: remove-from-session + /// The hosting person removes client `id` from their file (app sessions; see + /// [`HubIn::Remove`]). + pub(crate) fn remove(&self, id: u32) { + let _ = self.hub.send(HubIn::Remove(id)); + } + // end feat: remove-from-session + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { @@ -1108,6 +1128,18 @@ fn unsupported(protocol: u32) -> String { /// sharing): the session's end for them. pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open"; +// start feat: remove-from-session +/// What someone removed from a file is told: the session's end for them (they keep their copy; +/// nothing retries). +pub const REMOVED: &str = "You were removed from this session. Your copy stays open"; + +/// Why a removed person's address can't join the file again. +const BARRED: &str = "you were removed from this session"; + +/// The most addresses one file keeps barred. +const MAX_BARRED: usize = 256; +// end feat: remove-from-session + /// What everyone in a file is told when its changes couldn't be saved (they reconnect). const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting"; @@ -1121,6 +1153,10 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, + // start feat: remove-from-session + /// Addresses ([`ip_key`]) removed from each file: they can't join it again while the hub runs. + barred: HashMap>, + // end feat: remove-from-session } impl Hub { @@ -1208,6 +1244,15 @@ impl Hub { self.client_message(&file, id, msg, None); } } + // start feat: remove-from-session + HubIn::Remove(id) => { + if let Some(file) = self.local.clone() + && let Err(why) = self.remove_peer(&file, id) + { + log::info!("live session: couldn't remove client {id}: {why}"); + } + } + // end feat: remove-from-session #[cfg(test)] HubIn::Cut => { for c in self.conns.values() { @@ -1232,6 +1277,16 @@ impl Hub { self.client_message(&file, id, msg, Some(bytes)); } Some(Kind::Hosting { owns }) => match msg { + // start feat: remove-from-session + ClientMsg::Admin(Admin::RemovePeer { file, client }) => { + let reply = if owns.as_deref() == Some(file.as_str()) { + self.removed_reply(&file, client) + } else { + AdminReply::Failed { reason: "an owner token manages only its own file".into() } + }; + self.answer(conn, &ServerMsg::Admin(Box::new(reply))); + } + // end feat: remove-from-session ClientMsg::Admin(req) => { let owns = owns.clone(); let rooms = &self.rooms; @@ -1243,6 +1298,12 @@ impl Hub { _ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"), }, Some(Kind::Managing) => match msg { + // start feat: remove-from-session + ClientMsg::Admin(Admin::RemovePeer { file, client }) => { + let reply = self.removed_reply(&file, client); + self.answer(conn, &ServerMsg::Admin(Box::new(reply))); + } + // end feat: remove-from-session ClientMsg::Admin(req) => self.admin(conn, req), ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), @@ -1305,6 +1366,12 @@ impl Hub { Ok(a) => a, Err(why) => return refuse(&c.out, &why), }; + // start feat: remove-from-session + if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) { + log::info!("event=refused ip={} reason=removed", c.ip); + return refuse(&c.out, BARRED); + } + // end feat: remove-from-session let hosting = reads_hosting(&hello.client_version); // The backend checked the secret: the sessions don't keep it. hello.token.clear(); @@ -1362,6 +1429,36 @@ impl Hub { } } + // start feat: remove-from-session + /// Remove client `id` from `file`: they are told the session is over for them and + /// disconnected, and their address can't join the file again while the hub runs. The person + /// hosting can't be removed. + fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> { + let room = self.rooms.get(file).ok_or("nobody is in this file")?; + if room.local.as_ref().is_some_and(|(local, _)| *local == id) { + return Err("the person hosting can't be removed".into()); + } + let conn = *room.ids.get(&id).ok_or_else(|| format!("nobody with client id {id} is in this file"))?; + let c = self.conns.get(&conn).ok_or_else(|| format!("nobody with client id {id} is in this file"))?; + let barred = self.barred.entry(file.to_string()).or_default(); + if barred.len() < MAX_BARRED { + barred.insert(c.ip); + } + log::info!("event=removed file={file} client={id} ip={}", c.ip); + let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())); + self.gone(conn); + Ok(()) + } + + /// [`remove_peer`](Self::remove_peer) as a managing connection's answer. + fn removed_reply(&mut self, file: &str, id: u32) -> AdminReply { + match self.remove_peer(file, id) { + Ok(()) => AdminReply::Done, + Err(reason) => AdminReply::Failed { reason }, + } + } + // end feat: remove-from-session + /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip }); diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 754f1529..49e085ed 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -243,6 +243,20 @@ impl LiveLink for NetLink { } } + // start feat: remove-from-session + fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> { + match self.sessions.get(session) { + Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()), + Some(Active::Host(h)) => { + h.remove(client); + Ok(()) + } + Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()), + None => Err("this session is over".into()), + } + } + // end feat: remove-from-session + fn leave(&mut self, session: SessionId) { if let Some(active) = self.sessions.remove(session) { active.end(); diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 814d0004..65cbef36 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; use crate::guest::{Guest, GuestConfig}; -use crate::host::{ENDED, Host, HostConfig}; +use crate::host::{ENDED, Host, HostConfig, REMOVED}; use crate::inbox::Inbox; use crate::invite::Invite; use crate::net::{Bucket, NetLimits}; @@ -611,3 +611,36 @@ fn until_presence_or_pong(r: &mut Raw) -> Option { } } } + +// start feat: remove-from-session +#[test] +fn the_host_removes_someone_and_they_cant_come_back() { + let (mut host, mut hp, invite) = start(); + let (_a, mut ap) = join(&invite, "Ada"); + let (b, mut bp) = join(&invite, "Bo"); + ap.wait("welcome", welcomed); + bp.wait("welcome", welcomed); + let (ada, bo) = (ap.me(), bp.me()); + connections_settle(&host, 2); + // The host can't remove themselves, nor someone who isn't there: nothing happens. + host.remove(host.me().client_id); + host.remove(9999); + host.remove(ada); + // Ada is told the session is over for her (no reconnecting); the others see her leave. + ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED))); + hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada))); + bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada))); + connections_settle(&host, 1); + assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect"); + assert!(!hp.left().contains(&bo), "only Ada was removed"); + // Her address can't join again with the same invite (every guest here comes from 127.0.0.1). + let (_again, mut againp) = join(&invite, "Ada"); + againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed")))); + assert!(!welcomed(&againp.events)); + // Bo, already in, stays and can still talk to the host. + b.presence(presence(3.0, 4.0)); + hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo))); + host.stop(); + assert_eq!(host.connections(), 0); +} +// end feat: remove-from-session diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 84931904..08f15d22 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -75,6 +75,9 @@ fn samples() -> (Vec, Vec) { ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }), ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }), ClientMsg::Admin(Admin::Blocked), + // start feat: remove-from-session + ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }), + // end feat: remove-from-session ClientMsg::Report { reason: "Spam ☕".into() }, ]; let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap(); diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 0eb0bd8c..1dc22bec 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -274,6 +274,12 @@ pub enum Admin { /// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them: /// [`AdminReply::Owned`]. Servers older than this request can't read it. Owned { files: Vec<(String, String)> }, + // start feat: remove-from-session + /// Remove client `client` from `file`'s session: they are disconnected and their address + /// can't join the file again while the server runs. With the server key, or on an open + /// hosting connection with `file`'s owner token. Servers older than this request can't read it. + RemovePeer { file: String, client: u32 }, + // end feat: remove-from-session } /// The most files one [`Admin::Owned`] asks about. diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 09c5831d..e621fd36 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -517,6 +517,13 @@ pub trait LiveLink: Send { fn background(&mut self, _job: u64, _req: NetRequest) -> bool { false } + // start feat: remove-from-session + /// Remove client `client` from session `session`, which this link hosts: they are + /// disconnected and can't join again from their address while it runs. Returns at once. + fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> { + Err("removing people needs the desktop app or the MCP server".into()) + } + // end feat: remove-from-session /// End session `session` (stop hosting or leave); the others go on. Nothing for a session /// that is over already. fn leave(&mut self, session: SessionId); @@ -1889,6 +1896,61 @@ fn follow(s: &mut Session, p: &Value) -> Result { Ok(json!({"follow": s.live().follow})) } +// start feat: remove-from-session +/// Remove someone from the open file's session: in a session this app hosts, the hub drops them; +/// in a file of yours on a server, the server does (with the file's owner token). They are told +/// the session is over for them, keep their copy, and can't join again from their address while +/// the session (or the server) runs. +fn remove_person(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.removePerson"; + let client = + get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?; + let live = s.live(); + if live.status != Status::Live { + return Err(bad(CMD, "the open file isn't in a live session")); + } + let name = + live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?; + let (id, role, server_file) = (live.id, live.role(), live.server_file()); + match (role, server_file) { + (Some(Role::Host), _) => { + let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?; + link.remove(id, client).map_err(EngineError::Other)?; + } + (Some(Role::Guest), Some((server, file))) => { + let owner = + s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty()); + let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?; + let req = Some(Admin::RemovePeer { file: file.clone(), client }); + match hosting_request(s, CMD, &server, &file, &owner, req) { + Ok((_, Some(AdminReply::Done))) => {} + Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))), + // A server older than this request can't read it. + Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))), + } + } + _ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")), + } + s.message = Some(format!("{name} was removed from the session")); + Ok(json!({"removed": client, "name": name})) +} + +/// You may remove others from the open file's session: you host it, or it is your file on a +/// server. +pub fn can_remove_people(s: &Session) -> bool { + let live = s.live(); + live.status == Status::Live + && match live.role() { + Some(Role::Host) => true, + Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| { + live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty()) + || s.online_owner(&server, &file).is_some_and(|o| !o.is_empty()) + }), + None => false, + } +} +// end feat: remove-from-session + fn cursor(s: &mut Session, p: &Value) -> Result { let at = match (num(p, "x"), num(p, "y")) { (Some(x), Some(y)) => Some((x, y)), @@ -1997,6 +2059,15 @@ pub fn specs() -> Vec { |s, _| Ok(s.live_summary()) ), spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow), + // start feat: remove-from-session + spec!( + "collab.removePerson", + "Remove from session", + "{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it", + false, + remove_person + ), + // end feat: remove-from-session spec!( "collab.cursor", "Show cursor at", diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index 6b05199b..f2d8cfb8 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -28,6 +28,10 @@ pub(crate) struct Script { /// The session each presence in `sent` and each message in `msgs` was for. pub(crate) sent_to: Vec, pub(crate) msgs_to: Vec, + // start feat: remove-from-session + /// The people removed: `(session, client)`. + pub(crate) removed: Vec<(u64, u32)>, + // end feat: remove-from-session } pub(crate) struct Mock(pub(crate) Arc>); @@ -62,6 +66,12 @@ impl LiveLink for Mock { s.published.push(opts); Ok(()) } + // start feat: remove-from-session + fn remove(&mut self, session: u64, client: u32) -> Result<(), String> { + self.0.lock().unwrap().removed.push((session, client)); + Ok(()) + } + // end feat: remove-from-session fn leave(&mut self, session: u64) { let mut s = self.0.lock().unwrap(); s.left += 1; @@ -314,6 +324,45 @@ fn the_host_ending_resets_everything() { assert_eq!(s.message.as_deref(), Some("The host ended the session")); } +// start feat: remove-from-session +#[test] +fn the_host_removes_people_and_guests_cant() { + let (mut s, script) = session(); + let remove = |s: &mut Session, client: serde_json::Value| s.execute("collab.removePerson", &json!({ "client": client })); + // Not in a session: nobody to remove. + assert!(matches!(remove(&mut s, json!(2)), Err(EngineError::BadParams { .. }))); + assert!(!crate::live::can_remove_people(&s)); + s.execute("collab.host", &json!({})).unwrap(); + let id = s.live().id; + push(&script, vec![Event::PeerJoined(peer(2, "Ada")), Event::PeerJoined(peer(3, "Bo"))]); + s.live_poll(); + assert!(crate::live::can_remove_people(&s)); + // Only someone who is here, by a client id. + for bad in [json!(9), json!(1), json!(-2), json!("2"), json!(4_294_967_296u64)] { + assert!(matches!(remove(&mut s, bad.clone()), Err(EngineError::BadParams { .. })), "{bad}"); + } + let r = remove(&mut s, json!(2)).unwrap(); + assert_eq!((r["removed"].as_u64(), r["name"].as_str()), (Some(2), Some("Ada"))); + assert_eq!(script.lock().unwrap().removed, [(id, 2)]); + assert_eq!(s.message.as_deref(), Some("Ada was removed from the session")); + // The host's own session tells them she left. + push(&script, vec![Event::PeerLeft(2)]); + s.live_poll(); + assert_eq!(s.live().peers.keys().copied().collect::>(), [3]); + s.execute("collab.stop", &json!({})).unwrap(); + + // A guest in someone else's session can't remove anyone. + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Bo")] }]); + s.live_poll(); + assert!(!crate::live::can_remove_people(&s)); + let e = remove(&mut s, json!(3)).unwrap_err().to_string(); + assert!(e.contains("only the person hosting"), "{e}"); + assert!(script.lock().unwrap().removed.is_empty()); +} +// end feat: remove-from-session + #[test] fn collab_commands_survive_junk() { let junk = [ diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c35b1677..56a7acd7 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1045,6 +1045,7 @@ name = "English" "Remove the else and its actions" = "Remove the else and its actions" "Remove this condition and its actions" = "Remove this condition and its actions" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." +"Remove {name} from session" = "Remove {name} from session" "Remove {name} from {server}?" = "Remove {name} from {server}?" "Removed" = "Removed" "Removed from the server" = "Removed from the server" diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 46687a68..03bfd3ce 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -444,7 +444,8 @@ fn follow_frame(painter: &egui::Painter, canvas: Rect, name: &str, fill: Color32 } /// You and the others at the top of the right panel, within `area`: click someone to follow -/// them (again to stop); the chevron lists everyone. +/// them (again to stop); right-click them to remove them from the session (when you host it or +/// own the file); the chevron lists everyone. pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let t = Tokens::get(ui.ctx()); const R: f32 = 12.0; @@ -459,6 +460,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect(); let follow = live.follow; let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting); + // start feat: remove-from-session + let can_remove = figcraft_engine::live::can_remove_people(&app.session); + let mut remove = None; + // end feat: remove-from-session // Room for the avatars after yours and the chevron. let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1); let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) }; @@ -496,6 +501,16 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if resp.clicked() { clicked = Some(*id); } + // start feat: remove-from-session + if can_remove { + crate::right::context_popup(&resp).show(|ui| { + if menu_item(ui, &trf("Remove {name} from session", &[("name", name)]), "") { + remove = Some(*id); + ui.close(); + } + }); + } + // end feat: remove-from-session } if more > 0 { x += STEP; @@ -539,6 +554,12 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if spotlight { app.run("collab.spotlight", json!({"on": !spotlighting})); } + // start feat: remove-from-session + // A server is asked without the window waiting for it. + if let Some(id) = remove { + app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {})); + } + // end feat: remove-from-session } /// The Share button's popover: the live session (start, invite link, stop or leave) and export. diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 7888241d..7f99ddfb 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -386,6 +386,16 @@ resuming with its offline edits, and leaving the session going back to snapshot outlines in the person's colour, and avatars (initials) at the top of the right panel beside Share; click an avatar to **follow** that person's viewport (`collab.follow {client}`), click again, press Escape or pan/zoom yourself to stop. The chevron lists everyone. + +- **Removing someone.** Right-click their avatar ▸ "Remove from session" + (`collab.removePerson {client}`), when you host the session or own the file on a server. The + hub closes their connection with "You were removed from this session" (close code 1001: the + session is over for them, no reconnecting) and bars their address (its `ip_key`) from that file + while it runs, so the invite they hold no longer gets them in; everyone else stays connected. On + a server the owner asks on an open hosting connection with the file's owner token + (`Admin::RemovePeer {file, client}`; the operator may too, with the server key). Addresses are + the only identity a session has: someone behind the same address (one NAT) is barred too. + - **Text carets.** While someone types in a text layer of the shared document on your page, their caret and selected characters show on it in their colour (the text layout's own caret and selection geometry, so they sit at the right glyphs of your copy), with their name in a tag above @@ -463,7 +473,7 @@ resuming with its offline edits, and leaving the session going back to snapshot `collab.share {via?: "online" | "local", access?, …}`, `collab.host {port?, bind?: "local" | "lan" | }`, `collab.stop`, `collab.join {code | link}`, `collab.leave`, `collab.peers`, `collab.follow {client?}`, `collab.cursor {x?, y?}` (an agent's - pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`. + pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`, `collab.removePerson {client}`. - **Sessions are per document.** Every open file has its own session or none: hosting, joined, being published, reconnecting or failed. Several run at once (host one file, join another, publish a third), each with its own people, links, undo history, cursor chat, spotlight and diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index e7eb468f..95084d74 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | -| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The list is per computer: no accounts, teams or projects | 8–17 | +| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged | | 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | | 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): percentages and hours unchanged | | 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 2b66657c..0f20ed6b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1090,6 +1090,13 @@ only: a host stops hosting it, a guest leaves it. pointer. Your selection, page and view go out by themselves when they change. - `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any pan or zoom of your own, or Escape). + +- `collab.removePerson {client}` removes that person from the session: they are told the session + is over for them (they keep their copy and don't reconnect), and their address can't join the + file again while the session (or the server) runs. Only the person hosting, or the owner of a + file on a server (with its owner token), can; a server older than this request says it can't. + Returns `{removed, name}`. + - `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the cursor somewhere first with `collab.cursor`): at most 100 characters, control characters become spaces, `""` closes it. Others see it fade 5 seconds after its last change, so send it again to From f387d3f464dfcec5e76c1aabba83d50a69854ca3 Mon Sep 17 00:00:00 2001 From: HaSh3003 <64496882+HaSh3003@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:00:42 +0300 Subject: [PATCH 2/4] M9: translate Remove from session The menu item and the collab.removePerson label in es, de, fr, ja, ko, pt-BR and zh-Hans (es must be complete). --- crates/ui-egui/locales/de.toml | 2 ++ crates/ui-egui/locales/es.toml | 2 ++ crates/ui-egui/locales/fr.toml | 2 ++ crates/ui-egui/locales/ja.toml | 2 ++ crates/ui-egui/locales/ko.toml | 2 ++ crates/ui-egui/locales/pt-BR.toml | 2 ++ crates/ui-egui/locales/zh-Hans.toml | 2 ++ 7 files changed, 14 insertions(+) diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 7552cca3..83aa6df9 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -997,6 +997,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "„Sonst“ und seine Aktionen entfernen" "Remove this condition and its actions" = "Diese Bedingung und ihre Aktionen entfernen" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Diese Datei vom Server entfernen? Sie kann von dort nicht mehr geteilt oder geöffnet werden. Ihre Kopie bleibt hier geöffnet." +"Remove {name} from session" = "{name} aus der Sitzung entfernen" "Remove {name} from {server}?" = "{name} von {server} entfernen?" "Removed" = "Entfernt" "Removed from the server" = "Vom Server entfernt" @@ -1568,6 +1569,7 @@ status = "draft — needs native review" "collab.publish" = "Auf Server veröffentlichen" "collab.recoverFile" = "Online-Datei wiederherstellen" "collab.registerLinks" = "Einladungslinks in FigCraft öffnen" +"collab.removePerson" = "Aus der Sitzung entfernen" "collab.report" = "Datei dem Serverbetreiber melden" "collab.serverInfo" = "Serverinformationen" "collab.share" = "Mit Beitrittscode teilen" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6514ac7b..06f38c15 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1046,6 +1046,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Quitar el «si no» y sus acciones" "Remove this condition and its actions" = "Quitar esta condición y sus acciones" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "¿Quitar este archivo del servidor? No se podrá volver a compartir ni abrir desde allí. Tu copia sigue abierta aquí." +"Remove {name} from session" = "Quitar a {name} de la sesión" "Remove {name} from {server}?" = "¿Quitar {name} de {server}?" "Removed" = "Eliminado" "Removed from the server" = "Quitado del servidor" @@ -1643,6 +1644,7 @@ status = "draft — needs native review" "collab.publish" = "Publicar en el servidor" "collab.recoverFile" = "Recuperar archivo en línea" "collab.registerLinks" = "Abrir enlaces de invitación en FigCraft" +"collab.removePerson" = "Quitar de la sesión" "collab.report" = "Denunciar archivo al operador del servidor" "collab.serverInfo" = "Información del servidor" "collab.share" = "Compartir con un código para unirse" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c8665934..2f904c60 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -997,6 +997,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Retirer le « sinon » et ses actions" "Remove this condition and its actions" = "Retirer cette condition et ses actions" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Retirer ce fichier du serveur ? Il ne pourra plus être partagé ni ouvert depuis celui-ci. Votre copie reste ouverte ici." +"Remove {name} from session" = "Retirer {name} de la session" "Remove {name} from {server}?" = "Retirer {name} de {server} ?" "Removed" = "Supprimé" "Removed from the server" = "Retiré du serveur" @@ -1568,6 +1569,7 @@ status = "draft — needs native review" "collab.publish" = "Publier sur un serveur" "collab.recoverFile" = "Récupérer un fichier en ligne" "collab.registerLinks" = "Ouvrir les liens d’invitation dans FigCraft" +"collab.removePerson" = "Retirer de la session" "collab.report" = "Signaler le fichier à l’opérateur du serveur" "collab.serverInfo" = "Informations sur le serveur" "collab.share" = "Partager avec un code d’accès" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index f7b9cf9f..1801f0c4 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -999,6 +999,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "「それ以外」とそのアクションを削除" "Remove this condition and its actions" = "この条件とそのアクションを削除" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "このファイルをサーバーから削除しますか? サーバーからの共有や再オープンはできなくなる。手元のコピーは開いたまま。" +"Remove {name} from session" = "{name} をセッションから削除" "Remove {name} from {server}?" = "{name} を {server} から削除しますか?" "Removed" = "削除" "Removed from the server" = "サーバーから削除済み" @@ -1570,6 +1571,7 @@ status = "draft — needs native review" "collab.publish" = "サーバーに公開" "collab.recoverFile" = "オンラインファイルを復元" "collab.registerLinks" = "招待リンクを FigCraft で開く" +"collab.removePerson" = "セッションから削除" "collab.report" = "ファイルをサーバーの運営者に報告" "collab.serverInfo" = "サーバー情報" "collab.share" = "参加コードで共有" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 59bb2386..657b6817 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -998,6 +998,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "\"그 외\"와 해당 액션 제거" "Remove this condition and its actions" = "이 조건과 해당 액션 제거" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "이 파일을 서버에서 제거할까요? 서버에서 다시 공유하거나 열 수 없게 됨. 내 사본은 여기 열린 채로 유지됨." +"Remove {name} from session" = "{name} 님을 세션에서 제거" "Remove {name} from {server}?" = "{name}을(를) {server}에서 제거할까요?" "Removed" = "제거됨" "Removed from the server" = "서버에서 제거됨" @@ -1569,6 +1570,7 @@ status = "draft — needs native review" "collab.publish" = "서버에 게시" "collab.recoverFile" = "온라인 파일 복구" "collab.registerLinks" = "초대 링크를 FigCraft에서 열기" +"collab.removePerson" = "세션에서 제거" "collab.report" = "서버 운영자에게 파일 신고" "collab.serverInfo" = "서버 정보" "collab.share" = "참여 코드로 공유" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index a692a8ce..40336098 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -996,6 +996,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Remover o \"senão\" e suas ações" "Remove this condition and its actions" = "Remover esta condição e suas ações" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remover este arquivo do servidor? Ele não poderá mais ser compartilhado nem aberto a partir de lá. Sua cópia continua aberta aqui." +"Remove {name} from session" = "Remover {name} da sessão" "Remove {name} from {server}?" = "Remover {name} de {server}?" "Removed" = "Removido" "Removed from the server" = "Removido do servidor" @@ -1567,6 +1568,7 @@ status = "draft — needs native review" "collab.publish" = "Publicar no servidor" "collab.recoverFile" = "Recuperar arquivo online" "collab.registerLinks" = "Abrir links de convite no FigCraft" +"collab.removePerson" = "Remover da sessão" "collab.report" = "Denunciar arquivo ao operador do servidor" "collab.serverInfo" = "Informações do servidor" "collab.share" = "Compartilhar com um código de acesso" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d2431df0..87edf016 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -999,6 +999,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "移除“否则”及其操作" "Remove this condition and its actions" = "移除此条件及其操作" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "要从服务器移除此文件吗?之后将无法再从服务器分享或打开它。你的副本仍在此处打开。" +"Remove {name} from session" = "将 {name} 移出会话" "Remove {name} from {server}?" = "要从 {server} 移除 {name} 吗?" "Removed" = "已删除" "Removed from the server" = "已从服务器移除" @@ -1570,6 +1571,7 @@ status = "draft — needs native review" "collab.publish" = "发布到服务器" "collab.recoverFile" = "恢复在线文件" "collab.registerLinks" = "在 FigCraft 中打开邀请链接" +"collab.removePerson" = "移出会话" "collab.report" = "向服务器运营者举报文件" "collab.serverInfo" = "服务器信息" "collab.share" = "用加入代码分享" From 8bcb0f78ddc1d922723cef291a388383633bf59c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:37:52 +0700 Subject: [PATCH 3/4] #338: drop the start/end feat marker comments --- apps/figcraft-server/src/store.rs | 2 -- crates/collab-net/src/host.rs | 6 +----- crates/collab-net/src/hub.rs | 18 ------------------ crates/collab-net/src/link.rs | 2 -- crates/collab-net/src/loopback_tests.rs | 2 -- crates/collab/src/codec_tests.rs | 2 -- crates/collab/src/message.rs | 2 -- crates/engine/src/live.rs | 6 ------ crates/engine/src/live_tests.rs | 6 ------ crates/ui-egui/src/live.rs | 6 ------ docs/collab-protocol.md | 2 -- docs/mcp.md | 2 -- 12 files changed, 1 insertion(+), 55 deletions(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 9f97ee35..4886bc88 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1489,10 +1489,8 @@ impl Backend for Store { Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), Admin::Owned { files } => self.owned(&files).into(), - // start feat: remove-from-session // The hub removes people itself (it knows who is connected); it never asks the store. Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), - // end feat: remove-from-session Admin::Remove { file } => { if !self.files.contains_key(&file) { return failed("this file isn't on the server (it may have been removed)".into()); diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs index a198ab5c..24be22d3 100644 --- a/crates/collab-net/src/host.rs +++ b/crates/collab-net/src/host.rs @@ -11,12 +11,10 @@ use figcraft_collab::{Outgoing, Server, same_secret}; use figcraft_doc::Document; use figcraft_engine::live::{Event, PEOPLE}; +pub use crate::hub::REMOVED; use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local}; use crate::inbox::Inbox; pub use crate::join::ENDED; -// start feat: remove-from-session -pub use crate::hub::REMOVED; -// end feat: remove-from-session use crate::net::NetLimits; /// What a hosted session needs. @@ -132,13 +130,11 @@ impl Host { self.hosting.send(msg); } - // start feat: remove-from-session /// Remove client `id` from the session: they are disconnected and can't join again from /// their address while it runs. The host can't remove themselves. pub fn remove(&self, id: u32) { self.hosting.remove(id); } - // end feat: remove-from-session /// Cut every connection as a network failure would (no close frame). #[cfg(test)] diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 69910754..cdd06915 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -413,10 +413,8 @@ enum HubIn { }, Local(Presence), LocalMsg(ClientMsg), - // start feat: remove-from-session /// The person hosting removes client `id` from their file (app sessions). Remove(u32), - // end feat: remove-from-session /// Cut every connection without a word (tests: a network failure). #[cfg(test)] Cut, @@ -563,13 +561,11 @@ impl Hosting { let _ = self.hub.send(HubIn::LocalMsg(msg)); } - // start feat: remove-from-session /// The hosting person removes client `id` from their file (app sessions; see /// [`HubIn::Remove`]). pub(crate) fn remove(&self, id: u32) { let _ = self.hub.send(HubIn::Remove(id)); } - // end feat: remove-from-session /// Cut every connection as a network failure would (no close frame). #[cfg(test)] @@ -1128,7 +1124,6 @@ fn unsupported(protocol: u32) -> String { /// sharing): the session's end for them. pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open"; -// start feat: remove-from-session /// What someone removed from a file is told: the session's end for them (they keep their copy; /// nothing retries). pub const REMOVED: &str = "You were removed from this session. Your copy stays open"; @@ -1138,7 +1133,6 @@ const BARRED: &str = "you were removed from this session"; /// The most addresses one file keeps barred. const MAX_BARRED: usize = 256; -// end feat: remove-from-session /// What everyone in a file is told when its changes couldn't be saved (they reconnect). const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting"; @@ -1153,10 +1147,8 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, - // start feat: remove-from-session /// Addresses ([`ip_key`]) removed from each file: they can't join it again while the hub runs. barred: HashMap>, - // end feat: remove-from-session } impl Hub { @@ -1244,7 +1236,6 @@ impl Hub { self.client_message(&file, id, msg, None); } } - // start feat: remove-from-session HubIn::Remove(id) => { if let Some(file) = self.local.clone() && let Err(why) = self.remove_peer(&file, id) @@ -1252,7 +1243,6 @@ impl Hub { log::info!("live session: couldn't remove client {id}: {why}"); } } - // end feat: remove-from-session #[cfg(test)] HubIn::Cut => { for c in self.conns.values() { @@ -1277,7 +1267,6 @@ impl Hub { self.client_message(&file, id, msg, Some(bytes)); } Some(Kind::Hosting { owns }) => match msg { - // start feat: remove-from-session ClientMsg::Admin(Admin::RemovePeer { file, client }) => { let reply = if owns.as_deref() == Some(file.as_str()) { self.removed_reply(&file, client) @@ -1286,7 +1275,6 @@ impl Hub { }; self.answer(conn, &ServerMsg::Admin(Box::new(reply))); } - // end feat: remove-from-session ClientMsg::Admin(req) => { let owns = owns.clone(); let rooms = &self.rooms; @@ -1298,12 +1286,10 @@ impl Hub { _ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"), }, Some(Kind::Managing) => match msg { - // start feat: remove-from-session ClientMsg::Admin(Admin::RemovePeer { file, client }) => { let reply = self.removed_reply(&file, client); self.answer(conn, &ServerMsg::Admin(Box::new(reply))); } - // end feat: remove-from-session ClientMsg::Admin(req) => self.admin(conn, req), ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong), _ => self.kick(&[conn], "only requests to manage files are spoken on this connection"), @@ -1366,12 +1352,10 @@ impl Hub { Ok(a) => a, Err(why) => return refuse(&c.out, &why), }; - // start feat: remove-from-session if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) { log::info!("event=refused ip={} reason=removed", c.ip); return refuse(&c.out, BARRED); } - // end feat: remove-from-session let hosting = reads_hosting(&hello.client_version); // The backend checked the secret: the sessions don't keep it. hello.token.clear(); @@ -1429,7 +1413,6 @@ impl Hub { } } - // start feat: remove-from-session /// Remove client `id` from `file`: they are told the session is over for them and /// disconnected, and their address can't join the file again while the hub runs. The person /// hosting can't be removed. @@ -1457,7 +1440,6 @@ impl Hub { Err(reason) => AdminReply::Failed { reason }, } } - // end feat: remove-from-session /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 49e085ed..a84395aa 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -243,7 +243,6 @@ impl LiveLink for NetLink { } } - // start feat: remove-from-session fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> { match self.sessions.get(session) { Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()), @@ -255,7 +254,6 @@ impl LiveLink for NetLink { None => Err("this session is over".into()), } } - // end feat: remove-from-session fn leave(&mut self, session: SessionId) { if let Some(active) = self.sessions.remove(session) { diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 65cbef36..32c86bd0 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -612,7 +612,6 @@ fn until_presence_or_pong(r: &mut Raw) -> Option { } } -// start feat: remove-from-session #[test] fn the_host_removes_someone_and_they_cant_come_back() { let (mut host, mut hp, invite) = start(); @@ -643,4 +642,3 @@ fn the_host_removes_someone_and_they_cant_come_back() { host.stop(); assert_eq!(host.connections(), 0); } -// end feat: remove-from-session diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 08f15d22..9f5d12fb 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -75,9 +75,7 @@ fn samples() -> (Vec, Vec) { ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }), ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }), ClientMsg::Admin(Admin::Blocked), - // start feat: remove-from-session ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }), - // end feat: remove-from-session ClientMsg::Report { reason: "Spam ☕".into() }, ]; let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap(); diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 1dc22bec..0463ccd7 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -274,12 +274,10 @@ pub enum Admin { /// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them: /// [`AdminReply::Owned`]. Servers older than this request can't read it. Owned { files: Vec<(String, String)> }, - // start feat: remove-from-session /// Remove client `client` from `file`'s session: they are disconnected and their address /// can't join the file again while the server runs. With the server key, or on an open /// hosting connection with `file`'s owner token. Servers older than this request can't read it. RemovePeer { file: String, client: u32 }, - // end feat: remove-from-session } /// The most files one [`Admin::Owned`] asks about. diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index e621fd36..4001b156 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -517,13 +517,11 @@ pub trait LiveLink: Send { fn background(&mut self, _job: u64, _req: NetRequest) -> bool { false } - // start feat: remove-from-session /// Remove client `client` from session `session`, which this link hosts: they are /// disconnected and can't join again from their address while it runs. Returns at once. fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> { Err("removing people needs the desktop app or the MCP server".into()) } - // end feat: remove-from-session /// End session `session` (stop hosting or leave); the others go on. Nothing for a session /// that is over already. fn leave(&mut self, session: SessionId); @@ -1896,7 +1894,6 @@ fn follow(s: &mut Session, p: &Value) -> Result { Ok(json!({"follow": s.live().follow})) } -// start feat: remove-from-session /// Remove someone from the open file's session: in a session this app hosts, the hub drops them; /// in a file of yours on a server, the server does (with the file's owner token). They are told /// the session is over for them, keep their copy, and can't join again from their address while @@ -1949,7 +1946,6 @@ pub fn can_remove_people(s: &Session) -> bool { None => false, } } -// end feat: remove-from-session fn cursor(s: &mut Session, p: &Value) -> Result { let at = match (num(p, "x"), num(p, "y")) { @@ -2059,7 +2055,6 @@ pub fn specs() -> Vec { |s, _| Ok(s.live_summary()) ), spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow), - // start feat: remove-from-session spec!( "collab.removePerson", "Remove from session", @@ -2067,7 +2062,6 @@ pub fn specs() -> Vec { false, remove_person ), - // end feat: remove-from-session spec!( "collab.cursor", "Show cursor at", diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index f2d8cfb8..cacc4f37 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -28,10 +28,8 @@ pub(crate) struct Script { /// The session each presence in `sent` and each message in `msgs` was for. pub(crate) sent_to: Vec, pub(crate) msgs_to: Vec, - // start feat: remove-from-session /// The people removed: `(session, client)`. pub(crate) removed: Vec<(u64, u32)>, - // end feat: remove-from-session } pub(crate) struct Mock(pub(crate) Arc>); @@ -66,12 +64,10 @@ impl LiveLink for Mock { s.published.push(opts); Ok(()) } - // start feat: remove-from-session fn remove(&mut self, session: u64, client: u32) -> Result<(), String> { self.0.lock().unwrap().removed.push((session, client)); Ok(()) } - // end feat: remove-from-session fn leave(&mut self, session: u64) { let mut s = self.0.lock().unwrap(); s.left += 1; @@ -324,7 +320,6 @@ fn the_host_ending_resets_everything() { assert_eq!(s.message.as_deref(), Some("The host ended the session")); } -// start feat: remove-from-session #[test] fn the_host_removes_people_and_guests_cant() { let (mut s, script) = session(); @@ -361,7 +356,6 @@ fn the_host_removes_people_and_guests_cant() { assert!(e.contains("only the person hosting"), "{e}"); assert!(script.lock().unwrap().removed.is_empty()); } -// end feat: remove-from-session #[test] fn collab_commands_survive_junk() { diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 03bfd3ce..5cd5f723 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -460,10 +460,8 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect(); let follow = live.follow; let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting); - // start feat: remove-from-session let can_remove = figcraft_engine::live::can_remove_people(&app.session); let mut remove = None; - // end feat: remove-from-session // Room for the avatars after yours and the chevron. let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1); let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) }; @@ -501,7 +499,6 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if resp.clicked() { clicked = Some(*id); } - // start feat: remove-from-session if can_remove { crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, &trf("Remove {name} from session", &[("name", name)]), "") { @@ -510,7 +507,6 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { } }); } - // end feat: remove-from-session } if more > 0 { x += STEP; @@ -554,12 +550,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if spotlight { app.run("collab.spotlight", json!({"on": !spotlighting})); } - // start feat: remove-from-session // A server is asked without the window waiting for it. if let Some(id) = remove { app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {})); } - // end feat: remove-from-session } /// The Share button's popover: the live session (start, invite link, stop or leave) and export. diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 21396651..38ef96ec 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -386,7 +386,6 @@ resuming with its offline edits, and leaving the session going back to snapshot outlines in the person's colour, and avatars (initials) at the top of the right panel beside Share; click an avatar to **follow** that person's viewport (`collab.follow {client}`), click again, press Escape or pan/zoom yourself to stop. The chevron lists everyone. - - **Removing someone.** Right-click their avatar ▸ "Remove from session" (`collab.removePerson {client}`), when you host the session or own the file on a server. The hub closes their connection with "You were removed from this session" (close code 1001: the @@ -395,7 +394,6 @@ resuming with its offline edits, and leaving the session going back to snapshot a server the owner asks on an open hosting connection with the file's owner token (`Admin::RemovePeer {file, client}`; the operator may too, with the server key). Addresses are the only identity a session has: someone behind the same address (one NAT) is barred too. - - **Text carets.** While someone types in a text layer of the shared document on your page, their caret and selected characters show on it in their colour (the text layout's own caret and selection geometry, so they sit at the right glyphs of your copy), with their name in a tag above diff --git a/docs/mcp.md b/docs/mcp.md index af49937a..0e147bb0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1090,13 +1090,11 @@ only: a host stops hosting it, a guest leaves it. pointer. Your selection, page and view go out by themselves when they change. - `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any pan or zoom of your own, or Escape). - - `collab.removePerson {client}` removes that person from the session: they are told the session is over for them (they keep their copy and don't reconnect), and their address can't join the file again while the session (or the server) runs. Only the person hosting, or the owner of a file on a server (with its owner token), can; a server older than this request says it can't. Returns `{removed, name}`. - - `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the cursor somewhere first with `collab.cursor`): at most 100 characters, control characters become spaces, `""` closes it. Others see it fade 5 seconds after its last change, so send it again to From aeeb1a7cdeae25dfa59b2e4aaabf06140ebfd4a2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:41:10 +0700 Subject: [PATCH 4/4] #338: forget a file's barred addresses when its session closes The hub kept the addresses removed from every file it ever had open. The entry now goes when the file is closed (everyone left) or removed from the server, so a long-running public server doesn't keep collecting them. --- crates/collab-net/src/barred_tests.rs | 53 +++++++++++++++++++++++++++ crates/collab-net/src/hub.rs | 13 +++++-- crates/collab/src/message.rs | 2 +- docs/mcp.md | 2 +- 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 crates/collab-net/src/barred_tests.rs diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/barred_tests.rs new file mode 100644 index 00000000..c9355a5f --- /dev/null +++ b/crates/collab-net/src/barred_tests.rs @@ -0,0 +1,53 @@ +//! Addresses removed from a file are kept only while its session lasts, so a long-running server +//! doesn't collect them for every file it ever had open. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; + +use figcraft_collab::{Limits, Server}; +use figcraft_doc::Document; + +use super::{Admission, Backend, Closing, Hub, InFlight, Room}; + +/// A backend that closes a file as soon as everyone left it. +struct Closes; + +impl Backend for Closes { + fn admit(&mut self, _file: &str, _token: &str) -> Result { + Err("no".into()) + } + + fn open(&mut self, _file: &str) -> Result { + Err("no".into()) + } + + fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { + true + } + + fn closing(&self) -> Closing { + Closing::Restarting + } +} + +#[test] +fn a_closed_file_forgets_whom_it_barred() { + let mut doc = Document::new("File"); + doc.repair(); + let mut hub = Hub { + in_flight: InFlight(Default::default()), + backend: Closes, + rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + barred: HashMap::new(), + }; + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7)); + hub.barred.entry("f".into()).or_default().insert(ip); + hub.barred.entry("other".into()).or_default().insert(ip); + hub.close_if_empty("f"); + assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed"); + assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it"); + assert!(hub.barred.contains_key("other"), "another file's stay"); +} diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index cdd06915..5cf3e519 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -1147,7 +1147,8 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, - /// Addresses ([`ip_key`]) removed from each file: they can't join it again while the hub runs. + /// Addresses ([`ip_key`]) removed from each open file: they can't join it again until its + /// session closes (the file is closed or removed; the entry goes with it). barred: HashMap>, } @@ -1414,8 +1415,8 @@ impl Hub { } /// Remove client `id` from `file`: they are told the session is over for them and - /// disconnected, and their address can't join the file again while the hub runs. The person - /// hosting can't be removed. + /// disconnected, and their address can't join the file again while its session lasts. The + /// person hosting can't be removed. fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> { let room = self.rooms.get(file).ok_or("nobody is in this file")?; if room.local.as_ref().is_some_and(|(local, _)| *local == id) { @@ -1558,6 +1559,7 @@ impl Hub { .collect(); // Closed without saving: the file is gone. self.rooms.remove(&file); + self.barred.remove(&file); self.kick(&users, "this file was removed from the server"); } if let Some(ip) = handled.blocked { @@ -1635,6 +1637,7 @@ impl Hub { let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return }; if self.backend.idle(file, &mut room.server) { self.rooms.remove(file); + self.barred.remove(file); } } @@ -1740,3 +1743,7 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) { #[cfg(test)] #[path = "queue_tests.rs"] mod queue_tests; + +#[cfg(test)] +#[path = "barred_tests.rs"] +mod barred_tests; diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 0463ccd7..d035042b 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -275,7 +275,7 @@ pub enum Admin { /// [`AdminReply::Owned`]. Servers older than this request can't read it. Owned { files: Vec<(String, String)> }, /// Remove client `client` from `file`'s session: they are disconnected and their address - /// can't join the file again while the server runs. With the server key, or on an open + /// can't join the file again while its session lasts. With the server key, or on an open /// hosting connection with `file`'s owner token. Servers older than this request can't read it. RemovePeer { file: String, client: u32 }, } diff --git a/docs/mcp.md b/docs/mcp.md index 0e147bb0..8459987d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1092,7 +1092,7 @@ only: a host stops hosting it, a guest leaves it. pan or zoom of your own, or Escape). - `collab.removePerson {client}` removes that person from the session: they are told the session is over for them (they keep their copy and don't reconnect), and their address can't join the - file again while the session (or the server) runs. Only the person hosting, or the owner of a + file again while the session runs. Only the person hosting, or the owner of a file on a server (with its owner token), can; a server older than this request says it can't. Returns `{removed, name}`. - `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the