Commit 21b9227729a4
tarball12 files changed+766-318
@@ -138,16 +138,23 @@ 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, only when it's newer than the thread's latest, and a reply sync142
-brings to a thread we haven't loaded fetches its root and adds it. one a back143
-pagination brings doesn't, since that says nothing about the thread being144
-active. a redacted reply doesn't uncount, an edit doesn't change the latest145
-reply's preview, and a preview's own reply target comes back `Unavailable`146
-rather than fetched. a lagged listener misses whatever it skipped until the147
-thread moves again148
-149
-a list's media belongs to its own scope, bounded like an event context's so a150
-long open list sheds the previews its threads moved past, and released on close141
+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 that147
+says nothing about the thread being active, and a root the server doesn't have148
+isn't asked for again. a redacted reply doesn't uncount, an edit doesn't change149
+the latest reply's preview, and a preview's own reply target comes back150
+`Unavailable` rather than fetched151
+152
+a lagged listener can't know what it missed, so the list fetches its first page153
+again and sends a `ThreadListReset` with it. leaving the room, or being kicked154
+or banned, closes the list with `ThreadListClosed` naming why, the same as155
+`Close` does. a list's media belongs to its own scope, bounded like an event156
+context's so a long open list sheds the previews its threads moved past, and157
+released on close151158
152159
## warming up to send153160
@@ -354,23 +361,35 @@ sdk ui types stay behind the boundary
354361
355362
`rooms` normalises on demand with bounded concurrency and sorts by latest event,356363
then name, then id. `RoomsChanged` is an invalidation, coalesced so one sync357
-touching fifty rooms is one event. a left room stays in the list with its364
+touching fifty rooms is one event. a sync's room info lands before the event365
+cache has stored its events, so the watcher waits a tenth of a second after366
+anything wakes it and takes the lot as one. an event cache slower than that367
+costs a second one. a left room stays in the list with its358368
membership until it's forgotten. push rule changes never touch room info, so359369
`NotificationSettingsChanged` brings a `RoomsChanged` along for the modes in360370
the summaries361371
362372
with threads on, the sdk leaves thread events out of a room's counts and keeps363
-none of its own for them. `thread_unreads` works them out per root the way the364
-sdk does a room's, over the events its cache holds in memory, newest first until365
-a receipt for that thread (msc3771), an unthreaded one, or the user's own reply.366
-a main-timeline receipt doesn't reach into threads. a thread's mentions also count367
-in the room's `unread_mentions`, so nobody misses one, while its messages and368
-notifications stay apart, since they don't move the main timeline's read marker.369
-thread events and threaded receipts leave room info alone, so the room list370
-watcher also recounts a room on each event cache update and each receipt, and371
-only says `RoomsChanged` when the counts moved. counting in the sdk would be372
-cheaper and survive memory being trimmed, but that's a behaviour change in the373
-fork373
+none of its own for them. `thread_unreads` counts them per root from the event374
+cache store, which keeps every reply it ever took whatever it holds in memory,375
+so closing a room, the cache trimming itself or a restart leaves them be. a376
+reply counts once it's newer than a receipt for its thread (msc3771), an377
+unthreaded one, or the user's own reply. a main-timeline receipt doesn't reach378
+into threads. the store has lost stream order between a thread and the rest of379
+the room, so an event's timestamp stands for its place, and a receipt on an380
+event the store never had falls back to the receipt's own time. a thread's381
+mentions also count in the room's `unread_mentions`, so nobody misses one,382
+while its messages and notifications stay apart, since they don't move the main383
+timeline's read marker384
+385
+the counts are kept in the state store per room, only for threads with386
+something unread, so `rooms` reads them without touching the event cache and a387
+cold room never gets one made for it. thread events and threaded receipts leave388
+room info alone, so the room list watcher recounts on its own. a synced thread389
+event names the thread to count, an event cache update recounts the room's390
+threads already holding something unread, since a late decryption can turn up391
+a mention, and so does a receipt of the user's own. counting in the sdk would392
+be cheaper, but that's a behaviour change in the fork374393
375394
## link previews376395
@@ -14,11 +14,11 @@ use tokio::sync::{Mutex, broadcast::error::RecvError};
1414
1515
use crate::{1616
Error, ErrorKind, Event, EventContext, EventContextCloseReason, EventContextCommand,17
- EventContextPage, EventContextSession, MediaId, Sender, TimelineEvent, TimelineItem,17
+ EventContextPage, EventContextSession, Sender, TimelineEvent, TimelineItem,1818
engine::MediaService,1919
normalise::{20
- MediaRegistration, MediaScope, apply_profiles, normalise_context, normalise_observed,21
- resolve_replies,20
+ MediaRegistration, MediaScope, apply_profiles, member_sender, normalise_context,21
+ normalise_observed, resolve_replies,2222
},2323
transport::EventSender,2424
};@@ -525,16 +525,7 @@ async fn profiles<'a>(
525525
unique.sort();526526
unique.dedup();527527
join_all(unique.into_iter().map(|user_id| async move {528
- let member = room.get_member(user_id).await.ok().flatten()?;529
- Some((530
- user_id.clone(),531
- Sender {532
- user_id: user_id.clone(),533
- display_name: member.display_name().map(str::to_owned),534
- avatar: member.avatar_url().map(MediaId::plain),535
- name_ambiguous: member.name_ambiguous(),536
- },537
- ))528
+ Some((user_id.clone(), member_sender(room, user_id).await?))538529
}))539530
.await540531
.into_iter()@@ -1,10 +1,11 @@
11
use std::{2
- collections::{HashMap, hash_map::Entry},2
+ collections::{HashMap, HashSet, VecDeque},33
sync::Arc,44
};55
6
+use imbl::Vector;67
use matrix_sdk::{7
- Client, Room,8
+ Client, Room, RoomState,89
deserialized_responses::TimelineEvent as SdkTimelineEvent,910
event_cache::{EventsOrigin, RoomEventCacheSubscriber, RoomEventCacheUpdate},1011
room::ListThreadsOptions,@@ -17,16 +18,21 @@ use tokio::sync::{broadcast::error::RecvError, mpsc};
1718
use super::{1819
MediaService,1920
commands::Job,20
- executor::{TaskSet, is_panic, resume_unwind},21
+ executor::{AbortHandle, TaskSet, is_panic, resume_unwind},2122
};2223
use crate::{23
- Error, ErrorKind, Event, HistoryState, MediaId, Sender, ThreadListCommand, ThreadListEntry,24
+ Error, ErrorKind, Event, HistoryState, ThreadListClosure, ThreadListCommand, ThreadListEntry,2425
ThreadSummary, TimelineItem,25
- normalise::{MediaRegistration, MediaScope, normalise_observed, resolve_replies},26
+ normalise::{27
+ MediaRegistration, MediaScope, member_sender, normalise_observed, resolve_replies,28
+ },2629
transport::{EventSender, Responder},2730
};2831
2932
const MAX_PAGE: u16 = 50;33
+/// the first page again after a lag, when nobody has paginated yet34
+const DEFAULT_PAGE: u16 = 20;35
+const MAX_ROOT_FETCHES: usize = 4;3036
3137
struct Normalised {3238
event_id: OwnedEventId,@@ -52,7 +58,16 @@ enum Arrival {
5258
room_id: OwnedRoomId,5359
generation: u64,5460
root_event_id: OwnedEventId,55
- built: Option<Built>,61
+ fetched: Fetched,62
+ },63
+ Lagged {64
+ room_id: OwnedRoomId,65
+ generation: u64,66
+ },67
+ Left {68
+ room_id: OwnedRoomId,69
+ generation: u64,70
+ reason: ThreadListClosure,5671
},5772
}5873
@@ -60,12 +75,20 @@ enum Live {
6075
Reply {6176
root_event_id: OwnedEventId,6277
reply: Normalised,63
- /// back pagination, which says nothing about a thread being active64
- paginated: bool,78
+ /// sync brought it into the room just now. a replay, a reload from the store or a79
+ /// back pagination says nothing about the thread moving80
+ fresh: bool,6581
},6682
Root(Normalised),6783
}6884
85
+enum Fetched {86
+ Built(Built),87
+ /// the server doesn't have it, or it isn't a thread root we can show88
+ Missing,89
+ Failed,90
+}91
+6992
struct Page {7093
entries: Vec<Built>,7194
token: Option<String>,@@ -76,18 +99,32 @@ struct Built {
7699
media: Vec<MediaRegistration>,77100
}78101
102
+/// what a list is loading, and who waits on it103
+enum Load {104
+ Idle,105
+ Page(Vec<Responder>),106
+ /// the first page again, replacing everything. whoever waits wants the page after it107
+ Refresh(Vec<Responder>),108
+}109
+79110
struct List {80111
room: Room,81112
generation: u64,82113
scope: MediaScope,83
- entries: Vec<ThreadListEntry>,114
+ entries: Vector<ThreadListEntry>,84115
token: Option<String>,85116
end_reached: bool,86
- /// non-empty while a page is in flight, everyone waiting on it87
- paginating: Vec<Responder>,88
- /// roots being fetched, with the replies that arrived meanwhile117
+ page_limit: u16,118
+ load: Load,119
+ refresh_owed: bool,120
+ /// replies already counted, so the same event synced twice counts once121
+ counted: HashSet<OwnedEventId>,122
+ /// roots being fetched or waiting for a slot, with the replies that arrived meanwhile89123
fetching: HashMap<OwnedEventId, Vec<Normalised>>,90
- listener: super::executor::AbortHandle,124
+ waiting_roots: VecDeque<OwnedEventId>,125
+ fetches_in_flight: usize,126
+ unfetchable: HashSet<OwnedEventId>,127
+ listener: AbortHandle,91128
}92129
93130
pub(super) async fn run(@@ -142,7 +179,15 @@ impl ThreadLists {
142179
ThreadListCommand::Paginate { room_id, limit } => {143180
self.paginate(room_id, limit, reply).await;144181
}145
- ThreadListCommand::Close(room_id) => self.close(room_id, reply).await,182
+ ThreadListCommand::Close(room_id) => {183
+ let Some(list) = self.lists.remove(&room_id) else {184
+ return reply.done(Err(not_open()));185
+ };186
+ let closed = self187
+ .finish(room_id, list, ThreadListClosure::Requested)188
+ .await;189
+ reply.done(closed);190
+ }146191
}147192
}148193
@@ -150,7 +195,7 @@ impl ThreadLists {
150195
if let Some(list) = self.lists.get(&room_id) {151196
let reset = Event::ThreadListReset {152197
room_id,153
- entries: list.entries.iter().cloned().collect(),198
+ entries: list.entries.clone(),154199
};155200
return reply.done(self.emit(reset).await);156201
}@@ -171,17 +216,23 @@ impl ThreadLists {
171216
room,172217
generation,173218
scope: self.media.context_scope(),174
- entries: Vec::new(),219
+ entries: Vector::new(),175220
token: None,176221
end_reached: false,177
- paginating: Vec::new(),222
+ page_limit: DEFAULT_PAGE,223
+ load: Load::Idle,224
+ refresh_owed: false,225
+ counted: HashSet::new(),178226
fetching: HashMap::new(),227
+ waiting_roots: VecDeque::new(),228
+ fetches_in_flight: 0,229
+ unfetchable: HashSet::new(),179230
listener,180231
},181232
);182233
let reset = Event::ThreadListReset {183234
room_id,184
- entries: imbl::Vector::new(),235
+ entries: Vector::new(),185236
};186237
reply.done(self.emit(reset).await);187238
}@@ -196,17 +247,26 @@ impl ThreadLists {
196247
let Some(list) = self.lists.get_mut(&room_id) else {197248
return reply.done(Err(not_open()));198249
};250
+ list.page_limit = limit;251
+ if let Load::Page(waiting) | Load::Refresh(waiting) = &mut list.load {252
+ return waiting.push(reply);253
+ }199254
if list.end_reached {200255
return reply.done(Ok(()));201256
}202
- list.paginating.push(reply);203
- if list.paginating.len() > 1 {257
+ list.load = Load::Page(vec![reply]);258
+ self.load(&room_id, false).await;259
+ }260
+261
+ /// starts the fetch `list.load` already names262
+ async fn load(&mut self, room_id: &OwnedRoomId, refresh: bool) {263
+ let Some(list) = self.lists.get(room_id) else {204264
return;205
- }265
+ };206266
let fetch = fetch_page(207267
list.room.clone(),208
- list.token.clone(),209
- limit,268
+ if refresh { None } else { list.token.clone() },269
+ list.page_limit,210270
self.current_user_id.clone(),211271
);212272
let generation = list.generation;@@ -222,22 +282,27 @@ impl ThreadLists {
222282
});223283
let _ = self224284
.emit(Event::ThreadListHistoryChanged {225
- room_id,285
+ room_id: room_id.clone(),226286
state: HistoryState::Loading,227287
})228288
.await;229289
}230290
231
- async fn close(&mut self, room_id: OwnedRoomId, reply: Responder) {232
- let Some(list) = self.lists.remove(&room_id) else {233
- return reply.done(Err(not_open()));234
- };291
+ /// the list is already out of `lists`292
+ async fn finish(293
+ &mut self,294
+ room_id: OwnedRoomId,295
+ list: List,296
+ reason: ThreadListClosure,297
+ ) -> Result<(), Error> {235298
list.listener.abort();236
- for waiting in list.paginating {237
- waiting.done(Err(Error::interrupted()));299
+ if let Load::Page(waiting) | Load::Refresh(waiting) = list.load {300
+ for reply in waiting {301
+ reply.done(Err(Error::interrupted()));302
+ }238303
}239304
self.media.release(&list.scope).await;240
- reply.done(self.emit(Event::ThreadListClosed { room_id }).await);305
+ self.emit(Event::ThreadListClosed { room_id, reason }).await241306
}242307
243308
async fn arrive(&mut self, arrival: Arrival) {@@ -256,22 +321,36 @@ impl ThreadLists {
256321
room_id,257322
generation,258323
root_event_id,259
- built,324
+ fetched,325
+ } => {326
+ self.root_arrived(room_id, generation, root_event_id, fetched)327
+ .await;328
+ }329
+ Arrival::Lagged {330
+ room_id,331
+ generation,260332
} => {261333
let Some(list) = current(&mut self.lists, &room_id, generation) else {262334
return;263335
};264
- let replies = list.fetching.remove(&root_event_id).unwrap_or_default();265
- let Some(mut built) = built else {266
- return;267
- };268
- if list.position(&root_event_id).is_some() {336
+ if matches!(list.load, Load::Idle) {337
+ list.load = Load::Refresh(Vec::new());338
+ self.load(&room_id, true).await;339
+ } else {340
+ list.refresh_owed = true;341
+ }342
+ }343
+ Arrival::Left {344
+ room_id,345
+ generation,346
+ reason,347
+ } => {348
+ if current(&mut self.lists, &room_id, generation).is_none() {269349
return;270350
}271
- for reply in replies {272
- built.media.extend(add_reply(&mut built.entry, reply));351
+ if let Some(list) = self.lists.remove(&room_id) {352
+ let _ = self.finish(room_id, list, reason).await;273353
}274
- self.publish(&room_id, vec![built]).await;275354
}276355
}277356
}@@ -285,28 +364,36 @@ impl ThreadLists {
285364
let Some(list) = current(&mut self.lists, &room_id, generation) else {286365
return;287366
};288
- let waiting = std::mem::take(&mut list.paginating);367
+ let (waiting, refresh) = match std::mem::replace(&mut list.load, Load::Idle) {368
+ Load::Page(waiting) => (waiting, false),369
+ Load::Refresh(waiting) => (waiting, true),370
+ Load::Idle => return,371
+ };289372
let (result, state) = match page {290373
Ok(page) => {291374
list.end_reached = page.token.is_none();292375
list.token = page.token;293
- let fresh = page294
- .entries295
- .into_iter()296
- .filter(|built| {297
- list.position(&built.entry.root_event_id)298
- .is_none_or(|index| {299
- list.entries[index].latest_activity_ms300
- <= built.entry.latest_activity_ms301
- })302
- })303
- .collect();304376
let state = if list.end_reached {305377
HistoryState::StartReached306378
} else {307379
HistoryState::Idle308380
};309
- self.publish(&room_id, fresh).await;381
+ if refresh {382
+ self.replace(&room_id, page.entries).await;383
+ } else {384
+ let fresh = page385
+ .entries386
+ .into_iter()387
+ .filter(|built| {388
+ list.position(&built.entry.root_event_id)389
+ .is_none_or(|index| {390
+ list.entries[index].latest_activity_ms391
+ <= built.entry.latest_activity_ms392
+ })393
+ })394
+ .collect();395
+ self.publish(&room_id, fresh).await;396
+ }310397
(Ok(()), state)311398
}312399
Err(error) => {@@ -315,10 +402,32 @@ impl ThreadLists {
315402
}316403
};317404
let _ = self318
- .emit(Event::ThreadListHistoryChanged { room_id, state })405
+ .emit(Event::ThreadListHistoryChanged {406
+ room_id: room_id.clone(),407
+ state,408
+ })319409
.await;320
- for reply in waiting {321
- reply.done(result.clone());410
+ let carried = if refresh && result.is_ok() {411
+ waiting412
+ } else {413
+ for reply in waiting {414
+ reply.done(result.clone());415
+ }416
+ Vec::new()417
+ };418
+ let Some(list) = self.lists.get_mut(&room_id) else {419
+ return;420
+ };421
+ if std::mem::take(&mut list.refresh_owed) {422
+ list.load = Load::Refresh(carried);423
+ self.load(&room_id, true).await;424
+ } else if list.end_reached {425
+ for reply in carried {426
+ reply.done(Ok(()));427
+ }428
+ } else if !carried.is_empty() {429
+ list.load = Load::Page(carried);430
+ self.load(&room_id, false).await;322431
}323432
}324433
@@ -330,38 +439,25 @@ impl ThreadLists {
330439
Live::Reply {331440
root_event_id,332441
reply,333
- paginated,442
+ fresh,334443
} => {335444
if let Some(index) = list.position(&root_event_id) {336445
let mut entry = list.entries[index].clone();337
- let media = add_reply(&mut entry, reply);446
+ let media = add_reply(&mut entry, reply, fresh, &mut list.counted);338447
if entry != list.entries[index] {339448
self.publish(&room_id, vec![Built { entry, media }]).await;340449
}341450
return;342451
}343
- if paginated {344
- return;452
+ if let Some(waiting) = list.fetching.get_mut(&root_event_id) {453
+ return waiting.push(reply);345454
}346
- match list.fetching.entry(root_event_id.clone()) {347
- Entry::Occupied(mut waiting) => waiting.get_mut().push(reply),348
- Entry::Vacant(vacant) => {349
- vacant.insert(vec![reply]);350
- let room = list.room.clone();351
- let current_user_id = self.current_user_id.clone();352
- let arrivals = self.arrivals.clone();353
- self.tasks.spawn(async move {354
- let built =355
- fetch_root(&room, &root_event_id, current_user_id).await;356
- let _ = arrivals.send(Arrival::Root {357
- room_id: room.room_id().to_owned(),358
- generation,359
- root_event_id,360
- built,361
- });362
- });363
- }455
+ if !fresh || list.unfetchable.contains(&root_event_id) {456
+ return;364457
}458
+ list.fetching.insert(root_event_id.clone(), vec![reply]);459
+ list.waiting_roots.push_back(root_event_id);460
+ self.fetch_roots(&room_id);365461
}366462
Live::Root(root) => {367463
let Some(index) = list.position(&root.event_id) else {@@ -377,6 +473,76 @@ impl ThreadLists {
377473
}378474
}379475
476
+ /// as many waiting roots as there are free slots477
+ fn fetch_roots(&mut self, room_id: &OwnedRoomId) {478
+ let Some(list) = self.lists.get_mut(room_id) else {479
+ return;480
+ };481
+ while list.fetches_in_flight < MAX_ROOT_FETCHES {482
+ let Some(root_event_id) = list.waiting_roots.pop_front() else {483
+ return;484
+ };485
+ list.fetches_in_flight += 1;486
+ let room = list.room.clone();487
+ let generation = list.generation;488
+ let current_user_id = self.current_user_id.clone();489
+ let arrivals = self.arrivals.clone();490
+ self.tasks.spawn(async move {491
+ let fetched = fetch_root(&room, &root_event_id, current_user_id).await;492
+ let _ = arrivals.send(Arrival::Root {493
+ room_id: room.room_id().to_owned(),494
+ generation,495
+ root_event_id,496
+ fetched,497
+ });498
+ });499
+ }500
+ }501
+502
+ async fn root_arrived(503
+ &mut self,504
+ room_id: OwnedRoomId,505
+ generation: u64,506
+ root_event_id: OwnedEventId,507
+ fetched: Fetched,508
+ ) {509
+ let Some(list) = current(&mut self.lists, &room_id, generation) else {510
+ return;511
+ };512
+ list.fetches_in_flight = list.fetches_in_flight.saturating_sub(1);513
+ let replies = list.fetching.remove(&root_event_id).unwrap_or_default();514
+ let built = match (fetched, list.position(&root_event_id)) {515
+ (_, Some(index)) => {516
+ let entry = list.entries[index].clone();517
+ let mut built = Built {518
+ entry: entry.clone(),519
+ media: Vec::new(),520
+ };521
+ for reply in replies {522
+ let media = add_reply(&mut built.entry, reply, true, &mut list.counted);523
+ built.media.extend(media);524
+ }525
+ (built.entry != entry).then_some(built)526
+ }527
+ (Fetched::Built(mut built), None) => {528
+ for reply in replies {529
+ let media = add_reply(&mut built.entry, reply, true, &mut list.counted);530
+ built.media.extend(media);531
+ }532
+ Some(built)533
+ }534
+ (Fetched::Missing, None) => {535
+ list.unfetchable.insert(root_event_id);536
+ None537
+ }538
+ (Fetched::Failed, None) => None,539
+ };540
+ self.fetch_roots(&room_id);541
+ if let Some(built) = built {542
+ self.publish(&room_id, vec![built]).await;543
+ }544
+ }545
+380546
/// the one place a thread list's media is registered. its scope is bounded like an event381547
/// context's, so previews a long open list replaced age out, and it goes on close382548
async fn publish(&mut self, room_id: &OwnedRoomId, built: Vec<Built>) {@@ -387,7 +553,6 @@ impl ThreadLists {
387553
return;388554
};389555
let mut media = Vec::new();390
- let scope = list.scope.clone();391556
let mut upserts = Vec::with_capacity(built.len());392557
for Built {393558
entry,@@ -395,9 +560,10 @@ impl ThreadLists {
395560
} in built396561
{397562
media.extend(registrations);398
- list.upsert(entry.clone());563
+ ThreadListEntry::upsert(&mut list.entries, entry.clone());399564
upserts.push(entry);400565
}566
+ let scope = list.scope.clone();401567
self.media.register(&scope, media).await;402568
let _ = self403569
.emit(Event::ThreadListChanged {@@ -407,6 +573,32 @@ impl ThreadLists {
407573
.await;408574
}409575
576
+ /// a refreshed first page, replacing whatever the list held577
+ async fn replace(&mut self, room_id: &OwnedRoomId, built: Vec<Built>) {578
+ let Some(list) = self.lists.get_mut(room_id) else {579
+ return;580
+ };581
+ let mut media = Vec::new();582
+ list.entries = Vector::new();583
+ for Built {584
+ entry,585
+ media: registrations,586
+ } in built587
+ {588
+ media.extend(registrations);589
+ ThreadListEntry::upsert(&mut list.entries, entry);590
+ }591
+ let entries = list.entries.clone();592
+ let scope = list.scope.clone();593
+ self.media.register(&scope, media).await;594
+ let _ = self595
+ .emit(Event::ThreadListReset {596
+ room_id: room_id.clone(),597
+ entries,598
+ })599
+ .await;600
+ }601
+410602
async fn emit(&self, event: Event) -> Result<(), Error> {411603
self.events412604
.send(event)@@ -421,16 +613,6 @@ impl List {
421613
.iter()422614
.position(|entry| &entry.root_event_id == root_event_id)423615
}424
-425
- fn upsert(&mut self, entry: ThreadListEntry) {426
- if let Some(index) = self.position(&entry.root_event_id) {427
- self.entries.remove(index);428
- }429
- let index = self430
- .entries431
- .partition_point(|known| known.sorts_before(&entry));432
- self.entries.insert(index, entry);433
- }434616
}435617
436618
fn current<'a>(@@ -447,24 +629,40 @@ fn not_open() -> Error {
447629
Error::new(ErrorKind::TimelineNotOpen, "thread list is not open")448630
}449631
450
-/// a reply newer than the thread's latest counts once. the event cache replays what it451
-/// already had on a reset or a reload, and back pagination brings older ones, neither of452
-/// which the server's count left out453
-fn add_reply(entry: &mut ThreadListEntry, reply: Normalised) -> Vec<MediaRegistration> {454
- if entry.summary.latest_event_id.as_ref() == Some(&reply.event_id) {455
- entry.latest_reply = Some(reply.item);456
- return reply.media;457
- }458
- if reply.timestamp_ms < entry.summary.latest_event_timestamp_ms.unwrap_or(0) {459
- return Vec::new();632
+/// the reply the thread already ends on only fills in its preview and time. anything else633
+/// counts once, when sync brought it and it's newer than a latest we know the time of. a634
+/// summary from the event cache names its latest reply without the event, so its time is635
+/// often unknown until that reply turns up636
+fn add_reply(637
+ entry: &mut ThreadListEntry,638
+ reply: Normalised,639
+ fresh: bool,640
+ counted: &mut HashSet<OwnedEventId>,641
+) -> Vec<MediaRegistration> {642
+ let Normalised {643
+ event_id,644
+ timestamp_ms,645
+ item,646
+ media,647
+ } = reply;648
+ if entry.summary.latest_event_id.as_ref() != Some(&event_id) {649
+ let newer = entry650
+ .summary651
+ .latest_event_timestamp_ms652
+ .is_none_or(|latest| timestamp_ms > latest);653
+ if !fresh || !newer || !counted.insert(event_id.clone()) {654
+ return Vec::new();655
+ }656
+ entry.summary.reply_count = entry.summary.reply_count.saturating_add(1);657
+ entry.summary.latest_event_id = Some(event_id);658
+ } else {659
+ counted.insert(event_id);460660
}461
- entry.summary.reply_count = entry.summary.reply_count.saturating_add(1);462
- entry.summary.latest_event_id = Some(reply.event_id);463
- entry.summary.latest_event_timestamp_ms = Some(reply.timestamp_ms);464
- entry.latest_activity_ms = entry.latest_activity_ms.max(reply.timestamp_ms);465
- entry.latest_reply = Some(reply.item);661
+ entry.summary.latest_event_timestamp_ms = Some(timestamp_ms);662
+ entry.latest_activity_ms = entry.latest_activity_ms.max(timestamp_ms);663
+ entry.latest_reply = Some(item);466664
entry.root = with_summary(entry.root.clone(), &entry.summary);467
- reply.media665
+ media468666
}469667
470668
fn with_summary(mut root: TimelineItem, summary: &ThreadSummary) -> TimelineItem {@@ -504,15 +702,27 @@ async fn fetch_root(
504702
room: &Room,505703
root_event_id: &OwnedEventId,506704
current_user_id: Option<OwnedUserId>,507
-) -> Option<Built> {508
- let root = room509
- .load_or_fetch_event(root_event_id, None)510
- .await511
- .inspect_err(512
- |error| tracing::debug!(%root_event_id, %error, "fetching a thread root failed"),513
- )514
- .ok()?;515
- build_entry(room, root, current_user_id.as_deref()).await705
+) -> Fetched {706
+ let root = match room.load_or_fetch_event(root_event_id, None).await {707
+ Ok(root) => root,708
+ Err(source) => {709
+ let error = Error::from_sdk("fetching a thread root failed", source);710
+ tracing::debug!(%root_event_id, %error, "fetching a thread root failed");711
+ let missing = error712
+ .server713
+ .as_ref()714
+ .is_some_and(|server| server.status == 404);715
+ return if missing {716
+ Fetched::Missing717
+ } else {718
+ Fetched::Failed719
+ };720
+ }721
+ };722
+ match build_entry(room, root, current_user_id.as_deref()).await {723
+ Some(built) => Fetched::Built(built),724
+ None => Fetched::Missing,725
+ }516726
}517727
518728
async fn build_entry(@@ -554,52 +764,63 @@ async fn build_entry(
554764
})555765
}556766
767
+/// the sdk's corrected timestamp, which a sender's clock running ahead can't push past now557768
async fn normalise(558769
room: &Room,559770
event: &SdkTimelineEvent,560771
current_user_id: Option<&ruma::UserId>,561772
) -> Option<Normalised> {562773
let event_id = event.event_id()?;774
+ let timestamp_ms = u64::from(event.timestamp()?.get());563775
let value: serde_json::Value = serde_json::from_str(event.raw().json().get()).ok()?;564776
let identity = format!("{}\n{event_id}", room.room_id());565777
let (mut item, media) = normalise_observed(&value, current_user_id, &identity)?;566778
let TimelineItem::Event(normalised) = &mut item else {567779
return None;568780
};781
+ normalised.timestamp_ms = timestamp_ms;569782
normalised.highlight = event570783
.push_actions()571784
.is_some_and(|actions| actions.iter().any(Action::is_highlight));572
- if let Some(sender) = sender(room, &normalised.sender.user_id).await {785
+ if let Some(sender) = member_sender(room, &normalised.sender.user_id).await {573786
normalised.sender = sender;574787
}575788
// a preview doesn't chase what a root or a reply was replying to576789
resolve_replies(std::iter::once(&mut **normalised), &HashMap::new());577790
Some(Normalised {578791
event_id,579
- timestamp_ms: normalised.timestamp_ms,792
+ timestamp_ms,580793
item,581794
media,582795
})583796
}584797
585
-async fn sender(room: &Room, user_id: &OwnedUserId) -> Option<Sender> {586
- let member = room.get_member(user_id).await.ok().flatten()?;587
- Some(Sender {588
- user_id: user_id.clone(),589
- display_name: member.display_name().map(str::to_owned),590
- avatar: member.avatar_url().map(MediaId::plain),591
- name_ambiguous: member.name_ambiguous(),592
- })798
+fn closure(state: RoomState) -> Option<ThreadListClosure> {799
+ match state {800
+ RoomState::Left => Some(ThreadListClosure::Left),801
+ RoomState::Banned => Some(ThreadListClosure::Banned),802
+ _ => None,803
+ }593804
}594805
595
-/// the room's event cache, followed for as long as the list is open. a lag loses whatever596
-/// it skipped until the thread moves again806
+/// the room's event cache and membership, followed for as long as the list is open597807
async fn listen(598808
room: Room,599809
generation: u64,600810
current_user_id: Option<OwnedUserId>,601811
arrivals: mpsc::UnboundedSender<Arrival>,602812
) {813
+ let room_id = room.room_id().to_owned();814
+ let left = |reason| Arrival::Left {815
+ room_id: room_id.clone(),816
+ generation,817
+ reason,818
+ };819
+ let mut info = room.subscribe_info();820
+ if let Some(reason) = closure(room.state()) {821
+ let _ = arrivals.send(left(reason));822
+ return;823
+ }603824
let subscribed = async {604825
let (cache, drop_handles) = room.event_cache().await?;605826
let (_, subscriber) = cache.subscribe().await?;@@ -609,16 +830,32 @@ async fn listen(
609830
let (_drop_handles, mut subscriber): (_, RoomEventCacheSubscriber) = match subscribed {610831
Ok(subscribed) => subscribed,611832
Err(error) => {612
- tracing::warn!(room_id = %room.room_id(), %error, "thread list can't follow the room");833
+ tracing::warn!(%room_id, %error, "thread list can't follow the room");613834
return;614835
}615836
};616
- let room_id = room.room_id().to_owned();617837
loop {618
- let update = match subscriber.recv().await {838
+ let update = tokio::select! {839
+ update = subscriber.recv() => update,840
+ Some(changed) = info.next() => {841
+ if let Some(reason) = closure(changed.state()) {842
+ let _ = arrivals.send(left(reason));843
+ return;844
+ }845
+ continue;846
+ }847
+ };848
+ let update = match update {619849
Ok(update) => update,620850
Err(RecvError::Lagged(skipped)) => {621
- tracing::warn!(%room_id, skipped, "thread list listener lagged");851
+ tracing::debug!(%room_id, skipped, "thread list listener lagged, refreshing");852
+ let lagged = Arrival::Lagged {853
+ room_id: room_id.clone(),854
+ generation,855
+ };856
+ if arrivals.send(lagged).is_err() {857
+ return;858
+ }622859
continue;623860
}624861
Err(RecvError::Closed) => return,@@ -626,14 +863,13 @@ async fn listen(
626863
let RoomEventCacheUpdate::UpdateTimelineEvents(update) = update else {627864
continue;628865
};629
- let paginated = matches!(update.origin, EventsOrigin::Pagination);630
- for event in added(update.diffs) {866
+ let synced = matches!(update.origin, EventsOrigin::Sync);867
+ for (event, inserted) in added(update.diffs) {631868
let thread_root = extract_thread_root(event.raw());632869
if thread_root.is_none() && event.thread_summary.summary().is_none() {633870
continue;634871
}635
- let Some(normalised) =636
- normalise(&room, &event, current_user_id.as_deref()).await872
+ let Some(normalised) = normalise(&room, &event, current_user_id.as_deref()).await637873
else {638874
continue;639875
};@@ -641,7 +877,7 @@ async fn listen(
641877
Some(root_event_id) => Live::Reply {642878
root_event_id,643879
reply: normalised,644
- paginated,880
+ fresh: synced && inserted,645881
},646882
None => Live::Root(normalised),647883
};@@ -657,15 +893,21 @@ async fn listen(
657893
}658894
}659895
660
-fn added(diffs: Vec<VectorDiff<SdkTimelineEvent>>) -> Vec<SdkTimelineEvent> {896
+/// each event with whether it's new to the cache, rather than a replacement or a reset897
+fn added(diffs: Vec<VectorDiff<SdkTimelineEvent>>) -> Vec<(SdkTimelineEvent, bool)> {661898
let mut events = Vec::new();662899
for diff in diffs {663900
match diff {664
- VectorDiff::Append { values } | VectorDiff::Reset { values } => events.extend(values),901
+ VectorDiff::Append { values } => {902
+ events.extend(values.into_iter().map(|event| (event, true)));903
+ }904
+ VectorDiff::Reset { values } => {905
+ events.extend(values.into_iter().map(|event| (event, false)));906
+ }665907
VectorDiff::PushBack { value }666908
| VectorDiff::PushFront { value }667
- | VectorDiff::Insert { value, .. }668
- | VectorDiff::Set { value, .. } => events.push(value),909
+ | VectorDiff::Insert { value, .. } => events.push((value, true)),910
+ VectorDiff::Set { value, .. } => events.push((value, false)),669911
VectorDiff::Clear670912
| VectorDiff::PopBack671913
| VectorDiff::PopFront@@ -691,31 +933,33 @@ mod tests {
691933
}692934
}693935
694
- fn entry(root: &str, latest_activity_ms: u64) -> ThreadListEntry {936
+ fn entry(root: &str, latest_timestamp_ms: Option<u64>) -> ThreadListEntry {695937
ThreadListEntry {696938
root_event_id: root.try_into().unwrap(),697939
root: TimelineItem::DayDivider { timestamp_ms: 0 },698940
summary: ThreadSummary {699941
reply_count: 2,700942
latest_event_id: Some(owned_event_id!("$latest")),701
- latest_event_timestamp_ms: Some(latest_activity_ms),943
+ latest_event_timestamp_ms: latest_timestamp_ms,702944
},703945
latest_reply: None,704
- latest_activity_ms,946
+ latest_activity_ms: latest_timestamp_ms.unwrap_or(1),705947
}706948
}707949
708950
#[test]709951
fn a_reply_counts_once_and_only_when_newer() {710
- let mut thread = entry("$root", 20);711
- add_reply(&mut thread, reply("$latest", 20));712
- add_reply(&mut thread, reply("$older", 10));952
+ let mut counted = HashSet::new();953
+ let mut thread = entry("$root", Some(20));954
+ add_reply(&mut thread, reply("$latest", 20), true, &mut counted);955
+ add_reply(&mut thread, reply("$older", 10), true, &mut counted);956
+ add_reply(&mut thread, reply("$replayed", 40), false, &mut counted);713957
assert_eq!(714958
thread.summary.reply_count, 2,715
- "a replayed or paginated reply was counted"959
+ "a replayed or older reply was counted"716960
);717
- add_reply(&mut thread, reply("$newer", 30));718
- add_reply(&mut thread, reply("$newer", 30));961
+ add_reply(&mut thread, reply("$newer", 30), true, &mut counted);962
+ add_reply(&mut thread, reply("$newer", 30), true, &mut counted);719963
assert_eq!(thread.summary.reply_count, 3);720964
assert_eq!(thread.latest_activity_ms, 30);721965
assert_eq!(@@ -725,12 +969,24 @@ mod tests {
725969
}726970
727971
#[test]972
+ fn the_latest_reply_dates_a_summary_without_one() {973
+ let mut counted = HashSet::new();974
+ let mut thread = entry("$root", None);975
+ add_reply(&mut thread, reply("$latest", 50), false, &mut counted);976
+ assert_eq!(thread.summary.reply_count, 2);977
+ assert_eq!(thread.latest_activity_ms, 50);978
+ add_reply(&mut thread, reply("$latest", 50), true, &mut counted);979
+ add_reply(&mut thread, reply("$replayed", 60), false, &mut counted);980
+ assert_eq!(thread.summary.reply_count, 2, "a replay moved the count");981
+ }982
+983
+ #[test]728984
fn upserts_keep_newest_activity_first() {729
- let mut entries = imbl::Vector::new();985
+ let mut entries = Vector::new();730986
for (root, activity) in [("$a", 10), ("$b", 30), ("$c", 20), ("$d", 20)] {731
- ThreadListEntry::upsert(&mut entries, entry(root, activity));987
+ ThreadListEntry::upsert(&mut entries, entry(root, Some(activity)));732988
}733
- ThreadListEntry::upsert(&mut entries, entry("$a", 40));989
+ ThreadListEntry::upsert(&mut entries, entry("$a", Some(40)));734990
let order: Vec<_> = entries735991
.iter()736992
.map(|entry| entry.root_event_id.as_str())@@ -1,5 +1,5 @@
11
use std::{2
- collections::{BTreeMap, HashMap, HashSet, VecDeque},2
+ collections::{HashMap, HashSet, VecDeque},33
sync::Arc,44
time::Duration,55
};@@ -7,6 +7,8 @@ use std::{
77
use futures_util::StreamExt;88
use matrix_sdk::{99
Client, Room,10
+ event_handler::EventHandlerHandle,11
+ serde_helpers::extract_thread_root,1012
ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},1113
send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},1214
};@@ -16,19 +18,23 @@ use ruma::{
1618
OwnedEventId, OwnedRoomId, OwnedTransactionId,1719
events::{1820
AnyGlobalAccountDataEvent, AnyRoomAccountDataEvent, AnySyncStateEvent,19
- receipt::SyncReceiptEvent,21
+ AnySyncTimelineEvent, receipt::SyncReceiptEvent,2022
},2123
serde::Raw,2224
time::Instant,2325
};24
-use tokio::sync::{Notify, broadcast::error::RecvError, mpsc, watch};26
+use tokio::sync::{27
+ Notify,28
+ broadcast::error::{RecvError, TryRecvError},29
+ mpsc, watch,30
+};2531
2632
use super::executor::TaskSet;2733
use crate::{28
- ConnectionState, Event, ThreadUnread, UploadOutcome,34
+ ConnectionState, Event, UploadOutcome,2935
encryption::encryption_state,3036
error::classify_sdk_error,31
- normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, thread_unreads},37
+ normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, recount_thread_unreads},3238
space_order,3339
transport::EventSender,3440
};@@ -436,71 +442,122 @@ pub(super) async fn rooms(client: Client, events: EventSender) {
436442
}437443
}),438444
];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
- });445
+ let (touches, mut touched) = mpsc::unbounded_channel();446
+ let thread_handlers = thread_handlers(&client, &touches);444447
let mut timelines = client.event_cache().subscribe_to_room_generic_updates();445
- let mut thread_unreads = ThreadUnreads::default();446448
'invalidate: loop {447449
if events.send(Event::RoomsChanged).await.is_err() {448450
break;449451
}450452
// thread events and threaded receipts leave room info alone, so neither reaches `info`453
+ let mut pending = Touched::default();451454
loop {452
- let room_id = tokio::select! {453
- update = rooms.next() => if update.is_none() { break 'invalidate } else { break },455
+ let mut invalidate = tokio::select! {456
+ update = rooms.next() => if update.is_none() { break 'invalidate } else { true },454457
update = info.recv() => {455458
if matches!(update, Err(RecvError::Closed)) { break 'invalidate }456
- break;459
+ true460
+ }461
+ () = changed.notified() => true,462
+ Some((room_id, root)) = touched.recv() => {463
+ pending.add(room_id, root);464
+ false457465
}458
- () = changed.notified() => break,459
- Some(room_id) = receipt_rooms.recv() => room_id,460466
update = timelines.recv() => match update {461
- Ok(update) => update.room_id,462
- Err(RecvError::Lagged(_)) => {463
- thread_unreads.0.clear();464
- break;467
+ Ok(update) => {468
+ pending.add(update.room_id, None);469
+ false465470
}471
+ Err(RecvError::Lagged(_)) => true,466472
Err(RecvError::Closed) => break 'invalidate,467473
},468474
};469
- if thread_unreads.changed(&client, room_id).await {475
+ // a sync's room info lands before the event cache has stored its events, so476
+ // give it a moment and take the whole sync as one invalidation477
+ matrix_sdk::sleep::sleep(SYNC_SETTLE).await;478
+ loop {479
+ match info.try_recv() {480
+ Ok(_) | Err(TryRecvError::Lagged(_)) => invalidate = true,481
+ Err(_) => break,482
+ }483
+ }484
+ while let Ok((room_id, root)) = touched.try_recv() {485
+ pending.add(room_id, root);486
+ }487
+ loop {488
+ match timelines.try_recv() {489
+ Ok(update) => pending.add(update.room_id, None),490
+ Err(TryRecvError::Lagged(_)) => invalidate = true,491
+ Err(_) => break,492
+ }493
+ }494
+ for (room_id, roots) in pending.0.drain() {495
+ if let Some(room) = client.get_room(&room_id)496
+ && recount_thread_unreads(&room, roots).await.is_some()497
+ {498
+ invalidate = true;499
+ }500
+ }501
+ if invalidate {470502
break;471503
}472504
}473
- // a sync touching fifty rooms is one invalidation, not fifty474
- while let Ok(_) | Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) =475
- info.try_recv()476
- {}477505
}478
- for handler in handlers.into_iter().chain([receipt_handler]) {506
+ for handler in handlers.into_iter().chain(thread_handlers) {479507
client.remove_event_handler(handler);480508
}481509
}482510
511
+const SYNC_SETTLE: Duration = Duration::from_millis(100);512
+513
+/// rooms to recount, with the threads a sync brought news of483514
#[derive(Default)]484
-struct ThreadUnreads(HashMap<OwnedRoomId, BTreeMap<OwnedEventId, ThreadUnread>>);515
+struct Touched(HashMap<OwnedRoomId, HashSet<OwnedEventId>>);485516
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 self493
- .0494
- .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
- true517
+impl Touched {518
+ fn add(&mut self, room_id: OwnedRoomId, root: Option<OwnedEventId>) {519
+ let roots = self.0.entry(room_id).or_default();520
+ roots.extend(root);501521
}502522
}503523
524
+/// a thread event names its thread before the event cache has it, and only the user's own525
+/// receipts move their counts526
+fn thread_handlers(527
+ client: &Client,528
+ touches: &mpsc::UnboundedSender<(OwnedRoomId, Option<OwnedEventId>)>,529
+) -> [EventHandlerHandle; 2] {530
+ let own_user_id = client.user_id().map(ToOwned::to_owned);531
+ [532
+ client.add_event_handler({533
+ let touches = touches.clone();534
+ move |event: Raw<AnySyncTimelineEvent>, room: Room| {535
+ if let Some(root) = extract_thread_root(&event) {536
+ let _ = touches.send((room.room_id().to_owned(), Some(root)));537
+ }538
+ async {}539
+ }540
+ }),541
+ client.add_event_handler({542
+ let touches = touches.clone();543
+ move |event: SyncReceiptEvent, room: Room| {544
+ let own = own_user_id.as_ref().is_some_and(|own_user_id| {545
+ event546
+ .content547
+ .0548
+ .values()549
+ .flat_map(|receipts| receipts.values())550
+ .any(|users| users.contains_key(own_user_id))551
+ });552
+ if own {553
+ let _ = touches.send((room.room_id().to_owned(), None));554
+ }555
+ async {}556
+ }557
+ }),558
+ ]559
+}560
+504561
/// packs live in room state and account data, and leaving a room takes its packs with it505562
pub(super) async fn image_packs(client: Client, events: EventSender) {506563
let changed = Arc::new(Notify::new());@@ -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, ThreadListCommand, ThreadListEntry,71
+ RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadListClosure,72
+ ThreadListCommand, ThreadListEntry,7273
ThreadSummary, ThreadUnread, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,7374
TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,7475
UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,@@ -21,7 +21,8 @@ use tokio::sync::broadcast;
2121
use crate::{2222
ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,2323
EventContextCloseReason, HistoryRepairReport, HistoryState, ImagePacks, RecentHistoryState,24
- RoomSummary, ThreadListEntry, TimelineCommand, TimelineDiff, TimelineId, TimelineItem, TypingState,24
+ RoomSummary, ThreadListClosure, ThreadListEntry, TimelineCommand, TimelineDiff, TimelineId,25
+ TimelineItem, TypingState,2526
UploadOutcome, VerificationState, VerificationTarget, engine::executor::EngineBoxFuture,2627
transport::PublicationRegistration,2728
};@@ -200,7 +201,7 @@ pub enum ThreadListChange {
200201
entries: Vector<ThreadListEntry>,201202
},202203
History(HistoryState),203
- Closed,204
+ Closed(ThreadListClosure),204205
}205206
206207
/// drains the event stream into a `State` that any number of `MirrorHandle`s watch@@ -482,11 +483,11 @@ impl Mirror {
482483
change: ThreadListChange::History(history),483484
})484485
}),485
- Event::ThreadListClosed { room_id } => self.publish(|state| {486
+ Event::ThreadListClosed { room_id, reason } => self.publish(|state| {486487
state.thread_lists.remove(&room_id)?;487488
Some(StateChange::ThreadList {488489
room_id,489
- change: ThreadListChange::Closed,490
+ change: ThreadListChange::Closed(reason),490491
})491492
}),492493
Event::TypingChanged(typing) => self.publish(|state| {@@ -982,6 +982,7 @@ pub enum Event {
982982
/// nothing more comes for it until another `ThreadListReset`983983
ThreadListClosed {984984
room_id: OwnedRoomId,985
+ reason: ThreadListClosure,985986
},986987
/// a room that stops being watched says so with an empty `users`987988
TypingChanged(TypingState),@@ -1914,6 +1915,16 @@ pub struct ThreadSummary {
19141915
pub latest_event_timestamp_ms: Option<u64>,19151916
}19161917
1918
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]1919
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1920
+pub enum ThreadListClosure {1921
+ /// `Close` asked for it1922
+ Requested,1923
+ /// left or kicked1924
+ Left,1925
+ Banned,1926
+}1927
+19171928
#[derive(Clone, Debug, Eq, PartialEq)]19181929
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]19191930
#[non_exhaustive]@@ -1,7 +1,7 @@
1
-use matrix_sdk::room::RoomMember;2
-use ruma::events::room::member::MembershipState;1
+use matrix_sdk::{Room, room::RoomMember};2
+use ruma::{OwnedUserId, events::room::member::MembershipState};33
4
-use crate::{MediaId, MemberMembership, MemberSummary};4
+use crate::{MediaId, MemberMembership, MemberSummary, Sender};55
66
pub(crate) fn member_summary(member: RoomMember) -> MemberSummary {77
MemberSummary {@@ -23,3 +23,13 @@ pub(crate) fn member_summary(member: RoomMember) -> MemberSummary {
2323
service: member.is_service_member(),2424
}2525
}26
+27
+pub(crate) async fn member_sender(room: &Room, user_id: &OwnedUserId) -> Option<Sender> {28
+ let member = room.get_member(user_id).await.ok().flatten()?;29
+ Some(Sender {30
+ user_id: user_id.clone(),31
+ display_name: member.display_name().map(str::to_owned),32
+ avatar: member.avatar_url().map(MediaId::plain),33
+ name_ambiguous: member.name_ambiguous(),34
+ })35
+}@@ -15,9 +15,9 @@ pub(crate) use image_pack::{
1515
};1616
pub(crate) use link_preview::{ROOM_PREVIEW_URLS, server_preview, url_previews_allowed};1717
pub(crate) use media::{MediaRegistration, MediaScope, MediaSourceInfo};18
-pub(crate) use member::member_summary;18
+pub(crate) use member::{member_sender, member_summary};1919
pub(crate) use observed::normalise_observed;2020
pub(crate) use room::notification_mode;2121
pub(crate) use room::{room_summaries, space_child, stored_space_order};22
-pub(crate) use thread::thread_unreads;22
+pub(crate) use thread::{recount_thread_unreads, stored_thread_unreads};2323
pub(crate) use timeline::{message_kind, normalise_timeline, normalise_timeline_diffs};@@ -62,7 +62,7 @@ async fn room_summary(room: &Room, spaces: &SpaceService) -> RoomSummary {
6262
} else {6363
Vec::new()6464
};65
- let thread_unreads = super::thread_unreads(room).await;65
+ let thread_unreads = super::stored_thread_unreads(room).await;6666
RoomSummary {6767
room_id: room.room_id().to_owned(),6868
name,@@ -1,115 +1,157 @@
1
-use std::collections::{BTreeMap, HashMap};1
+use std::collections::{BTreeMap, HashSet};22
33
use matrix_sdk::{4
- Room, RoomState, deserialized_responses::TimelineEvent, serde_helpers::extract_thread_root,4
+ Room, RoomState, cross_process_lock::MappedCrossProcessLockState,5
+ deserialized_responses::TimelineEvent,56
};7
+use matrix_sdk_base::event_cache::store::EventCacheStoreLockGuard;68
use ruma::{7
- OwnedEventId, UserId,8
- events::receipt::{ReceiptThread, ReceiptType},9
+ OwnedEventId,10
+ events::{11
+ receipt::{ReceiptThread, ReceiptType},12
+ relation::RelationType,13
+ },914
};10
-use serde_json::Value;15
+use serde_json::{Value, json};1116
1217
use crate::ThreadUnread;1318
14
-struct Reply {15
- event_id: Option<OwnedEventId>,16
- own: bool,17
- counts: bool,18
- notify: bool,19
- highlight: bool,19
+fn key(room: &Room) -> Vec<u8> {20
+ format!("macltok.thread_unreads.{}", room.room_id()).into_bytes()2021
}2122
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()),23
+/// what the last recount left, so a room list never touches the event cache24
+pub(crate) async fn stored_thread_unreads(room: &Room) -> BTreeMap<OwnedEventId, ThreadUnread> {25
+ if room.state() != RoomState::Joined {26
+ return BTreeMap::new();27
+ }28
+ match room.client().state_store().get_custom_value(&key(room)).await {29
+ Ok(Some(bytes)) => decode(&bytes),30
+ Ok(None) => BTreeMap::new(),31
+ Err(error) => {32
+ tracing::debug!(room_id = %room.room_id(), %error, "reading thread unreads failed");33
+ BTreeMap::new()3334
}3435
}3536
}3637
3738
/// the sdk leaves thread events out of the room's counts once threads are on and keeps none38
-/// of its own for them. so these are worked out the same way over the events it has in39
-/// memory, newest first until a receipt for that thread (msc3771), an unthreaded receipt or40
-/// the user's own reply. a receipt older than memory counts everything in memory, as the41
-/// sdk's does42
-pub(crate) async fn thread_unreads(room: &Room) -> BTreeMap<OwnedEventId, ThreadUnread> {43
- if room.state() != RoomState::Joined {44
- return BTreeMap::new();39
+/// of its own for them. this counts `touched` and every thread that still had something40
+/// unread from the event cache store, which holds every reply it ever took in whatever is41
+/// in memory, against a receipt for that thread (msc3771), an unthreaded one or the user's42
+/// own reply. `None` when nothing moved43
+pub(crate) async fn recount_thread_unreads(44
+ room: &Room,45
+ touched: HashSet<OwnedEventId>,46
+) -> Option<BTreeMap<OwnedEventId, ThreadUnread>> {47
+ let known = stored_thread_unreads(room).await;48
+ let mut roots = touched;49
+ roots.extend(known.keys().cloned());50
+ if roots.is_empty() || room.state() != RoomState::Joined {51
+ return None;4552
}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();53
+ let store = match room.client().event_cache_store().lock().await {54
+ Ok(MappedCrossProcessLockState::Clean(store) | MappedCrossProcessLockState::Dirty(store)) => {55
+ store56
+ }57
+ Err(error) => {58
+ tracing::debug!(room_id = %room.room_id(), %error, "locking the event cache store failed");59
+ return None;60
+ }5061
};51
- let mut threads: HashMap<OwnedEventId, Vec<Reply>> = HashMap::new();52
- let scanned = cache53
- .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(());62
+ let own_user_id = room.own_user_id();63
+ let unthreaded = read_through(room, &store, ReceiptThread::Unthreaded).await;64
+ let mut counted = BTreeMap::new();65
+ for root in roots {66
+ let replies = match store67
+ .find_event_relations(room.room_id(), &root, Some(&[RelationType::Thread][..]))68
+ .await69
+ {70
+ Ok(replies) => replies,71
+ Err(error) => {72
+ tracing::debug!(room_id = %room.room_id(), %root, %error, "reading a thread failed");73
+ if let Some(unread) = known.get(&root) {74
+ counted.insert(root, *unread);75
+ }76
+ continue;5777
}58
- if let Some(root) = extract_thread_root(event.raw()) {59
- threads60
- .entry(root)61
- .or_default()62
- .push(Reply::of(event, own_user_id));78
+ };79
+ let mut read = unthreaded.max(read_through(room, &store, ReceiptThread::Thread(root.clone())).await);80
+ for (reply, _) in &replies {81
+ if reply.sender().as_deref() == Some(own_user_id) {82
+ read = read.max(timestamp_ms(reply));6383
}64
- None65
- })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;84
+ }7485
let mut unread = ThreadUnread::default();75
- for reply in replies {76
- if reply.own77
- || reply78
- .event_id79
- .as_ref()80
- .is_some_and(|event_id| read.contains(event_id))86
+ for (reply, _) in &replies {87
+ let Some(at) = timestamp_ms(reply) else {88
+ continue;89
+ };90
+ if read.is_some_and(|read| at <= read)91
+ || reply.sender().as_deref() == Some(own_user_id)92
+ || !counts_as_unread(reply)8193
{82
- break;94
+ continue;8395
}84
- unread.messages += u64::from(reply.counts);85
- unread.notifications += u64::from(reply.notify);86
- unread.mentions += u64::from(reply.highlight);96
+ let actions = reply.push_actions().unwrap_or_default();97
+ unread.messages += 1;98
+ unread.notifications += u64::from(actions.iter().any(|action| action.should_notify()));99
+ unread.mentions += u64::from(actions.iter().any(|action| action.is_highlight()));87100
}88101
if unread != ThreadUnread::default() {89
- unreads.insert(root, unread);102
+ counted.insert(root, unread);90103
}91104
}92
- unreads105
+ if counted == known {106
+ return None;107
+ }108
+ let stored = room109
+ .client()110
+ .state_store()111
+ .set_custom_value(&key(room), encode(&counted))112
+ .await;113
+ if let Err(error) = stored {114
+ tracing::debug!(room_id = %room.room_id(), %error, "saving thread unreads failed");115
+ }116
+ Some(counted)93117
}94118
95
-async fn own_receipts(room: &Room, thread: ReceiptThread) -> Vec<OwnedEventId> {96
- let mut found = Vec::new();119
+/// receipts sit on events, and stream order between a thread and the rest of the room is120
+/// gone once it's in the store, so the event's timestamp stands for its place. a receipt121
+/// on an event the store never had falls back to when it was sent122
+async fn read_through(123
+ room: &Room,124
+ store: &EventCacheStoreLockGuard,125
+ thread: ReceiptThread,126
+) -> Option<u64> {127
+ let mut latest = None;97128
for receipt_type in [ReceiptType::Read, ReceiptType::ReadPrivate] {98
- match room129
+ let (event_id, receipt) = match room99130
.load_user_receipt(receipt_type, thread.clone(), room.own_user_id())100131
.await101132
{102
- Ok(Some((event_id, _))) => found.push(event_id),103
- Ok(None) => {}133
+ Ok(Some(found)) => found,134
+ Ok(None) => continue,104135
Err(error) => {105136
tracing::debug!(room_id = %room.room_id(), %error, "reading a receipt failed");137
+ continue;106138
}139
+ };140
+ let at = match store.find_event(room.room_id(), &event_id).await {141
+ Ok(Some(event)) => timestamp_ms(&event),142
+ _ => None,107143
}144
+ .or_else(|| receipt.ts.map(|ts| u64::from(ts.get())));145
+ latest = latest.max(at);108146
}109
- found147
+ latest110148
}111149
112
-/// the sdk's rule for its room counts, which is private to it150
+fn timestamp_ms(event: &TimelineEvent) -> Option<u64> {151
+ event.timestamp().map(|ts| u64::from(ts.get()))152
+}153
+154
+/// the sdk's `marks_as_unread` for its room counts, which is private to it113155
fn counts_as_unread(event: &TimelineEvent) -> bool {114156
let raw = event.raw();115157
let redacted = raw@@ -117,10 +159,22 @@ fn counts_as_unread(event: &TimelineEvent) -> bool {
117159
.ok()118160
.flatten()119161
.is_some_and(|unsigned| unsigned.get("redacted_because").is_some());162
+ let edit = raw163
+ .get_field::<Value>("content")164
+ .ok()165
+ .flatten()166
+ .and_then(|content| {167
+ content168
+ .pointer("/m.relates_to/rel_type")169
+ .and_then(Value::as_str)170
+ .map(|rel_type| rel_type == "m.replace")171
+ })172
+ .unwrap_or(false);120173
let message_like = matches!(121174
raw.get_field::<String>("type").ok().flatten().as_deref(),122175
Some(123
- "m.room.message"176
+ "m.message"177
+ | "m.room.message"124178
| "m.room.encrypted"125179
| "m.sticker"126180
| "m.poll.start"@@ -129,5 +183,37 @@ fn counts_as_unread(event: &TimelineEvent) -> bool {
129183
| "org.matrix.msc3381.poll.end"130184
)131185
);132
- message_like && !redacted186
+ message_like && !edit && !redacted187
+}188
+189
+fn encode(unreads: &BTreeMap<OwnedEventId, ThreadUnread>) -> Vec<u8> {190
+ let value: serde_json::Map<String, Value> = unreads191
+ .iter()192
+ .map(|(root, unread)| {193
+ (194
+ root.to_string(),195
+ json!([unread.messages, unread.mentions, unread.notifications]),196
+ )197
+ })198
+ .collect();199
+ serde_json::to_vec(&value).unwrap_or_default()200
+}201
+202
+fn decode(bytes: &[u8]) -> BTreeMap<OwnedEventId, ThreadUnread> {203
+ let Ok(value) = serde_json::from_slice::<BTreeMap<OwnedEventId, [u64; 3]>>(bytes) else {204
+ return BTreeMap::new();205
+ };206
+ value207
+ .into_iter()208
+ .map(|(root, [messages, mentions, notifications])| {209
+ (210
+ root,211
+ ThreadUnread {212
+ messages,213
+ mentions,214
+ notifications,215
+ },216
+ )217
+ })218
+ .collect()133219
}@@ -7,6 +7,7 @@ use macltok::{
77
Termination, ThreadUnread,88
};99
use matrix_sdk::{10
+ ThreadingSupport,1011
ruma::{1112
RoomId,1213
api::client::sync::sync_events::v5,@@ -254,15 +255,30 @@ async fn thread_unreads_follow_threaded_receipts() {
254255
.ok(v5::Response::new("1".to_owned()))255256
.mount()256257
.await;257
- let client = server.client_builder().build().await;258
+ let client = server259
+ .client_builder()260
+ .on_builder(|builder| {261
+ builder.with_threading_support(ThreadingSupport::Enabled {262
+ with_subscriptions: false,263
+ })264
+ })265
+ .build()266
+ .await;258267
let room_id = room_id!("!threads:localhost");268
+ let own = client.user_id().unwrap().to_owned();259269
server.mock_room_state_encryption().plain().mount().await;260
- server.sync_joined_room(&client, room_id).await;261270
client.event_cache().subscribe().unwrap();271
+ // without our own member event there's no push context, so no push actions to count272
+ server273
+ .sync_room(274
+ &client,275
+ JoinedRoomBuilder::new(room_id)276
+ .add_state_event(EventFactory::new().room(room_id).member(&own)),277
+ )278
+ .await;262279
let config = EngineConfig::default().with_initial_history_limit(0);263280
let (engine, handle, mut events) = Engine::from_client(client.clone(), config).await.unwrap();264281
let running = tokio::spawn(engine.run());265
- let own = client.user_id().unwrap().to_owned();266282
267283
let (root, first, second) = (268284
event_id!("$root"),