Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions crates/engine/src/cmd/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ pub(super) fn commands() -> Vec<CommandSpec> {
.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(),
Expand Down Expand Up @@ -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 })
}
Expand Down
10 changes: 7 additions & 3 deletions crates/hub/src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,13 @@ pub(crate) fn plan(hub: &mut Hub, actor: UserId, op: Op, now: Ts) -> Result<Plan
member(&hub.state, id, actor)?;
ok(vec![Event::ChannelStarred { user: actor, channel: id, starred }])
}
Op::VerifyUser { .. } | Op::Verification { .. } | Op::SetUpRecovery | Op::Recover { .. } | Op::SignOut => {
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()));
Expand Down
5 changes: 4 additions & 1 deletion crates/matrix/examples/rooms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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::<Vec<_>>(), r.rooms.invited.keys().collect::<Vec<_>>());
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(())
}
52 changes: 49 additions & 3 deletions crates/matrix/src/mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ pub struct RoomInfo {
pub favourite: bool,
/// Joined members: `(mxid, display name)`.
pub members: Vec<(String, Option<String>)>,
/// Pinned event ids (`m.room.pinned_events`).
pub pinned: Vec<String>,
/// Notifications muted for us (push rules).
pub muted: bool,
}

/// The mapper.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<MessageId> = 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<MessageId> = 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::<Vec<_>>() {
self.emit(Event::SavedChanged { user: me, message: m, saved: true });
}
for m in have.difference(&want).copied().collect::<Vec<_>>() {
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 };
Expand Down
32 changes: 32 additions & 0 deletions crates/matrix/src/mapper/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, 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);
Expand All @@ -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");
Expand Down
Loading