Commit e5c6427adb35
tarball8 files changed+374-20
@@ -334,6 +334,19 @@ membership until it's forgotten. push rule changes never touch room info, so
334334
`NotificationSettingsChanged` brings a `RoomsChanged` along for the modes in335335
the summaries336336
337
+with threads on, the sdk leaves thread events out of a room's counts and keeps338
+none of its own for them. `thread_unreads` works them out per root the way the339
+sdk does a room's, over the events its cache holds in memory, newest first until340
+a receipt for that thread (msc3771), an unthreaded one, or the user's own reply.341
+a main-timeline receipt doesn't reach into threads. a thread's mentions also count342
+in the room's `unread_mentions`, so nobody misses one, while its messages and343
+notifications stay apart, since they don't move the main timeline's read marker.344
+thread events and threaded receipts leave room info alone, so the room list345
+watcher also recounts a room on each event cache update and each receipt, and346
+only says `RoomsChanged` when the counts moved. counting in the sdk would be347
+cheaper and survive memory being trimmed, but that's a behaviour change in the348
+fork349
+337350
## link previews338351
339352
a text message can carry its sender's own previews (msc4095), and those come@@ -1,31 +1,34 @@
11
use std::{2
- collections::{HashSet, VecDeque},2
+ collections::{BTreeMap, HashMap, HashSet, VecDeque},33
sync::Arc,44
time::Duration,55
};66
77
use futures_util::StreamExt;88
use matrix_sdk::{9
- Client,9
+ Client, Room,1010
ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},1111
send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},1212
};1313
use matrix_sdk_base::RoomInfoNotableUpdateReasons;1414
use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};1515
use ruma::{16
- OwnedRoomId, OwnedTransactionId,17
- events::{AnyGlobalAccountDataEvent, AnyRoomAccountDataEvent, AnySyncStateEvent},16
+ OwnedEventId, OwnedRoomId, OwnedTransactionId,17
+ events::{18
+ AnyGlobalAccountDataEvent, AnyRoomAccountDataEvent, AnySyncStateEvent,19
+ receipt::SyncReceiptEvent,20
+ },1821
serde::Raw,1922
time::Instant,2023
};21
-use tokio::sync::{Notify, broadcast::error::RecvError, watch};24
+use tokio::sync::{Notify, broadcast::error::RecvError, mpsc, watch};2225
2326
use super::executor::TaskSet;2427
use crate::{25
- ConnectionState, Event, UploadOutcome,28
+ ConnectionState, Event, ThreadUnread, UploadOutcome,2629
encryption::encryption_state,2730
error::classify_sdk_error,28
- normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK},31
+ normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, thread_unreads},2932
space_order,3033
transport::EventSender,3134
};@@ -433,25 +436,71 @@ pub(super) async fn rooms(client: Client, events: EventSender) {
433436
}434437
}),435438
];436
- loop {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
+ });444
+ let mut timelines = client.event_cache().subscribe_to_room_generic_updates();445
+ let mut thread_unreads = ThreadUnreads::default();446
+ 'invalidate: loop {437447
if events.send(Event::RoomsChanged).await.is_err() {438448
break;439449
}440
- tokio::select! {441
- update = rooms.next() => if update.is_none() { break },442
- update = info.recv() => if matches!(update, Err(RecvError::Closed)) { break },443
- () = changed.notified() => {}450
+ // thread events and threaded receipts leave room info alone, so neither reaches `info`451
+ loop {452
+ let room_id = tokio::select! {453
+ update = rooms.next() => if update.is_none() { break 'invalidate } else { break },454
+ update = info.recv() => {455
+ if matches!(update, Err(RecvError::Closed)) { break 'invalidate }456
+ break;457
+ }458
+ () = changed.notified() => break,459
+ Some(room_id) = receipt_rooms.recv() => room_id,460
+ update = timelines.recv() => match update {461
+ Ok(update) => update.room_id,462
+ Err(RecvError::Lagged(_)) => {463
+ thread_unreads.0.clear();464
+ break;465
+ }466
+ Err(RecvError::Closed) => break 'invalidate,467
+ },468
+ };469
+ if thread_unreads.changed(&client, room_id).await {470
+ break;471
+ }444472
}445473
// a sync touching fifty rooms is one invalidation, not fifty446474
while let Ok(_) | Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) =447475
info.try_recv()448476
{}449477
}450
- for handler in handlers {478
+ for handler in handlers.into_iter().chain([receipt_handler]) {451479
client.remove_event_handler(handler);452480
}453481
}454482
483
+#[derive(Default)]484
+struct ThreadUnreads(HashMap<OwnedRoomId, BTreeMap<OwnedEventId, ThreadUnread>>);485
+486
+impl ThreadUnreads {487
+ async fn changed(&mut self, client: &Client, room_id: OwnedRoomId) -> bool {488
+ let Some(room) = client.get_room(&room_id) else {489
+ return self.0.remove(&room_id).is_some();490
+ };491
+ let unreads = thread_unreads(&room).await;492
+ if self493
+ .0494
+ .get(&room_id)495
+ .map_or(unreads.is_empty(), |known| *known == unreads)496
+ {497
+ return false;498
+ }499
+ self.0.insert(room_id, unreads);500
+ true501
+ }502
+}503
+455504
/// packs live in room state and account data, and leaving a room takes its packs with it456505
pub(super) async fn image_packs(client: Client, events: EventSender) {457506
let changed = Arc::new(Notify::new());@@ -68,7 +68,8 @@ pub use model::{
6868
RecentHistoryState, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText,6969
RoomCategory, RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership,7070
RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary,71
- RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand,71
+ RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadSummary, ThreadUnread,72
+ TimelineCommand,7273
TimelineContent, TimelineDiff, TimelineDiffError, TimelineEvent, TimelineId, TimelineItem,7374
TimelineItemId, TypingState, UploadDetails, UploadOutcome, UploadThumbnail, UtdCause,7475
VerificationAction, VerificationFailure, VerificationState, VerificationTarget,@@ -1,4 +1,4 @@
1
-use std::{fmt, sync::Arc};1
+use std::{collections::BTreeMap, fmt, sync::Arc};22
33
use imbl::Vector;44
use ruma::{@@ -1047,10 +1047,15 @@ pub struct RoomSummary {
10471047
pub room_id: OwnedRoomId,10481048
pub name: String,10491049
pub membership: RoomMembership,1050
+ /// the main timeline's, threads have theirs in `thread_unreads`10501051
pub unread_messages: u64,1052
+ /// thread mentions included10511053
pub unread_mentions: u64,1052
- /// unread events the push rules said to notify about, which is neither of the above1054
+ /// unread events the push rules said to notify about, which is neither of the above.1055
+ /// the main timeline's10531056
pub unread_notifications: u64,1057
+ /// threads with anything unread, by root1058
+ pub thread_unreads: BTreeMap<OwnedEventId, ThreadUnread>,10541059
pub encrypted: bool,10551060
pub space: bool,10561061
pub direct: bool,@@ -1083,6 +1088,15 @@ pub struct RoomSummary {
10831088
pub can_pin: bool,10841089
}10851090
1091
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]1092
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1093
+#[non_exhaustive]1094
+pub struct ThreadUnread {1095
+ pub messages: u64,1096
+ pub mentions: u64,1097
+ pub notifications: u64,1098
+}1099
+10861100
#[derive(Clone, Debug, Eq, PartialEq)]10871101
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]10881102
#[non_exhaustive]@@ -5,6 +5,7 @@ mod media;
55
mod member;66
mod observed;77
mod room;8
+mod thread;89
mod timeline;910
1011
pub(crate) use context::{apply_profiles, normalise_context, resolve_replies};@@ -18,4 +19,5 @@ pub(crate) use member::member_summary;
1819
pub(crate) use observed::normalise_observed;1920
pub(crate) use room::notification_mode;2021
pub(crate) use room::{room_summaries, space_child, stored_space_order};22
+pub(crate) use thread::thread_unreads;2123
pub(crate) use timeline::{message_kind, normalise_timeline, normalise_timeline_diffs};@@ -62,13 +62,19 @@ async fn room_summary(room: &Room, spaces: &SpaceService) -> RoomSummary {
6262
} else {6363
Vec::new()6464
};65
+ let thread_unreads = super::thread_unreads(room).await;6566
RoomSummary {6667
room_id: room.room_id().to_owned(),6768
name,6869
membership: membership(room.state()),6970
unread_messages: room.num_unread_messages(),70
- unread_mentions: room.num_unread_mentions(),71
+ unread_mentions: room.num_unread_mentions()72
+ + thread_unreads73
+ .values()74
+ .map(|unread| unread.mentions)75
+ .sum::<u64>(),7176
unread_notifications: room.num_unread_notifications(),77
+ thread_unreads,7278
encrypted: room.encryption_state().is_encrypted(),7379
space: room.is_space(),7480
direct: room.is_direct().await.unwrap_or(false),@@ -0,0 +1,133 @@
1
+use std::collections::{BTreeMap, HashMap};2
+3
+use matrix_sdk::{4
+ Room, RoomState, deserialized_responses::TimelineEvent, serde_helpers::extract_thread_root,5
+};6
+use ruma::{7
+ OwnedEventId, UserId,8
+ events::receipt::{ReceiptThread, ReceiptType},9
+};10
+use serde_json::Value;11
+12
+use crate::ThreadUnread;13
+14
+struct Reply {15
+ event_id: Option<OwnedEventId>,16
+ own: bool,17
+ counts: bool,18
+ notify: bool,19
+ highlight: bool,20
+}21
+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()),33
+ }34
+ }35
+}36
+37
+/// the sdk leaves thread events out of the room's counts once threads are on and keeps none38
+/// of its own for them. so these are worked out the same way over the events it has in39
+/// memory, newest first until a receipt for that thread (msc3771), an unthreaded receipt or40
+/// the user's own reply. a receipt older than memory counts everything in memory, as the41
+/// sdk's does42
+pub(crate) async fn thread_unreads(room: &Room) -> BTreeMap<OwnedEventId, ThreadUnread> {43
+ if room.state() != RoomState::Joined {44
+ return BTreeMap::new();45
+ }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();50
+ };51
+ let mut threads: HashMap<OwnedEventId, Vec<Reply>> = HashMap::new();52
+ let scanned = cache53
+ .rfind_map_event_in_memory_by(|event| {54
+ let event_id = event.event_id();55
+ if event_id.is_some_and(|event_id| unthreaded.contains(&event_id)) {56
+ return Some(());57
+ }58
+ if let Some(root) = extract_thread_root(event.raw()) {59
+ threads60
+ .entry(root)61
+ .or_default()62
+ .push(Reply::of(event, own_user_id));63
+ }64
+ None65
+ })66
+ .await;67
+ if let Err(error) = scanned {68
+ tracing::debug!(room_id = %room.room_id(), %error, "reading events for thread unreads failed");69
+ return BTreeMap::new();70
+ }71
+ let mut unreads = BTreeMap::new();72
+ for (root, replies) in threads {73
+ let read = own_receipts(room, ReceiptThread::Thread(root.clone())).await;74
+ let mut unread = ThreadUnread::default();75
+ for reply in replies {76
+ if reply.own77
+ || reply78
+ .event_id79
+ .as_ref()80
+ .is_some_and(|event_id| read.contains(event_id))81
+ {82
+ break;83
+ }84
+ unread.messages += u64::from(reply.counts);85
+ unread.notifications += u64::from(reply.notify);86
+ unread.mentions += u64::from(reply.highlight);87
+ }88
+ if unread != ThreadUnread::default() {89
+ unreads.insert(root, unread);90
+ }91
+ }92
+ unreads93
+}94
+95
+async fn own_receipts(room: &Room, thread: ReceiptThread) -> Vec<OwnedEventId> {96
+ let mut found = Vec::new();97
+ for receipt_type in [ReceiptType::Read, ReceiptType::ReadPrivate] {98
+ match room99
+ .load_user_receipt(receipt_type, thread.clone(), room.own_user_id())100
+ .await101
+ {102
+ Ok(Some((event_id, _))) => found.push(event_id),103
+ Ok(None) => {}104
+ Err(error) => {105
+ tracing::debug!(room_id = %room.room_id(), %error, "reading a receipt failed");106
+ }107
+ }108
+ }109
+ found110
+}111
+112
+/// the sdk's rule for its room counts, which is private to it113
+fn counts_as_unread(event: &TimelineEvent) -> bool {114
+ let raw = event.raw();115
+ let redacted = raw116
+ .get_field::<Value>("unsigned")117
+ .ok()118
+ .flatten()119
+ .is_some_and(|unsigned| unsigned.get("redacted_because").is_some());120
+ let message_like = matches!(121
+ raw.get_field::<String>("type").ok().flatten().as_deref(),122
+ Some(123
+ "m.room.message"124
+ | "m.room.encrypted"125
+ | "m.sticker"126
+ | "m.poll.start"127
+ | "org.matrix.msc3381.poll.start"128
+ | "m.poll.end"129
+ | "org.matrix.msc3381.poll.end"130
+ )131
+ );132
+ message_like && !redacted133
+}@@ -4,13 +4,23 @@ use std::time::Duration;
44
55
use macltok::{66
ConnectionState, Engine, EngineConfig, EngineError, Event, PackUsage, Retain, RoomMembership,7
- Termination,7
+ Termination, ThreadUnread,88
};99
use matrix_sdk::{10
- ruma::{api::client::sync::sync_events::v5, events::AnySyncStateEvent, room_id, serde::Raw},10
+ ruma::{11
+ RoomId,12
+ api::client::sync::sync_events::v5,13
+ event_id,14
+ events::{15
+ AnySyncStateEvent, Mentions,16
+ receipt::{ReceiptThread, ReceiptType},17
+ },18
+ room_id,19
+ serde::Raw,20
+ },1121
test_utils::mocks::MatrixMockServer,1222
};13
-use matrix_sdk_test::JoinedRoomBuilder;23
+use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};1424
use serde_json::json;1525
use tokio::sync::watch;1626
@@ -233,3 +243,129 @@ async fn image_packs_follow_room_state_and_account_data() {
233243
234244
support::shut_down(&handle, &mut events, running).await;235245
}246
+247
+/// the sdk counts none of this once threads are on, and a threaded receipt never reaches248
+/// room info, so only our own recount notices it249
+#[tokio::test]250
+async fn thread_unreads_follow_threaded_receipts() {251
+ let server = MatrixMockServer::new().await;252
+ server253
+ .mock_sliding_sync()254
+ .ok(v5::Response::new("1".to_owned()))255
+ .mount()256
+ .await;257
+ let client = server.client_builder().build().await;258
+ let room_id = room_id!("!threads:localhost");259
+ server.mock_room_state_encryption().plain().mount().await;260
+ server.sync_joined_room(&client, room_id).await;261
+ client.event_cache().subscribe().unwrap();262
+ let config = EngineConfig::default().with_initial_history_limit(0);263
+ let (engine, handle, mut events) = Engine::from_client(client.clone(), config).await.unwrap();264
+ let running = tokio::spawn(engine.run());265
+ let own = client.user_id().unwrap().to_owned();266
+267
+ let (root, first, second) = (268
+ event_id!("$root"),269
+ event_id!("$first"),270
+ event_id!("$second"),271
+ );272
+ let factory = EventFactory::new().room(room_id).sender(&ALICE);273
+ server274
+ .sync_room(275
+ &client,276
+ JoinedRoomBuilder::new(room_id)277
+ .add_timeline_event(factory.text_msg("root").event_id(root))278
+ .add_timeline_event(279
+ factory280
+ .text_msg("you there?")281
+ .in_thread(root, root)282
+ .mentions(Mentions::with_user_ids([own.clone()]))283
+ .event_id(first),284
+ )285
+ .add_timeline_event(286
+ factory287
+ .text_msg("hello?")288
+ .in_thread(root, first)289
+ .event_id(second),290
+ ),291
+ )292
+ .await;293
+ let unread = thread_unread_once(&handle, &mut events, room_id, |unread| {294
+ unread.is_some_and(|unread| unread.messages == 2)295
+ })296
+ .await;297
+ assert_eq!(unread.mentions, 1);298
+ let summary = room(&handle, room_id).await;299
+ assert!(300
+ summary.unread_mentions >= 1,301
+ "the thread mention left the room's count"302
+ );303
+304
+ server305
+ .sync_room(306
+ &client,307
+ JoinedRoomBuilder::new(room_id).add_receipt(308
+ factory309
+ .read_receipts()310
+ .add(311
+ first,312
+ &own,313
+ ReceiptType::Read,314
+ ReceiptThread::Thread(root.to_owned()),315
+ )316
+ .into_event(),317
+ ),318
+ )319
+ .await;320
+ let unread = thread_unread_once(&handle, &mut events, room_id, |unread| {321
+ unread.is_some_and(|unread| unread.messages == 1)322
+ })323
+ .await;324
+ assert_eq!(unread.mentions, 0);325
+326
+ server327
+ .sync_room(328
+ &client,329
+ JoinedRoomBuilder::new(room_id).add_receipt(330
+ factory331
+ .read_receipts()332
+ .add(second, &own, ReceiptType::Read, ReceiptThread::Unthreaded)333
+ .into_event(),334
+ ),335
+ )336
+ .await;337
+ thread_unread_once(&handle, &mut events, room_id, |unread| unread.is_none()).await;338
+339
+ support::shut_down(&handle, &mut events, running).await;340
+}341
+342
+async fn room(handle: &macltok::ClientHandle, room_id: &RoomId) -> macltok::RoomSummary {343
+ handle344
+ .rooms()345
+ .await346
+ .into_iter()347
+ .find(|summary| summary.room_id == room_id)348
+ .expect("synced room was absent from the room summaries")349
+}350
+351
+/// waits out `RoomsChanged` until the root's counts satisfy `done`352
+async fn thread_unread_once(353
+ handle: &macltok::ClientHandle,354
+ events: &mut macltok::EventStream,355
+ room_id: &RoomId,356
+ done: impl Fn(Option<&ThreadUnread>) -> bool,357
+) -> ThreadUnread {358
+ let root = event_id!("$root");359
+ tokio::time::timeout(Duration::from_secs(10), async {360
+ loop {361
+ let summary = room(handle, room_id).await;362
+ let unread = summary.thread_unreads.get(root);363
+ if done(unread) {364
+ return unread.copied().unwrap_or_default();365
+ }366
+ support::receive_until(events, |event| matches!(event, Event::RoomsChanged)).await;367
+ }368
+ })369
+ .await370
+ .expect("thread unreads never settled")371
+}