atagen

macltok

public
Star0
Watch0

Commit 60454002af5f

tarball

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

7 files changed+71-51
threads: give thread list media its owner's prefix and match the close shape

The fix stack keys every media id by its owning scope's nonce so two owners of one event never share an entry, and thread list ids lacked that prefix. ThreadListClosed now carries a ThreadListCloseReason with RoomLeft, matching EventContextCloseReason, where a kick and a ban both read as leaving.
Modified file: docs/design-decisions.md+6 additions−4 deletions
@@ -151,10 +151,12 @@ the latest reply's preview, and a preview's own reply target comes back
151151
152152 a lagged listener can't know what it missed, so the list fetches its first page
153153 again and sends a `ThreadListReset` with it. leaving the room, or being kicked
154-or banned, closes the list with `ThreadListClosed` naming why, the same as
155-`Close` does. a list's media belongs to its own scope, bounded like an event
156-context's so a long open list sheds the previews its threads moved past, and
157-released on close
154+or banned, closes the list with `ThreadListClosed` and `RoomLeft`, the shape an
155+event context closing on a leave has. `Close` sends one too, with `Requested`,
156+since a mirror holds the list and has to drop it. a list's media belongs to its
157+own scope and its ids carry it, like an event context's, bounded the same way so
158+a long open list sheds the previews its threads moved past, and released on
159+close
158160
159161 ## warming up to send
160162
Modified file: src/engine/thread_list.rs+31 additions−16 deletions
@@ -21,8 +21,8 @@ use super::{
2121 executor::{AbortHandle, TaskSet, is_panic, resume_unwind},
2222 };
2323 use crate::{
24- Error, ErrorKind, Event, HistoryState, ThreadListClosure, ThreadListCommand, ThreadListEntry,
25- ThreadSummary, TimelineItem,
24+ Error, ErrorKind, Event, HistoryState, ThreadListCloseReason, ThreadListCommand,
25+ ThreadListEntry, ThreadSummary, TimelineItem,
2626 normalise::{
2727 MediaRegistration, MediaScope, member_sender, normalise_observed, resolve_replies,
2828 },
@@ -67,7 +67,7 @@ enum Arrival {
6767 Left {
6868 room_id: OwnedRoomId,
6969 generation: u64,
70- reason: ThreadListClosure,
70+ reason: ThreadListCloseReason,
7171 },
7272 }
7373
@@ -111,6 +111,7 @@ struct List {
111111 room: Room,
112112 generation: u64,
113113 scope: MediaScope,
114+ media_prefix: String,
114115 entries: Vector<ThreadListEntry>,
115116 token: Option<String>,
116117 end_reached: bool,
@@ -184,7 +185,7 @@ impl ThreadLists {
184185 return reply.done(Err(not_open()));
185186 };
186187 let closed = self
187- .finish(room_id, list, ThreadListClosure::Requested)
188+ .finish(room_id, list, ThreadListCloseReason::Requested)
188189 .await;
189190 reply.done(closed);
190191 }
@@ -204,10 +205,13 @@ impl ThreadLists {
204205 };
205206 self.next_generation = self.next_generation.wrapping_add(1);
206207 let generation = self.next_generation;
208+ let scope = self.media.context_scope();
209+ let media_prefix = format!("{}#", scope.nonce());
207210 let listener = self.tasks.spawn(listen(
208211 room.clone(),
209212 generation,
210213 self.current_user_id.clone(),
214+ media_prefix.clone(),
211215 self.arrivals.clone(),
212216 ));
213217 self.lists.insert(
@@ -215,7 +219,8 @@ impl ThreadLists {
215219 List {
216220 room,
217221 generation,
218- scope: self.media.context_scope(),
222+ scope,
223+ media_prefix,
219224 entries: Vector::new(),
220225 token: None,
221226 end_reached: false,
@@ -268,6 +273,7 @@ impl ThreadLists {
268273 if refresh { None } else { list.token.clone() },
269274 list.page_limit,
270275 self.current_user_id.clone(),
276+ list.media_prefix.clone(),
271277 );
272278 let generation = list.generation;
273279 let arrivals = self.arrivals.clone();
@@ -293,7 +299,7 @@ impl ThreadLists {
293299 &mut self,
294300 room_id: OwnedRoomId,
295301 list: List,
296- reason: ThreadListClosure,
302+ reason: ThreadListCloseReason,
297303 ) -> Result<(), Error> {
298304 list.listener.abort();
299305 if let Load::Page(waiting) | Load::Refresh(waiting) = list.load {
@@ -486,9 +492,11 @@ impl ThreadLists {
486492 let room = list.room.clone();
487493 let generation = list.generation;
488494 let current_user_id = self.current_user_id.clone();
495+ let media_prefix = list.media_prefix.clone();
489496 let arrivals = self.arrivals.clone();
490497 self.tasks.spawn(async move {
491- let fetched = fetch_root(&room, &root_event_id, current_user_id).await;
498+ let fetched =
499+ fetch_root(&room, &root_event_id, current_user_id, &media_prefix).await;
492500 let _ = arrivals.send(Arrival::Root {
493501 room_id: room.room_id().to_owned(),
494502 generation,
@@ -677,6 +685,7 @@ async fn fetch_page(
677685 from: Option<String>,
678686 limit: u16,
679687 current_user_id: Option<OwnedUserId>,
688+ media_prefix: String,
680689 ) -> Result<Page, Error> {
681690 let roots = room
682691 .list_threads(ListThreadsOptions {
@@ -688,7 +697,9 @@ async fn fetch_page(
688697 .map_err(|source| Error::from_sdk("listing threads failed", source))?;
689698 let mut entries = Vec::with_capacity(roots.chunk.len());
690699 for root in roots.chunk {
691- if let Some(built) = build_entry(&room, root, current_user_id.as_deref()).await {
700+ if let Some(built) =
701+ build_entry(&room, root, current_user_id.as_deref(), &media_prefix).await
702+ {
692703 entries.push(built);
693704 }
694705 }
@@ -702,6 +713,7 @@ async fn fetch_root(
702713 room: &Room,
703714 root_event_id: &OwnedEventId,
704715 current_user_id: Option<OwnedUserId>,
716+ media_prefix: &str,
705717 ) -> Fetched {
706718 let root = match room.load_or_fetch_event(root_event_id, None).await {
707719 Ok(root) => root,
@@ -719,7 +731,7 @@ async fn fetch_root(
719731 };
720732 }
721733 };
722- match build_entry(room, root, current_user_id.as_deref()).await {
734+ match build_entry(room, root, current_user_id.as_deref(), media_prefix).await {
723735 Some(built) => Fetched::Built(built),
724736 None => Fetched::Missing,
725737 }
@@ -729,12 +741,13 @@ async fn build_entry(
729741 room: &Room,
730742 root: SdkTimelineEvent,
731743 current_user_id: Option<&ruma::UserId>,
744+ media_prefix: &str,
732745 ) -> Option<Built> {
733746 let summary = root.thread_summary.summary().cloned();
734747 let latest = root.bundled_latest_thread_event.clone();
735- let root = normalise(room, &root, current_user_id).await?;
748+ let root = normalise(room, &root, current_user_id, media_prefix).await?;
736749 let latest = match latest {
737- Some(latest) => normalise(room, &latest, current_user_id).await,
750+ Some(latest) => normalise(room, &latest, current_user_id, media_prefix).await,
738751 None => None,
739752 };
740753 let summary = ThreadSummary {
@@ -769,11 +782,12 @@ async fn normalise(
769782 room: &Room,
770783 event: &SdkTimelineEvent,
771784 current_user_id: Option<&ruma::UserId>,
785+ media_prefix: &str,
772786 ) -> Option<Normalised> {
773787 let event_id = event.event_id()?;
774788 let timestamp_ms = u64::from(event.timestamp()?.get());
775789 let value: serde_json::Value = serde_json::from_str(event.raw().json().get()).ok()?;
776- let identity = format!("{}\n{event_id}", room.room_id());
790+ let identity = format!("{media_prefix}{}\n{event_id}", room.room_id());
777791 let (mut item, media) = normalise_observed(&value, current_user_id, &identity)?;
778792 let TimelineItem::Event(normalised) = &mut item else {
779793 return None;
@@ -795,10 +809,9 @@ async fn normalise(
795809 })
796810 }
797811
798-fn closure(state: RoomState) -> Option<ThreadListClosure> {
812+fn closure(state: RoomState) -> Option<ThreadListCloseReason> {
799813 match state {
800- RoomState::Left => Some(ThreadListClosure::Left),
801- RoomState::Banned => Some(ThreadListClosure::Banned),
814+ RoomState::Left | RoomState::Banned => Some(ThreadListCloseReason::RoomLeft),
802815 _ => None,
803816 }
804817 }
@@ -808,6 +821,7 @@ async fn listen(
808821 room: Room,
809822 generation: u64,
810823 current_user_id: Option<OwnedUserId>,
824+ media_prefix: String,
811825 arrivals: mpsc::UnboundedSender<Arrival>,
812826 ) {
813827 let room_id = room.room_id().to_owned();
@@ -869,7 +883,8 @@ async fn listen(
869883 if thread_root.is_none() && event.thread_summary.summary().is_none() {
870884 continue;
871885 }
872- let Some(normalised) = normalise(&room, &event, current_user_id.as_deref()).await
886+ let Some(normalised) =
887+ normalise(&room, &event, current_user_id.as_deref(), &media_prefix).await
873888 else {
874889 continue;
875890 };
Modified file: src/engine/watchers.rs+1 additions−1 deletions
@@ -8,9 +8,9 @@ use futures_util::StreamExt;
88 use matrix_sdk::{
99 Client, Room,
1010 event_handler::EventHandlerHandle,
11- serde_helpers::extract_thread_root,
1211 ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},
1312 send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},
13+ serde_helpers::extract_thread_root,
1414 };
1515 use matrix_sdk_base::RoomInfoNotableUpdateReasons;
1616 use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};
Modified file: src/lib.rs+14 additions−15 deletions
@@ -57,23 +57,22 @@ pub use mirror::{
5757 pub use model::{
5858 AccountCommand, AttachmentEncryption, AttachmentUpload, Change, Command, CommandOutput,
5959 ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind,
60- EngineFailure, Event, EventContext, EventContextCloseReason, EventContextCommand, EventContextPage, EventContextSession,
61- EventRef, HistoryState, ImagePack, ImagePackSource, ImagePacks, KeyBackupState, LatestEvent,
62- LifecycleCommand, LinkPreview, LinkPreviewImage, Media, MediaCommand, MediaDescriptor,
63- MediaFile, MediaId, MediaKind, MediaLocation, MediaSize, MemberFilter, MemberMembership,
64- MemberSummary, MembershipChange, Message, MessageContent, MessageEditContent, MessageKind,
65- MessageMentions, NotificationCommand, OwnDevice, PackCommand, PackImage, PackImageInfo,
66- PackUsage, Patch, PowerLevelChanges, Profile, PushNotification, PushNotificationEvent,
67- PushNotificationRoom, PushNotificationSender, PushNotificationStatus, Reaction,
68- RecentHistoryState, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText,
60+ EngineFailure, Event, EventContext, EventContextCloseReason, EventContextCommand,
61+ EventContextPage, EventContextSession, EventRef, HistoryState, ImagePack, ImagePackSource,
62+ ImagePacks, KeyBackupState, LatestEvent, LifecycleCommand, LinkPreview, LinkPreviewImage,
63+ Media, MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,
64+ MemberFilter, MemberMembership, MemberSummary, MembershipChange, Message, MessageContent,
65+ MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice, PackCommand,
66+ PackImage, PackImageInfo, PackUsage, Patch, PowerLevelChanges, Profile, PushNotification,
67+ PushNotificationEvent, PushNotificationRoom, PushNotificationSender, PushNotificationStatus,
68+ Reaction, 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, ThreadListClosure,
72- ThreadListCommand, ThreadListEntry,
73- ThreadSummary, ThreadUnread, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,
74- TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,
75- UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,
76- VerificationState, VerificationTarget,
71+ RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadListCloseReason,
72+ ThreadListCommand, ThreadListEntry, ThreadSummary, ThreadUnread, TimelineCommand,
73+ TimelineContent, TimelineDiff, TimelineDiffError, TimelineEvent, TimelineId, TimelineItem,
74+ TimelineItemId, TypingState, UploadDetails, UploadOutcome, UploadThumbnail, UtdCause,
75+ VerificationAction, VerificationFailure, VerificationState, VerificationTarget,
7776 };
7877 pub use repair_model::{
7978 HistoryContinuity, HistoryGap, HistoryGapLifecycle, HistoryRepairCommand, HistoryRepairPhase,
Modified file: src/mirror.rs+4 additions−5 deletions
@@ -21,10 +21,9 @@ use tokio::sync::broadcast;
2121 use crate::{
2222 ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,
2323 EventContextCloseReason, HistoryRepairReport, HistoryState, ImagePacks, RecentHistoryState,
24- RoomSummary, ThreadListClosure, ThreadListEntry, TimelineCommand, TimelineDiff, TimelineId,
25- TimelineItem, TypingState,
26- UploadOutcome, VerificationState, VerificationTarget, engine::executor::EngineBoxFuture,
27- transport::PublicationRegistration,
24+ RoomSummary, ThreadListCloseReason, ThreadListEntry, TimelineCommand, TimelineDiff, TimelineId,
25+ TimelineItem, TypingState, UploadOutcome, VerificationState, VerificationTarget,
26+ engine::executor::EngineBoxFuture, transport::PublicationRegistration,
2827 };
2928
3029 #[cfg(not(target_family = "wasm"))]
@@ -201,7 +200,7 @@ pub enum ThreadListChange {
201200 entries: Vector<ThreadListEntry>,
202201 },
203202 History(HistoryState),
204- Closed(ThreadListClosure),
203+ Closed(ThreadListCloseReason),
205204 }
206205
207206 /// drains the event stream into a `State` that any number of `MirrorHandle`s watch
Modified file: src/model.rs+4 additions−5 deletions
@@ -982,7 +982,7 @@ pub enum Event {
982982 /// nothing more comes for it until another `ThreadListReset`
983983 ThreadListClosed {
984984 room_id: OwnedRoomId,
985- reason: ThreadListClosure,
985+ reason: ThreadListCloseReason,
986986 },
987987 /// a room that stops being watched says so with an empty `users`
988988 TypingChanged(TypingState),
@@ -1917,12 +1917,11 @@ pub struct ThreadSummary {
19171917
19181918 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
19191919 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1920-pub enum ThreadListClosure {
1920+pub enum ThreadListCloseReason {
19211921 /// `Close` asked for it
19221922 Requested,
1923- /// left or kicked
1924- Left,
1925- Banned,
1923+ /// left, kicked or banned
1924+ RoomLeft,
19261925 }
19271926
19281927 #[derive(Clone, Debug, Eq, PartialEq)]
Modified file: src/normalise/thread.rs+11 additions−5 deletions
@@ -25,7 +25,12 @@ pub(crate) async fn stored_thread_unreads(room: &Room) -> BTreeMap<OwnedEventId,
2525 if room.state() != RoomState::Joined {
2626 return BTreeMap::new();
2727 }
28- match room.client().state_store().get_custom_value(&key(room)).await {
28+ match room
29+ .client()
30+ .state_store()
31+ .get_custom_value(&key(room))
32+ .await
33+ {
2934 Ok(Some(bytes)) => decode(&bytes),
3035 Ok(None) => BTreeMap::new(),
3136 Err(error) => {
@@ -51,9 +56,9 @@ pub(crate) async fn recount_thread_unreads(
5156 return None;
5257 }
5358 let store = match room.client().event_cache_store().lock().await {
54- Ok(MappedCrossProcessLockState::Clean(store) | MappedCrossProcessLockState::Dirty(store)) => {
55- store
56- }
59+ Ok(
60+ MappedCrossProcessLockState::Clean(store) | MappedCrossProcessLockState::Dirty(store),
61+ ) => store,
5762 Err(error) => {
5863 tracing::debug!(room_id = %room.room_id(), %error, "locking the event cache store failed");
5964 return None;
@@ -76,7 +81,8 @@ pub(crate) async fn recount_thread_unreads(
7681 continue;
7782 }
7883 };
79- let mut read = unthreaded.max(read_through(room, &store, ReceiptThread::Thread(root.clone())).await);
84+ let mut read =
85+ unthreaded.max(read_through(room, &store, ReceiptThread::Thread(root.clone())).await);
8086 for (reply, _) in &replies {
8187 if reply.sender().as_deref() == Some(own_user_id) {
8288 read = read.max(timestamp_ms(reply));