matrix_sdk_ui/timeline/controller/metadata.rs
1// Copyright 2025 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::{
16 collections::{BTreeSet, HashMap},
17 sync::Arc,
18};
19
20use imbl::Vector;
21use matrix_sdk::deserialized_responses::EncryptionInfo;
22use ruma::{
23 EventId, OwnedEventId, OwnedUserId,
24 events::{
25 AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent,
26 BundledMessageLikeRelations, poll::unstable_start::UnstablePollStartEventContent,
27 relation::Replacement, room::message::RelationWithoutReplacement,
28 },
29 room_version_rules::RoomVersionRules,
30 serde::Raw,
31};
32use tracing::trace;
33
34use super::{
35 super::{TimelineItem, TimelineItemKind, TimelineUniqueId, subscriber::skip::SkipCount},
36 Aggregation, AggregationKind, Aggregations, AllRemoteEvents, ObservableItemsTransaction,
37 PendingEdit, PendingEditKind,
38 read_receipts::ReadReceipts,
39};
40use crate::{
41 timeline::{
42 InReplyToDetails, TimelineEventItemId,
43 event_item::{
44 extract_bundled_edit_event_json, extract_poll_edit_content,
45 extract_room_msg_edit_content,
46 },
47 },
48 unable_to_decrypt_hook::UtdHookManager,
49};
50
51/// All parameters to [`TimelineAction::from_content`] that only apply if an
52/// event is a remote echo.
53pub(crate) struct RemoteEventContext<'a> {
54 pub event_id: &'a EventId,
55 pub raw_event: &'a Raw<AnySyncTimelineEvent>,
56 pub relations: BundledMessageLikeRelations<AnySyncMessageLikeEvent>,
57 pub bundled_edit_encryption_info: Option<Arc<EncryptionInfo>>,
58}
59
60#[derive(Clone, Debug)]
61pub(in crate::timeline) struct TimelineMetadata {
62 // **** CONSTANT FIELDS ****
63 /// An optional prefix for internal IDs, defined during construction of the
64 /// timeline.
65 ///
66 /// This value is constant over the lifetime of the metadata.
67 internal_id_prefix: Option<String>,
68
69 /// The `count` value for the `Skip` higher-order stream used by the
70 /// `TimelineSubscriber`. See its documentation to learn more.
71 pub(super) subscriber_skip_count: SkipCount,
72
73 /// The hook to call whenever we run into a unable-to-decrypt event.
74 ///
75 /// This value is constant over the lifetime of the metadata.
76 pub unable_to_decrypt_hook: Option<Arc<UtdHookManager>>,
77
78 /// A boolean indicating whether the room the timeline is attached to is
79 /// actually encrypted or not.
80 ///
81 /// May be false until we fetch the actual room encryption state.
82 pub is_room_encrypted: bool,
83
84 /// Rules of the version of the timeline's room, or a sensible default.
85 ///
86 /// This value is constant over the lifetime of the metadata.
87 pub room_version_rules: RoomVersionRules,
88
89 /// The own [`OwnedUserId`] of the client who opened the timeline.
90 pub(crate) own_user_id: OwnedUserId,
91
92 // **** DYNAMIC FIELDS ****
93 /// The next internal identifier for timeline items, used for both local and
94 /// remote echoes.
95 ///
96 /// This is never cleared, but always incremented, to avoid issues with
97 /// reusing a stale internal id across timeline clears. We don't expect
98 /// we can hit `u64::max_value()` realistically, but if this would
99 /// happen, we do a wrapping addition when incrementing this
100 /// id; the previous 0 value would have disappeared a long time ago, unless
101 /// the device has terabytes of RAM.
102 next_internal_id: u64,
103
104 /// Aggregation metadata and pending aggregations.
105 pub aggregations: Aggregations,
106
107 /// Given an event, what are all the events that are replies to it?
108 ///
109 /// Only works for remote events *and* replies which are remote-echoed.
110 pub replies: HashMap<OwnedEventId, BTreeSet<OwnedEventId>>,
111
112 /// Identifier of the fully-read event, helping knowing where to introduce
113 /// the read marker.
114 pub fully_read_event: Option<OwnedEventId>,
115
116 /// Whether we have a fully read-marker item in the timeline, that's up to
117 /// date with the room's read marker.
118 ///
119 /// This is false when:
120 /// - The fully-read marker points to an event that is not in the timeline,
121 /// - The fully-read marker item would be the last item in the timeline.
122 pub has_up_to_date_read_marker_item: bool,
123
124 /// Read receipts related state.
125 ///
126 /// TODO: move this over to the event cache (see also #3058).
127 pub(super) read_receipts: ReadReceipts,
128}
129
130impl TimelineMetadata {
131 pub(in crate::timeline) fn new(
132 own_user_id: OwnedUserId,
133 room_version_rules: RoomVersionRules,
134 internal_id_prefix: Option<String>,
135 unable_to_decrypt_hook: Option<Arc<UtdHookManager>>,
136 is_room_encrypted: bool,
137 ) -> Self {
138 Self {
139 subscriber_skip_count: SkipCount::new(),
140 own_user_id,
141 next_internal_id: Default::default(),
142 aggregations: Default::default(),
143 replies: Default::default(),
144 fully_read_event: Default::default(),
145 // It doesn't make sense to set this to false until we fill the `fully_read_event`
146 // field, otherwise we'll keep on exiting early in `Self::update_read_marker`.
147 has_up_to_date_read_marker_item: true,
148 read_receipts: Default::default(),
149 room_version_rules,
150 unable_to_decrypt_hook,
151 internal_id_prefix,
152 is_room_encrypted,
153 }
154 }
155
156 pub(super) fn clear(&mut self) {
157 // Note: we don't clear the next internal id to avoid bad cases of stale unique
158 // ids across timeline clears.
159 self.aggregations.clear();
160 self.replies.clear();
161 self.fully_read_event = None;
162 // We forgot about the fully read marker right above, so wait for a new one
163 // before attempting to update it for each new timeline item.
164 self.has_up_to_date_read_marker_item = true;
165 self.read_receipts.clear();
166 }
167
168 /// Get the relative positions of two events in the timeline.
169 ///
170 /// This method assumes that all events since the end of the timeline are
171 /// known.
172 ///
173 /// Returns `None` if none of the two events could be found in the timeline.
174 pub(in crate::timeline) fn compare_events_positions(
175 event_a: &EventId,
176 event_b: &EventId,
177 all_remote_events: &AllRemoteEvents,
178 ) -> Option<RelativePosition> {
179 if event_a == event_b {
180 return Some(RelativePosition::Same);
181 }
182
183 // We can make early returns here because we know all events since the end of
184 // the timeline, so the first event encountered is the oldest one.
185 for event_meta in all_remote_events.iter().rev() {
186 if event_meta.event_id == event_a {
187 return Some(RelativePosition::Before);
188 }
189 if event_meta.event_id == event_b {
190 return Some(RelativePosition::After);
191 }
192 }
193
194 None
195 }
196
197 /// Returns the next internal id for a timeline item (and increment our
198 /// internal counter).
199 fn next_internal_id(&mut self) -> TimelineUniqueId {
200 let val = self.next_internal_id;
201 self.next_internal_id = self.next_internal_id.wrapping_add(1);
202 let prefix = self.internal_id_prefix.as_deref().unwrap_or("");
203 TimelineUniqueId(format!("{prefix}{val}"))
204 }
205
206 /// Returns a new timeline item with a fresh internal id.
207 pub fn new_timeline_item(&mut self, kind: impl Into<TimelineItemKind>) -> Arc<TimelineItem> {
208 TimelineItem::new(kind, self.next_internal_id())
209 }
210
211 /// Returns a new timeline item reusing the recycled internal id, or with a
212 /// fresh internal id.
213 pub fn new_timeline_item_with_internal_id(
214 &mut self,
215 kind: impl Into<TimelineItemKind>,
216 recycled_timeline_id: Option<TimelineUniqueId>,
217 ) -> Arc<TimelineItem> {
218 TimelineItem::new(kind, recycled_timeline_id.unwrap_or_else(|| self.next_internal_id()))
219 }
220
221 /// Try to update the read marker item in the timeline.
222 pub(crate) fn update_read_marker(&mut self, items: &mut ObservableItemsTransaction<'_>) {
223 let Some(fully_read_event) = &self.fully_read_event else { return };
224 trace!(?fully_read_event, "Updating read marker");
225
226 let read_marker_idx = items
227 .iter_remotes_region()
228 .rev()
229 .find_map(|(idx, item)| item.is_read_marker().then_some(idx));
230
231 let mut fully_read_event_idx = items.iter_remotes_region().rev().find_map(|(idx, item)| {
232 (item.as_event()?.event_id() == Some(fully_read_event)).then_some(idx)
233 });
234
235 if let Some(fully_read_event_idx) = &mut fully_read_event_idx {
236 // The item at position `i` is the first item that's fully read, we're about to
237 // insert a read marker just after it.
238 //
239 // Do another forward pass to skip all the events we've sent too.
240
241 // Find the position of the first element…
242 let next = items
243 .iter_remotes_region()
244 // …strictly *after* the fully read event…
245 .skip_while(|(idx, _)| idx <= fully_read_event_idx)
246 // …that's not virtual and not sent by us…
247 .find_map(|(idx, item)| {
248 (item.as_event()?.sender() != self.own_user_id).then_some(idx)
249 });
250
251 if let Some(next) = next {
252 // `next` point to the first item that's not sent by us, so the *previous* of
253 // next is the right place where to insert the fully read marker.
254 *fully_read_event_idx = next.wrapping_sub(1);
255 } else {
256 // There's no event after the read marker that's not sent by us, i.e. the full
257 // timeline has been read: the fully read marker goes to the end, even after the
258 // local timeline items.
259 //
260 // TODO (@hywan): Should we introduce a `items.position_of_last_remote()` to
261 // insert before the local timeline items?
262 *fully_read_event_idx = items.len().wrapping_sub(1);
263 }
264 }
265
266 match (read_marker_idx, fully_read_event_idx) {
267 (None, None) => {
268 // We didn't have a previous read marker, and we didn't find the fully-read
269 // event in the timeline items. Don't do anything, and retry on
270 // the next event we add.
271 self.has_up_to_date_read_marker_item = false;
272 }
273
274 (None, Some(idx)) => {
275 // Only insert the read marker if it is not at the end of the timeline.
276 if idx + 1 < items.len() {
277 let idx = idx + 1;
278 items.insert(idx, TimelineItem::read_marker(), None);
279 self.has_up_to_date_read_marker_item = true;
280 } else {
281 // The next event might require a read marker to be inserted at the current
282 // end.
283 self.has_up_to_date_read_marker_item = false;
284 }
285 }
286
287 (Some(_), None) => {
288 // We didn't find the timeline item containing the event referred to by the read
289 // marker. Retry next time we get a new event.
290 self.has_up_to_date_read_marker_item = false;
291 }
292
293 (Some(from), Some(to)) => {
294 if from >= to {
295 // The read marker can't move backwards.
296 if from + 1 == items.len() {
297 // The read marker has nothing after it. An item disappeared; remove it.
298 items.remove(from);
299 }
300 self.has_up_to_date_read_marker_item = true;
301 return;
302 }
303
304 let prev_len = items.len();
305 let read_marker = items.remove(from);
306
307 // Only insert the read marker if it is not at the end of the timeline.
308 if to + 1 < prev_len {
309 // Since the fully-read event's index was shifted to the left
310 // by one position by the remove call above, insert the fully-
311 // read marker at its previous position, rather than that + 1
312 items.insert(to, read_marker, None);
313 self.has_up_to_date_read_marker_item = true;
314 } else {
315 self.has_up_to_date_read_marker_item = false;
316 }
317 }
318 }
319 }
320
321 /// Extract the content from a remote message-like event and process its
322 /// relations.
323 pub(crate) fn process_event_relations(
324 &mut self,
325 event: &AnySyncTimelineEvent,
326 raw_event: &Raw<AnySyncTimelineEvent>,
327 bundled_edit_encryption_info: Option<Arc<EncryptionInfo>>,
328 timeline_items: &Vector<Arc<TimelineItem>>,
329 is_thread_focus: bool,
330 ) -> (Option<InReplyToDetails>, Option<OwnedEventId>) {
331 if let AnySyncTimelineEvent::MessageLike(ev) = event
332 && let Some(content) = ev.original_content()
333 {
334 let remote_ctx = Some(RemoteEventContext {
335 event_id: ev.event_id(),
336 raw_event,
337 relations: ev.relations(),
338 bundled_edit_encryption_info,
339 });
340 self.process_content_relations(&content, remote_ctx, timeline_items, is_thread_focus)
341 } else {
342 (None, None)
343 }
344 }
345
346 /// Extracts the in-reply-to details and thread root from the content of a
347 /// message-like event, and take care of internal bookkeeping as well
348 /// (like marking responses).
349 ///
350 /// Returns the in-reply-to details and the thread root event ID, if any.
351 pub(crate) fn process_content_relations(
352 &mut self,
353 content: &AnyMessageLikeEventContent,
354 remote_ctx: Option<RemoteEventContext<'_>>,
355 timeline_items: &Vector<Arc<TimelineItem>>,
356 is_thread_focus: bool,
357 ) -> (Option<InReplyToDetails>, Option<OwnedEventId>) {
358 match content {
359 AnyMessageLikeEventContent::Sticker(content) => {
360 let (in_reply_to, thread_root) = Self::extract_reply_and_thread_root(
361 content.relates_to.clone().and_then(|rel| rel.try_into().ok()),
362 timeline_items,
363 is_thread_focus,
364 );
365
366 if let Some(event_id) = remote_ctx.map(|ctx| ctx.event_id) {
367 self.mark_response(event_id, in_reply_to.as_ref());
368 }
369
370 (in_reply_to, thread_root)
371 }
372
373 AnyMessageLikeEventContent::UnstablePollStart(UnstablePollStartEventContent::New(
374 c,
375 )) => {
376 let (in_reply_to, thread_root) = Self::extract_reply_and_thread_root(
377 c.relates_to.clone(),
378 timeline_items,
379 is_thread_focus,
380 );
381
382 // Record the bundled edit in the aggregations set, if any.
383 if let Some(ctx) = remote_ctx {
384 // Extract a potentially bundled edit.
385 if let Some((edit_event_id, new_content)) =
386 extract_poll_edit_content(ctx.relations)
387 {
388 let edit_json = extract_bundled_edit_event_json(ctx.raw_event);
389 let aggregation = Aggregation::new(
390 TimelineEventItemId::EventId(edit_event_id),
391 AggregationKind::Edit(PendingEdit {
392 kind: PendingEditKind::Poll(Replacement::new(
393 ctx.event_id.to_owned(),
394 new_content,
395 )),
396 edit_json,
397 encryption_info: ctx.bundled_edit_encryption_info,
398 bundled_item_owner: Some(ctx.event_id.to_owned()),
399 }),
400 );
401 self.aggregations.add(
402 TimelineEventItemId::EventId(ctx.event_id.to_owned()),
403 aggregation,
404 );
405 }
406
407 self.mark_response(ctx.event_id, in_reply_to.as_ref());
408 }
409
410 (in_reply_to, thread_root)
411 }
412
413 AnyMessageLikeEventContent::RoomMessage(msg) => {
414 let (in_reply_to, thread_root) = Self::extract_reply_and_thread_root(
415 msg.relates_to.clone().and_then(|rel| rel.try_into().ok()),
416 timeline_items,
417 is_thread_focus,
418 );
419
420 // Record the bundled edit in the aggregations set, if any.
421 if let Some(ctx) = remote_ctx {
422 // Extract a potentially bundled edit.
423 if let Some((edit_event_id, new_content)) =
424 extract_room_msg_edit_content(ctx.relations)
425 {
426 let edit_json = extract_bundled_edit_event_json(ctx.raw_event);
427 let aggregation = Aggregation::new(
428 TimelineEventItemId::EventId(edit_event_id),
429 AggregationKind::Edit(PendingEdit {
430 kind: PendingEditKind::RoomMessage(Replacement::new(
431 ctx.event_id.to_owned(),
432 new_content,
433 )),
434 edit_json,
435 encryption_info: ctx.bundled_edit_encryption_info,
436 bundled_item_owner: Some(ctx.event_id.to_owned()),
437 }),
438 );
439 self.aggregations.add(
440 TimelineEventItemId::EventId(ctx.event_id.to_owned()),
441 aggregation,
442 );
443 }
444
445 self.mark_response(ctx.event_id, in_reply_to.as_ref());
446 }
447
448 (in_reply_to, thread_root)
449 }
450
451 _ => (None, None),
452 }
453 }
454
455 /// Extracts the in-reply-to details and thread root from a relation, if
456 /// available.
457 fn extract_reply_and_thread_root(
458 relates_to: Option<RelationWithoutReplacement>,
459 timeline_items: &Vector<Arc<TimelineItem>>,
460 is_thread_focus: bool,
461 ) -> (Option<InReplyToDetails>, Option<OwnedEventId>) {
462 let mut thread_root = None;
463
464 let in_reply_to = relates_to.and_then(|relation| match relation {
465 RelationWithoutReplacement::Reply(reply) => {
466 Some(InReplyToDetails::new(reply.in_reply_to.event_id, timeline_items))
467 }
468 RelationWithoutReplacement::Thread(thread) => {
469 thread_root = Some(thread.event_id);
470
471 if is_thread_focus && thread.is_falling_back {
472 // In general, a threaded event is marked as a response to the previous message
473 // in the thread, to maintain backwards compatibility with clients not
474 // supporting threads.
475 //
476 // But we can have actual replies to other in-thread events. The
477 // `is_falling_back` bool helps distinguishing both use cases.
478 //
479 // If this timeline is thread-focused, we only mark non-falling-back replies as
480 // actual in-thread replies.
481 None
482 } else {
483 thread.in_reply_to.map(|in_reply_to| {
484 InReplyToDetails::new(in_reply_to.event_id, timeline_items)
485 })
486 }
487 }
488 _ => None,
489 });
490
491 (in_reply_to, thread_root)
492 }
493
494 /// Mark a message as a response to another message, if it is a reply.
495 fn mark_response(&mut self, event_id: &EventId, in_reply_to: Option<&InReplyToDetails>) {
496 // If this message is a reply to another message, add an entry in the
497 // inverted mapping.
498 if let Some(replied_to_event_id) = in_reply_to.as_ref().map(|details| &details.event_id) {
499 // This is a reply! Add an entry.
500 self.replies
501 .entry(replied_to_event_id.to_owned())
502 .or_default()
503 .insert(event_id.to_owned());
504 }
505 }
506}
507
508/// Result of comparing events position in the timeline.
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub(in crate::timeline) enum RelativePosition {
511 /// Event B is after (more recent than) event A.
512 After,
513 /// They are the same event.
514 Same,
515 /// Event B is before (older than) event A.
516 Before,
517}
518
519/// Metadata about an event that needs to be kept in memory.
520#[derive(Debug, Clone)]
521pub(in crate::timeline) struct EventMeta {
522 /// The ID of the event.
523 pub event_id: OwnedEventId,
524
525 /// If this event is part of a thread, this will contain its thread root
526 /// event id.
527 pub thread_root_id: Option<OwnedEventId>,
528
529 /// Whether the event is among the timeline items.
530 pub visible: bool,
531
532 /// Whether the event can show read receipts.
533 pub can_show_read_receipts: bool,
534
535 /// Foundation for the mapping between remote events to timeline items.
536 ///
537 /// Let's explain it. The events represent the first set and are stored in
538 /// [`ObservableItems::all_remote_events`], and the timeline
539 /// items represent the second set and are stored in
540 /// [`ObservableItems::items`].
541 ///
542 /// Each event is mapped to at most one timeline item:
543 ///
544 /// - `None` if the event isn't rendered in the timeline (e.g. some state
545 /// events, or malformed events) or is rendered as a timeline item that
546 /// attaches to or groups with another item, like reactions,
547 /// - `Some(_)` if the event is rendered in the timeline.
548 ///
549 /// This is neither a surjection nor an injection. Every timeline item may
550 /// not be attached to an event, for example with a virtual timeline item.
551 /// We can formulate other rules:
552 ///
553 /// - a timeline item that doesn't _move_ and that is represented by an
554 /// event has a mapping to an event,
555 /// - a virtual timeline item has no mapping to an event.
556 ///
557 /// Imagine the following remote events:
558 ///
559 /// | index | remote events |
560 /// +-------+---------------+
561 /// | 0 | `$ev0` |
562 /// | 1 | `$ev1` |
563 /// | 2 | `$ev2` |
564 /// | 3 | `$ev3` |
565 /// | 4 | `$ev4` |
566 /// | 5 | `$ev5` |
567 ///
568 /// Once rendered in a timeline, it for example produces:
569 ///
570 /// | index | item | related items |
571 /// +-------+-------------------+----------------------+
572 /// | 0 | content of `$ev0` | |
573 /// | 1 | content of `$ev2` | reaction with `$ev4` |
574 /// | 2 | date divider | |
575 /// | 3 | content of `$ev3` | |
576 /// | 4 | content of `$ev5` | |
577 ///
578 /// Note the date divider that is a virtual item. Also note `$ev4` which is
579 /// a reaction to `$ev2`. Finally note that `$ev1` is not rendered in
580 /// the timeline.
581 ///
582 /// The mapping between remote event index to timeline item index will look
583 /// like this:
584 ///
585 /// | remote event index | timeline item index | comment |
586 /// +--------------------+---------------------+--------------------------------------------+
587 /// | 0 | `Some(0)` | `$ev0` is rendered as the #0 timeline item |
588 /// | 1 | `None` | `$ev1` isn't rendered in the timeline |
589 /// | 2 | `Some(1)` | `$ev2` is rendered as the #1 timeline item |
590 /// | 3 | `Some(3)` | `$ev3` is rendered as the #3 timeline item |
591 /// | 4 | `None` | `$ev4` is a reaction to item #1 |
592 /// | 5 | `Some(4)` | `$ev5` is rendered as the #4 timeline item |
593 ///
594 /// Note that the #2 timeline item (the day divider) doesn't map to any
595 /// remote event, but if it moves, it has an impact on this mapping.
596 pub timeline_item_index: Option<usize>,
597}
598
599impl EventMeta {
600 pub fn new(
601 event_id: OwnedEventId,
602 visible: bool,
603 can_show_read_receipts: bool,
604 thread_root_id: Option<OwnedEventId>,
605 ) -> Self {
606 Self {
607 event_id,
608 thread_root_id,
609 visible,
610 can_show_read_receipts,
611 timeline_item_index: None,
612 }
613 }
614}