atagen

macltok

public
Star0
Watch0

Commit 21b9227729a4

tarball

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

12 files changed+766-318
threads: count from the store and stop list counts drifting

Thread unreads counted over the event cache's memory window moved whenever a room closed, the cache shrank or the app restarted, and recounting made an event cache for every cold room. They are now counted from the event cache store's thread relations and kept in the state store, so rooms() never touches the event cache. Replays still inflated thread list counts, since an unknown latest timestamp read as zero, so only a reply sync just inserted, newer than a dated latest and not seen before now counts. A lag refetches the first page behind a ThreadListReset, and a leave, kick or ban closes the list with a ThreadListClosure.
Modified file: docs/design-decisions.md+41 additions−22 deletions
@@ -138,16 +138,23 @@ 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, only when it's newer than the thread's latest, and a reply sync
142-brings to a thread we haven't loaded fetches its root and adds it. one a back
143-pagination brings doesn't, since that says nothing about the thread being
144-active. a redacted reply doesn't uncount, an edit doesn't change the latest
145-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 the
147-thread moves again
148-
149-a list's media belongs to its own scope, bounded like an event context's so a
150-long open list sheds the previews its threads moved past, and released on close
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
147+says nothing about the thread being active, and a root the server doesn't have
148+isn't asked for again. a redacted reply doesn't uncount, an edit doesn't change
149+the latest reply's preview, and a preview's own reply target comes back
150+`Unavailable` rather than fetched
151+
152+a lagged listener can't know what it missed, so the list fetches its first page
153+again and sends a `ThreadListReset` with it. leaving the room, or being kicked
154+or banned, closes the list with `ThreadListClosed` naming why, the same as
155+`Close` does. a list's media belongs to its own scope, bounded like an event
156+context's so a long open list sheds the previews its threads moved past, and
157+released on close
151158
152159 ## warming up to send
153160
@@ -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 sync
357-touching fifty rooms is one event. a left room stays in the list with its
364+touching fifty rooms is one event. a sync's room info lands before the event
365+cache has stored its events, so the watcher waits a tenth of a second after
366+anything wakes it and takes the lot as one. an event cache slower than that
367+costs a second one. a left room stays in the list with its
358368 membership until it's forgotten. push rule changes never touch room info, so
359369 `NotificationSettingsChanged` brings a `RoomsChanged` along for the modes in
360370 the summaries
361371
362372 with threads on, the sdk leaves thread events out of a room's counts and keeps
363-none of its own for them. `thread_unreads` works them out per root the way the
364-sdk does a room's, over the events its cache holds in memory, newest first until
365-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 count
367-in the room's `unread_mentions`, so nobody misses one, while its messages and
368-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 list
370-watcher also recounts a room on each event cache update and each receipt, and
371-only says `RoomsChanged` when the counts moved. counting in the sdk would be
372-cheaper and survive memory being trimmed, but that's a behaviour change in the
373-fork
373+none of its own for them. `thread_unreads` counts them per root from the event
374+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. a
376+reply counts once it's newer than a receipt for its thread (msc3771), an
377+unthreaded one, or the user's own reply. a main-timeline receipt doesn't reach
378+into threads. the store has lost stream order between a thread and the rest of
379+the room, so an event's timestamp stands for its place, and a receipt on an
380+event the store never had falls back to the receipt's own time. a thread's
381+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 main
383+timeline's read marker
384+
385+the counts are kept in the state store per room, only for threads with
386+something unread, so `rooms` reads them without touching the event cache and a
387+cold room never gets one made for it. thread events and threaded receipts leave
388+room info alone, so the room list watcher recounts on its own. a synced thread
389+event names the thread to count, an event cache update recounts the room's
390+threads already holding something unread, since a late decryption can turn up
391+a mention, and so does a receipt of the user's own. counting in the sdk would
392+be cheaper, but that's a behaviour change in the fork
374393
375394 ## link previews
376395
Modified file: src/engine/context.rs+4 additions−13 deletions
@@ -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 .await
540531 .into_iter()
Modified file: src/engine/thread_list.rs+408 additions−152 deletions
@@ -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 yet
34+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 active
64- paginated: bool,
78+ /// sync brought it into the room just now. a replay, a reload from the store or a
79+ /// back pagination says nothing about the thread moving
80+ 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 show
88+ 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 it
103+enum Load {
104+ Idle,
105+ Page(Vec<Responder>),
106+ /// the first page again, replacing everything. whoever waits wants the page after it
107+ 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 it
87- paginating: Vec<Responder>,
88- /// roots being fetched, with the replies that arrived meanwhile
117+ page_limit: u16,
118+ load: Load,
119+ refresh_owed: bool,
120+ /// replies already counted, so the same event synced twice counts once
121+ counted: HashSet<OwnedEventId>,
122+ /// roots being fetched or waiting for a slot, with the replies that arrived meanwhile
89123 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 = self
187+ .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 names
262+ 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 _ = self
224284 .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 }).await
241306 }
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 = page
294- .entries
295- .into_iter()
296- .filter(|built| {
297- list.position(&built.entry.root_event_id)
298- .is_none_or(|index| {
299- list.entries[index].latest_activity_ms
300- <= built.entry.latest_activity_ms
301- })
302- })
303- .collect();
304376 let state = if list.end_reached {
305377 HistoryState::StartReached
306378 } else {
307379 HistoryState::Idle
308380 };
309- self.publish(&room_id, fresh).await;
381+ if refresh {
382+ self.replace(&room_id, page.entries).await;
383+ } else {
384+ let fresh = page
385+ .entries
386+ .into_iter()
387+ .filter(|built| {
388+ list.position(&built.entry.root_event_id)
389+ .is_none_or(|index| {
390+ list.entries[index].latest_activity_ms
391+ <= built.entry.latest_activity_ms
392+ })
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 _ = self
318- .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+ waiting
412+ } 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 slots
477+ 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+ None
537+ }
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 event
381547 /// context's, so previews a long open list replaced age out, and it goes on close
382548 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 built
396561 {
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 _ = self
403569 .emit(Event::ThreadListChanged {
@@ -407,6 +573,32 @@ impl ThreadLists {
407573 .await;
408574 }
409575
576+ /// a refreshed first page, replacing whatever the list held
577+ 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 built
587+ {
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 _ = self
595+ .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.events
412604 .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 = self
430- .entries
431- .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 it
451-/// already had on a reset or a reload, and back pagination brings older ones, neither of
452-/// which the server's count left out
453-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 else
633+/// counts once, when sync brought it and it's newer than a latest we know the time of. a
634+/// summary from the event cache names its latest reply without the event, so its time is
635+/// often unknown until that reply turns up
636+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 = entry
650+ .summary
651+ .latest_event_timestamp_ms
652+ .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.media
665+ media
468666 }
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 = room
509- .load_or_fetch_event(root_event_id, None)
510- .await
511- .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()).await
705+) -> 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 = error
712+ .server
713+ .as_ref()
714+ .is_some_and(|server| server.status == 404);
715+ return if missing {
716+ Fetched::Missing
717+ } else {
718+ Fetched::Failed
719+ };
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 now
557768 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 = event
570783 .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 to
576789 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 whatever
596-/// it skipped until the thread moves again
806+/// the room's event cache and membership, followed for as long as the list is open
597807 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()).await
872+ let Some(normalised) = normalise(&room, &event, current_user_id.as_deref()).await
637873 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 reset
897+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::Clear
670912 | VectorDiff::PopBack
671913 | 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<_> = entries
735991 .iter()
736992 .map(|entry| entry.root_event_id.as_str())
Modified file: src/engine/watchers.rs+99 additions−42 deletions
@@ -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+ true
460+ }
461+ () = changed.notified() => true,
462+ Some((room_id, root)) = touched.recv() => {
463+ pending.add(room_id, root);
464+ false
457465 }
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+ false
465470 }
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, so
476+ // give it a moment and take the whole sync as one invalidation
477+ 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 fifty
474- 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 of
483514 #[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 self
493- .0
494- .get(&room_id)
495- .map_or(unreads.is_empty(), |known| *known == unreads)
496- {
497- return false;
498- }
499- self.0.insert(room_id, unreads);
500- true
517+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 own
525+/// receipts move their counts
526+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+ event
546+ .content
547+ .0
548+ .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 it
505562 pub(super) async fn image_packs(client: Client, events: EventSender) {
506563 let changed = Arc::new(Notify::new());
Modified file: src/lib.rs+2 additions−1 deletions
@@ -68,7 +68,8 @@ pub use model::{
6868 RecentHistoryState, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText,
6969 RoomCategory, RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership,
7070 RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary,
71- RoomVisibility, SendState, Sender, SpaceChild, Termination, 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,
Modified file: src/mirror.rs+5 additions−4 deletions
@@ -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| {
Modified file: src/model.rs+11 additions
@@ -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 it
1922+ Requested,
1923+ /// left or kicked
1924+ Left,
1925+ Banned,
1926+}
1927+
19171928 #[derive(Clone, Debug, Eq, PartialEq)]
19181929 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
19191930 #[non_exhaustive]
Modified file: src/normalise/member.rs+13 additions−3 deletions
@@ -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+}
Modified file: src/normalise/mod.rs+2 additions−2 deletions
@@ -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};
Modified file: src/normalise/room.rs+1 additions−1 deletions
@@ -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,
Modified file: src/normalise/thread.rs+161 additions−75 deletions
@@ -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 cache
24+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 none
38-/// of its own for them. so these are worked out the same way over the events it has in
39-/// memory, newest first until a receipt for that thread (msc3771), an unthreaded receipt or
40-/// the user's own reply. a receipt older than memory counts everything in memory, as the
41-/// sdk's does
42-pub(crate) async fn thread_unreads(room: &Room) -> BTreeMap<OwnedEventId, ThreadUnread> {
43- if room.state() != RoomState::Joined {
44- return BTreeMap::new();
39+/// of its own for them. this counts `touched` and every thread that still had something
40+/// unread from the event cache store, which holds every reply it ever took in whatever is
41+/// in memory, against a receipt for that thread (msc3771), an unthreaded one or the user's
42+/// own reply. `None` when nothing moved
43+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+ store
56+ }
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 = cache
53- .rfind_map_event_in_memory_by(|event| {
54- let event_id = event.event_id();
55- if event_id.is_some_and(|event_id| unthreaded.contains(&event_id)) {
56- return Some(());
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 store
67+ .find_event_relations(room.room_id(), &root, Some(&[RelationType::Thread][..]))
68+ .await
69+ {
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- threads
60- .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- None
65- })
66- .await;
67- if let Err(error) = scanned {
68- tracing::debug!(room_id = %room.room_id(), %error, "reading events for thread unreads failed");
69- return BTreeMap::new();
70- }
71- let mut unreads = BTreeMap::new();
72- for (root, replies) in threads {
73- let read = own_receipts(room, ReceiptThread::Thread(root.clone())).await;
84+ }
7485 let mut unread = ThreadUnread::default();
75- for reply in replies {
76- if reply.own
77- || reply
78- .event_id
79- .as_ref()
80- .is_some_and(|event_id| read.contains(event_id))
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- unreads
105+ if counted == known {
106+ return None;
107+ }
108+ let stored = room
109+ .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 is
120+/// gone once it's in the store, so the event's timestamp stands for its place. a receipt
121+/// on an event the store never had falls back to when it was sent
122+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 room
129+ let (event_id, receipt) = match room
99130 .load_user_receipt(receipt_type, thread.clone(), room.own_user_id())
100131 .await
101132 {
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- found
147+ latest
110148 }
111149
112-/// the sdk's rule for its room counts, which is private to it
150+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 it
113155 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 = raw
163+ .get_field::<Value>("content")
164+ .ok()
165+ .flatten()
166+ .and_then(|content| {
167+ content
168+ .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 && !redacted
186+ message_like && !edit && !redacted
187+}
188+
189+fn encode(unreads: &BTreeMap<OwnedEventId, ThreadUnread>) -> Vec<u8> {
190+ let value: serde_json::Map<String, Value> = unreads
191+ .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+ value
207+ .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 }
Modified file: tests/state.rs+19 additions−3 deletions
@@ -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 = server
259+ .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 count
272+ server
273+ .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"),