Commit e7c1b037154d
tarball3 files changed+249-57
@@ -138,12 +138,16 @@ can
138138
the sdk's `ThreadListService` was passed over. it never adds a thread that wasn't139139
on a page already loaded, never reorders, and adds one to the count for every140140
event the cache hands it, replays and back pagination included. here a reply141
-counts once, by id, when sync just brought it and it's newer than a latest142
-whose time we know, by the sdk's corrected timestamp. the reply a thread already143
-ends on only dates it, since a summary from the event cache names its latest144
-reply without the event. replays, reloads from the store and back pagination145
-never count. a reply sync brings to a thread the list hasn't got fetches its146
-root, four at a time, and adds it. one from anywhere else doesn't, since that141
+counts once, by id, when sync just brought it, it's newer than the thread's142
+latest and it was sent after the count was read off the server, all by the sdk's143
+corrected timestamp. the latest alone can't date a count. a summary from the144
+event cache names its latest reply without the event, and continuwuity 26.9145
+bundles the latest as bare content, so with only that check every reply the146
+cache re-inserted read as new and a page's count doubled. a newer reply that147
+doesn't count still becomes the latest. replays, reloads from the store and148
+back pagination never count. a reply sync brings to a thread the list hasn't149
+got fetches its root, four at a time, and adds it. one from anywhere else150
+doesn't, since that147151
says nothing about the thread being active, and a root the server doesn't have148152
isn't asked for again. a redacted reply doesn't uncount, an edit doesn't change149153
the latest reply's preview, and a preview's own reply target comes back@@ -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 now101
+ read_at: Option<u64>,102
+}103
+104
+#[derive(Default)]105
+struct Tally {106
+ /// replies already counted, so the same event synced twice counts once107
+ counted: HashSet<OwnedEventId>,108
+ /// when each thread's count was read off the server, which had every reply sent before109
+ 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 once122
- counted: HashSet<OwnedEventId>,131
+ tally: Tally,123132
/// roots being fetched or waiting for a slot, with the replies that arrived meanwhile124133
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 built569
- {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 built595
- {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 = self644
+ .read_at645
+ .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 else641
-/// counts once, when sync brought it and it's newer than a latest we know the time of. a642
-/// summary from the event cache names its latest reply without the event, so its time is643
-/// often unknown until that reply turns up666
+/// a reply newer than the thread's latest becomes its latest, and counts once if sync just667
+/// brought it and it was sent after the count was read off the server. the latest's time668
+/// alone can't tell, since a summary can arrive without a usable latest event, an event669
+/// 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 newer644671
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 = entry683
+ if entry.summary.latest_event_id.as_ref() == Some(&event_id) {684
+ tally.counted.insert(event_id);685
+ } else {686
+ if entry658687
.summary659688
.latest_event_timestamp_ms660
- .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 = tally694
+ .read_at695
+ .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 = room691726
.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).await735
+ if let Some(built) = build_entry(736
+ &room,737
+ root,738
+ current_user_id.as_deref(),739
+ &media_prefix,740
+ read_at,741
+ )742
+ .await702743
{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
+ .await784
+ {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 now781837
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 the1055
+ /// server already counted used to count each one again1056
+ #[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();@@ -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
.await386386
.expect("thread unreads never settled")387387
}388
+389
+/// continuwuity 26.9 bundles a thread's latest reply as bare content, so nothing dates the390
+/// page's count, and the cache handing back replies the server already counted counted them391
+/// again392
+#[tokio::test]393
+async fn a_thread_list_page_keeps_the_servers_count() {394
+ let server = MatrixMockServer::new().await;395
+ server396
+ .mock_sliding_sync()397
+ .ok(v5::Response::new("1".to_owned()))398
+ .mount()399
+ .await;400
+ let client = server401
+ .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
+ factory422
+ .text_msg(body)423
+ .in_thread(root, previous)424
+ .event_id(event_id)425
+ };426
+ server427
+ .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
+ server451
+ .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
+ handle461
+ .paginate_thread_list(room_id.to_owned(), 20)462
+ .await463
+ .unwrap();464
+ tokio::time::sleep(Duration::from_millis(5)).await;465
+ server466
+ .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
+ .await484
+ .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
+}