atagen

macltok

public
Star0
Watch0

Commit 89bc9f8d246d

tarball

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

6 files changed+357-2
timeline: add independent event context pagination

Pinned-message views need bounded history without moving a room timeline cursor. The context worker now owns focused requests, opaque directional pagination, normalisation, and distinct unavailable-event errors.
Added file: src/engine/context.rs+224 additions
@@ -0,0 +1,224 @@
1+use std::sync::Arc;
2+
3+use matrix_sdk::{room::MessagesOptions, Room};
4+use matrix_sdk_ui::timeline::{RoomExt, TimelineEventFocusThreadMode, TimelineFocus};
5+use ruma::{uint, OwnedEventId};
6+
7+use crate::{
8+ normalise::{normalise_timeline, MediaScope},
9+ Error, ErrorKind, EventContext, EventContextCommand, EventContextDirection, EventContextPage,
10+ TimelineItem,
11+};
12+
13+const MAX_CONTEXT_LIMIT: u16 = 50;
14+
15+pub(crate) async fn execute(
16+ room: Room,
17+ current_user_id: Option<ruma::OwnedUserId>,
18+ command: EventContextCommand,
19+) -> Result<crate::CommandOutput, Error> {
20+ if let EventContextCommand::Before { token, limit, .. }
21+ | EventContextCommand::After { token, limit, .. } = &command
22+ {
23+ validate_limit(*limit)?;
24+ if token.is_empty() {
25+ return Err(Error::new(
26+ ErrorKind::Invalid,
27+ "context pagination token is empty",
28+ ));
29+ }
30+ return execute_page(
31+ room,
32+ current_user_id,
33+ token,
34+ *limit,
35+ matches!(command, EventContextCommand::Before { .. }),
36+ )
37+ .await;
38+ }
39+
40+ let (event_id, limit) = match &command {
41+ EventContextCommand::Fetch {
42+ event_id,
43+ before_limit,
44+ after_limit,
45+ ..
46+ } => {
47+ validate_limit(*before_limit)?;
48+ validate_limit(*after_limit)?;
49+ (event_id.clone(), (*before_limit).max(*after_limit))
50+ }
51+ EventContextCommand::Before { .. } | EventContextCommand::After { .. } => unreachable!(),
52+ };
53+ validate_limit(limit)?;
54+
55+ let raw_context = if matches!(command, EventContextCommand::Fetch { .. }) {
56+ Some(
57+ room.event_with_context(&event_id, false, uint!(0), None)
58+ .await
59+ .map_err(|source| {
60+ let error = Error::from_sdk("fetching event context failed", source);
61+ if error
62+ .server
63+ .as_ref()
64+ .is_some_and(|server| server.status == 404)
65+ {
66+ Error::new(ErrorKind::EventNotFound, "event was not found")
67+ } else {
68+ error
69+ }
70+ })?,
71+ )
72+ } else {
73+ None
74+ };
75+
76+ let focus = TimelineFocus::Event {
77+ target: event_id.clone(),
78+ num_context_events: limit,
79+ thread_mode: TimelineEventFocusThreadMode::Automatic {
80+ hide_threaded_events: true,
81+ },
82+ };
83+ let timeline = room
84+ .timeline_builder()
85+ .with_focus(focus)
86+ .build()
87+ .await
88+ .map_err(|source| {
89+ Error::with_source(ErrorKind::Timeline, "building event context failed", source)
90+ })?;
91+
92+ if let EventContextCommand::Fetch { .. } = command {
93+ let raw = raw_context.expect("fetch context response");
94+ let normalized = normalize_items(timeline.items().await, current_user_id.as_deref());
95+ let (before, event, after) = split_items(normalized, &event_id);
96+ if event.is_none() && raw.event.is_none() {
97+ return Err(Error::new(ErrorKind::EventNotFound, "event was not found"));
98+ }
99+ return Ok(crate::CommandOutput::EventContext(EventContext {
100+ events_before: before,
101+ event,
102+ events_after: after,
103+ start_token: raw.prev_batch_token,
104+ end_token: raw.next_batch_token,
105+ }));
106+ }
107+
108+ unreachable!("fetch context returned above")
109+}
110+
111+async fn execute_page(
112+ room: Room,
113+ current_user_id: Option<ruma::OwnedUserId>,
114+ token: &str,
115+ limit: u16,
116+ backwards: bool,
117+) -> Result<crate::CommandOutput, Error> {
118+ let mut options = if backwards {
119+ MessagesOptions::backward()
120+ } else {
121+ MessagesOptions::forward()
122+ };
123+ options = options.from(token);
124+ options.limit = ruma::UInt::from(limit);
125+ let messages = room
126+ .messages(options)
127+ .await
128+ .map_err(|source| Error::from_sdk("fetching event context page failed", source))?;
129+
130+ let ids: Vec<_> = messages
131+ .chunk
132+ .iter()
133+ .filter_map(|event| event.event_id())
134+ .collect();
135+ let normalized = if let Some(target) = ids.first() {
136+ let focus = TimelineFocus::Event {
137+ target: target.clone(),
138+ num_context_events: limit,
139+ thread_mode: TimelineEventFocusThreadMode::Automatic {
140+ hide_threaded_events: false,
141+ },
142+ };
143+ let timeline = room
144+ .timeline_builder()
145+ .with_focus(focus)
146+ .build()
147+ .await
148+ .map_err(|source| {
149+ Error::with_source(
150+ ErrorKind::Timeline,
151+ "building event context page failed",
152+ source,
153+ )
154+ })?;
155+ normalize_items(timeline.items().await, current_user_id.as_deref())
156+ } else {
157+ Vec::new()
158+ };
159+
160+ let mut by_id = std::collections::HashMap::new();
161+ for item in normalized {
162+ if let TimelineItem::Event(event) = &item {
163+ if let Some(event_id) = &event.event_id {
164+ by_id.insert(event_id.clone(), item);
165+ }
166+ }
167+ }
168+ let mut events: Vec<_> = ids.iter().filter_map(|id| by_id.remove(id)).collect();
169+ if backwards {
170+ events.reverse();
171+ }
172+ let next_token = messages.end.clone();
173+ let end_reached = next_token.is_none();
174+ let direction = if backwards {
175+ EventContextDirection::Before
176+ } else {
177+ EventContextDirection::After
178+ };
179+ Ok(crate::CommandOutput::EventContextPage(EventContextPage {
180+ direction,
181+ events,
182+ next_token,
183+ end_reached,
184+ }))
185+}
186+
187+fn validate_limit(limit: u16) -> Result<(), Error> {
188+ if limit > MAX_CONTEXT_LIMIT {
189+ Err(Error::new(ErrorKind::Invalid, "context limit exceeds 50"))
190+ } else {
191+ Ok(())
192+ }
193+}
194+
195+fn normalize_items(
196+ items: imbl::Vector<Arc<matrix_sdk_ui::timeline::TimelineItem>>,
197+ current_user_id: Option<&ruma::UserId>,
198+) -> Vec<TimelineItem> {
199+ let scope = MediaScope::new();
200+ normalise_timeline(items.iter(), current_user_id, &scope)
201+ .items
202+ .into_iter()
203+ .collect()
204+}
205+
206+fn split_items(
207+ items: Vec<TimelineItem>,
208+ event_id: &OwnedEventId,
209+) -> (Vec<TimelineItem>, Option<TimelineItem>, Vec<TimelineItem>) {
210+ let items: Vec<_> = items
211+ .into_iter()
212+ .filter(|item| matches!(item, TimelineItem::Event(_)))
213+ .collect();
214+ let target = items.iter().position(|item| {
215+ matches!(item, TimelineItem::Event(event) if event.event_id.as_ref() == Some(event_id))
216+ });
217+ let Some(index) = target else {
218+ return (items, None, Vec::new());
219+ };
220+ let mut before = items;
221+ let after = before.split_off(index + 1);
222+ let event = before.pop();
223+ (before, event, after)
224+}
Modified file: src/engine/mod.rs+16 additions
@@ -1,4 +1,5 @@
11 mod commands;
2+mod context;
23 pub(crate) mod executor;
34 mod media;
45 mod mutations;
@@ -742,6 +743,21 @@ impl Engine {
742743 },
743744 ),
744745 Command::Timeline(command) => self.route_timeline(command, reply),
746+ Command::EventContext(command) => {
747+ let room_id = match &command {
748+ crate::EventContextCommand::Fetch { room_id, .. }
749+ | crate::EventContextCommand::Before { room_id, .. }
750+ | crate::EventContextCommand::After { room_id, .. } => room_id,
751+ };
752+ let Some(room) = client.get_room(room_id) else {
753+ reply.done(Err(Error::room_unknown()));
754+ return None;
755+ };
756+ let current_user_id = client.user_id().map(ToOwned::to_owned);
757+ self.tasks.spawn(TaskContext::Command("event context"), async move {
758+ reply.send(context::execute(room, current_user_id, command).await);
759+ });
760+ }
745761 Command::Media(MediaCommand::Upload(upload)) => self.upload(upload, reply),
746762 Command::Encryption(command) => enqueue(&self.encryption, Job { command, reply }),
747763 Command::Notification(command) => {
Modified file: src/error.rs+1 additions
@@ -53,6 +53,7 @@ pub enum ErrorKind {
5353 RoomUnknown,
5454 RoomNotOpen,
5555 TimelineNotOpen,
56+ EventNotFound,
5657 RoomBusy,
5758 QueueFull,
5859 EmptyBody,
Modified file: src/lib.rs+2 additions−1 deletions
@@ -62,7 +62,8 @@ pub use model::{
6262 Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff,
6363 TimelineDiffError, TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState,
6464 UploadDetails, UploadOutcome, UploadThumbnail, UtdCause, VerificationAction,
65- VerificationFailure, VerificationState, VerificationTarget,
65+ VerificationFailure, VerificationState, VerificationTarget, EventContext, EventContextCommand,
66+ EventContextDirection, EventContextPage,
6667 };
6768 pub use ruma;
6869 pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};
Modified file: src/model.rs+53 additions
@@ -23,6 +23,7 @@ pub enum Command {
2323 Account(AccountCommand),
2424 Room(RoomCommand),
2525 Timeline(TimelineCommand),
26+ EventContext(EventContextCommand),
2627 Media(MediaCommand),
2728 Encryption(EncryptionCommand),
2829 Notification(NotificationCommand),
@@ -30,6 +31,28 @@ pub enum Command {
3031 Pack(PackCommand),
3132 }
3233
34+#[derive(Clone, Debug, Eq, PartialEq)]
35+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
36+#[non_exhaustive]
37+pub enum EventContextCommand {
38+ Fetch {
39+ room_id: OwnedRoomId,
40+ event_id: OwnedEventId,
41+ before_limit: u16,
42+ after_limit: u16,
43+ },
44+ Before {
45+ room_id: OwnedRoomId,
46+ limit: u16,
47+ token: String,
48+ },
49+ After {
50+ room_id: OwnedRoomId,
51+ limit: u16,
52+ token: String,
53+ },
54+}
55+
3356 #[derive(Debug)]
3457 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
3558 #[non_exhaustive]
@@ -640,6 +663,36 @@ pub enum CommandOutput {
640663 shortcodes: Vec<String>,
641664 skipped: Vec<String>,
642665 },
666+ EventContext(EventContext),
667+ EventContextPage(EventContextPage),
668+}
669+
670+#[derive(Clone, Debug, Eq, PartialEq)]
671+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
672+#[non_exhaustive]
673+pub struct EventContext {
674+ pub events_before: Vec<TimelineItem>,
675+ pub event: Option<TimelineItem>,
676+ pub events_after: Vec<TimelineItem>,
677+ pub start_token: Option<String>,
678+ pub end_token: Option<String>,
679+}
680+
681+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
682+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
683+pub enum EventContextDirection {
684+ Before,
685+ After,
686+}
687+
688+#[derive(Clone, Debug, Eq, PartialEq)]
689+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
690+#[non_exhaustive]
691+pub struct EventContextPage {
692+ pub direction: EventContextDirection,
693+ pub events: Vec<TimelineItem>,
694+ pub next_token: Option<String>,
695+ pub end_reached: bool,
643696 }
644697
645698 #[derive(Clone, Debug, Eq, PartialEq)]
Modified file: src/transport.rs+61 additions−1 deletions
@@ -28,7 +28,8 @@ use tokio::sync::{OnceCell, mpsc, oneshot, watch};
2828
2929 use crate::{
3030 AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,
31- EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, ImagePackSource,
31+ EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventContext, EventContextCommand,
32+ EventContextPage, EventRef, ImagePackSource,
3233 ImagePacks, InteractiveAuth, InteractiveAuthOutcome, LifecycleCommand, LinkPreview, Media,
3334 MediaCommand, MediaId, MediaLocation, MediaSize, MemberFilter, MemberSummary, MessageContent,
3435 MessageEditContent, NotificationCommand, OwnDevice, PackCommand, PackImageInfo, PackUsage,
@@ -325,6 +326,65 @@ impl ClientHandle {
325326 .await
326327 }
327328
329+ pub async fn event_context(
330+ &self,
331+ room_id: &RoomId,
332+ event_id: &EventId,
333+ before_limit: u32,
334+ after_limit: u32,
335+ ) -> Result<EventContext, Error> {
336+ match self
337+ .execute(Command::EventContext(EventContextCommand::Fetch {
338+ room_id: room_id.to_owned(),
339+ event_id: event_id.to_owned(),
340+ before_limit: before_limit.min(50) as u16,
341+ after_limit: after_limit.min(50) as u16,
342+ }))
343+ .await?
344+ {
345+ CommandOutput::EventContext(context) => Ok(context),
346+ output => Err(unexpected("event context", &output)),
347+ }
348+ }
349+
350+ pub async fn event_context_before(
351+ &self,
352+ room_id: &RoomId,
353+ token: &str,
354+ limit: u32,
355+ ) -> Result<EventContextPage, Error> {
356+ self.event_context_page(EventContextCommand::Before {
357+ room_id: room_id.to_owned(),
358+ limit: limit.min(50) as u16,
359+ token: token.to_owned(),
360+ })
361+ .await
362+ }
363+
364+ pub async fn event_context_after(
365+ &self,
366+ room_id: &RoomId,
367+ token: &str,
368+ limit: u32,
369+ ) -> Result<EventContextPage, Error> {
370+ self.event_context_page(EventContextCommand::After {
371+ room_id: room_id.to_owned(),
372+ limit: limit.min(50) as u16,
373+ token: token.to_owned(),
374+ })
375+ .await
376+ }
377+
378+ async fn event_context_page(
379+ &self,
380+ command: EventContextCommand,
381+ ) -> Result<EventContextPage, Error> {
382+ match self.execute(Command::EventContext(command)).await? {
383+ CommandOutput::EventContextPage(page) => Ok(page),
384+ output => Err(unexpected("event context page", &output)),
385+ }
386+ }
387+
328388 pub async fn send_message(
329389 &self,
330390 timeline_id: TimelineId,