atagen

macltok

public
Star0
Watch0

Commit 66db1b3cd895

tarball

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

7 files changed+979-14
threads: list a room's threads and keep them current

Malice needs a thread list per room that stays current as threads gain replies, without opening the room. matrix-sdk-ui's ThreadListService never adds a thread it hasn't paged in, never reorders, and counts every event the cache replays or back paginates as a new reply. ThreadListCommand drives a worker that pages /threads through Room::list_threads, follows the room's event cache to count each newer reply once and pull in roots of newly active threads, and emits keyed upserts that ThreadListEntry::upsert orders so the mirror can't diverge.
Modified file: docs/design-decisions.md+25 additions
@@ -124,6 +124,31 @@ a custom emote reaction keys on the image's mxc uri and carries its shortcode
124124 which image it is and the shortcode any sender gave, and `toggle_reaction`
125125 takes the shortcode to send
126126
127+## thread lists
128+
129+a room's thread list is its own thing, not a timeline and not the room actor's.
130+it needs only the room, so a room needn't be open for its list. one worker holds
131+every open list. `Open` sends an empty `ThreadListReset`, `Paginate` asks
132+`/rooms/{id}/threads` through the sdk's `Room::list_threads`, and the list
133+follows the room's event cache for as long as it's open. changes go out as
134+upserts keyed by root, which `ThreadListEntry::upsert` puts in order, newest
135+activity first and ties by root, so a list can't diverge the way an index diff
136+can
137+
138+the sdk's `ThreadListService` was passed over. it never adds a thread that wasn't
139+on a page already loaded, never reorders, and adds one to the count for every
140+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
151+
127152 ## warming up to send
128153
129154 the sdk does all its key work on the send path, in the room's send queue before
Modified file: src/engine/mod.rs+18 additions−2 deletions
@@ -8,6 +8,7 @@ mod prepare;
88 mod repair;
99 mod room;
1010 mod session;
11+mod thread_list;
1112 mod timeline;
1213 mod url_preview;
1314 mod watchers;
@@ -51,7 +52,7 @@ use crate::{
5152 AccountCommand, ClientHandle, Command, CommandOutput, ConnectionState, EncryptionCommand,
5253 EngineErrorKind, EngineFailure, Error, ErrorKind, Event, EventStream, ImagePackSource,
5354 LifecycleCommand, MediaCommand, NotificationCommand, PackCommand, RoomCommand, Termination,
54- TimelineCommand,
55+ ThreadListCommand, TimelineCommand,
5556 normalise::{ROOM_PACKS, ROOM_PREVIEW_URLS},
5657 transport::{DriverPort, Request, Responder, transport_pair},
5758 };
@@ -289,6 +290,7 @@ struct Queues {
289290 mutations: mpsc::Receiver<MutationJob>,
290291 notifications: mpsc::Receiver<Job<NotificationCommand>>,
291292 encryption: mpsc::Receiver<Job<EncryptionCommand>>,
293+ thread_lists: mpsc::Receiver<Job<ThreadListCommand>>,
292294 session_changes: broadcast::Receiver<SessionChange>,
293295 persistence: Option<Persistence>,
294296 }
@@ -314,6 +316,7 @@ pub struct Engine {
314316 mutations: mpsc::Sender<MutationJob>,
315317 notifications: mpsc::Sender<Job<NotificationCommand>>,
316318 encryption: mpsc::Sender<Job<EncryptionCommand>>,
319+ thread_lists: mpsc::Sender<Job<ThreadListCommand>>,
317320 queues: Option<Queues>,
318321 /// the client can outlive us, so the handler has to come off it when we stop
319322 verification_handler: Option<matrix_sdk::event_handler::EventHandlerHandle>,
@@ -403,6 +406,7 @@ impl Engine {
403406 let (mutations, mutation_queue) = mpsc::channel(config.command_capacity);
404407 let (notifications, notification_queue) = mpsc::channel(config.command_capacity);
405408 let (encryption, encryption_queue) = mpsc::channel(config.command_capacity);
409+ let (thread_lists, thread_list_queue) = mpsc::channel(config.command_capacity);
406410 let engine = Self {
407411 contexts: Arc::new(context::Sessions::new(port.event_sender())),
408412 client,
@@ -421,10 +425,12 @@ impl Engine {
421425 mutations,
422426 notifications,
423427 encryption,
428+ thread_lists,
424429 queues: Some(Queues {
425430 mutations: mutation_queue,
426431 notifications: notification_queue,
427432 encryption: encryption_queue,
433+ thread_lists: thread_list_queue,
428434 session_changes,
429435 persistence,
430436 }),
@@ -538,7 +544,7 @@ impl Engine {
538544 let client = &self.client;
539545 let events = || self.port.event_sender();
540546 let (notices, notice_queue) = mpsc::unbounded_channel();
541- let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 14] = [
547+ let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 15] = [
542548 (
543549 "upload tracker",
544550 Box::pin(watchers::uploads(
@@ -642,6 +648,15 @@ impl Engine {
642648 incoming_verifications,
643649 )),
644650 ),
651+ (
652+ "thread list worker",
653+ Box::pin(thread_list::run(
654+ client.clone(),
655+ events(),
656+ self.media.clone(),
657+ queues.thread_lists,
658+ )),
659+ ),
645660 ];
646661 for (label, task) in watchers {
647662 self.tasks.spawn(TaskContext::Watcher(label), task);
@@ -847,6 +862,7 @@ impl Engine {
847862 enqueue(&self.notifications, Job { command, reply });
848863 }
849864 Command::Pack(command) => self.pack(command, reply),
865+ Command::ThreadList(command) => enqueue(&self.thread_lists, Job { command, reply }),
850866 }
851867 None
852868 }
Added file: src/engine/thread_list.rs+740 additions
@@ -0,0 +1,740 @@
1+use std::{
2+ collections::{HashMap, hash_map::Entry},
3+ sync::Arc,
4+};
5+
6+use matrix_sdk::{
7+ Client, Room,
8+ deserialized_responses::TimelineEvent as SdkTimelineEvent,
9+ event_cache::{EventsOrigin, RoomEventCacheSubscriber, RoomEventCacheUpdate},
10+ room::ListThreadsOptions,
11+ serde_helpers::extract_thread_root,
12+};
13+use matrix_sdk_ui::eyeball_im::VectorDiff;
14+use ruma::{OwnedEventId, OwnedRoomId, OwnedUserId, UInt, push::Action};
15+use tokio::sync::{broadcast::error::RecvError, mpsc};
16+
17+use super::{
18+ MediaService,
19+ commands::Job,
20+ executor::{TaskSet, is_panic, resume_unwind},
21+};
22+use crate::{
23+ Error, ErrorKind, Event, HistoryState, MediaId, Sender, ThreadListCommand, ThreadListEntry,
24+ ThreadSummary, TimelineItem,
25+ normalise::{MediaRegistration, MediaScope, normalise_observed, resolve_replies},
26+ transport::{EventSender, Responder},
27+};
28+
29+const MAX_PAGE: u16 = 50;
30+
31+struct Normalised {
32+ event_id: OwnedEventId,
33+ timestamp_ms: u64,
34+ item: TimelineItem,
35+ media: Vec<MediaRegistration>,
36+}
37+
38+enum Arrival {
39+ Page {
40+ room_id: OwnedRoomId,
41+ generation: u64,
42+ page: Result<Page, Error>,
43+ },
44+ /// something landed in a thread, or a root changed, as the room's event cache saw it
45+ Live {
46+ room_id: OwnedRoomId,
47+ generation: u64,
48+ live: Live,
49+ },
50+ /// a root we heard a reply for before we knew of its thread
51+ Root {
52+ room_id: OwnedRoomId,
53+ generation: u64,
54+ root_event_id: OwnedEventId,
55+ built: Option<Built>,
56+ },
57+}
58+
59+enum Live {
60+ Reply {
61+ root_event_id: OwnedEventId,
62+ reply: Normalised,
63+ /// back pagination, which says nothing about a thread being active
64+ paginated: bool,
65+ },
66+ Root(Normalised),
67+}
68+
69+struct Page {
70+ entries: Vec<Built>,
71+ token: Option<String>,
72+}
73+
74+struct Built {
75+ entry: ThreadListEntry,
76+ media: Vec<MediaRegistration>,
77+}
78+
79+struct List {
80+ room: Room,
81+ generation: u64,
82+ scope: MediaScope,
83+ entries: Vec<ThreadListEntry>,
84+ token: Option<String>,
85+ 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
89+ fetching: HashMap<OwnedEventId, Vec<Normalised>>,
90+ listener: super::executor::AbortHandle,
91+}
92+
93+pub(super) async fn run(
94+ client: Client,
95+ events: EventSender,
96+ media: Arc<MediaService>,
97+ mut jobs: mpsc::Receiver<Job<ThreadListCommand>>,
98+) {
99+ let (arrivals, mut arrived) = mpsc::unbounded_channel();
100+ let mut lists = ThreadLists {
101+ current_user_id: client.user_id().map(ToOwned::to_owned),
102+ client,
103+ events,
104+ media,
105+ lists: HashMap::new(),
106+ next_generation: 0,
107+ arrivals,
108+ tasks: TaskSet::new(),
109+ };
110+ loop {
111+ tokio::select! {
112+ job = jobs.recv() => {
113+ let Some(Job { command, reply }) = job else { break };
114+ lists.execute(command, reply).await;
115+ }
116+ Some(arrival) = arrived.recv() => lists.arrive(arrival).await,
117+ Some(done) = lists.tasks.join_next(), if !lists.tasks.is_empty() => {
118+ if let Err(error) = done && is_panic(&error) {
119+ resume_unwind(error);
120+ }
121+ }
122+ }
123+ }
124+ lists.tasks.shutdown().await;
125+}
126+
127+struct ThreadLists {
128+ client: Client,
129+ current_user_id: Option<OwnedUserId>,
130+ events: EventSender,
131+ media: Arc<MediaService>,
132+ lists: HashMap<OwnedRoomId, List>,
133+ next_generation: u64,
134+ arrivals: mpsc::UnboundedSender<Arrival>,
135+ tasks: TaskSet<()>,
136+}
137+
138+impl ThreadLists {
139+ async fn execute(&mut self, command: ThreadListCommand, reply: Responder) {
140+ match command {
141+ ThreadListCommand::Open(room_id) => self.open(room_id, reply).await,
142+ ThreadListCommand::Paginate { room_id, limit } => {
143+ self.paginate(room_id, limit, reply).await;
144+ }
145+ ThreadListCommand::Close(room_id) => self.close(room_id, reply).await,
146+ }
147+ }
148+
149+ async fn open(&mut self, room_id: OwnedRoomId, reply: Responder) {
150+ if let Some(list) = self.lists.get(&room_id) {
151+ let reset = Event::ThreadListReset {
152+ room_id,
153+ entries: list.entries.iter().cloned().collect(),
154+ };
155+ return reply.done(self.emit(reset).await);
156+ }
157+ let Some(room) = self.client.get_room(&room_id) else {
158+ return reply.done(Err(Error::room_unknown()));
159+ };
160+ self.next_generation = self.next_generation.wrapping_add(1);
161+ let generation = self.next_generation;
162+ let listener = self.tasks.spawn(listen(
163+ room.clone(),
164+ generation,
165+ self.current_user_id.clone(),
166+ self.arrivals.clone(),
167+ ));
168+ self.lists.insert(
169+ room_id.clone(),
170+ List {
171+ room,
172+ generation,
173+ scope: self.media.context_scope(),
174+ entries: Vec::new(),
175+ token: None,
176+ end_reached: false,
177+ paginating: Vec::new(),
178+ fetching: HashMap::new(),
179+ listener,
180+ },
181+ );
182+ let reset = Event::ThreadListReset {
183+ room_id,
184+ entries: imbl::Vector::new(),
185+ };
186+ reply.done(self.emit(reset).await);
187+ }
188+
189+ async fn paginate(&mut self, room_id: OwnedRoomId, limit: u16, reply: Responder) {
190+ if limit == 0 || limit > MAX_PAGE {
191+ return reply.done(Err(Error::new(
192+ ErrorKind::Invalid,
193+ "thread list page limit must be between 1 and 50",
194+ )));
195+ }
196+ let Some(list) = self.lists.get_mut(&room_id) else {
197+ return reply.done(Err(not_open()));
198+ };
199+ if list.end_reached {
200+ return reply.done(Ok(()));
201+ }
202+ list.paginating.push(reply);
203+ if list.paginating.len() > 1 {
204+ return;
205+ }
206+ let fetch = fetch_page(
207+ list.room.clone(),
208+ list.token.clone(),
209+ limit,
210+ self.current_user_id.clone(),
211+ );
212+ let generation = list.generation;
213+ let arrivals = self.arrivals.clone();
214+ let page_room_id = room_id.clone();
215+ self.tasks.spawn(async move {
216+ let page = fetch.await;
217+ let _ = arrivals.send(Arrival::Page {
218+ room_id: page_room_id,
219+ generation,
220+ page,
221+ });
222+ });
223+ let _ = self
224+ .emit(Event::ThreadListHistoryChanged {
225+ room_id,
226+ state: HistoryState::Loading,
227+ })
228+ .await;
229+ }
230+
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+ };
235+ list.listener.abort();
236+ for waiting in list.paginating {
237+ waiting.done(Err(Error::interrupted()));
238+ }
239+ self.media.release(&list.scope).await;
240+ reply.done(self.emit(Event::ThreadListClosed { room_id }).await);
241+ }
242+
243+ async fn arrive(&mut self, arrival: Arrival) {
244+ match arrival {
245+ Arrival::Page {
246+ room_id,
247+ generation,
248+ page,
249+ } => self.page_arrived(room_id, generation, page).await,
250+ Arrival::Live {
251+ room_id,
252+ generation,
253+ live,
254+ } => self.live_arrived(room_id, generation, live).await,
255+ Arrival::Root {
256+ room_id,
257+ generation,
258+ root_event_id,
259+ built,
260+ } => {
261+ let Some(list) = current(&mut self.lists, &room_id, generation) else {
262+ return;
263+ };
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() {
269+ return;
270+ }
271+ for reply in replies {
272+ built.media.extend(add_reply(&mut built.entry, reply));
273+ }
274+ self.publish(&room_id, vec![built]).await;
275+ }
276+ }
277+ }
278+
279+ async fn page_arrived(
280+ &mut self,
281+ room_id: OwnedRoomId,
282+ generation: u64,
283+ page: Result<Page, Error>,
284+ ) {
285+ let Some(list) = current(&mut self.lists, &room_id, generation) else {
286+ return;
287+ };
288+ let waiting = std::mem::take(&mut list.paginating);
289+ let (result, state) = match page {
290+ Ok(page) => {
291+ list.end_reached = page.token.is_none();
292+ 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();
304+ let state = if list.end_reached {
305+ HistoryState::StartReached
306+ } else {
307+ HistoryState::Idle
308+ };
309+ self.publish(&room_id, fresh).await;
310+ (Ok(()), state)
311+ }
312+ Err(error) => {
313+ let state = HistoryState::Failed(error.message.clone());
314+ (Err(error), state)
315+ }
316+ };
317+ let _ = self
318+ .emit(Event::ThreadListHistoryChanged { room_id, state })
319+ .await;
320+ for reply in waiting {
321+ reply.done(result.clone());
322+ }
323+ }
324+
325+ async fn live_arrived(&mut self, room_id: OwnedRoomId, generation: u64, live: Live) {
326+ let Some(list) = current(&mut self.lists, &room_id, generation) else {
327+ return;
328+ };
329+ match live {
330+ Live::Reply {
331+ root_event_id,
332+ reply,
333+ paginated,
334+ } => {
335+ if let Some(index) = list.position(&root_event_id) {
336+ let mut entry = list.entries[index].clone();
337+ let media = add_reply(&mut entry, reply);
338+ if entry != list.entries[index] {
339+ self.publish(&room_id, vec![Built { entry, media }]).await;
340+ }
341+ return;
342+ }
343+ if paginated {
344+ return;
345+ }
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+ }
364+ }
365+ }
366+ Live::Root(root) => {
367+ let Some(index) = list.position(&root.event_id) else {
368+ return;
369+ };
370+ let mut entry = list.entries[index].clone();
371+ entry.root = with_summary(root.item, &entry.summary);
372+ if entry != list.entries[index] {
373+ let media = root.media;
374+ self.publish(&room_id, vec![Built { entry, media }]).await;
375+ }
376+ }
377+ }
378+ }
379+
380+ /// the one place a thread list's media is registered. its scope is bounded like an event
381+ /// context's, so previews a long open list replaced age out, and it goes on close
382+ async fn publish(&mut self, room_id: &OwnedRoomId, built: Vec<Built>) {
383+ if built.is_empty() {
384+ return;
385+ }
386+ let Some(list) = self.lists.get_mut(room_id) else {
387+ return;
388+ };
389+ let mut media = Vec::new();
390+ let scope = list.scope.clone();
391+ let mut upserts = Vec::with_capacity(built.len());
392+ for Built {
393+ entry,
394+ media: registrations,
395+ } in built
396+ {
397+ media.extend(registrations);
398+ list.upsert(entry.clone());
399+ upserts.push(entry);
400+ }
401+ self.media.register(&scope, media).await;
402+ let _ = self
403+ .emit(Event::ThreadListChanged {
404+ room_id: room_id.clone(),
405+ upserts,
406+ })
407+ .await;
408+ }
409+
410+ async fn emit(&self, event: Event) -> Result<(), Error> {
411+ self.events
412+ .send(event)
413+ .await
414+ .map_err(|_| Error::interrupted())
415+ }
416+}
417+
418+impl List {
419+ fn position(&self, root_event_id: &OwnedEventId) -> Option<usize> {
420+ self.entries
421+ .iter()
422+ .position(|entry| &entry.root_event_id == root_event_id)
423+ }
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+ }
434+}
435+
436+fn current<'a>(
437+ lists: &'a mut HashMap<OwnedRoomId, List>,
438+ room_id: &OwnedRoomId,
439+ generation: u64,
440+) -> Option<&'a mut List> {
441+ lists
442+ .get_mut(room_id)
443+ .filter(|list| list.generation == generation)
444+}
445+
446+fn not_open() -> Error {
447+ Error::new(ErrorKind::TimelineNotOpen, "thread list is not open")
448+}
449+
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();
460+ }
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);
466+ entry.root = with_summary(entry.root.clone(), &entry.summary);
467+ reply.media
468+}
469+
470+fn with_summary(mut root: TimelineItem, summary: &ThreadSummary) -> TimelineItem {
471+ if let TimelineItem::Event(event) = &mut root {
472+ event.thread_summary = Some(summary.clone());
473+ }
474+ root
475+}
476+
477+async fn fetch_page(
478+ room: Room,
479+ from: Option<String>,
480+ limit: u16,
481+ current_user_id: Option<OwnedUserId>,
482+) -> Result<Page, Error> {
483+ let roots = room
484+ .list_threads(ListThreadsOptions {
485+ from,
486+ limit: Some(UInt::from(limit)),
487+ ..ListThreadsOptions::default()
488+ })
489+ .await
490+ .map_err(|source| Error::from_sdk("listing threads failed", source))?;
491+ let mut entries = Vec::with_capacity(roots.chunk.len());
492+ for root in roots.chunk {
493+ if let Some(built) = build_entry(&room, root, current_user_id.as_deref()).await {
494+ entries.push(built);
495+ }
496+ }
497+ Ok(Page {
498+ entries,
499+ token: roots.prev_batch_token,
500+ })
501+}
502+
503+async fn fetch_root(
504+ room: &Room,
505+ root_event_id: &OwnedEventId,
506+ 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
516+}
517+
518+async fn build_entry(
519+ room: &Room,
520+ root: SdkTimelineEvent,
521+ current_user_id: Option<&ruma::UserId>,
522+) -> Option<Built> {
523+ let summary = root.thread_summary.summary().cloned();
524+ let latest = root.bundled_latest_thread_event.clone();
525+ let root = normalise(room, &root, current_user_id).await?;
526+ let latest = match latest {
527+ Some(latest) => normalise(room, &latest, current_user_id).await,
528+ None => None,
529+ };
530+ let summary = ThreadSummary {
531+ reply_count: summary.as_ref().map_or(0, |summary| summary.num_replies),
532+ latest_event_id: summary
533+ .and_then(|summary| summary.latest_reply)
534+ .or_else(|| latest.as_ref().map(|latest| latest.event_id.clone())),
535+ latest_event_timestamp_ms: latest.as_ref().map(|latest| latest.timestamp_ms),
536+ };
537+ let mut media = root.media;
538+ let latest_reply = latest.map(|latest| {
539+ media.extend(latest.media);
540+ latest.item
541+ });
542+ Some(Built {
543+ entry: ThreadListEntry {
544+ root_event_id: root.event_id,
545+ latest_activity_ms: summary
546+ .latest_event_timestamp_ms
547+ .unwrap_or(root.timestamp_ms)
548+ .max(root.timestamp_ms),
549+ root: with_summary(root.item, &summary),
550+ summary,
551+ latest_reply,
552+ },
553+ media,
554+ })
555+}
556+
557+async fn normalise(
558+ room: &Room,
559+ event: &SdkTimelineEvent,
560+ current_user_id: Option<&ruma::UserId>,
561+) -> Option<Normalised> {
562+ let event_id = event.event_id()?;
563+ let value: serde_json::Value = serde_json::from_str(event.raw().json().get()).ok()?;
564+ let identity = format!("{}\n{event_id}", room.room_id());
565+ let (mut item, media) = normalise_observed(&value, current_user_id, &identity)?;
566+ let TimelineItem::Event(normalised) = &mut item else {
567+ return None;
568+ };
569+ normalised.highlight = event
570+ .push_actions()
571+ .is_some_and(|actions| actions.iter().any(Action::is_highlight));
572+ if let Some(sender) = sender(room, &normalised.sender.user_id).await {
573+ normalised.sender = sender;
574+ }
575+ // a preview doesn't chase what a root or a reply was replying to
576+ resolve_replies(std::iter::once(&mut **normalised), &HashMap::new());
577+ Some(Normalised {
578+ event_id,
579+ timestamp_ms: normalised.timestamp_ms,
580+ item,
581+ media,
582+ })
583+}
584+
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+ })
593+}
594+
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
597+async fn listen(
598+ room: Room,
599+ generation: u64,
600+ current_user_id: Option<OwnedUserId>,
601+ arrivals: mpsc::UnboundedSender<Arrival>,
602+) {
603+ let subscribed = async {
604+ let (cache, drop_handles) = room.event_cache().await?;
605+ let (_, subscriber) = cache.subscribe().await?;
606+ matrix_sdk::event_cache::Result::Ok((drop_handles, subscriber))
607+ }
608+ .await;
609+ let (_drop_handles, mut subscriber): (_, RoomEventCacheSubscriber) = match subscribed {
610+ Ok(subscribed) => subscribed,
611+ Err(error) => {
612+ tracing::warn!(room_id = %room.room_id(), %error, "thread list can't follow the room");
613+ return;
614+ }
615+ };
616+ let room_id = room.room_id().to_owned();
617+ loop {
618+ let update = match subscriber.recv().await {
619+ Ok(update) => update,
620+ Err(RecvError::Lagged(skipped)) => {
621+ tracing::warn!(%room_id, skipped, "thread list listener lagged");
622+ continue;
623+ }
624+ Err(RecvError::Closed) => return,
625+ };
626+ let RoomEventCacheUpdate::UpdateTimelineEvents(update) = update else {
627+ continue;
628+ };
629+ let paginated = matches!(update.origin, EventsOrigin::Pagination);
630+ for event in added(update.diffs) {
631+ let thread_root = extract_thread_root(event.raw());
632+ if thread_root.is_none() && event.thread_summary.summary().is_none() {
633+ continue;
634+ }
635+ let Some(normalised) =
636+ normalise(&room, &event, current_user_id.as_deref()).await
637+ else {
638+ continue;
639+ };
640+ let live = match thread_root {
641+ Some(root_event_id) => Live::Reply {
642+ root_event_id,
643+ reply: normalised,
644+ paginated,
645+ },
646+ None => Live::Root(normalised),
647+ };
648+ let arrival = Arrival::Live {
649+ room_id: room_id.clone(),
650+ generation,
651+ live,
652+ };
653+ if arrivals.send(arrival).is_err() {
654+ return;
655+ }
656+ }
657+ }
658+}
659+
660+fn added(diffs: Vec<VectorDiff<SdkTimelineEvent>>) -> Vec<SdkTimelineEvent> {
661+ let mut events = Vec::new();
662+ for diff in diffs {
663+ match diff {
664+ VectorDiff::Append { values } | VectorDiff::Reset { values } => events.extend(values),
665+ VectorDiff::PushBack { value }
666+ | VectorDiff::PushFront { value }
667+ | VectorDiff::Insert { value, .. }
668+ | VectorDiff::Set { value, .. } => events.push(value),
669+ VectorDiff::Clear
670+ | VectorDiff::PopBack
671+ | VectorDiff::PopFront
672+ | VectorDiff::Remove { .. }
673+ | VectorDiff::Truncate { .. } => {}
674+ }
675+ }
676+ events
677+}
678+
679+#[cfg(test)]
680+mod tests {
681+ use ruma::{event_id, owned_event_id};
682+
683+ use super::*;
684+
685+ fn reply(event_id: &str, timestamp_ms: u64) -> Normalised {
686+ Normalised {
687+ event_id: event_id.try_into().unwrap(),
688+ timestamp_ms,
689+ item: TimelineItem::DayDivider { timestamp_ms },
690+ media: Vec::new(),
691+ }
692+ }
693+
694+ fn entry(root: &str, latest_activity_ms: u64) -> ThreadListEntry {
695+ ThreadListEntry {
696+ root_event_id: root.try_into().unwrap(),
697+ root: TimelineItem::DayDivider { timestamp_ms: 0 },
698+ summary: ThreadSummary {
699+ reply_count: 2,
700+ latest_event_id: Some(owned_event_id!("$latest")),
701+ latest_event_timestamp_ms: Some(latest_activity_ms),
702+ },
703+ latest_reply: None,
704+ latest_activity_ms,
705+ }
706+ }
707+
708+ #[test]
709+ 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));
713+ assert_eq!(
714+ thread.summary.reply_count, 2,
715+ "a replayed or paginated reply was counted"
716+ );
717+ add_reply(&mut thread, reply("$newer", 30));
718+ add_reply(&mut thread, reply("$newer", 30));
719+ assert_eq!(thread.summary.reply_count, 3);
720+ assert_eq!(thread.latest_activity_ms, 30);
721+ assert_eq!(
722+ thread.summary.latest_event_id.as_deref(),
723+ Some(event_id!("$newer"))
724+ );
725+ }
726+
727+ #[test]
728+ fn upserts_keep_newest_activity_first() {
729+ let mut entries = imbl::Vector::new();
730+ for (root, activity) in [("$a", 10), ("$b", 30), ("$c", 20), ("$d", 20)] {
731+ ThreadListEntry::upsert(&mut entries, entry(root, activity));
732+ }
733+ ThreadListEntry::upsert(&mut entries, entry("$a", 40));
734+ let order: Vec<_> = entries
735+ .iter()
736+ .map(|entry| entry.root_event_id.as_str())
737+ .collect();
738+ assert_eq!(order, ["$a", "$b", "$c", "$d"]);
739+ }
740+}
Modified file: src/lib.rs+7 additions−7 deletions
@@ -51,8 +51,8 @@ pub use mentions_model::{
5151 };
5252 #[cfg(feature = "mirror")]
5353 pub use mirror::{
54- Mirror, MirrorHandle, SessionStatus, State, StateChange, Subscription, TimelineChange,
55- TimelineState, Update, UploadFailure, UploadState,
54+ Mirror, MirrorHandle, SessionStatus, State, StateChange, Subscription, ThreadListChange,
55+ ThreadListState, TimelineChange, TimelineState, Update, UploadFailure, UploadState,
5656 };
5757 pub use model::{
5858 AccountCommand, AttachmentEncryption, AttachmentUpload, Change, Command, CommandOutput,
@@ -68,11 +68,11 @@ pub use model::{
6868 RecentHistoryState, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText,
6969 RoomCategory, RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership,
7070 RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary,
71- RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadSummary, ThreadUnread,
72- TimelineCommand,
73- TimelineContent, TimelineDiff, TimelineDiffError, TimelineEvent, TimelineId, TimelineItem,
74- TimelineItemId, TypingState, UploadDetails, UploadOutcome, UploadThumbnail, UtdCause,
75- VerificationAction, VerificationFailure, VerificationState, VerificationTarget,
71+ RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadListCommand, ThreadListEntry,
72+ ThreadSummary, ThreadUnread, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,
73+ TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,
74+ UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,
75+ VerificationState, VerificationTarget,
7676 };
7777 pub use repair_model::{
7878 HistoryContinuity, HistoryGap, HistoryGapLifecycle, HistoryRepairCommand, HistoryRepairPhase,
Modified file: src/mirror.rs+78 additions−3 deletions
@@ -20,9 +20,9 @@ use tokio::sync::broadcast;
2020
2121 use crate::{
2222 ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,
23- EventContextCloseReason, HistoryRepairReport, HistoryState, ImagePacks, RecentHistoryState, RoomSummary,
24- TimelineCommand, TimelineDiff, TimelineId, TimelineItem, TypingState, UploadOutcome,
25- VerificationState, VerificationTarget, engine::executor::EngineBoxFuture,
23+ EventContextCloseReason, HistoryRepairReport, HistoryState, ImagePacks, RecentHistoryState,
24+ RoomSummary, ThreadListEntry, TimelineCommand, TimelineDiff, TimelineId, TimelineItem, TypingState,
25+ UploadOutcome, VerificationState, VerificationTarget, engine::executor::EngineBoxFuture,
2626 transport::PublicationRegistration,
2727 };
2828
@@ -50,6 +50,7 @@ pub struct State {
5050 pub rooms: Vector<RoomSummary>,
5151 pub image_packs: ImagePacks,
5252 pub timelines: imbl::HashMap<TimelineId, TimelineState>,
53+ pub thread_lists: imbl::HashMap<OwnedRoomId, ThreadListState>,
5354 /// a room without an entry is idle. closing a room drops its entries here and in `repairs`
5455 pub recent_history: imbl::HashMap<OwnedRoomId, RecentHistoryState>,
5556 pub repairs: imbl::HashMap<OwnedRoomId, HistoryRepairReport>,
@@ -71,6 +72,14 @@ pub struct TimelineState {
7172 pub diverged: bool,
7273 }
7374
75+#[derive(Clone, Debug, PartialEq)]
76+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
77+#[non_exhaustive]
78+pub struct ThreadListState {
79+ pub entries: Vector<ThreadListEntry>,
80+ pub history: HistoryState,
81+}
82+
7483 #[derive(Clone, Debug, Eq, PartialEq)]
7584 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
7685 #[non_exhaustive]
@@ -139,6 +148,10 @@ pub enum StateChange {
139148 timeline_id: TimelineId,
140149 state: HistoryState,
141150 },
151+ ThreadList {
152+ room_id: OwnedRoomId,
153+ change: ThreadListChange,
154+ },
142155 RecentHistory {
143156 room_id: OwnedRoomId,
144157 state: RecentHistoryState,
@@ -176,6 +189,20 @@ pub enum TimelineChange {
176189 Closed,
177190 }
178191
192+#[derive(Clone, Debug)]
193+pub enum ThreadListChange {
194+ Reset {
195+ entries: Vector<ThreadListEntry>,
196+ },
197+ /// `entries` is what applying `upserts` left
198+ Changed {
199+ upserts: Arc<[ThreadListEntry]>,
200+ entries: Vector<ThreadListEntry>,
201+ },
202+ History(HistoryState),
203+ Closed,
204+}
205+
179206 /// drains the event stream into a `State` that any number of `MirrorHandle`s watch
180207 pub struct Mirror {
181208 handle: ClientHandle,
@@ -220,6 +247,7 @@ impl Mirror {
220247 rooms: Vector::new(),
221248 image_packs: ImagePacks::default(),
222249 timelines: imbl::HashMap::new(),
250+ thread_lists: imbl::HashMap::new(),
223251 recent_history: imbl::HashMap::new(),
224252 repairs: imbl::HashMap::new(),
225253 typing: imbl::HashMap::new(),
@@ -414,6 +442,53 @@ impl Mirror {
414442 Event::EventContextClosed { context_id, reason } => {
415443 self.publish(|_| Some(StateChange::EventContextClosed { context_id, reason }));
416444 }
445+ Event::ThreadListReset { room_id, entries } => self.publish(|state| {
446+ let history = state
447+ .thread_lists
448+ .get(&room_id)
449+ .map_or(HistoryState::Idle, |list| list.history.clone());
450+ state.thread_lists.insert(
451+ room_id.clone(),
452+ ThreadListState {
453+ entries: entries.clone(),
454+ history,
455+ },
456+ );
457+ Some(StateChange::ThreadList {
458+ room_id,
459+ change: ThreadListChange::Reset { entries },
460+ })
461+ }),
462+ Event::ThreadListChanged { room_id, upserts } => self.publish(|state| {
463+ let list = state.thread_lists.get_mut(&room_id)?;
464+ for entry in &upserts {
465+ ThreadListEntry::upsert(&mut list.entries, entry.clone());
466+ }
467+ Some(StateChange::ThreadList {
468+ room_id,
469+ change: ThreadListChange::Changed {
470+ upserts: upserts.into(),
471+ entries: list.entries.clone(),
472+ },
473+ })
474+ }),
475+ Event::ThreadListHistoryChanged {
476+ room_id,
477+ state: history,
478+ } => self.publish(|state| {
479+ state.thread_lists.get_mut(&room_id)?.history = history.clone();
480+ Some(StateChange::ThreadList {
481+ room_id,
482+ change: ThreadListChange::History(history),
483+ })
484+ }),
485+ Event::ThreadListClosed { room_id } => self.publish(|state| {
486+ state.thread_lists.remove(&room_id)?;
487+ Some(StateChange::ThreadList {
488+ room_id,
489+ change: ThreadListChange::Closed,
490+ })
491+ }),
417492 Event::TypingChanged(typing) => self.publish(|state| {
418493 if typing.users.is_empty() {
419494 state.typing.remove(&typing.room_id);
Modified file: src/model.rs+85 additions
@@ -31,6 +31,7 @@ pub enum Command {
3131 Lifecycle(LifecycleCommand),
3232 Pack(PackCommand),
3333 HistoryRepair(crate::HistoryRepairCommand),
34+ ThreadList(ThreadListCommand),
3435 }
3536
3637 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -404,6 +405,24 @@ pub enum MediaCommand {
404405 Upload(AttachmentUpload),
405406 }
406407
408+/// a room's threads, newest activity first, kept current as they gain replies. the room
409+/// needn't be open
410+#[derive(Clone, Debug, Eq, PartialEq)]
411+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
412+#[non_exhaustive]
413+pub enum ThreadListCommand {
414+ /// answers once `ThreadListReset` has gone out, empty until a page arrives. opening an
415+ /// open list sends a fresh reset
416+ Open(OwnedRoomId),
417+ /// the next `limit` threads from the server, at most 50. one page runs at a time and a
418+ /// second ask waits for it
419+ Paginate {
420+ room_id: OwnedRoomId,
421+ limit: u16,
422+ },
423+ Close(OwnedRoomId),
424+}
425+
407426 /// msc2545 pack editing. edits read the server's copy and keep keys macltok doesn't model
408427 #[derive(Clone, Debug, Eq, PartialEq)]
409428 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
@@ -945,6 +964,25 @@ pub enum Event {
945964 context_id: String,
946965 reason: EventContextCloseReason,
947966 },
967+ /// replaces whatever you had for this room's thread list, newest activity first
968+ ThreadListReset {
969+ room_id: OwnedRoomId,
970+ entries: Vector<ThreadListEntry>,
971+ },
972+ /// apply each with `ThreadListEntry::upsert`
973+ ThreadListChanged {
974+ room_id: OwnedRoomId,
975+ upserts: Vec<ThreadListEntry>,
976+ },
977+ /// `StartReached` once the oldest thread is in
978+ ThreadListHistoryChanged {
979+ room_id: OwnedRoomId,
980+ state: HistoryState,
981+ },
982+ /// nothing more comes for it until another `ThreadListReset`
983+ ThreadListClosed {
984+ room_id: OwnedRoomId,
985+ },
948986 /// a room that stops being watched says so with an empty `users`
949987 TypingChanged(TypingState),
950988 MediaUploadProgress {
@@ -1876,6 +1914,53 @@ pub struct ThreadSummary {
18761914 pub latest_event_timestamp_ms: Option<u64>,
18771915 }
18781916
1917+#[derive(Clone, Debug, Eq, PartialEq)]
1918+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1919+#[non_exhaustive]
1920+pub struct ThreadListEntry {
1921+ pub root_event_id: OwnedEventId,
1922+ /// always an event, carrying `summary` as its own
1923+ pub root: TimelineItem,
1924+ pub summary: ThreadSummary,
1925+ pub latest_reply: Option<TimelineItem>,
1926+ /// the latest reply's timestamp, or the root's before any reply
1927+ pub latest_activity_ms: u64,
1928+}
1929+
1930+impl ThreadListEntry {
1931+ /// replaces the entry with the same root, or adds it, where its activity puts it.
1932+ /// ties go by root event id
1933+ pub fn upsert(entries: &mut Vector<Self>, entry: Self) {
1934+ if let Some(index) = entries
1935+ .iter()
1936+ .position(|known| known.root_event_id == entry.root_event_id)
1937+ {
1938+ entries.remove(index);
1939+ }
1940+ let index = entries
1941+ .binary_search_by(|known| {
1942+ if known.sorts_before(&entry) {
1943+ std::cmp::Ordering::Less
1944+ } else {
1945+ std::cmp::Ordering::Greater
1946+ }
1947+ })
1948+ .unwrap_or_else(|index| index);
1949+ entries.insert(index, entry);
1950+ }
1951+
1952+ #[must_use]
1953+ pub fn sorts_before(&self, other: &Self) -> bool {
1954+ (
1955+ std::cmp::Reverse(self.latest_activity_ms),
1956+ &self.root_event_id,
1957+ ) < (
1958+ std::cmp::Reverse(other.latest_activity_ms),
1959+ &other.root_event_id,
1960+ )
1961+ }
1962+}
1963+
18791964 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
18801965 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
18811966 pub enum SendState {
Modified file: src/transport.rs+26 additions−2 deletions
@@ -36,8 +36,8 @@ use crate::{
3636 NotificationCommand, OwnDevice, PackCommand, PackImageInfo, PackUsage, Patch,
3737 PowerLevelChanges, Profile, PushNotification, PushNotificationEvent, PushNotificationStatus,
3838 RoomCommand, RoomListFilter, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels,
39- RoomSummary, SavedMentionContext, SpaceChild, TimelineCommand, TimelineId, VerificationAction,
40- VerificationTarget,
39+ RoomSummary, SavedMentionContext, SpaceChild, ThreadListCommand, TimelineCommand, TimelineId,
40+ VerificationAction, VerificationTarget,
4141 encryption::encryption_state,
4242 engine::{MediaService, StoreLock},
4343 model::{PushNotificationRoom, PushNotificationSender},
@@ -304,6 +304,30 @@ impl ClientHandle {
304304 .await
305305 }
306306
307+ /// the room's threads, newest activity first, kept current as they gain replies.
308+ /// the room needn't be open. it starts empty, `paginate_thread_list` fills it
309+ pub async fn open_thread_list(&self, room_id: OwnedRoomId) -> Result<(), Error> {
310+ self.run(Command::ThreadList(ThreadListCommand::Open(room_id)))
311+ .await
312+ }
313+
314+ pub async fn paginate_thread_list(
315+ &self,
316+ room_id: OwnedRoomId,
317+ limit: u16,
318+ ) -> Result<(), Error> {
319+ self.run(Command::ThreadList(ThreadListCommand::Paginate {
320+ room_id,
321+ limit,
322+ }))
323+ .await
324+ }
325+
326+ pub async fn close_thread_list(&self, room_id: OwnedRoomId) -> Result<(), Error> {
327+ self.run(Command::ThreadList(ThreadListCommand::Close(room_id)))
328+ .await
329+ }
330+
307331 /// reads the server's list so a pin made elsewhere just before isn't dropped
308332 pub async fn set_pinned(
309333 &self,