diff --git a/ROADMAP.md b/ROADMAP.md index c028077..cb068bf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,6 +33,7 @@ end-to-end encryption instead of its own protocol. See [docs/matrix.md](docs/mat ## Progress log +- **2026-11-01** — Matrix coverage: pins, mutes and saved items live on the server (and in other clients where Matrix defines them); public room directory with join by id or address; public channels get an address. Fixed: names reset by message senders, stale state served by Synapse's sync cache on quick restarts, room v12 ids. - **2026-11-01** — Matrix rich text: received HTML (pills, bold, links, lists, quotes, code) becomes ChatCraft markup; sent messages carry HTML from ChatCraft's own parser so other clients see the intended formatting. - **2026-11-01** — M1-E: device secrets in the OS keychain (with file fallback and migration); sign-in screen with a remembered account; sign out that revokes the session on the homeserver and deletes the device's keys. - **2026-11-01** — M1-E: identity verification by emoji (Matrix SAS) with cross-signing, trust shown in profiles and member lists; key backup with a recovery key, restore on new devices, late-key re-decryption. Integration tests against Synapse for both. diff --git a/crates/engine/src/cmd/channel.rs b/crates/engine/src/cmd/channel.rs index 69f796d..883f47b 100644 --- a/crates/engine/src/cmd/channel.rs +++ b/crates/engine/src/cmd/channel.rs @@ -36,6 +36,11 @@ pub(super) fn commands() -> Vec { .key("Mod+N") .params(r#"{"users": ["@handle"]}"#) .when(signed_in), + CommandSpec::new("channel.directory", "Room directory", "Sidebar › Add channels", |s, v| { + s.submit(Op::Directory { query: p::str(v, "query").unwrap_or_default().to_string() }) + }) + .params(r#"{"query"?: string}"#) + .when(signed_in), CommandSpec::new("channel.browse", "Browse channels", "Sidebar › Add channels", browse).params(r#"{"query"?: string}"#).pure(), CommandSpec::new("channel.members", "Members", "Channel › Members", members).params(r#"{"channel"?}"#).pure(), CommandSpec::new("channel.info", "Channel details", "Channel › About", info).params(r#"{"channel"?}"#).pure(), @@ -93,6 +98,13 @@ fn create(s: &mut Session, v: &Value) -> CmdResult { } fn join(s: &mut Session, v: &Value) -> CmdResult { + // A Matrix room this replica hasn't seen: a room id (`!abc`, or `!abc:server` in older room + // versions) or an alias (`#name:server`). + if let Some(room) = p::str(v, "channel").filter(|r| (r.starts_with('!') && r.len() > 1) || (r.starts_with('#') && r.contains(':'))) + && s.state.resolve_channel(room, s.me).is_none() + { + return s.submit_and_open(Op::JoinExternal { room: room.to_string() }); + } let c = s.target_channel(v)?; s.submit_and_open(Op::JoinChannel { channel: c }) } diff --git a/crates/hub/src/ops.rs b/crates/hub/src/ops.rs index 2223abf..e5df04a 100644 --- a/crates/hub/src/ops.rs +++ b/crates/hub/src/ops.rs @@ -315,9 +315,13 @@ pub(crate) fn plan(hub: &mut Hub, actor: UserId, op: Op, now: Ts) -> Result { - Err(HubError::Invalid("verification and key recovery need a Matrix account (end-to-end encryption)".into())) - } + Op::VerifyUser { .. } + | Op::Verification { .. } + | Op::SetUpRecovery + | Op::Recover { .. } + | Op::SignOut + | Op::Directory { .. } + | Op::JoinExternal { .. } => Err(HubError::Invalid("this needs a Matrix account".into())), Op::RenameWorkspace { name } => { if !is_admin(&hub.state, actor) { return Err(HubError::Forbidden("only admins can rename the workspace".into())); diff --git a/crates/matrix/examples/rooms.rs b/crates/matrix/examples/rooms.rs index 8716df9..85cc599 100644 --- a/crates/matrix/examples/rooms.rs +++ b/crates/matrix/examples/rooms.rs @@ -11,10 +11,13 @@ async fn main() -> Result<(), Box> { let client = Client::builder().server_name_or_homeserver_url(&hs).sqlite_store(dir.join("store"), Some(pass.trim())).build().await?; let session: MatrixSession = serde_json::from_str(&std::fs::read_to_string(dir.join("session.json"))?)?; client.restore_session(session).await?; - let r = client.sync_once(matrix_sdk::config::SyncSettings::default().token(matrix_sdk::config::SyncToken::NoToken).full_state(true)).await?; + let r = client.sync_once(matrix_sdk::config::SyncSettings::default().timeout(std::time::Duration::from_secs(0))).await?; println!("response: joined {:?} invited {:?}", r.rooms.joined.keys().collect::>(), r.rooms.invited.keys().collect::>()); for room in client.rooms() { println!("store: {} state={:?} direct={:?}", room.room_id(), room.state(), room.is_direct().await); + for m in room.members(matrix_sdk::RoomMemberships::JOIN).await.unwrap_or_default() { + println!(" member {} display_name={:?} name={:?}", m.user_id(), m.display_name(), m.name()); + } } Ok(()) } diff --git a/crates/matrix/src/mapper.rs b/crates/matrix/src/mapper.rs index 6af40a6..825bfd2 100644 --- a/crates/matrix/src/mapper.rs +++ b/crates/matrix/src/mapper.rs @@ -48,6 +48,10 @@ pub struct RoomInfo { pub favourite: bool, /// Joined members: `(mxid, display name)`. pub members: Vec<(String, Option)>, + /// Pinned event ids (`m.room.pinned_events`). + pub pinned: Vec, + /// Notifications muted for us (push rules). + pub muted: bool, } /// The mapper. @@ -136,12 +140,14 @@ impl Mapper { if taken { mxid.trim_start_matches('@').to_lowercase() } else { local } } - /// Interns a user, announcing them (or their new name) to the replica. + /// Interns a user, announcing them (or their new name) to the replica. `None` means the name + /// isn't known here (a message's sender), which never overwrites a known name. pub fn user(&mut self, mxid: &str, display_name: Option<&str>) -> UserId { let id = UserId(self.users.get_or_add(mxid)); - let name = display_name.map(str::trim).filter(|n| !n.is_empty()).unwrap_or_else(|| localpart(mxid)).to_string(); + let given = display_name.map(str::trim).filter(|n| !n.is_empty()); + let name = given.unwrap_or_else(|| localpart(mxid)).to_string(); match self.state.user(id) { - Some(u) if u.display_name == name => {} + Some(u) if u.display_name == name || given.is_none() => {} Some(u) => { let mut u = u.clone(); u.display_name = name; @@ -210,10 +216,50 @@ impl Mapper { if starred != info.favourite { self.emit(Event::ChannelStarred { user: me, channel: id, starred: info.favourite }); } + self.set_muted(id, info.muted); + } + // Pins: the room's list is the truth for the messages we know. + let pinned: HashSet = info.pinned.iter().filter_map(|e| self.events.get(e)).map(MessageId).collect(); + let changes: Vec<(MessageId, bool)> = self + .state + .messages + .values() + .filter(|m| m.channel == id && !m.deleted && m.pinned != pinned.contains(&m.id)) + .map(|m| (m.id, !m.pinned)) + .collect(); + for (m, on) in changes { + self.set_pinned(m, on); } id } + pub fn set_pinned(&mut self, message: MessageId, pinned: bool) { + let by = self.me.unwrap_or_default(); + if self.state.message(message).is_some_and(|m| m.pinned != pinned) { + self.emit(Event::PinChanged { message, pinned, by }); + } + } + + pub fn set_muted(&mut self, channel: ChannelId, muted: bool) { + let Some(me) = self.me else { return }; + if self.state.muted.get(&me).is_some_and(|s| s.contains(&channel)) != muted { + self.emit(Event::ChannelMuted { user: me, channel, muted }); + } + } + + /// Saved items, from the account's `dev.chatcraft.saved` list of `(room, event)` ids. + pub fn set_saved_list(&mut self, list: &[(String, String)]) { + let Some(me) = self.me else { return }; + let want: BTreeSet = list.iter().filter_map(|(_, e)| self.events.get(e)).map(MessageId).collect(); + let have = self.state.saved.get(&me).cloned().unwrap_or_default(); + for m in want.difference(&have).copied().collect::>() { + self.emit(Event::SavedChanged { user: me, message: m, saved: true }); + } + for m in have.difference(&want).copied().collect::>() { + self.emit(Event::SavedChanged { user: me, message: m, saved: false }); + } + } + /// The viewer's trust in someone's identity. pub fn set_verified(&mut self, mxid: &str, verified: bool) { let Some(u) = self.users.get(mxid).map(UserId) else { return }; diff --git a/crates/matrix/src/mapper/tests.rs b/crates/matrix/src/mapper/tests.rs index 3bf0310..e9bfbcd 100644 --- a/crates/matrix/src/mapper/tests.rs +++ b/crates/matrix/src/mapper/tests.rs @@ -139,6 +139,35 @@ fn formatted_bodies_keep_their_formatting() { assert_eq!(m.state().timeline(c)[1].text, src); } +#[test] +fn pins_mutes_and_saved_items_follow_the_account() { + let mut m = Mapper::new(ME); + let info = |pinned: Vec, muted: bool| RoomInfo { + room_id: "!r:test".into(), + name: "general".into(), + members: vec![(ME.into(), None)], + pinned, + muted, + ..RoomInfo::default() + }; + let c = m.upsert_room(&info(vec![], false)); + m.timeline("!r:test", &msg("$1", ME, "keep this"), false, false); + m.timeline("!r:test", &msg("$2", ME, "and this"), false, false); + m.upsert_room(&info(vec!["$1".into(), "$unknown".into()], true)); + let tl = m.state().timeline(c); + assert!(tl[0].pinned && !tl[1].pinned); + assert!(m.state().muted[&m.me().unwrap()].contains(&c)); + m.upsert_room(&info(vec!["$2".into()], false)); + let tl = m.state().timeline(c); + assert!(!tl[0].pinned && tl[1].pinned); + assert!(!m.state().muted[&m.me().unwrap()].contains(&c)); + m.set_saved_list(&[("!r:test".into(), "$2".into()), ("!old:test".into(), "$gone".into())]); + let me = m.me().unwrap(); + assert_eq!(m.state().saved[&me].len(), 1); + m.set_saved_list(&[]); + assert!(m.state().saved[&me].is_empty()); +} + #[test] fn dms_handles_presence_status() { let mut m = Mapper::new(ME); @@ -150,6 +179,9 @@ fn dms_handles_presence_status() { }); assert_eq!(m.state().channel(c).unwrap().kind, ChannelKind::Direct); assert_eq!(m.state().title(c, m.me()), "Bob B"); + // A message from Bob (sender without a name) doesn't reset his name. + m.timeline("!d:test", &msg("$n", "@bob:test", "hi"), true, false); + assert_eq!(m.state().title(c, m.me()), "Bob B"); // Same localpart on another server gets a qualified handle. let other = m.user("@bob:elsewhere.org", None); assert_eq!(m.state().user(other).unwrap().handle, "bob:elsewhere.org"); diff --git a/crates/matrix/src/worker.rs b/crates/matrix/src/worker.rs index ef42d79..960b8d3 100644 --- a/crates/matrix/src/worker.rs +++ b/crates/matrix/src/worker.rs @@ -120,9 +120,14 @@ async fn room_info(room: &Room) -> RoomInfo { encrypted: room.encryption_state().is_encrypted(), favourite: room.is_favourite(), members, + pinned: room.pinned_event_ids().unwrap_or_default().iter().map(ToString::to_string).collect(), + muted: false, } } +/// The account data type ChatCraft keeps saved items in. +const SAVED: &str = "dev.chatcraft.saved"; + fn sync_settings() -> SyncSettings { let mut timeline = RoomEventFilter::default(); timeline.limit = Some(UInt::from(100u32)); @@ -147,6 +152,8 @@ struct Worker { outbox: Vec, /// The person signed out: stop, and forget this device. signed_out: bool, + /// Saved items as stored in account data: the truth, including items outside loaded history. + saved: Vec<(String, String)>, } /// How many recent events of each conversation the welcome snapshot carries. @@ -155,41 +162,59 @@ const HISTORY: u32 = 100; impl Worker { /// The first sync and the welcome snapshot. async fn start(&mut self, fresh: bool) -> R<()> { - // From scratch (not from a saved token) so the snapshot carries recent history; later - // syncs continue from this one's token. - let first = sync_settings().timeout(Duration::from_secs(0)).token(matrix_sdk::config::SyncToken::NoToken).full_state(true); - let r = self.client.sync_once(first).await.map_err(err)?; - // Homeservers may answer a repeated initial sync from a cache (Synapse does, for a couple - // of minutes), so catch up with one immediate incremental sync before the snapshot. - let catch_up = self.client.sync_once(sync_settings().timeout(Duration::from_secs(0))).await.ok(); + // Continue from this device's stored sync token (a fresh device has none and gets a + // full sync). The snapshot below reads rooms from the store and fetches history itself, + // so it doesn't depend on what this sync returns. (Starting from scratch on every launch + // made identical requests that homeservers answer from a cache — Synapse does for a couple + // of minutes — and served stale account data such as push rules.) + let r = self.client.sync_once(sync_settings().timeout(Duration::from_secs(0))).await.map_err(err)?; // Accept waiting invitations first, so they are in the snapshot. self.accept_invites(&r).await; - if let Some(c) = &catch_up { - self.accept_invites(c).await; - } self.snapshot().await; self.absorb_rooms(&r).await; - if let Some(c) = &catch_up { - self.absorb_rooms(c).await; - } // A new device of an account without cross-signing sets it up, so people can verify it. if fresh && let Err(e) = crate::verify::bootstrap_cross_signing(&self.client, &self.cfg.user, &self.cfg.password).await { self.problems.push(format!("couldn't set up cross-signing: {e}")); } self.refresh_trust().await; + self.load_saved().await; Ok(()) } async fn info(&self, room: &Room) -> RoomInfo { let mut info = room_info(room).await; info.direct |= self.direct.contains(&info.room_id); + info.muted = matches!( + self.client.notification_settings().await.get_user_defined_room_notification_mode(room.room_id()).await, + Some(matrix_sdk::notification_settings::RoomNotificationMode::Mute) + ); info } + /// The saved list from account data (`[{"room", "event"}]`). + async fn load_saved(&mut self) { + let Ok(Some(raw)) = self.client.account().account_data_raw(SAVED.into()).await else { return }; + let Ok(v) = serde_json::from_str::(raw.json().get()) else { return }; + self.saved = v + .get("events") + .and_then(Value::as_array) + .map(|a| { + a.iter().filter_map(|x| Some((x.get("room")?.as_str()?.to_string(), x.get("event")?.as_str()?.to_string()))).take(1000).collect() + }) + .unwrap_or_default(); + self.map.set_saved_list(&self.saved); + } + /// The welcome snapshot: every joined room from the SDK's store, with its recent history /// fetched (and decrypted) explicitly, so it doesn't depend on what the first sync returned. async fn snapshot(&mut self) { for room in self.client.joined_rooms() { + // Refresh each member list once per launch: names and memberships the store holds + // may be stale (the incremental first sync doesn't carry full room state). + room.mark_members_missing(); + if let Err(e) = room.sync_members().await { + log::warn!("members of {}: {e}", room.room_id()); + } let info = self.info(&room).await; self.map.upsert_room(&info); let mut opts = matrix_sdk::room::MessagesOptions::backward(); @@ -371,6 +396,13 @@ impl Worker { for rid in r.rooms.left.keys() { self.map.left_room(rid.as_str()); } + let saved_changed = r + .account_data + .iter() + .any(|e| serde_json::from_str::(e.json().get()).is_ok_and(|v| v.get("type").and_then(Value::as_str) == Some(SAVED))); + if saved_changed { + self.load_saved().await; + } for p in &r.presence { let Ok(json) = serde_json::from_str::(p.json().get()) else { continue }; let (Some(who), Some(c)) = (json.get("sender").and_then(Value::as_str), json.get("content")) else { continue }; @@ -458,7 +490,35 @@ impl Worker { if encrypted { req.initial_state = vec![InitialStateEvent::with_empty_state_key(RoomEncryptionEventContent::with_recommended_defaults()).to_raw_any()]; } - let room = self.client.create_room(req).await.map_err(err)?; + // Public channels get an address (`#name:server`) so they can be shared and joined even + // where the server doesn't list them in its directory. + if !private && !direct { + req.room_alias_name = name.as_deref().and_then(|n| chatcraft_core::normalize_channel_name(n).ok()); + } + let alias = req.room_alias_name.clone(); + let room = match self.client.create_room(req.clone()).await { + Ok(r) => r, + // The address is taken: create the channel without one. + Err(e) if req.room_alias_name.is_some() && e.to_string().contains("M_ROOM_IN_USE") => { + req.room_alias_name = None; + self.client.create_room(req).await.map_err(err)? + } + Err(e) => return Err(err(e)), + }; + if !private && !direct { + let listed = self + .client + .send(matrix_sdk::ruma::api::client::directory::get_room_visibility::v3::Request::new(room.room_id().to_owned())) + .await + .map(|r| r.visibility == Visibility::Public) + .unwrap_or(false); + if !listed { + let server = self.me.server_name().to_string(); + let address = room.canonical_alias().map(|a| a.to_string()).or_else(|| alias.as_ref().map(|a| format!("#{a}:{server}"))); + let address = address.map(|a| format!(" People can join with its address, {a}.")).unwrap_or_default(); + self.problems.push(format!("Your homeserver doesn't let you list rooms in its directory, so the channel isn't listed.{address}")); + } + } if direct { self.direct.insert(room.room_id().to_string()); // Matrix keeps DMs in each person's `m.direct` list; record it for us. @@ -475,8 +535,8 @@ impl Worker { public: !encrypted, direct, encrypted, - favourite: false, members, + ..RoomInfo::default() }; Ok(self.map.upsert_room(&info)) } @@ -653,10 +713,84 @@ impl Worker { self.signed_out = true; Ok(json!({ "signedOut": true })) } + Op::Pin { message, pinned } => { + let eid = self.event_id(message)?; + let room = self.room(self.map.state().message(message).map(|m| m.channel).unwrap_or_default())?; + if pinned { + room.pin_event(&eid).await.map_err(err)?; + } else { + room.unpin_event(&eid).await.map_err(err)?; + } + self.map.set_pinned(message, pinned); + Ok(Value::Null) + } + Op::Mute { channel, muted } => { + let room = self.room(channel)?; + let settings = self.client.notification_settings().await; + if muted { + settings + .set_room_notification_mode(room.room_id(), matrix_sdk::notification_settings::RoomNotificationMode::Mute) + .await + .map_err(err)?; + } else { + settings.delete_user_defined_room_rules(room.room_id()).await.map_err(err)?; + } + self.map.set_muted(channel, muted); + Ok(Value::Null) + } + Op::Save { message, saved } => { + let eid = self.event_id(message)?.to_string(); + let rid = self.map.room_id(self.map.state().message(message).map(|m| m.channel).unwrap_or_default()).unwrap_or_default().to_string(); + let mut list = self.saved.clone(); + list.retain(|(_, e)| *e != eid); + if saved { + list.push((rid, eid)); + } + let content = json!({ "events": list.iter().map(|(r, e)| json!({ "room": r, "event": e })).collect::>() }); + let raw = matrix_sdk::ruma::serde::Raw::from_json_string(content.to_string()).map_err(err)?; + self.client.account().set_account_data_raw(SAVED.into(), raw).await.map_err(err)?; + self.saved = list; + self.map.set_saved_list(&self.saved); + Ok(Value::Null) + } + Op::Directory { query } => { + // Signed in and filtered on the server (many homeservers list rooms only to members). + let mut req = matrix_sdk::ruma::api::client::directory::get_public_rooms_filtered::v3::Request::new(); + req.limit = Some(UInt::from(100u32)); + let mut filter = matrix_sdk::ruma::directory::Filter::new(); + filter.generic_search_term = Some(query.trim().trim_start_matches('#').to_string()).filter(|q| !q.is_empty()); + req.filter = filter; + let res = self.client.send(req).await.map_err(err)?; + let q = query.trim().trim_start_matches('#').to_lowercase(); + let rooms: Vec = res + .chunk + .iter() + .filter(|r| { + q.is_empty() + || r.name.as_deref().is_some_and(|n| n.to_lowercase().contains(&q)) + || r.topic.as_deref().is_some_and(|t| t.to_lowercase().contains(&q)) + || r.canonical_alias.as_ref().is_some_and(|a| a.as_str().to_lowercase().contains(&q)) + }) + .map(|r| { + json!({ + "room": r.room_id.to_string(), + "alias": r.canonical_alias.as_ref().map(ToString::to_string), + "name": r.name.clone().unwrap_or_else(|| r.room_id.to_string()), + "topic": r.topic.clone().unwrap_or_default(), + "members": u64::from(r.num_joined_members), + "joined": self.map.known_channel(r.room_id.as_str()).is_some_and(|c| self.map.state().is_member(self.map.me().unwrap_or_default(), c)), + }) + }) + .collect(); + Ok(json!({ "rooms": rooms })) + } + Op::JoinExternal { room } => { + let id = matrix_sdk::ruma::RoomOrAliasId::parse(room.trim()).map_err(|e| format!("`{room}` isn't a room id or alias: {e}"))?; + let joined = self.client.join_room_by_id_or_alias(&id, &[]).await.map_err(err)?; + let info = self.info(&joined).await; + Ok(json!({ "channel": self.map.upsert_room(&info) })) + } Op::Archive { .. } => Err("archiving isn't available on Matrix yet".into()), - Op::Pin { .. } => Err("pinning isn't available on Matrix yet".into()), - Op::Save { .. } => Err("saved items aren't available on Matrix yet".into()), - Op::Mute { .. } => Err("muting isn't available on Matrix yet".into()), Op::RenameWorkspace { .. } => Err("a Matrix homeserver has no workspace name to change".into()), } } @@ -721,6 +855,7 @@ pub(crate) async fn run( verify: Verifications::new(vtx), outbox: Vec::new(), signed_out: false, + saved: Vec::new(), }; if let Err(e) = w.start(fresh).await { @@ -772,6 +907,7 @@ pub(crate) async fn run( let r = w.op(op).await; let mut frames: Vec = w.map.take().into_iter().map(ServerFrame::Event).collect(); frames.append(&mut w.outbox); + frames.extend(w.problems.drain(..).map(|message| ServerFrame::Error { message })); frames.push(match r { Ok(result) => ServerFrame::Ack { req, ok: true, result, error: None }, Err(e) => ServerFrame::Ack { req, ok: false, result: Value::Null, error: Some(e) }, diff --git a/crates/matrix/tests/verify.rs b/crates/matrix/tests/verify.rs index 8405f62..9277b1d 100644 --- a/crates/matrix/tests/verify.rs +++ b/crates/matrix/tests/verify.rs @@ -212,3 +212,62 @@ fn signing_out_forgets_the_device_everywhere() { assert!(matches!(s.link_status(), chatcraft_engine::LinkStatus::Offline(w) if w == "signed out")); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn directory_pins_saved_items_and_mutes_live_on_the_server() { + let (Some(hs), Some(_)) = (env("CHATCRAFT_TEST_HOMESERVER"), env("CHATCRAFT_TEST_REGISTER")) else { + eprintln!("skipped: set CHATCRAFT_TEST_HOMESERVER and CHATCRAFT_TEST_REGISTER=1"); + return; + }; + let tag = format!("{}{}", std::process::id(), Instant::now().elapsed().as_nanos() % 1000); + let (p_name, q_name) = (format!("pia{tag}"), format!("quil{tag}")); + register(&hs, &p_name, "pw-pia-123"); + register(&hs, &q_name, "pw-quil-123"); + let dir = std::env::temp_dir().join(format!("chatcraft-coverage-{tag}")); + let mut pia = session(&hs, &p_name, "pw-pia-123", &dir.join("pia")); + let mut quil = session(&hs, &q_name, "pw-quil-123", &dir.join("quil")); + + // A public channel shows up in the directory; Quil finds and joins it. + let lobby = format!("lobby-{tag}"); + let r = run(&mut pia, "channel.create", json!({ "name": lobby, "topic": "open to all" })); + let pc = chatcraft_engine::model::ChannelId(r["channel"].as_u64().unwrap()); + assert!(!pia.state.channel(pc).unwrap().encrypted, "public channels aren't encrypted"); + let found = run(&mut quil, "channel.directory", json!({ "query": lobby })); + let listed = found["rooms"].as_array().unwrap().iter().find(|x| x["name"] == lobby.as_str()).map(|r| r["room"].clone()); + let target = match listed { + Some(room) => { + eprintln!("found in the directory"); + room + } + None => { + // The homeserver doesn't let Pia publish to its directory: she was told, and the + // channel's address still works. + let server = env("CHATCRAFT_TEST_SERVER_NAME").unwrap_or_else(|| "localhost".into()); + assert!(pia.notices.iter().any(|n| n.text.contains("isn't listed")), "Pia hears the channel isn't listed: {:?}", pia.notices); + eprintln!("not listed; joining by address"); + json!(format!("#{lobby}:{server}")) + } + }; + run(&mut quil, "channel.join", json!({ "channel": target })); + until(&mut pia, &mut quil, "Quil is in the channel", |_, q| q.state.channel_by_name(&lobby).is_some()); + + // Pins are room state: Quil sees Pia's pin. + run(&mut pia, "message.send", json!({ "channel": pc.to_string(), "text": "house rules: be kind" })); + let mid = pia.state.timeline(pc).last().unwrap().id; + run(&mut pia, "message.pin", json!({ "message": mid.0, "pinned": true })); + until(&mut pia, &mut quil, "Quil sees the pin", |_, q| q.state.messages.values().any(|m| m.text == "house rules: be kind" && m.pinned)); + + // Saved items and mutes are Quil's own, kept by the server. + let qc = quil.state.channel_by_name(&lobby).unwrap().id; + let qm = quil.state.timeline(qc).iter().find(|m| m.text == "house rules: be kind").unwrap().id; + run(&mut quil, "message.save", json!({ "message": qm.0, "saved": true })); + run(&mut quil, "channel.mute", json!({ "channel": qc.to_string(), "muted": true })); + drop(quil); + let quil = session(&hs, &q_name, "", &dir.join("quil")); + let me = quil.me.unwrap(); + let qc = quil.state.channel_by_name(&lobby).unwrap().id; + assert!(quil.state.muted.get(&me).is_some_and(|s| s.contains(&qc)), "the mute survives a restart"); + let saved: Vec<&str> = quil.state.saved.get(&me).into_iter().flatten().filter_map(|m| quil.state.message(*m)).map(|m| m.text.as_str()).collect(); + assert_eq!(saved, vec!["house rules: be kind"], "the saved item survives a restart"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/proto/src/lib.rs b/crates/proto/src/lib.rs index 9064a6c..e3c3f58 100644 --- a/crates/proto/src/lib.rs +++ b/crates/proto/src/lib.rs @@ -153,6 +153,15 @@ pub enum Op { }, /// Signs this device out and forgets it: session, secrets and local key store (Matrix only). SignOut, + /// Searches the homeserver's public room directory (Matrix only). + Directory { + #[serde(default)] + query: String, + }, + /// Joins a room this replica doesn't know yet, by room id or alias (Matrix only). + JoinExternal { + room: String, + }, } /// What the person does in a verification. diff --git a/crates/ui-egui/examples/shot.rs b/crates/ui-egui/examples/shot.rs index 9bbb74a..3d3210a 100644 --- a/crates/ui-egui/examples/shot.rs +++ b/crates/ui-egui/examples/shot.rs @@ -90,6 +90,13 @@ fn main() { harness.run_steps(3); } harness.run_steps(6); + // A live account answers over the network: give it a moment. + if matrix.is_some() { + for _ in 0..30 { + std::thread::sleep(std::time::Duration::from_millis(100)); + harness.run_steps(1); + } + } match harness.render() { Ok(img) => match img.save(&out) { Ok(()) => println!("{out}"), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3ab6bf9..3e88c11 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -233,14 +233,40 @@ pub(crate) fn show(app: &mut ChatApp, ctx: &egui::Context, acts: &mut Vec) } }); } - Dialog::Browse { query } => { + Dialog::Browse { query, directory, waiting, asked, unavailable } => { ui.set_width(560.0); heading(ui, app, "Browse channels"); let r = ui.add(egui::TextEdit::singleline(query).hint_text("Search by name or description").desired_width(f32::INFINITY)); r.request_focus(); + // The homeserver's directory: asked when the dialog opens and on Enter. + let enter = r.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter)); + if !*unavailable && waiting.is_none() && (asked.is_none() || (enter && asked.as_deref() != Some(query.as_str()))) { + *asked = Some(query.clone()); + match app.session.run("channel.directory", &json!({ "query": query })) { + Ok(v) => match v.get("pending").and_then(serde_json::Value::as_u64) { + Some(req) => *waiting = Some(req), + None => *directory = v.get("rooms").and_then(serde_json::Value::as_array).cloned(), + }, + // The demo workspace has no directory. + Err(_) => *unavailable = true, + } + } + if let Some(req) = *waiting { + match app.session.take_result(req) { + Some(Ok(v)) => { + *waiting = None; + *directory = v.get("rooms").and_then(serde_json::Value::as_array).cloned(); + } + Some(Err(_)) => { + *waiting = None; + *unavailable = true; + } + None => ui.ctx().request_repaint_after(std::time::Duration::from_millis(150)), + } + } let list = app.session.run("channel.browse", &json!({ "query": query })).unwrap_or_default(); ui.add_space(6.0); - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("browse-local").max_height(220.0).show(ui, |ui| { for c in list.as_array().into_iter().flatten() { let id = c["id"].as_u64().map(ChannelId).unwrap_or_default(); let joined = c["joined"].as_bool().unwrap_or(false); @@ -276,6 +302,47 @@ pub(crate) fn show(app: &mut ChatApp, ctx: &egui::Context, acts: &mut Vec) ui.separator(); } }); + if !*unavailable { + ui.scope(|ui| { + let server = app.session.state.workspace.name.clone(); + ui.add_space(8.0); + ui.label(RichText::new(format!("Public rooms on {server}")).strong().color(t.strong)); + ui.label(RichText::new("Press Enter to search the directory.").small().color(t.muted)); + if waiting.is_some() { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new("Searching…").color(t.muted)); + }); + } + for room in directory.iter().flatten().take(8) { + let name = room["name"].as_str().unwrap_or_default(); + let address = room["alias"].as_str().unwrap_or_default(); + let joined = room["joined"].as_bool().unwrap_or(false); + Frame::new().inner_margin(Margin::symmetric(4, 6)).show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(RichText::new(name).strong().color(t.strong)); + let members = room["members"].as_u64().unwrap_or(0); + let topic = room["topic"].as_str().unwrap_or_default(); + ui.label(RichText::new(format!("{members} members · {address} {topic}")).small().color(t.muted)); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if joined { + ui.label(RichText::new("Joined").small().color(t.muted)); + } else if primary(ui, app, "Join", true) { + acts.push(Act::Run("channel.join", json!({ "channel": room["room"] }))); + close = true; + } + }); + }); + }); + ui.separator(); + } + if directory.as_ref().is_some_and(Vec::is_empty) { + ui.label(RichText::new("No public rooms match.").color(t.muted)); + } + }); + } } Dialog::People { query } => { ui.set_width(560.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0720a97..b682c06 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -69,6 +69,12 @@ pub(crate) enum Dialog { }, Browse { query: String, + /// The homeserver's public room directory (Matrix): results, the request in flight, the + /// query last asked, and whether this account has a directory at all. + directory: Option>, + waiting: Option, + asked: Option, + unavailable: bool, }, People { query: String, @@ -185,7 +191,9 @@ impl ChatApp { "ui.newChannel" => { self.st.dialog = Some(Dialog::CreateChannel { name: String::new(), purpose: String::new(), private: false, error: None }) } - "ui.browseChannels" => self.st.dialog = Some(Dialog::Browse { query: String::new() }), + "ui.browseChannels" => { + self.st.dialog = Some(Dialog::Browse { query: String::new(), directory: None, waiting: None, asked: None, unavailable: false }); + } "ui.newMessage" => self.st.dialog = Some(Dialog::NewMessage { query: String::new(), picked: Vec::new() }), "ui.people" => self.st.dialog = Some(Dialog::People { query: String::new() }), "ui.setStatus" => { diff --git a/docs/gaps.md b/docs/gaps.md index 4f8fe50..e7dce93 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -13,7 +13,7 @@ Ranked: work from the top. Each gap names the catalog command ids it unlocks, if 0.4 ~~Late keys~~ (done: undecryptable messages are retried when keys arrive and after restoring). 0.5 ~~Sign-in screen, remembered account, sign out~~ (done, 2026-11-01). Still open: SSO/OIDC sign-in, a device list (sign out other devices), registration from the app. 0.6 ~~Rich text from other clients~~ (done, 2026-11-01): HTML ↔ ChatCraft markup both ways. -0.7 **Matrix coverage**: room directory browsing, pins (`m.room.pinned_events`), saved items and mutes (account data / push rules), archiving (tombstones), history paging, typing and read receipts display. +0.7 Matrix coverage: ~~directory, pins, saved items, mutes~~ (done, 2026-11-01). Still open: archiving (tombstones or read-only power levels), history paging, typing and read-receipt display. 0.8 **Browser client** over Matrix (matrix-sdk's IndexedDB store on wasm). 0.9 **Retire `chatcraft-server`** once Matrix covers its features (or keep it as a tiny self-host option). diff --git a/docs/matrix.md b/docs/matrix.md index 0c47213..29aee85 100644 --- a/docs/matrix.md +++ b/docs/matrix.md @@ -1,6 +1,6 @@ # Matrix and end-to-end encryption -_Status: 2026-11-01 · revision 3 (keychain, sign-in, sign-out)_ +_Status: 2026-11-01 · revision 4 (pins, mute, saved, directory)_ ChatCraft speaks **Matrix**, the open, federated chat standard, instead of a protocol of its own (the direction proposed in storytold/artcraft#2138). Any Matrix homeserver works — Synapse, @@ -37,6 +37,11 @@ UI, the CLI and MCP are unchanged: | Formatting | Sent as `body` (ChatCraft markup) + `formatted_body` HTML rendered by ChatCraft's own parser (`*x*` is bold, as in Slack); received HTML (bold, italic, strike, code, links, pills, quotes, lists) converted back to markup (`crates/matrix/src/html.rs`). A ChatCraft message whose HTML re-renders from its body keeps its exact source | | Read state | `m.fully_read` + public read receipts | | Star | `m.favourite` tag | +| Pin | `m.room.pinned_events` (shared with other clients) | +| Mute | A room push rule (the same mechanism other clients use) | +| Saved for later | Account data `dev.chatcraft.saved` (`{"events": [{"room", "event"}]}`), synced across your devices | +| Browse / join | The homeserver's public room directory (signed in, filtered on the server); join by room id (`!abc`) or address (`#name:server`) | +| Public channel address | Created with an alias `#name:server`; if the server doesn't let you list rooms in its directory, ChatCraft says so and the address still works | | Presence, status | `m.presence`, `status_msg` (`:emoji: text`) | | Invite to a channel | Invites are accepted automatically (people added to a channel are in it) | @@ -91,8 +96,7 @@ servers share a localpart. People not met yet can be invited by Matrix ID (`@bob - Verifying **your own** devices (and QR-code verification); a stricter trust policy (only send keys to verified devices) as an option; warnings when a verified person's identity changes. -- Room directory browsing; pins, saved items, muting, archiving over Matrix; images, tables and - headings in received HTML are reduced to their text. +- Archiving over Matrix; images, tables and headings in received HTML are reduced to their text. - Encrypted public channels are possible in Matrix but not offered: anyone may join them, so encryption adds little and breaks history for newcomers.