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