atagen

macltok

public
Star0
Watch0

Commit e7c1b037154d

tarball

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

3 files changed+249-57
threads: count a page's replies only once they postdate it

continuwuity 26.9 bundles a thread's latest reply in /threads as bare content, so a page entry had no dated latest, and every reply the event cache re-inserted under a sync read as newer and counted on top of the server's count, doubling it. add_reply now also requires a reply to postdate when its thread's count was read off the server, kept per root in Tally, and a newer reply that doesn't count still becomes the latest.
Modified file: docs/design-decisions.md+10 additions−6 deletions
@@ -138,12 +138,16 @@ can
138138 the sdk's `ThreadListService` was passed over. it never adds a thread that wasn't
139139 on a page already loaded, never reorders, and adds one to the count for every
140140 event the cache hands it, replays and back pagination included. here a reply
141-counts once, by id, when sync just brought it and it's newer than a latest
142-whose time we know, by the sdk's corrected timestamp. the reply a thread already
143-ends on only dates it, since a summary from the event cache names its latest
144-reply without the event. replays, reloads from the store and back pagination
145-never count. a reply sync brings to a thread the list hasn't got fetches its
146-root, four at a time, and adds it. one from anywhere else doesn't, since that
141+counts once, by id, when sync just brought it, it's newer than the thread's
142+latest and it was sent after the count was read off the server, all by the sdk's
143+corrected timestamp. the latest alone can't date a count. a summary from the
144+event cache names its latest reply without the event, and continuwuity 26.9
145+bundles the latest as bare content, so with only that check every reply the
146+cache re-inserted read as new and a page's count doubled. a newer reply that
147+doesn't count still becomes the latest. replays, reloads from the store and
148+back pagination never count. a reply sync brings to a thread the list hasn't
149+got fetches its root, four at a time, and adds it. one from anywhere else
150+doesn't, since that
147151 says nothing about the thread being active, and a root the server doesn't have
148152 isn't asked for again. a redacted reply doesn't uncount, an edit doesn't change
149153 the latest reply's preview, and a preview's own reply target comes back
Modified file: src/engine/thread_list.rs+129 additions−49 deletions
@@ -97,6 +97,16 @@ struct Page {
9797 struct Built {
9898 entry: ThreadListEntry,
9999 media: Vec<MediaRegistration>,
100+ /// when its count was read off the server, for an entry fetched just now
101+ read_at: Option<u64>,
102+}
103+
104+#[derive(Default)]
105+struct Tally {
106+ /// replies already counted, so the same event synced twice counts once
107+ counted: HashSet<OwnedEventId>,
108+ /// when each thread's count was read off the server, which had every reply sent before
109+ read_at: HashMap<OwnedEventId, u64>,
100110 }
101111
102112 /// what a list is loading, and who waits on it
@@ -118,8 +128,7 @@ struct List {
118128 page_limit: u16,
119129 load: Load,
120130 refresh_owed: bool,
121- /// replies already counted, so the same event synced twice counts once
122- counted: HashSet<OwnedEventId>,
131+ tally: Tally,
123132 /// roots being fetched or waiting for a slot, with the replies that arrived meanwhile
124133 fetching: HashMap<OwnedEventId, Vec<Normalised>>,
125134 waiting_roots: VecDeque<OwnedEventId>,
@@ -227,7 +236,7 @@ impl ThreadLists {
227236 page_limit: DEFAULT_PAGE,
228237 load: Load::Idle,
229238 refresh_owed: false,
230- counted: HashSet::new(),
239+ tally: Tally::default(),
231240 fetching: HashMap::new(),
232241 waiting_roots: VecDeque::new(),
233242 fetches_in_flight: 0,
@@ -449,9 +458,14 @@ impl ThreadLists {
449458 } => {
450459 if let Some(index) = list.position(&root_event_id) {
451460 let mut entry = list.entries[index].clone();
452- let media = add_reply(&mut entry, reply, fresh, &mut list.counted);
461+ let media = add_reply(&mut entry, reply, fresh, &mut list.tally);
453462 if entry != list.entries[index] {
454- self.publish(&room_id, vec![Built { entry, media }]).await;
463+ let built = Built {
464+ entry,
465+ media,
466+ read_at: None,
467+ };
468+ self.publish(&room_id, vec![built]).await;
455469 }
456470 return;
457471 }
@@ -472,8 +486,12 @@ impl ThreadLists {
472486 let mut entry = list.entries[index].clone();
473487 entry.root = with_summary(root.item, &entry.summary);
474488 if entry != list.entries[index] {
475- let media = root.media;
476- self.publish(&room_id, vec![Built { entry, media }]).await;
489+ let built = Built {
490+ entry,
491+ media: root.media,
492+ read_at: None,
493+ };
494+ self.publish(&room_id, vec![built]).await;
477495 }
478496 }
479497 }
@@ -525,16 +543,18 @@ impl ThreadLists {
525543 let mut built = Built {
526544 entry: entry.clone(),
527545 media: Vec::new(),
546+ read_at: None,
528547 };
529548 for reply in replies {
530- let media = add_reply(&mut built.entry, reply, true, &mut list.counted);
549+ let media = add_reply(&mut built.entry, reply, true, &mut list.tally);
531550 built.media.extend(media);
532551 }
533552 (built.entry != entry).then_some(built)
534553 }
535554 (Fetched::Built(mut built), None) => {
555+ list.tally.note(&built);
536556 for reply in replies {
537- let media = add_reply(&mut built.entry, reply, true, &mut list.counted);
557+ let media = add_reply(&mut built.entry, reply, true, &mut list.tally);
538558 built.media.extend(media);
539559 }
540560 Some(built)
@@ -562,14 +582,11 @@ impl ThreadLists {
562582 };
563583 let mut media = Vec::new();
564584 let mut upserts = Vec::with_capacity(built.len());
565- for Built {
566- entry,
567- media: registrations,
568- } in built
569- {
570- media.extend(registrations);
571- ThreadListEntry::upsert(&mut list.entries, entry.clone());
572- upserts.push(entry);
585+ for built in built {
586+ list.tally.note(&built);
587+ media.extend(built.media);
588+ ThreadListEntry::upsert(&mut list.entries, built.entry.clone());
589+ upserts.push(built.entry);
573590 }
574591 let scope = list.scope.clone();
575592 self.media.register(&scope, media).await;
@@ -588,13 +605,10 @@ impl ThreadLists {
588605 };
589606 let mut media = Vec::new();
590607 list.entries = Vector::new();
591- for Built {
592- entry,
593- media: registrations,
594- } in built
595- {
596- media.extend(registrations);
597- ThreadListEntry::upsert(&mut list.entries, entry);
608+ for built in built {
609+ list.tally.note(&built);
610+ media.extend(built.media);
611+ ThreadListEntry::upsert(&mut list.entries, built.entry);
598612 }
599613 let entries = list.entries.clone();
600614 let scope = list.scope.clone();
@@ -623,6 +637,18 @@ impl List {
623637 }
624638 }
625639
640+impl Tally {
641+ fn note(&mut self, built: &Built) {
642+ if let Some(read_at) = built.read_at {
643+ let known = self
644+ .read_at
645+ .entry(built.entry.root_event_id.clone())
646+ .or_default();
647+ *known = (*known).max(read_at);
648+ }
649+ }
650+}
651+
626652 fn current<'a>(
627653 lists: &'a mut HashMap<OwnedRoomId, List>,
628654 room_id: &OwnedRoomId,
@@ -637,15 +663,16 @@ fn not_open() -> Error {
637663 Error::new(ErrorKind::TimelineNotOpen, "thread list is not open")
638664 }
639665
640-/// the reply the thread already ends on only fills in its preview and time. anything else
641-/// counts once, when sync brought it and it's newer than a latest we know the time of. a
642-/// summary from the event cache names its latest reply without the event, so its time is
643-/// often unknown until that reply turns up
666+/// a reply newer than the thread's latest becomes its latest, and counts once if sync just
667+/// brought it and it was sent after the count was read off the server. the latest's time
668+/// alone can't tell, since a summary can arrive without a usable latest event, an event
669+/// cache summary names one without the event and continuwuity 26.9 bundles only its content,
670+/// and then every reply the cache re-inserts reads as newer
644671 fn add_reply(
645672 entry: &mut ThreadListEntry,
646673 reply: Normalised,
647674 fresh: bool,
648- counted: &mut HashSet<OwnedEventId>,
675+ tally: &mut Tally,
649676 ) -> Vec<MediaRegistration> {
650677 let Normalised {
651678 event_id,
@@ -653,18 +680,25 @@ fn add_reply(
653680 item,
654681 media,
655682 } = reply;
656- if entry.summary.latest_event_id.as_ref() != Some(&event_id) {
657- let newer = entry
683+ if entry.summary.latest_event_id.as_ref() == Some(&event_id) {
684+ tally.counted.insert(event_id);
685+ } else {
686+ if entry
658687 .summary
659688 .latest_event_timestamp_ms
660- .is_none_or(|latest| timestamp_ms > latest);
661- if !fresh || !newer || !counted.insert(event_id.clone()) {
689+ .is_some_and(|latest| timestamp_ms <= latest)
690+ {
662691 return Vec::new();
663692 }
664- entry.summary.reply_count = entry.summary.reply_count.saturating_add(1);
693+ let read_at = tally
694+ .read_at
695+ .get(&entry.root_event_id)
696+ .copied()
697+ .unwrap_or(0);
698+ if fresh && timestamp_ms > read_at && tally.counted.insert(event_id.clone()) {
699+ entry.summary.reply_count = entry.summary.reply_count.saturating_add(1);
700+ }
665701 entry.summary.latest_event_id = Some(event_id);
666- } else {
667- counted.insert(event_id);
668702 }
669703 entry.summary.latest_event_timestamp_ms = Some(timestamp_ms);
670704 entry.latest_activity_ms = entry.latest_activity_ms.max(timestamp_ms);
@@ -687,6 +721,7 @@ async fn fetch_page(
687721 current_user_id: Option<OwnedUserId>,
688722 media_prefix: String,
689723 ) -> Result<Page, Error> {
724+ let read_at = now_ms();
690725 let roots = room
691726 .list_threads(ListThreadsOptions {
692727 from,
@@ -697,8 +732,14 @@ async fn fetch_page(
697732 .map_err(|source| Error::from_sdk("listing threads failed", source))?;
698733 let mut entries = Vec::with_capacity(roots.chunk.len());
699734 for root in roots.chunk {
700- if let Some(built) =
701- build_entry(&room, root, current_user_id.as_deref(), &media_prefix).await
735+ if let Some(built) = build_entry(
736+ &room,
737+ root,
738+ current_user_id.as_deref(),
739+ &media_prefix,
740+ read_at,
741+ )
742+ .await
702743 {
703744 entries.push(built);
704745 }
@@ -715,6 +756,7 @@ async fn fetch_root(
715756 current_user_id: Option<OwnedUserId>,
716757 media_prefix: &str,
717758 ) -> Fetched {
759+ let read_at = now_ms();
718760 let root = match room.load_or_fetch_event(root_event_id, None).await {
719761 Ok(root) => root,
720762 Err(source) => {
@@ -731,7 +773,15 @@ async fn fetch_root(
731773 };
732774 }
733775 };
734- match build_entry(room, root, current_user_id.as_deref(), media_prefix).await {
776+ match build_entry(
777+ room,
778+ root,
779+ current_user_id.as_deref(),
780+ media_prefix,
781+ read_at,
782+ )
783+ .await
784+ {
735785 Some(built) => Fetched::Built(built),
736786 None => Fetched::Missing,
737787 }
@@ -742,6 +792,7 @@ async fn build_entry(
742792 root: SdkTimelineEvent,
743793 current_user_id: Option<&ruma::UserId>,
744794 media_prefix: &str,
795+ read_at: u64,
745796 ) -> Option<Built> {
746797 let summary = root.thread_summary.summary().cloned();
747798 let latest = root.bundled_latest_thread_event.clone();
@@ -774,9 +825,14 @@ async fn build_entry(
774825 latest_reply,
775826 },
776827 media,
828+ read_at: Some(read_at),
777829 })
778830 }
779831
832+fn now_ms() -> u64 {
833+ u64::from(ruma::MilliSecondsSinceUnixEpoch::now().get())
834+}
835+
780836 /// the sdk's corrected timestamp, which a sender's clock running ahead can't push past now
781837 async fn normalise(
782838 room: &Room,
@@ -964,17 +1020,17 @@ mod tests {
9641020
9651021 #[test]
9661022 fn a_reply_counts_once_and_only_when_newer() {
967- let mut counted = HashSet::new();
1023+ let mut tally = Tally::default();
9681024 let mut thread = entry("$root", Some(20));
969- add_reply(&mut thread, reply("$latest", 20), true, &mut counted);
970- add_reply(&mut thread, reply("$older", 10), true, &mut counted);
971- add_reply(&mut thread, reply("$replayed", 40), false, &mut counted);
1025+ add_reply(&mut thread, reply("$latest", 20), true, &mut tally);
1026+ add_reply(&mut thread, reply("$older", 10), true, &mut tally);
1027+ add_reply(&mut thread, reply("$replayed", 25), false, &mut tally);
9721028 assert_eq!(
9731029 thread.summary.reply_count, 2,
9741030 "a replayed or older reply was counted"
9751031 );
976- add_reply(&mut thread, reply("$newer", 30), true, &mut counted);
977- add_reply(&mut thread, reply("$newer", 30), true, &mut counted);
1032+ add_reply(&mut thread, reply("$newer", 30), true, &mut tally);
1033+ add_reply(&mut thread, reply("$newer", 30), true, &mut tally);
9781034 assert_eq!(thread.summary.reply_count, 3);
9791035 assert_eq!(thread.latest_activity_ms, 30);
9801036 assert_eq!(
@@ -985,16 +1041,40 @@ mod tests {
9851041
9861042 #[test]
9871043 fn the_latest_reply_dates_a_summary_without_one() {
988- let mut counted = HashSet::new();
1044+ let mut tally = Tally::default();
9891045 let mut thread = entry("$root", None);
990- add_reply(&mut thread, reply("$latest", 50), false, &mut counted);
1046+ add_reply(&mut thread, reply("$latest", 50), false, &mut tally);
9911047 assert_eq!(thread.summary.reply_count, 2);
9921048 assert_eq!(thread.latest_activity_ms, 50);
993- add_reply(&mut thread, reply("$latest", 50), true, &mut counted);
994- add_reply(&mut thread, reply("$replayed", 60), false, &mut counted);
1049+ add_reply(&mut thread, reply("$latest", 50), true, &mut tally);
1050+ add_reply(&mut thread, reply("$replayed", 60), false, &mut tally);
9951051 assert_eq!(thread.summary.reply_count, 2, "a replay moved the count");
9961052 }
9971053
1054+ /// continuwuity's /threads names no latest reply, so the cache re-inserting replies the
1055+ /// server already counted used to count each one again
1056+ #[test]
1057+ fn replies_sent_before_the_count_was_read_never_count() {
1058+ let mut thread = entry("$root", None);
1059+ thread.summary.latest_event_id = None;
1060+ let mut tally = Tally::default();
1061+ tally.note(&Built {
1062+ entry: thread.clone(),
1063+ media: Vec::new(),
1064+ read_at: Some(100),
1065+ });
1066+ for (event_id, timestamp_ms) in [("$first", 40), ("$second", 60)] {
1067+ add_reply(&mut thread, reply(event_id, timestamp_ms), true, &mut tally);
1068+ }
1069+ assert_eq!(
1070+ thread.summary.reply_count, 2,
1071+ "already counted replies counted again"
1072+ );
1073+ assert_eq!(thread.latest_activity_ms, 60);
1074+ add_reply(&mut thread, reply("$after", 150), true, &mut tally);
1075+ assert_eq!(thread.summary.reply_count, 3);
1076+ }
1077+
9981078 #[test]
9991079 fn upserts_keep_newest_activity_first() {
10001080 let mut entries = Vector::new();
Modified file: tests/state.rs+110 additions−2 deletions
@@ -9,11 +9,11 @@ use macltok::{
99 use matrix_sdk::{
1010 ThreadingSupport,
1111 ruma::{
12- RoomId,
12+ MilliSecondsSinceUnixEpoch, RoomId,
1313 api::client::sync::sync_events::v5,
1414 event_id,
1515 events::{
16- AnySyncStateEvent, Mentions,
16+ AnySyncStateEvent, AnyTimelineEvent, Mentions,
1717 receipt::{ReceiptThread, ReceiptType},
1818 },
1919 room_id,
@@ -385,3 +385,111 @@ async fn thread_unread_once(
385385 .await
386386 .expect("thread unreads never settled")
387387 }
388+
389+/// continuwuity 26.9 bundles a thread's latest reply as bare content, so nothing dates the
390+/// page's count, and the cache handing back replies the server already counted counted them
391+/// again
392+#[tokio::test]
393+async fn a_thread_list_page_keeps_the_servers_count() {
394+ let server = MatrixMockServer::new().await;
395+ server
396+ .mock_sliding_sync()
397+ .ok(v5::Response::new("1".to_owned()))
398+ .mount()
399+ .await;
400+ let client = server
401+ .client_builder()
402+ .on_builder(|builder| {
403+ builder.with_threading_support(ThreadingSupport::Enabled {
404+ with_subscriptions: false,
405+ })
406+ })
407+ .build()
408+ .await;
409+ let room_id = room_id!("!thread-list:localhost");
410+ let own = client.user_id().unwrap().to_owned();
411+ server.mock_room_state_encryption().plain().mount().await;
412+ client.event_cache().subscribe().unwrap();
413+ let factory = EventFactory::new().room(room_id).sender(&ALICE);
414+ let (root, first, second, third) = (
415+ event_id!("$list-root"),
416+ event_id!("$list-first"),
417+ event_id!("$list-second"),
418+ event_id!("$list-third"),
419+ );
420+ let reply = |event_id, previous, body| {
421+ factory
422+ .text_msg(body)
423+ .in_thread(root, previous)
424+ .event_id(event_id)
425+ };
426+ server
427+ .sync_room(
428+ &client,
429+ JoinedRoomBuilder::new(room_id)
430+ .add_state_event(EventFactory::new().room(room_id).member(&own))
431+ .add_timeline_event(factory.text_msg("root").event_id(root))
432+ .add_timeline_event(reply(first, root, "one"))
433+ .add_timeline_event(reply(second, first, "two")),
434+ )
435+ .await;
436+ let listed: Raw<AnyTimelineEvent> = serde_json::from_value(json!({
437+ "type": "m.room.message",
438+ "event_id": root,
439+ "room_id": room_id,
440+ "sender": &*ALICE,
441+ "origin_server_ts": 1,
442+ "content": { "msgtype": "m.text", "body": "root" },
443+ "unsigned": { "m.relations": { "m.thread": {
444+ "latest_event": { "msgtype": "m.text", "body": "two" },
445+ "count": 2,
446+ "current_user_participated": false,
447+ } } },
448+ }))
449+ .unwrap();
450+ server
451+ .mock_room_threads()
452+ .ok(vec![listed], None)
453+ .mount()
454+ .await;
455+ let config = EngineConfig::default().with_initial_history_limit(0);
456+ let (engine, handle, mut events) = Engine::from_client(client.clone(), config).await.unwrap();
457+ let running = tokio::spawn(engine.run());
458+
459+ handle.open_thread_list(room_id.to_owned()).await.unwrap();
460+ handle
461+ .paginate_thread_list(room_id.to_owned(), 20)
462+ .await
463+ .unwrap();
464+ tokio::time::sleep(Duration::from_millis(5)).await;
465+ server
466+ .sync_room(
467+ &client,
468+ JoinedRoomBuilder::new(room_id)
469+ .add_timeline_event(reply(first, root, "one"))
470+ .add_timeline_event(reply(second, first, "two"))
471+ .add_timeline_event(
472+ reply(third, second, "three").server_ts(MilliSecondsSinceUnixEpoch::now()),
473+ ),
474+ )
475+ .await;
476+ let changed = tokio::time::timeout(
477+ Duration::from_secs(10),
478+ support::receive_until(&mut events, |event| {
479+ matches!(event, Event::ThreadListChanged { upserts, .. }
480+ if upserts.iter().any(|entry| entry.summary.latest_event_id.as_deref() == Some(third)))
481+ }),
482+ )
483+ .await
484+ .expect("the new reply never reached the thread list");
485+ let Event::ThreadListChanged { upserts, .. } = changed else {
486+ unreachable!()
487+ };
488+ let entry = upserts.last().unwrap();
489+ assert_eq!(
490+ entry.summary.reply_count, 3,
491+ "replies the server had counted were counted again"
492+ );
493+
494+ support::shut_down(&handle, &mut events, running).await;
495+}