Commit 66db1b3cd895
tarball7 files changed+979-14
@@ -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 send126126
127
+## thread lists128
+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 holds131
+every open list. `Open` sends an empty `ThreadListReset`, `Paginate` asks132
+`/rooms/{id}/threads` through the sdk's `Room::list_threads`, and the list133
+follows the room's event cache for as long as it's open. changes go out as134
+upserts keyed by root, which `ThreadListEntry::upsert` puts in order, newest135
+activity first and ties by root, so a list can't diverge the way an index diff136
+can137
+138
+the sdk's `ThreadListService` was passed over. it never adds a thread that wasn't139
+on a page already loaded, never reorders, and adds one to the count for every140
+event the cache hands it, replays and back pagination included. here a reply141
+counts once, only when it's newer than the thread's latest, and a reply sync142
+brings to a thread we haven't loaded fetches its root and adds it. one a back143
+pagination brings doesn't, since that says nothing about the thread being144
+active. a redacted reply doesn't uncount, an edit doesn't change the latest145
+reply's preview, and a preview's own reply target comes back `Unavailable`146
+rather than fetched. a lagged listener misses whatever it skipped until the147
+thread moves again148
+149
+a list's media belongs to its own scope, bounded like an event context's so a150
+long open list sheds the previews its threads moved past, and released on close151
+127152
## warming up to send128153
129154
the sdk does all its key work on the send path, in the room's send queue before@@ -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 stop319322
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
None852868
}@@ -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 it45
+ 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 thread51
+ 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 active64
+ 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 it87
+ paginating: Vec<Responder>,88
+ /// roots being fetched, with the replies that arrived meanwhile89
+ 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 _ = self224
+ .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 = page294
+ .entries295
+ .into_iter()296
+ .filter(|built| {297
+ list.position(&built.entry.root_event_id)298
+ .is_none_or(|index| {299
+ list.entries[index].latest_activity_ms300
+ <= built.entry.latest_activity_ms301
+ })302
+ })303
+ .collect();304
+ let state = if list.end_reached {305
+ HistoryState::StartReached306
+ } else {307
+ HistoryState::Idle308
+ };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 _ = self318
+ .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 event381
+ /// context's, so previews a long open list replaced age out, and it goes on close382
+ 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 built396
+ {397
+ media.extend(registrations);398
+ list.upsert(entry.clone());399
+ upserts.push(entry);400
+ }401
+ self.media.register(&scope, media).await;402
+ let _ = self403
+ .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.events412
+ .send(event)413
+ .await414
+ .map_err(|_| Error::interrupted())415
+ }416
+}417
+418
+impl List {419
+ fn position(&self, root_event_id: &OwnedEventId) -> Option<usize> {420
+ self.entries421
+ .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 = self430
+ .entries431
+ .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
+ lists442
+ .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 it451
+/// already had on a reset or a reload, and back pagination brings older ones, neither of452
+/// which the server's count left out453
+fn add_reply(entry: &mut ThreadListEntry, reply: Normalised) -> Vec<MediaRegistration> {454
+ if entry.summary.latest_event_id.as_ref() == Some(&reply.event_id) {455
+ entry.latest_reply = Some(reply.item);456
+ return reply.media;457
+ }458
+ if reply.timestamp_ms < entry.summary.latest_event_timestamp_ms.unwrap_or(0) {459
+ return Vec::new();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.media468
+}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
+ root475
+}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 = room484
+ .list_threads(ListThreadsOptions {485
+ from,486
+ limit: Some(UInt::from(limit)),487
+ ..ListThreadsOptions::default()488
+ })489
+ .await490
+ .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 = room509
+ .load_or_fetch_event(root_event_id, None)510
+ .await511
+ .inspect_err(512
+ |error| tracing::debug!(%root_event_id, %error, "fetching a thread root failed"),513
+ )514
+ .ok()?;515
+ build_entry(room, root, current_user_id.as_deref()).await516
+}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: summary533
+ .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.item541
+ });542
+ Some(Built {543
+ entry: ThreadListEntry {544
+ root_event_id: root.event_id,545
+ latest_activity_ms: summary546
+ .latest_event_timestamp_ms547
+ .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 = event570
+ .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 to576
+ 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 whatever596
+/// it skipped until the thread moves again597
+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()).await637
+ 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::Clear670
+ | VectorDiff::PopBack671
+ | VectorDiff::PopFront672
+ | VectorDiff::Remove { .. }673
+ | VectorDiff::Truncate { .. } => {}674
+ }675
+ }676
+ events677
+}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<_> = entries735
+ .iter()736
+ .map(|entry| entry.root_event_id.as_str())737
+ .collect();738
+ assert_eq!(order, ["$a", "$b", "$c", "$d"]);739
+ }740
+}@@ -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,@@ -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` left198
+ 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 watch180207
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 = state447
+ .thread_lists448
+ .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);@@ -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 room409
+/// needn't be open410
+#[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 an415
+ /// open list sends a fresh reset416
+ Open(OwnedRoomId),417
+ /// the next `limit` threads from the server, at most 50. one page runs at a time and a418
+ /// second ask waits for it419
+ 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 model408427
#[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 first968
+ 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 in978
+ 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 own1923
+ 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 reply1927
+ 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 id1933
+ pub fn upsert(entries: &mut Vector<Self>, entry: Self) {1934
+ if let Some(index) = entries1935
+ .iter()1936
+ .position(|known| known.root_event_id == entry.root_event_id)1937
+ {1938
+ entries.remove(index);1939
+ }1940
+ let index = entries1941
+ .binary_search_by(|known| {1942
+ if known.sorts_before(&entry) {1943
+ std::cmp::Ordering::Less1944
+ } else {1945
+ std::cmp::Ordering::Greater1946
+ }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 {@@ -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
.await305305
}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 it309
+ pub async fn open_thread_list(&self, room_id: OwnedRoomId) -> Result<(), Error> {310
+ self.run(Command::ThreadList(ThreadListCommand::Open(room_id)))311
+ .await312
+ }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
+ .await324
+ }325
+326
+ pub async fn close_thread_list(&self, room_id: OwnedRoomId) -> Result<(), Error> {327
+ self.run(Command::ThreadList(ThreadListCommand::Close(room_id)))328
+ .await329
+ }330
+307331
/// reads the server's list so a pin made elsewhere just before isn't dropped308332
pub async fn set_pinned(309333
&self,