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    events::{
24        poll::unstable_start::UnstablePollStartEventContent, relation::Replacement,
25        room::message::RelationWithoutReplacement, AnyMessageLikeEventContent,
26        AnySyncMessageLikeEvent, AnySyncTimelineEvent, BundledMessageLikeRelations,
27    },
28    serde::Raw,
29    EventId, OwnedEventId, OwnedUserId, RoomVersionId,
30};
31use tracing::trace;
32
33use super::{
34    super::{subscriber::skip::SkipCount, TimelineItem, TimelineItemKind, TimelineUniqueId},
35    read_receipts::ReadReceipts,
36    Aggregation, AggregationKind, Aggregations, AllRemoteEvents, ObservableItemsTransaction,
37    PendingEdit, PendingEditKind,
38};
39use crate::{
40    timeline::{
41        controller::TimelineFocusKind,
42        event_item::{
43            extract_bundled_edit_event_json, extract_poll_edit_content,
44            extract_room_msg_edit_content,
45        },
46        InReplyToDetails, TimelineEventItemId,
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    /// Matrix room 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: RoomVersionId,
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: RoomVersionId,
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,
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        &self,
176        event_a: &EventId,
177        event_b: &EventId,
178        all_remote_events: &AllRemoteEvents,
179    ) -> Option<RelativePosition> {
180        if event_a == event_b {
181            return Some(RelativePosition::Same);
182        }
183
184        // We can make early returns here because we know all events since the end of
185        // the timeline, so the first event encountered is the oldest one.
186        for event_meta in all_remote_events.iter().rev() {
187            if event_meta.event_id == event_a {
188                return Some(RelativePosition::Before);
189            }
190            if event_meta.event_id == event_b {
191                return Some(RelativePosition::After);
192            }
193        }
194
195        None
196    }
197
198    /// Returns the next internal id for a timeline item (and increment our
199    /// internal counter).
200    fn next_internal_id(&mut self) -> TimelineUniqueId {
201        let val = self.next_internal_id;
202        self.next_internal_id = self.next_internal_id.wrapping_add(1);
203        let prefix = self.internal_id_prefix.as_deref().unwrap_or("");
204        TimelineUniqueId(format!("{prefix}{val}"))
205    }
206
207    /// Returns a new timeline item with a fresh internal id.
208    pub fn new_timeline_item(&mut self, kind: impl Into<TimelineItemKind>) -> Arc<TimelineItem> {
209        TimelineItem::new(kind, self.next_internal_id())
210    }
211
212    /// Try to update the read marker item in the timeline.
213    pub(crate) fn update_read_marker(&mut self, items: &mut ObservableItemsTransaction<'_>) {
214        let Some(fully_read_event) = &self.fully_read_event else { return };
215        trace!(?fully_read_event, "Updating read marker");
216
217        let read_marker_idx = items
218            .iter_remotes_region()
219            .rev()
220            .find_map(|(idx, item)| item.is_read_marker().then_some(idx));
221
222        let mut fully_read_event_idx = items.iter_remotes_region().rev().find_map(|(idx, item)| {
223            (item.as_event()?.event_id() == Some(fully_read_event)).then_some(idx)
224        });
225
226        if let Some(fully_read_event_idx) = &mut fully_read_event_idx {
227            // The item at position `i` is the first item that's fully read, we're about to
228            // insert a read marker just after it.
229            //
230            // Do another forward pass to skip all the events we've sent too.
231
232            // Find the position of the first element…
233            let next = items
234                .iter_remotes_region()
235                // …strictly *after* the fully read event…
236                .skip_while(|(idx, _)| idx <= fully_read_event_idx)
237                // …that's not virtual and not sent by us…
238                .find_map(|(idx, item)| {
239                    (item.as_event()?.sender() != self.own_user_id).then_some(idx)
240                });
241
242            if let Some(next) = next {
243                // `next` point to the first item that's not sent by us, so the *previous* of
244                // next is the right place where to insert the fully read marker.
245                *fully_read_event_idx = next.wrapping_sub(1);
246            } else {
247                // There's no event after the read marker that's not sent by us, i.e. the full
248                // timeline has been read: the fully read marker goes to the end, even after the
249                // local timeline items.
250                //
251                // TODO (@hywan): Should we introduce a `items.position_of_last_remote()` to
252                // insert before the local timeline items?
253                *fully_read_event_idx = items.len().wrapping_sub(1);
254            }
255        }
256
257        match (read_marker_idx, fully_read_event_idx) {
258            (None, None) => {
259                // We didn't have a previous read marker, and we didn't find the fully-read
260                // event in the timeline items. Don't do anything, and retry on
261                // the next event we add.
262                self.has_up_to_date_read_marker_item = false;
263            }
264
265            (None, Some(idx)) => {
266                // Only insert the read marker if it is not at the end of the timeline.
267                if idx + 1 < items.len() {
268                    let idx = idx + 1;
269                    items.insert(idx, TimelineItem::read_marker(), None);
270                    self.has_up_to_date_read_marker_item = true;
271                } else {
272                    // The next event might require a read marker to be inserted at the current
273                    // end.
274                    self.has_up_to_date_read_marker_item = false;
275                }
276            }
277
278            (Some(_), None) => {
279                // We didn't find the timeline item containing the event referred to by the read
280                // marker. Retry next time we get a new event.
281                self.has_up_to_date_read_marker_item = false;
282            }
283
284            (Some(from), Some(to)) => {
285                if from >= to {
286                    // The read marker can't move backwards.
287                    if from + 1 == items.len() {
288                        // The read marker has nothing after it. An item disappeared; remove it.
289                        items.remove(from);
290                    }
291                    self.has_up_to_date_read_marker_item = true;
292                    return;
293                }
294
295                let prev_len = items.len();
296                let read_marker = items.remove(from);
297
298                // Only insert the read marker if it is not at the end of the timeline.
299                if to + 1 < prev_len {
300                    // Since the fully-read event's index was shifted to the left
301                    // by one position by the remove call above, insert the fully-
302                    // read marker at its previous position, rather than that + 1
303                    items.insert(to, read_marker, None);
304                    self.has_up_to_date_read_marker_item = true;
305                } else {
306                    self.has_up_to_date_read_marker_item = false;
307                }
308            }
309        }
310    }
311
312    /// Extract the content from a remote message-like event and process its
313    /// relations.
314    pub(crate) fn process_event_relations(
315        &mut self,
316        event: &AnySyncTimelineEvent,
317        raw_event: &Raw<AnySyncTimelineEvent>,
318        bundled_edit_encryption_info: Option<Arc<EncryptionInfo>>,
319        timeline_items: &Vector<Arc<TimelineItem>>,
320        timeline_focus: &TimelineFocusKind,
321    ) -> (Option<InReplyToDetails>, Option<OwnedEventId>) {
322        if let AnySyncTimelineEvent::MessageLike(ev) = event {
323            if let Some(content) = ev.original_content() {
324                let remote_ctx = Some(RemoteEventContext {
325                    event_id: ev.event_id(),
326                    raw_event,
327                    relations: ev.relations(),
328                    bundled_edit_encryption_info,
329                });
330                return self.process_content_relations(
331                    &content,
332                    remote_ctx,
333                    timeline_items,
334                    timeline_focus,
335                );
336            }
337        }
338        (None, None)
339    }
340
341    /// Extracts the in-reply-to details and thread root from the content of a
342    /// message-like event, and take care of internal bookkeeping as well
343    /// (like marking responses).
344    ///
345    /// Returns the in-reply-to details and the thread root event ID, if any.
346    pub(crate) fn process_content_relations(
347        &mut self,
348        content: &AnyMessageLikeEventContent,
349        remote_ctx: Option<RemoteEventContext<'_>>,
350        timeline_items: &Vector<Arc<TimelineItem>>,
351        timeline_focus: &TimelineFocusKind,
352    ) -> (Option<InReplyToDetails>, Option<OwnedEventId>) {
353        match content {
354            AnyMessageLikeEventContent::Sticker(content) => {
355                let (in_reply_to, thread_root) = Self::extract_reply_and_thread_root(
356                    content.relates_to.clone().and_then(|rel| rel.try_into().ok()),
357                    timeline_items,
358                    timeline_focus,
359                );
360
361                if let Some(event_id) = remote_ctx.map(|ctx| ctx.event_id) {
362                    self.mark_response(event_id, in_reply_to.as_ref());
363                }
364
365                (in_reply_to, thread_root)
366            }
367
368            AnyMessageLikeEventContent::UnstablePollStart(UnstablePollStartEventContent::New(
369                c,
370            )) => {
371                let (in_reply_to, thread_root) = Self::extract_reply_and_thread_root(
372                    c.relates_to.clone(),
373                    timeline_items,
374                    timeline_focus,
375                );
376
377                // Record the bundled edit in the aggregations set, if any.
378                if let Some(ctx) = remote_ctx {
379                    if let Some(new_content) = extract_poll_edit_content(ctx.relations) {
380                        // It is replacing the current event.
381                        if let Some(edit_event_id) =
382                            ctx.raw_event.get_field::<OwnedEventId>("event_id").ok().flatten()
383                        {
384                            let edit_json = extract_bundled_edit_event_json(ctx.raw_event);
385                            let aggregation = Aggregation::new(
386                                TimelineEventItemId::EventId(edit_event_id),
387                                AggregationKind::Edit(PendingEdit {
388                                    kind: PendingEditKind::Poll(Replacement::new(
389                                        ctx.event_id.to_owned(),
390                                        new_content,
391                                    )),
392                                    edit_json,
393                                    encryption_info: ctx.bundled_edit_encryption_info,
394                                }),
395                            );
396                            self.aggregations.add(
397                                TimelineEventItemId::EventId(ctx.event_id.to_owned()),
398                                aggregation,
399                            );
400                        }
401                    }
402
403                    self.mark_response(ctx.event_id, in_reply_to.as_ref());
404                }
405
406                (in_reply_to, thread_root)
407            }
408
409            AnyMessageLikeEventContent::RoomMessage(msg) => {
410                let (in_reply_to, thread_root) = Self::extract_reply_and_thread_root(
411                    msg.relates_to.clone().and_then(|rel| rel.try_into().ok()),
412                    timeline_items,
413                    timeline_focus,
414                );
415
416                // Record the bundled edit in the aggregations set, if any.
417                if let Some(ctx) = remote_ctx {
418                    if let Some(new_content) = extract_room_msg_edit_content(ctx.relations) {
419                        // It is replacing the current event.
420                        if let Some(edit_event_id) =
421                            ctx.raw_event.get_field::<OwnedEventId>("event_id").ok().flatten()
422                        {
423                            let edit_json = extract_bundled_edit_event_json(ctx.raw_event);
424                            let aggregation = Aggregation::new(
425                                TimelineEventItemId::EventId(edit_event_id),
426                                AggregationKind::Edit(PendingEdit {
427                                    kind: PendingEditKind::RoomMessage(Replacement::new(
428                                        ctx.event_id.to_owned(),
429                                        new_content,
430                                    )),
431                                    edit_json,
432                                    encryption_info: ctx.bundled_edit_encryption_info,
433                                }),
434                            );
435                            self.aggregations.add(
436                                TimelineEventItemId::EventId(ctx.event_id.to_owned()),
437                                aggregation,
438                            );
439                        }
440                    }
441
442                    self.mark_response(ctx.event_id, in_reply_to.as_ref());
443                }
444
445                (in_reply_to, thread_root)
446            }
447
448            _ => (None, None),
449        }
450    }
451
452    /// Extracts the in-reply-to details and thread root from a relation, if
453    /// available.
454    fn extract_reply_and_thread_root(
455        relates_to: Option<RelationWithoutReplacement>,
456        timeline_items: &Vector<Arc<TimelineItem>>,
457        timeline_focus: &TimelineFocusKind,
458    ) -> (Option<InReplyToDetails>, Option<OwnedEventId>) {
459        let mut thread_root = None;
460
461        let in_reply_to = relates_to.and_then(|relation| match relation {
462            RelationWithoutReplacement::Reply { in_reply_to } => {
463                Some(InReplyToDetails::new(in_reply_to.event_id, timeline_items))
464            }
465            RelationWithoutReplacement::Thread(thread) => {
466                thread_root = Some(thread.event_id);
467
468                if matches!(timeline_focus, TimelineFocusKind::Thread { .. })
469                    && thread.is_falling_back
470                {
471                    // In general, a threaded event is marked as a response to the previous message
472                    // in the thread, to maintain backwards compatibility with clients not
473                    // supporting threads.
474                    //
475                    // But we can have actual replies to other in-thread events. The
476                    // `is_falling_back` bool helps distinguishing both use cases.
477                    //
478                    // If this timeline is thread-focused, we only mark non-falling-back replies as
479                    // actual in-thread replies.
480                    None
481                } else {
482                    thread.in_reply_to.map(|in_reply_to| {
483                        InReplyToDetails::new(in_reply_to.event_id, timeline_items)
484                    })
485                }
486            }
487            _ => None,
488        });
489
490        (in_reply_to, thread_root)
491    }
492
493    /// Mark a message as a response to another message, if it is a reply.
494    fn mark_response(&mut self, event_id: &EventId, in_reply_to: Option<&InReplyToDetails>) {
495        // If this message is a reply to another message, add an entry in the
496        // inverted mapping.
497        if let Some(replied_to_event_id) = in_reply_to.as_ref().map(|details| &details.event_id) {
498            // This is a reply! Add an entry.
499            self.replies
500                .entry(replied_to_event_id.to_owned())
501                .or_default()
502                .insert(event_id.to_owned());
503        }
504    }
505}
506
507/// Result of comparing events position in the timeline.
508#[derive(Debug, Clone, Copy, PartialEq, Eq)]
509pub(in crate::timeline) enum RelativePosition {
510    /// Event B is after (more recent than) event A.
511    After,
512    /// They are the same event.
513    Same,
514    /// Event B is before (older than) event A.
515    Before,
516}
517
518/// Metadata about an event that needs to be kept in memory.
519#[derive(Debug, Clone)]
520pub(in crate::timeline) struct EventMeta {
521    /// The ID of the event.
522    pub event_id: OwnedEventId,
523
524    /// Whether the event is among the timeline items.
525    pub visible: bool,
526
527    /// Foundation for the mapping between remote events to timeline items.
528    ///
529    /// Let's explain it. The events represent the first set and are stored in
530    /// [`ObservableItems::all_remote_events`], and the timeline
531    /// items represent the second set and are stored in
532    /// [`ObservableItems::items`].
533    ///
534    /// Each event is mapped to at most one timeline item:
535    ///
536    /// - `None` if the event isn't rendered in the timeline (e.g. some state
537    ///   events, or malformed events) or is rendered as a timeline item that
538    ///   attaches to or groups with another item, like reactions,
539    /// - `Some(_)` if the event is rendered in the timeline.
540    ///
541    /// This is neither a surjection nor an injection. Every timeline item may
542    /// not be attached to an event, for example with a virtual timeline item.
543    /// We can formulate other rules:
544    ///
545    /// - a timeline item that doesn't _move_ and that is represented by an
546    ///   event has a mapping to an event,
547    /// - a virtual timeline item has no mapping to an event.
548    ///
549    /// Imagine the following remote events:
550    ///
551    /// | index | remote events |
552    /// +-------+---------------+
553    /// | 0     | `$ev0`        |
554    /// | 1     | `$ev1`        |
555    /// | 2     | `$ev2`        |
556    /// | 3     | `$ev3`        |
557    /// | 4     | `$ev4`        |
558    /// | 5     | `$ev5`        |
559    ///
560    /// Once rendered in a timeline, it for example produces:
561    ///
562    /// | index | item              | related items        |
563    /// +-------+-------------------+----------------------+
564    /// | 0     | content of `$ev0` |                      |
565    /// | 1     | content of `$ev2` | reaction with `$ev4` |
566    /// | 2     | date divider      |                      |
567    /// | 3     | content of `$ev3` |                      |
568    /// | 4     | content of `$ev5` |                      |
569    ///
570    /// Note the date divider that is a virtual item. Also note `$ev4` which is
571    /// a reaction to `$ev2`. Finally note that `$ev1` is not rendered in
572    /// the timeline.
573    ///
574    /// The mapping between remote event index to timeline item index will look
575    /// like this:
576    ///
577    /// | remote event index | timeline item index | comment                                    |
578    /// +--------------------+---------------------+--------------------------------------------+
579    /// | 0                  | `Some(0)`           | `$ev0` is rendered as the #0 timeline item |
580    /// | 1                  | `None`              | `$ev1` isn't rendered in the timeline      |
581    /// | 2                  | `Some(1)`           | `$ev2` is rendered as the #1 timeline item |
582    /// | 3                  | `Some(3)`           | `$ev3` is rendered as the #3 timeline item |
583    /// | 4                  | `None`              | `$ev4` is a reaction to item #1            |
584    /// | 5                  | `Some(4)`           | `$ev5` is rendered as the #4 timeline item |
585    ///
586    /// Note that the #2 timeline item (the day divider) doesn't map to any
587    /// remote event, but if it moves, it has an impact on this mapping.
588    pub timeline_item_index: Option<usize>,
589}
590
591impl EventMeta {
592    pub fn new(event_id: OwnedEventId, visible: bool) -> Self {
593        Self { event_id, visible, timeline_item_index: None }
594    }
595}