atagen

macltok

public
Star0
Watch0

Commit e5c6427adb35

tarball

atagen <boss@atagen.co>2026-10-25 13:25 UTC

8 files changed+374-20
rooms: count unread thread replies per root

With threading support on, the sdk leaves thread events out of RoomReadReceipts entirely, so thread mentions vanished from unread_mentions and nothing said a thread had unread replies. RoomSummary::thread_unreads counts them per root against threaded (MSC3771) and unthreaded receipts, and the room list watcher recounts on event cache updates and receipts since neither touches room info.
Modified file: docs/design-decisions.md+13 additions
@@ -334,6 +334,19 @@ membership until it's forgotten. push rule changes never touch room info, so
334334 `NotificationSettingsChanged` brings a `RoomsChanged` along for the modes in
335335 the summaries
336336
337+with threads on, the sdk leaves thread events out of a room's counts and keeps
338+none of its own for them. `thread_unreads` works them out per root the way the
339+sdk does a room's, over the events its cache holds in memory, newest first until
340+a receipt for that thread (msc3771), an unthreaded one, or the user's own reply.
341+a main-timeline receipt doesn't reach into threads. a thread's mentions also count
342+in the room's `unread_mentions`, so nobody misses one, while its messages and
343+notifications stay apart, since they don't move the main timeline's read marker.
344+thread events and threaded receipts leave room info alone, so the room list
345+watcher also recounts a room on each event cache update and each receipt, and
346+only says `RoomsChanged` when the counts moved. counting in the sdk would be
347+cheaper and survive memory being trimmed, but that's a behaviour change in the
348+fork
349+
337350 ## link previews
338351
339352 a text message can carry its sender's own previews (msc4095), and those come
Modified file: src/engine/watchers.rs+62 additions−13 deletions
@@ -1,31 +1,34 @@
11 use std::{
2- collections::{HashSet, VecDeque},
2+ collections::{BTreeMap, HashMap, HashSet, VecDeque},
33 sync::Arc,
44 time::Duration,
55 };
66
77 use futures_util::StreamExt;
88 use matrix_sdk::{
9- Client,
9+ Client, Room,
1010 ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},
1111 send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},
1212 };
1313 use matrix_sdk_base::RoomInfoNotableUpdateReasons;
1414 use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};
1515 use ruma::{
16- OwnedRoomId, OwnedTransactionId,
17- events::{AnyGlobalAccountDataEvent, AnyRoomAccountDataEvent, AnySyncStateEvent},
16+ OwnedEventId, OwnedRoomId, OwnedTransactionId,
17+ events::{
18+ AnyGlobalAccountDataEvent, AnyRoomAccountDataEvent, AnySyncStateEvent,
19+ receipt::SyncReceiptEvent,
20+ },
1821 serde::Raw,
1922 time::Instant,
2023 };
21-use tokio::sync::{Notify, broadcast::error::RecvError, watch};
24+use tokio::sync::{Notify, broadcast::error::RecvError, mpsc, watch};
2225
2326 use super::executor::TaskSet;
2427 use crate::{
25- ConnectionState, Event, UploadOutcome,
28+ ConnectionState, Event, ThreadUnread, UploadOutcome,
2629 encryption::encryption_state,
2730 error::classify_sdk_error,
28- normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK},
31+ normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, thread_unreads},
2932 space_order,
3033 transport::EventSender,
3134 };
@@ -433,25 +436,71 @@ pub(super) async fn rooms(client: Client, events: EventSender) {
433436 }
434437 }),
435438 ];
436- loop {
439+ let (receipts, mut receipt_rooms) = mpsc::unbounded_channel();
440+ let receipt_handler = client.add_event_handler(move |_: SyncReceiptEvent, room: Room| {
441+ let _ = receipts.send(room.room_id().to_owned());
442+ async {}
443+ });
444+ let mut timelines = client.event_cache().subscribe_to_room_generic_updates();
445+ let mut thread_unreads = ThreadUnreads::default();
446+ 'invalidate: loop {
437447 if events.send(Event::RoomsChanged).await.is_err() {
438448 break;
439449 }
440- tokio::select! {
441- update = rooms.next() => if update.is_none() { break },
442- update = info.recv() => if matches!(update, Err(RecvError::Closed)) { break },
443- () = changed.notified() => {}
450+ // thread events and threaded receipts leave room info alone, so neither reaches `info`
451+ loop {
452+ let room_id = tokio::select! {
453+ update = rooms.next() => if update.is_none() { break 'invalidate } else { break },
454+ update = info.recv() => {
455+ if matches!(update, Err(RecvError::Closed)) { break 'invalidate }
456+ break;
457+ }
458+ () = changed.notified() => break,
459+ Some(room_id) = receipt_rooms.recv() => room_id,
460+ update = timelines.recv() => match update {
461+ Ok(update) => update.room_id,
462+ Err(RecvError::Lagged(_)) => {
463+ thread_unreads.0.clear();
464+ break;
465+ }
466+ Err(RecvError::Closed) => break 'invalidate,
467+ },
468+ };
469+ if thread_unreads.changed(&client, room_id).await {
470+ break;
471+ }
444472 }
445473 // a sync touching fifty rooms is one invalidation, not fifty
446474 while let Ok(_) | Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) =
447475 info.try_recv()
448476 {}
449477 }
450- for handler in handlers {
478+ for handler in handlers.into_iter().chain([receipt_handler]) {
451479 client.remove_event_handler(handler);
452480 }
453481 }
454482
483+#[derive(Default)]
484+struct ThreadUnreads(HashMap<OwnedRoomId, BTreeMap<OwnedEventId, ThreadUnread>>);
485+
486+impl ThreadUnreads {
487+ async fn changed(&mut self, client: &Client, room_id: OwnedRoomId) -> bool {
488+ let Some(room) = client.get_room(&room_id) else {
489+ return self.0.remove(&room_id).is_some();
490+ };
491+ let unreads = thread_unreads(&room).await;
492+ if self
493+ .0
494+ .get(&room_id)
495+ .map_or(unreads.is_empty(), |known| *known == unreads)
496+ {
497+ return false;
498+ }
499+ self.0.insert(room_id, unreads);
500+ true
501+ }
502+}
503+
455504 /// packs live in room state and account data, and leaving a room takes its packs with it
456505 pub(super) async fn image_packs(client: Client, events: EventSender) {
457506 let changed = Arc::new(Notify::new());
Modified file: src/lib.rs+2 additions−1 deletions
@@ -68,7 +68,8 @@ pub use model::{
6868 RecentHistoryState, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText,
6969 RoomCategory, RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership,
7070 RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary,
71- RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand,
71+ RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadSummary, ThreadUnread,
72+ TimelineCommand,
7273 TimelineContent, TimelineDiff, TimelineDiffError, TimelineEvent, TimelineId, TimelineItem,
7374 TimelineItemId, TypingState, UploadDetails, UploadOutcome, UploadThumbnail, UtdCause,
7475 VerificationAction, VerificationFailure, VerificationState, VerificationTarget,
Modified file: src/model.rs+16 additions−2 deletions
@@ -1,4 +1,4 @@
1-use std::{fmt, sync::Arc};
1+use std::{collections::BTreeMap, fmt, sync::Arc};
22
33 use imbl::Vector;
44 use ruma::{
@@ -1047,10 +1047,15 @@ pub struct RoomSummary {
10471047 pub room_id: OwnedRoomId,
10481048 pub name: String,
10491049 pub membership: RoomMembership,
1050+ /// the main timeline's, threads have theirs in `thread_unreads`
10501051 pub unread_messages: u64,
1052+ /// thread mentions included
10511053 pub unread_mentions: u64,
1052- /// unread events the push rules said to notify about, which is neither of the above
1054+ /// unread events the push rules said to notify about, which is neither of the above.
1055+ /// the main timeline's
10531056 pub unread_notifications: u64,
1057+ /// threads with anything unread, by root
1058+ pub thread_unreads: BTreeMap<OwnedEventId, ThreadUnread>,
10541059 pub encrypted: bool,
10551060 pub space: bool,
10561061 pub direct: bool,
@@ -1083,6 +1088,15 @@ pub struct RoomSummary {
10831088 pub can_pin: bool,
10841089 }
10851090
1091+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1092+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1093+#[non_exhaustive]
1094+pub struct ThreadUnread {
1095+ pub messages: u64,
1096+ pub mentions: u64,
1097+ pub notifications: u64,
1098+}
1099+
10861100 #[derive(Clone, Debug, Eq, PartialEq)]
10871101 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
10881102 #[non_exhaustive]
Modified file: src/normalise/mod.rs+2 additions
@@ -5,6 +5,7 @@ mod media;
55 mod member;
66 mod observed;
77 mod room;
8+mod thread;
89 mod timeline;
910
1011 pub(crate) use context::{apply_profiles, normalise_context, resolve_replies};
@@ -18,4 +19,5 @@ pub(crate) use member::member_summary;
1819 pub(crate) use observed::normalise_observed;
1920 pub(crate) use room::notification_mode;
2021 pub(crate) use room::{room_summaries, space_child, stored_space_order};
22+pub(crate) use thread::thread_unreads;
2123 pub(crate) use timeline::{message_kind, normalise_timeline, normalise_timeline_diffs};
Modified file: src/normalise/room.rs+7 additions−1 deletions
@@ -62,13 +62,19 @@ async fn room_summary(room: &Room, spaces: &SpaceService) -> RoomSummary {
6262 } else {
6363 Vec::new()
6464 };
65+ let thread_unreads = super::thread_unreads(room).await;
6566 RoomSummary {
6667 room_id: room.room_id().to_owned(),
6768 name,
6869 membership: membership(room.state()),
6970 unread_messages: room.num_unread_messages(),
70- unread_mentions: room.num_unread_mentions(),
71+ unread_mentions: room.num_unread_mentions()
72+ + thread_unreads
73+ .values()
74+ .map(|unread| unread.mentions)
75+ .sum::<u64>(),
7176 unread_notifications: room.num_unread_notifications(),
77+ thread_unreads,
7278 encrypted: room.encryption_state().is_encrypted(),
7379 space: room.is_space(),
7480 direct: room.is_direct().await.unwrap_or(false),
Added file: src/normalise/thread.rs+133 additions
@@ -0,0 +1,133 @@
1+use std::collections::{BTreeMap, HashMap};
2+
3+use matrix_sdk::{
4+ Room, RoomState, deserialized_responses::TimelineEvent, serde_helpers::extract_thread_root,
5+};
6+use ruma::{
7+ OwnedEventId, UserId,
8+ events::receipt::{ReceiptThread, ReceiptType},
9+};
10+use serde_json::Value;
11+
12+use crate::ThreadUnread;
13+
14+struct Reply {
15+ event_id: Option<OwnedEventId>,
16+ own: bool,
17+ counts: bool,
18+ notify: bool,
19+ highlight: bool,
20+}
21+
22+impl Reply {
23+ fn of(event: &TimelineEvent, own_user_id: &UserId) -> Self {
24+ let own = event.sender().as_deref() == Some(own_user_id);
25+ let counts = !own && counts_as_unread(event);
26+ let actions = event.push_actions().unwrap_or_default();
27+ Self {
28+ event_id: event.event_id(),
29+ own,
30+ counts,
31+ notify: counts && actions.iter().any(|action| action.should_notify()),
32+ highlight: counts && actions.iter().any(|action| action.is_highlight()),
33+ }
34+ }
35+}
36+
37+/// the sdk leaves thread events out of the room's counts once threads are on and keeps none
38+/// of its own for them. so these are worked out the same way over the events it has in
39+/// memory, newest first until a receipt for that thread (msc3771), an unthreaded receipt or
40+/// the user's own reply. a receipt older than memory counts everything in memory, as the
41+/// sdk's does
42+pub(crate) async fn thread_unreads(room: &Room) -> BTreeMap<OwnedEventId, ThreadUnread> {
43+ if room.state() != RoomState::Joined {
44+ return BTreeMap::new();
45+ }
46+ let own_user_id = room.own_user_id();
47+ let unthreaded = own_receipts(room, ReceiptThread::Unthreaded).await;
48+ let Ok((cache, _drop_handles)) = room.event_cache().await else {
49+ return BTreeMap::new();
50+ };
51+ let mut threads: HashMap<OwnedEventId, Vec<Reply>> = HashMap::new();
52+ let scanned = cache
53+ .rfind_map_event_in_memory_by(|event| {
54+ let event_id = event.event_id();
55+ if event_id.is_some_and(|event_id| unthreaded.contains(&event_id)) {
56+ return Some(());
57+ }
58+ if let Some(root) = extract_thread_root(event.raw()) {
59+ threads
60+ .entry(root)
61+ .or_default()
62+ .push(Reply::of(event, own_user_id));
63+ }
64+ None
65+ })
66+ .await;
67+ if let Err(error) = scanned {
68+ tracing::debug!(room_id = %room.room_id(), %error, "reading events for thread unreads failed");
69+ return BTreeMap::new();
70+ }
71+ let mut unreads = BTreeMap::new();
72+ for (root, replies) in threads {
73+ let read = own_receipts(room, ReceiptThread::Thread(root.clone())).await;
74+ let mut unread = ThreadUnread::default();
75+ for reply in replies {
76+ if reply.own
77+ || reply
78+ .event_id
79+ .as_ref()
80+ .is_some_and(|event_id| read.contains(event_id))
81+ {
82+ break;
83+ }
84+ unread.messages += u64::from(reply.counts);
85+ unread.notifications += u64::from(reply.notify);
86+ unread.mentions += u64::from(reply.highlight);
87+ }
88+ if unread != ThreadUnread::default() {
89+ unreads.insert(root, unread);
90+ }
91+ }
92+ unreads
93+}
94+
95+async fn own_receipts(room: &Room, thread: ReceiptThread) -> Vec<OwnedEventId> {
96+ let mut found = Vec::new();
97+ for receipt_type in [ReceiptType::Read, ReceiptType::ReadPrivate] {
98+ match room
99+ .load_user_receipt(receipt_type, thread.clone(), room.own_user_id())
100+ .await
101+ {
102+ Ok(Some((event_id, _))) => found.push(event_id),
103+ Ok(None) => {}
104+ Err(error) => {
105+ tracing::debug!(room_id = %room.room_id(), %error, "reading a receipt failed");
106+ }
107+ }
108+ }
109+ found
110+}
111+
112+/// the sdk's rule for its room counts, which is private to it
113+fn counts_as_unread(event: &TimelineEvent) -> bool {
114+ let raw = event.raw();
115+ let redacted = raw
116+ .get_field::<Value>("unsigned")
117+ .ok()
118+ .flatten()
119+ .is_some_and(|unsigned| unsigned.get("redacted_because").is_some());
120+ let message_like = matches!(
121+ raw.get_field::<String>("type").ok().flatten().as_deref(),
122+ Some(
123+ "m.room.message"
124+ | "m.room.encrypted"
125+ | "m.sticker"
126+ | "m.poll.start"
127+ | "org.matrix.msc3381.poll.start"
128+ | "m.poll.end"
129+ | "org.matrix.msc3381.poll.end"
130+ )
131+ );
132+ message_like && !redacted
133+}
Modified file: tests/state.rs+139 additions−3 deletions
@@ -4,13 +4,23 @@ use std::time::Duration;
44
55 use macltok::{
66 ConnectionState, Engine, EngineConfig, EngineError, Event, PackUsage, Retain, RoomMembership,
7- Termination,
7+ Termination, ThreadUnread,
88 };
99 use matrix_sdk::{
10- ruma::{api::client::sync::sync_events::v5, events::AnySyncStateEvent, room_id, serde::Raw},
10+ ruma::{
11+ RoomId,
12+ api::client::sync::sync_events::v5,
13+ event_id,
14+ events::{
15+ AnySyncStateEvent, Mentions,
16+ receipt::{ReceiptThread, ReceiptType},
17+ },
18+ room_id,
19+ serde::Raw,
20+ },
1121 test_utils::mocks::MatrixMockServer,
1222 };
13-use matrix_sdk_test::JoinedRoomBuilder;
23+use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};
1424 use serde_json::json;
1525 use tokio::sync::watch;
1626
@@ -233,3 +243,129 @@ async fn image_packs_follow_room_state_and_account_data() {
233243
234244 support::shut_down(&handle, &mut events, running).await;
235245 }
246+
247+/// the sdk counts none of this once threads are on, and a threaded receipt never reaches
248+/// room info, so only our own recount notices it
249+#[tokio::test]
250+async fn thread_unreads_follow_threaded_receipts() {
251+ let server = MatrixMockServer::new().await;
252+ server
253+ .mock_sliding_sync()
254+ .ok(v5::Response::new("1".to_owned()))
255+ .mount()
256+ .await;
257+ let client = server.client_builder().build().await;
258+ let room_id = room_id!("!threads:localhost");
259+ server.mock_room_state_encryption().plain().mount().await;
260+ server.sync_joined_room(&client, room_id).await;
261+ client.event_cache().subscribe().unwrap();
262+ let config = EngineConfig::default().with_initial_history_limit(0);
263+ let (engine, handle, mut events) = Engine::from_client(client.clone(), config).await.unwrap();
264+ let running = tokio::spawn(engine.run());
265+ let own = client.user_id().unwrap().to_owned();
266+
267+ let (root, first, second) = (
268+ event_id!("$root"),
269+ event_id!("$first"),
270+ event_id!("$second"),
271+ );
272+ let factory = EventFactory::new().room(room_id).sender(&ALICE);
273+ server
274+ .sync_room(
275+ &client,
276+ JoinedRoomBuilder::new(room_id)
277+ .add_timeline_event(factory.text_msg("root").event_id(root))
278+ .add_timeline_event(
279+ factory
280+ .text_msg("you there?")
281+ .in_thread(root, root)
282+ .mentions(Mentions::with_user_ids([own.clone()]))
283+ .event_id(first),
284+ )
285+ .add_timeline_event(
286+ factory
287+ .text_msg("hello?")
288+ .in_thread(root, first)
289+ .event_id(second),
290+ ),
291+ )
292+ .await;
293+ let unread = thread_unread_once(&handle, &mut events, room_id, |unread| {
294+ unread.is_some_and(|unread| unread.messages == 2)
295+ })
296+ .await;
297+ assert_eq!(unread.mentions, 1);
298+ let summary = room(&handle, room_id).await;
299+ assert!(
300+ summary.unread_mentions >= 1,
301+ "the thread mention left the room's count"
302+ );
303+
304+ server
305+ .sync_room(
306+ &client,
307+ JoinedRoomBuilder::new(room_id).add_receipt(
308+ factory
309+ .read_receipts()
310+ .add(
311+ first,
312+ &own,
313+ ReceiptType::Read,
314+ ReceiptThread::Thread(root.to_owned()),
315+ )
316+ .into_event(),
317+ ),
318+ )
319+ .await;
320+ let unread = thread_unread_once(&handle, &mut events, room_id, |unread| {
321+ unread.is_some_and(|unread| unread.messages == 1)
322+ })
323+ .await;
324+ assert_eq!(unread.mentions, 0);
325+
326+ server
327+ .sync_room(
328+ &client,
329+ JoinedRoomBuilder::new(room_id).add_receipt(
330+ factory
331+ .read_receipts()
332+ .add(second, &own, ReceiptType::Read, ReceiptThread::Unthreaded)
333+ .into_event(),
334+ ),
335+ )
336+ .await;
337+ thread_unread_once(&handle, &mut events, room_id, |unread| unread.is_none()).await;
338+
339+ support::shut_down(&handle, &mut events, running).await;
340+}
341+
342+async fn room(handle: &macltok::ClientHandle, room_id: &RoomId) -> macltok::RoomSummary {
343+ handle
344+ .rooms()
345+ .await
346+ .into_iter()
347+ .find(|summary| summary.room_id == room_id)
348+ .expect("synced room was absent from the room summaries")
349+}
350+
351+/// waits out `RoomsChanged` until the root's counts satisfy `done`
352+async fn thread_unread_once(
353+ handle: &macltok::ClientHandle,
354+ events: &mut macltok::EventStream,
355+ room_id: &RoomId,
356+ done: impl Fn(Option<&ThreadUnread>) -> bool,
357+) -> ThreadUnread {
358+ let root = event_id!("$root");
359+ tokio::time::timeout(Duration::from_secs(10), async {
360+ loop {
361+ let summary = room(handle, room_id).await;
362+ let unread = summary.thread_unreads.get(root);
363+ if done(unread) {
364+ return unread.copied().unwrap_or_default();
365+ }
366+ support::receive_until(events, |event| matches!(event, Event::RoomsChanged)).await;
367+ }
368+ })
369+ .await
370+ .expect("thread unreads never settled")
371+}