Skip to main content

matrix_sdk_ui/timeline/controller/
aggregations.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
15//! An aggregation manager for the timeline.
16//!
17//! An aggregation is an event that relates to another event: for instance, a
18//! reaction, a poll response, and so on and so forth.
19//!
20//! Because of the sync mechanisms and federation, it can happen that a related
21//! event is received *before* receiving the event it relates to. Those events
22//! must be accounted for, stashed somewhere, and reapplied later, if/when the
23//! related-to event shows up.
24//!
25//! In addition to that, a room's event cache can also decide to move events
26//! around, in its own internal representation (likely because it ran into some
27//! duplicate events). When that happens, a timeline opened on the given room
28//! will see a removal then re-insertion of the given event. If that event was
29//! the target of aggregations, then those aggregations must be re-applied when
30//! the given event is reinserted.
31//!
32//! To satisfy both requirements, the [`Aggregations`] "manager" object provided
33//! by this module will take care of memoizing aggregations, for the entire
34//! lifetime of the timeline (or until it's [`Aggregations::clear()`]'ed by some
35//! caller). Aggregations are saved in memory, and have the same lifetime as
36//! that of a timeline. This makes it possible to apply pending aggregations
37//! to cater for the first use case, and to never lose any aggregations in the
38//! second use case.
39
40use std::{borrow::Cow, collections::HashMap, sync::Arc};
41
42use as_variant::as_variant;
43use matrix_sdk::{
44    check_validity_of_replacement_events,
45    deserialized_responses::EncryptionInfo,
46    send_queue::{RoomSendQueueStorageError, SendHandle, SendReactionHandle, SendRedactionHandle},
47};
48use ruma::{
49    MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, OwnedUserId, UserId,
50    events::{
51        AnySyncTimelineEvent, beacon_info::BeaconInfoEventContent,
52        poll::unstable_start::NewUnstablePollStartEventContentWithoutRelation,
53        relation::Replacement, room::message::RoomMessageEventContentWithoutRelation,
54    },
55    room_version_rules::RoomVersionRules,
56    serde::Raw,
57};
58use tracing::{error, info, trace, warn};
59
60use super::{ObservableItemsTransaction, rfind_event_by_item_id};
61use crate::timeline::{
62    BeaconInfo, EventSendState, EventTimelineItem, LiveLocationState, MsgLikeContent, MsgLikeKind,
63    PollState, ReactionInfo, TimelineEventItemId, TimelineItem, TimelineItemContent,
64    event_item::beacon_info_matches,
65};
66
67#[derive(Clone)]
68pub(in crate::timeline) enum PendingEditKind {
69    RoomMessage(Replacement<RoomMessageEventContentWithoutRelation>),
70    Poll(Replacement<NewUnstablePollStartEventContentWithoutRelation>),
71}
72
73impl std::fmt::Debug for PendingEditKind {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::RoomMessage(_) => f.debug_struct("RoomMessage").finish_non_exhaustive(),
77            Self::Poll(_) => f.debug_struct("Poll").finish_non_exhaustive(),
78        }
79    }
80}
81
82#[derive(Clone, Debug)]
83pub(in crate::timeline) struct PendingEdit {
84    /// The kind of edit this is.
85    pub kind: PendingEditKind,
86
87    /// The raw JSON for the edit.
88    pub edit_json: Option<Raw<AnySyncTimelineEvent>>,
89
90    /// The encryption info for this edit.
91    pub encryption_info: Option<Arc<EncryptionInfo>>,
92
93    /// If provided, this is the identifier of a remote event item that included
94    /// this bundled edit.
95    pub bundled_item_owner: Option<OwnedEventId>,
96}
97
98/// Which kind of aggregation (related event) is this?
99#[derive(Clone, Debug)]
100pub(crate) enum AggregationKind {
101    /// This is a response to a poll.
102    PollResponse {
103        /// Sender of the poll's response.
104        sender: OwnedUserId,
105        /// Timestamp at which the response has beens ent.
106        timestamp: MilliSecondsSinceUnixEpoch,
107        /// All the answers to the poll sent by the sender.
108        answers: Vec<String>,
109    },
110
111    /// This is the marker of the end of a poll.
112    PollEnd {
113        /// Timestamp at which the poll ends, i.e. all the responses with a
114        /// timestamp prior to this one should be taken into account
115        /// (and all the responses with a timestamp after this one
116        /// should be dropped).
117        end_date: MilliSecondsSinceUnixEpoch,
118    },
119
120    /// This is a reaction to another event.
121    Reaction {
122        /// The reaction "key" displayed by the client, often an emoji.
123        key: String,
124        /// Sender of the reaction.
125        sender: OwnedUserId,
126        /// Timestamp at which the reaction has been sent.
127        timestamp: MilliSecondsSinceUnixEpoch,
128    },
129
130    /// An event has been redacted.
131    ///
132    /// Our own pending redactions are applied reversibly, sent or remote ones
133    /// irreversibly; see [`Aggregation::is_local`].
134    Redaction,
135
136    /// An event has been edited.
137    ///
138    /// Note that edits can't be applied in isolation; we need to identify what
139    /// the *latest* edit is, based on the event ordering. As such, they're
140    /// handled exceptionally in `Aggregation::apply` and
141    /// `Aggregation::unapply`, and the callers have the responsibility of
142    /// considering all the edits and applying only the right one.
143    Edit(PendingEdit),
144
145    /// A location update for a live location sharing session (MSC3489).
146    BeaconUpdate { location: BeaconInfo },
147
148    /// A stop event for a live location sharing session (MSC3489).
149    ///
150    /// Carries the new (non-live) [`BeaconInfoEventContent`] that should
151    /// replace the stored content on the target item, flipping
152    /// [`LiveLocationState::is_live`] to `false`.
153    ///
154    /// Unlike [`BeaconUpdate`], a beacon stop is not reversible.
155    BeaconStop { content: BeaconInfoEventContent },
156
157    /// An m.rtc.decline event for an m.rtc.notification event
158    CallDeclined {
159        /// Sender of the decline.
160        sender: OwnedUserId,
161    },
162}
163
164/// The handle to abort an aggregation while it's still a local echo.
165#[derive(Clone, Debug)]
166pub(crate) enum AggregationSendHandle {
167    /// The aggregation was queued as a regular event.
168    Event(SendHandle),
169    /// A reaction to a local echo, queued as a child request of that echo.
170    Reaction(SendReactionHandle),
171    /// A redaction, queued as a dedicated request.
172    Redaction(SendRedactionHandle),
173}
174
175impl AggregationSendHandle {
176    pub async fn abort(&self) -> Result<bool, RoomSendQueueStorageError> {
177        match self {
178            Self::Event(handle) => handle.abort().await,
179            Self::Reaction(handle) => handle.abort().await,
180            Self::Redaction(handle) => handle.abort().await,
181        }
182    }
183}
184
185/// An aggregation is an event related to another event (for instance a
186/// reaction, a poll's response, etc.).
187///
188/// It can be either a local or a remote echo.
189#[derive(Clone, Debug)]
190pub(crate) struct Aggregation {
191    /// The kind of aggregation this represents.
192    pub kind: AggregationKind,
193
194    /// The own timeline identifier for an aggregation.
195    ///
196    /// It will be a transaction id when the aggregation is still a local echo,
197    /// and it will transition into an event id when the aggregation is a
198    /// remote echo (i.e. has been received in a sync response):
199    pub own_id: TimelineEventItemId,
200
201    /// `None` when the aggregation came from the server; `Some` for one of our
202    /// local echoes, with the same states as a standalone local event.
203    pub send_state: Option<EventSendState>,
204
205    /// Lets one of our local echoes be aborted while it's still pending.
206    pub send_handle: Option<AggregationSendHandle>,
207}
208
209/// Get the poll state from a given [`TimelineItemContent`].
210fn poll_state_from_item<'a>(
211    event: &'a mut Cow<'_, EventTimelineItem>,
212) -> Result<&'a mut PollState, AggregationError> {
213    let content = event.to_mut().content_mut();
214
215    if let TimelineItemContent::MsgLike(MsgLikeContent { kind: MsgLikeKind::Poll(state), .. }) =
216        content
217    {
218        Ok(state)
219    } else {
220        Err(AggregationError::InvalidType {
221            expected: "a poll".to_owned(),
222            actual: content.debug_string().to_owned(),
223        })
224    }
225}
226
227/// Get the [`LiveLocationState`] from a given [`TimelineItemContent`], mutably.
228fn live_location_state_from_item<'a>(
229    event: &'a mut Cow<'_, EventTimelineItem>,
230) -> Result<&'a mut LiveLocationState, AggregationError> {
231    let content = event.to_mut().content_mut();
232
233    if let TimelineItemContent::MsgLike(MsgLikeContent {
234        kind: MsgLikeKind::LiveLocation(state),
235        ..
236    }) = content
237    {
238        Ok(state)
239    } else {
240        Err(AggregationError::InvalidType {
241            expected: "a live location".to_owned(),
242            actual: content.debug_string().to_owned(),
243        })
244    }
245}
246
247/// Gets the mutable list of users that did decline this notification event.
248fn rtc_notification_declinations_from_item<'a>(
249    event: &'a mut Cow<'_, EventTimelineItem>,
250) -> Result<&'a mut Vec<OwnedUserId>, AggregationError> {
251    let content = event.to_mut().content_mut();
252
253    if let TimelineItemContent::RtcNotification { declined_by, .. } = content {
254        Ok(declined_by)
255    } else {
256        Err(AggregationError::InvalidType {
257            expected: "an rtc notification".to_owned(),
258            actual: content.debug_string().to_owned(),
259        })
260    }
261}
262
263impl Aggregation {
264    /// Create an aggregation received from the server.
265    pub fn new(own_id: TimelineEventItemId, kind: AggregationKind) -> Self {
266        Self { kind, own_id, send_state: None, send_handle: None }
267    }
268
269    /// Create an aggregation for one of our local echoes.
270    pub fn new_local(
271        own_id: TimelineEventItemId,
272        kind: AggregationKind,
273        send_handle: Option<AggregationSendHandle>,
274    ) -> Self {
275        Self {
276            kind,
277            own_id,
278            send_state: Some(EventSendState::NotSentYet { progress: None }),
279            send_handle,
280        }
281    }
282
283    /// Whether this is one of our local echoes that hasn't been sent yet.
284    pub fn is_local(&self) -> bool {
285        !matches!(self.send_state, None | Some(EventSendState::Sent { .. }))
286    }
287
288    /// Apply an aggregation in-place to a given [`TimelineItemContent`].
289    ///
290    /// In case of success, returns an enum indicating whether the applied
291    /// aggregation had an effect on the content; if it updated it, then the
292    /// caller has the responsibility to reflect that change.
293    ///
294    /// In case of error, returns an error detailing why the aggregation
295    /// couldn't be applied.
296    fn apply(
297        &self,
298        event: &mut Cow<'_, EventTimelineItem>,
299        rules: &RoomVersionRules,
300    ) -> ApplyAggregationResult {
301        match &self.kind {
302            AggregationKind::PollResponse { sender, timestamp, answers } => {
303                match poll_state_from_item(event) {
304                    Ok(state) => {
305                        state.add_response(sender.clone(), *timestamp, answers.clone());
306                        ApplyAggregationResult::UpdatedItem
307                    }
308                    Err(err) => ApplyAggregationResult::Error(err),
309                }
310            }
311
312            AggregationKind::Redaction => {
313                let is_local = self.is_local();
314                let is_local_redacted =
315                    event.content().is_redacted() && event.unredacted_item.is_some();
316                let is_remote_redacted =
317                    event.content().is_redacted() && event.unredacted_item.is_none();
318                if is_local && is_local_redacted || !is_local && is_remote_redacted {
319                    if event.redaction_send_state.is_some() && self.send_state.is_none() {
320                        // The remote echo of a redaction we sent: nothing pending anymore.
321                        event.to_mut().redaction_send_state = None;
322                        ApplyAggregationResult::UpdatedItem
323                    } else {
324                        ApplyAggregationResult::LeftItemIntact
325                    }
326                } else {
327                    let mut new_item = event.redact(&rules.redaction, is_local);
328                    new_item.redaction_send_state = self.send_state.clone();
329                    *event = Cow::Owned(new_item);
330                    ApplyAggregationResult::UpdatedItem
331                }
332            }
333
334            AggregationKind::PollEnd { end_date } => match poll_state_from_item(event) {
335                Ok(state) => {
336                    if !state.end(*end_date) {
337                        return ApplyAggregationResult::Error(AggregationError::PollAlreadyEnded);
338                    }
339                    ApplyAggregationResult::UpdatedItem
340                }
341                Err(err) => ApplyAggregationResult::Error(err),
342            },
343
344            AggregationKind::Reaction { key, sender, timestamp } => {
345                let Some(reactions) = event.content().reactions() else {
346                    // An item that can't hold any reactions.
347                    return ApplyAggregationResult::LeftItemIntact;
348                };
349
350                let previous_reaction = reactions.get(key).and_then(|by_user| by_user.get(sender));
351
352                // Same reaction, same origin: already applied.
353                let is_same = previous_reaction.is_some_and(|prev| {
354                    prev.timestamp == *timestamp
355                        && same_send_state_kind(prev.send_state.as_ref(), self.send_state.as_ref())
356                });
357
358                if is_same {
359                    ApplyAggregationResult::LeftItemIntact
360                } else {
361                    let reactions = event
362                        .to_mut()
363                        .content_mut()
364                        .reactions_mut()
365                        .expect("reactions was Some above");
366
367                    reactions.entry(key.clone()).or_default().insert(
368                        sender.clone(),
369                        ReactionInfo { timestamp: *timestamp, send_state: self.send_state.clone() },
370                    );
371
372                    ApplyAggregationResult::UpdatedItem
373                }
374            }
375
376            AggregationKind::Edit(_) => {
377                // Let the caller handle the edit.
378                ApplyAggregationResult::Edit
379            }
380
381            AggregationKind::BeaconUpdate { location } => {
382                match live_location_state_from_item(event) {
383                    Ok(state) => {
384                        state.add_location(location.clone());
385                        ApplyAggregationResult::UpdatedItem
386                    }
387                    Err(err) => ApplyAggregationResult::Error(err),
388                }
389            }
390
391            AggregationKind::BeaconStop { content } => match live_location_state_from_item(event) {
392                Ok(state) => {
393                    state.stop(content.clone());
394                    ApplyAggregationResult::UpdatedItem
395                }
396                Err(err) => ApplyAggregationResult::Error(err),
397            },
398
399            AggregationKind::CallDeclined { sender } => {
400                match rtc_notification_declinations_from_item(event) {
401                    Ok(declinations) => {
402                        if declinations.contains(sender) {
403                            ApplyAggregationResult::LeftItemIntact
404                        } else {
405                            declinations.push(sender.clone());
406                            ApplyAggregationResult::UpdatedItem
407                        }
408                    }
409                    Err(err) => ApplyAggregationResult::Error(err),
410                }
411            }
412        }
413    }
414
415    /// Undo an aggregation in-place to a given [`TimelineItemContent`].
416    ///
417    /// In case of success, returns an enum indicating whether unapplying the
418    /// aggregation had an effect on the content; if it updated it, then the
419    /// caller has the responsibility to reflect that change.
420    ///
421    /// In case of error, returns an error detailing why the aggregation
422    /// couldn't be unapplied.
423    fn unapply(&self, event: &mut Cow<'_, EventTimelineItem>) -> ApplyAggregationResult {
424        match &self.kind {
425            AggregationKind::PollResponse { sender, timestamp, .. } => {
426                let state = match poll_state_from_item(event) {
427                    Ok(state) => state,
428                    Err(err) => return ApplyAggregationResult::Error(err),
429                };
430                state.remove_response(sender, *timestamp);
431                ApplyAggregationResult::UpdatedItem
432            }
433
434            AggregationKind::PollEnd { .. } => {
435                // Assume we can't undo a poll end event at the moment.
436                ApplyAggregationResult::Error(AggregationError::CantUndoPollEnd)
437            }
438
439            AggregationKind::Redaction => {
440                if self.is_local() {
441                    if event.unredacted_item.is_some() {
442                        // Unapply local redaction.
443                        *event = Cow::Owned(event.unredact());
444                        ApplyAggregationResult::UpdatedItem
445                    } else {
446                        // Event isn't locally redacted. Nothing to do.
447                        ApplyAggregationResult::LeftItemIntact
448                    }
449                } else {
450                    // Remote redactions are not reversible.
451                    ApplyAggregationResult::Error(AggregationError::CantUndoRedaction)
452                }
453            }
454
455            AggregationKind::Reaction { key, sender, .. } => {
456                let Some(reactions) = event.content().reactions() else {
457                    // An item that can't hold any reactions.
458                    return ApplyAggregationResult::LeftItemIntact;
459                };
460
461                // We only need to remove the previous reaction if it was there.
462                //
463                // Search for it.
464
465                let had_entry =
466                    reactions.get(key).and_then(|by_user| by_user.get(sender)).is_some();
467
468                if had_entry {
469                    let reactions = event
470                        .to_mut()
471                        .content_mut()
472                        .reactions_mut()
473                        .expect("reactions was some above");
474                    let by_user = reactions.get_mut(key);
475                    if let Some(by_user) = by_user {
476                        by_user.swap_remove(sender);
477                        // If this was the last reaction, remove the entire map for this key.
478                        if by_user.is_empty() {
479                            reactions.swap_remove(key);
480                        }
481                    }
482                    ApplyAggregationResult::UpdatedItem
483                } else {
484                    ApplyAggregationResult::LeftItemIntact
485                }
486            }
487
488            AggregationKind::Edit(_) => {
489                // Let the caller handle the edit.
490                ApplyAggregationResult::Edit
491            }
492
493            AggregationKind::BeaconUpdate { location } => {
494                match live_location_state_from_item(event) {
495                    Ok(state) => {
496                        state.remove_location(location.ts);
497                        ApplyAggregationResult::UpdatedItem
498                    }
499                    Err(err) => ApplyAggregationResult::Error(err),
500                }
501            }
502
503            AggregationKind::BeaconStop { .. } => {
504                // Stopping a live location share is not reversible.
505                ApplyAggregationResult::Error(AggregationError::CantUndoBeaconStop)
506            }
507
508            AggregationKind::CallDeclined { .. } => {
509                // One cannot un-decline a call
510                ApplyAggregationResult::Error(AggregationError::CantUndoRtcDecline)
511            }
512        }
513    }
514
515    /// Reflect this aggregation's send state on the item it applies to,
516    /// without reapplying its content. Returns whether the item changed.
517    fn apply_send_state(
518        &self,
519        siblings: &[Aggregation],
520        event: &mut Cow<'_, EventTimelineItem>,
521    ) -> bool {
522        match &self.kind {
523            AggregationKind::Reaction { key, sender, .. } => {
524                let has_entry = event
525                    .content()
526                    .reactions()
527                    .and_then(|reactions| reactions.get(key)?.get(sender))
528                    .is_some();
529                if !has_entry {
530                    return false;
531                }
532                let reactions =
533                    event.to_mut().content_mut().reactions_mut().expect("reactions was Some above");
534                if let Some(info) =
535                    reactions.get_mut(key).and_then(|by_user| by_user.get_mut(sender))
536                {
537                    info.send_state = self.send_state.clone();
538                }
539                true
540            }
541
542            AggregationKind::Edit(_) => {
543                event.to_mut().edit_send_state = edit_send_state(siblings);
544                true
545            }
546
547            AggregationKind::Redaction => {
548                event.to_mut().redaction_send_state = self.send_state.clone();
549                true
550            }
551
552            AggregationKind::PollResponse { .. }
553            | AggregationKind::PollEnd { .. }
554            | AggregationKind::BeaconUpdate { .. }
555            | AggregationKind::BeaconStop { .. }
556            | AggregationKind::CallDeclined { .. } => false,
557        }
558    }
559}
560
561/// Manager for all known existing aggregations to all events in the timeline.
562#[derive(Clone, Debug, Default)]
563pub(crate) struct Aggregations {
564    /// Mapping of a target event to its list of aggregations.
565    related_events: HashMap<TimelineEventItemId, Vec<Aggregation>>,
566
567    /// Mapping of a related event identifier to its target.
568    inverted_map: HashMap<TimelineEventItemId, TimelineEventItemId>,
569
570    /// A pending beacon-stop aggregation received before the corresponding live
571    /// `beacon_info` start item has arrived.
572    ///
573    /// Keyed by the sender's user ID. When a live start item is eventually
574    /// inserted via `add_item`, we check if the pending stop matches and
575    /// promote it into [`Self::related_events`] so that [`Self::apply_all`]
576    /// can apply it immediately.
577    pending_beacon_stops: HashMap<OwnedUserId, Aggregation>,
578}
579
580impl Aggregations {
581    /// Clear all the known aggregations from all the mappings.
582    pub fn clear(&mut self) {
583        self.related_events.clear();
584        self.inverted_map.clear();
585        self.pending_beacon_stops.clear();
586    }
587
588    /// Stash a [`AggregationKind::BeaconStop`] that arrived before its target
589    /// live `beacon_info` item. It will be promoted into
590    /// [`Self::related_events`] (and thus picked up by [`Self::apply_all`])
591    /// when the live item is inserted via
592    /// [`Self::promote_pending_beacon_stop`].
593    pub fn add_pending_beacon_stop(&mut self, sender: OwnedUserId, aggregation: Aggregation) {
594        self.pending_beacon_stops.insert(sender, aggregation);
595    }
596
597    /// Promote a matching stashed beacon-stop aggregation for `sender` into the
598    /// regular aggregation map, now that the live start item's
599    /// `target_event_id` is known.
600    ///
601    /// The pending stop's content must match the start event's content (except
602    /// for the `live` field) for promotion to occur. If they don't match, the
603    /// pending stop is discarded because it belongs to a different session.
604    ///
605    /// Should be called from `add_item` just before `apply_all`, when inserting
606    /// a live `beacon_info` item.
607    fn promote_pending_beacon_stop(
608        &mut self,
609        sender: &OwnedUserId,
610        target_event_id: OwnedEventId,
611        start_content: &BeaconInfoEventContent,
612    ) {
613        if !start_content.live {
614            return;
615        }
616
617        let Some(stop) = self.pending_beacon_stops.remove(sender) else { return };
618
619        let AggregationKind::BeaconStop { content: stop_content } = &stop.kind else {
620            warn!("pending beacon stop has unexpected aggregation kind");
621            return;
622        };
623
624        if !beacon_info_matches(start_content, stop_content) {
625            trace!("discarding stale pending beacon stop (content mismatch)");
626            return;
627        }
628
629        let target = TimelineEventItemId::EventId(target_event_id);
630        self.add(target, stop);
631    }
632
633    /// Add a given aggregation that relates to the [`TimelineItemContent`]
634    /// identified by the given [`TimelineEventItemId`].
635    pub fn add(&mut self, related_to: TimelineEventItemId, aggregation: Aggregation) {
636        // If the aggregation is a redaction, it invalidates all the other aggregations;
637        // remove them.
638        if matches!(aggregation.kind, AggregationKind::Redaction) {
639            for agg in self.related_events.remove(&related_to).unwrap_or_default() {
640                self.inverted_map.remove(&agg.own_id);
641            }
642        }
643
644        // If there was any redaction among the current aggregation, adding a new one
645        // should be a noop.
646        if let Some(previous_aggregations) = self.related_events.get(&related_to)
647            && previous_aggregations
648                .iter()
649                .any(|agg| matches!(agg.kind, AggregationKind::Redaction))
650        {
651            return;
652        }
653
654        self.inverted_map.insert(aggregation.own_id.clone(), related_to.clone());
655
656        // We can have 3 different states for the same aggregation in related_events, in
657        // chronological order:
658        //
659        // 1. The local echo with a transaction ID.
660        // 2. The local echo with the event ID returned by the server after sending the
661        //    event.
662        // 3. The remote echo received via sync.
663        //
664        // The transition from states 1 to 2 is handled in `update_send_state()`.
665        // So here we need to handle the transition from states 2 to 3. We need to
666        // replace the local echo by the remote echo, which might have more data, like
667        // the raw JSON.
668        let related_events = self.related_events.entry(related_to).or_default();
669        if let Some(pos) = related_events.iter().position(|agg| agg.own_id == aggregation.own_id) {
670            related_events.remove(pos);
671        }
672        related_events.push(aggregation);
673    }
674
675    /// Is the given id one for a known aggregation to another event?
676    ///
677    /// If so, unapplies it by replacing the corresponding related item, if
678    /// needs be.
679    ///
680    /// Returns true if an aggregation was found. This doesn't mean
681    /// the underlying item has been updated, if it was missing from the
682    /// timeline for instance.
683    ///
684    /// May return an error if it found an aggregation, but it couldn't be
685    /// properly applied.
686    pub fn try_remove_aggregation(
687        &mut self,
688        aggregation_id: &TimelineEventItemId,
689        items: &mut ObservableItemsTransaction<'_>,
690    ) -> Result<bool, AggregationError> {
691        let Some(found) = self.inverted_map.get(aggregation_id) else { return Ok(false) };
692
693        // Find and remove the aggregation in the other mapping.
694        let aggregation = if let Some(aggregations) = self.related_events.get_mut(found) {
695            let removed = aggregations
696                .iter()
697                .position(|agg| agg.own_id == *aggregation_id)
698                .map(|idx| aggregations.remove(idx));
699
700            // If this was the last aggregation, remove the entry in the `related_events`
701            // mapping.
702            if aggregations.is_empty() {
703                self.related_events.remove(found);
704            }
705
706            removed
707        } else {
708            None
709        };
710
711        let Some(aggregation) = aggregation else {
712            warn!(
713                "incorrect internal state: {aggregation_id:?} was present in the inverted map, \
714                 not in related-to map."
715            );
716            return Ok(false);
717        };
718
719        if let Some((item_pos, item)) = rfind_event_by_item_id(items, found) {
720            let mut cowed = Cow::Borrowed(&*item);
721            match aggregation.unapply(&mut cowed) {
722                ApplyAggregationResult::UpdatedItem => {
723                    trace!("removed aggregation");
724                    items.replace(
725                        item_pos,
726                        TimelineItem::new(cowed.into_owned(), item.internal_id.to_owned()),
727                    );
728                }
729                ApplyAggregationResult::LeftItemIntact => {}
730                ApplyAggregationResult::Error(err) => {
731                    warn!("error when unapplying aggregation: {err}");
732                }
733                ApplyAggregationResult::Edit => {
734                    // This edit has been removed; try to find another that still applies.
735                    let resolved = self
736                        .related_events
737                        .get(found)
738                        .is_some_and(|aggregations| resolve_edits(aggregations, items, &mut cowed));
739                    // Otherwise nothing is pending anymore.
740                    // TODO likely need to change the item to indicate
741                    // it's been un-edited etc.
742                    if !resolved {
743                        if cowed.edit_send_state.is_none() {
744                            return Ok(true);
745                        }
746                        cowed.to_mut().edit_send_state = None;
747                    }
748                    items.replace(
749                        item_pos,
750                        TimelineItem::new(cowed.into_owned(), item.internal_id.to_owned()),
751                    );
752                }
753            }
754        } else {
755            info!("missing related-to item ({found:?}) for aggregation {aggregation_id:?}");
756        }
757
758        Ok(true)
759    }
760
761    /// Apply all the aggregations to a [`TimelineItemContent`].
762    ///
763    /// If `sender` is provided alongside a remote `item_id`, any
764    /// [`AggregationKind::BeaconStop`] events that arrived out-of-order (i.e.
765    /// before the live `beacon_info` start item) are first promoted from the
766    /// pending-stops stash into the regular aggregation map so they are picked
767    /// up here together with every other pending aggregation for this item.
768    ///
769    /// Will return an error at the first aggregation that couldn't be applied;
770    /// see [`Aggregation::apply`] which explains under which conditions it can
771    /// happen.
772    pub fn apply_all(
773        &mut self,
774        item_id: &TimelineEventItemId,
775        sender: &OwnedUserId,
776        event: &mut Cow<'_, EventTimelineItem>,
777        items: &mut ObservableItemsTransaction<'_>,
778        rules: &RoomVersionRules,
779    ) -> Result<(), AggregationError> {
780        // If a beacon-stop arrived before this live start item, it was stashed
781        // in `pending_beacon_stops` keyed by sender. Promote it into
782        // `related_events` under the now-known start event ID so the loop below
783        // applies it together with any other pending aggregations.
784        //
785        // The promotion verifies that the pending stop's content matches the
786        // start event's content to ensure we don't apply an old stop to a new
787        // session.
788        if let TimelineEventItemId::EventId(event_id) = item_id
789            && let Some(live_location) = event.content().as_live_location_state()
790        {
791            self.promote_pending_beacon_stop(sender, event_id.clone(), &live_location.beacon_info);
792        }
793
794        let Some(aggregations) = self.related_events.get(item_id) else {
795            return Ok(());
796        };
797
798        let mut has_edits = false;
799
800        for a in aggregations {
801            match a.apply(event, rules) {
802                ApplyAggregationResult::Edit => {
803                    has_edits = true;
804                }
805                ApplyAggregationResult::UpdatedItem | ApplyAggregationResult::LeftItemIntact => {}
806                ApplyAggregationResult::Error(err) => return Err(err),
807            }
808        }
809
810        if has_edits {
811            resolve_edits(aggregations, items, event);
812        }
813
814        Ok(())
815    }
816
817    /// Mark a target event as being sent (i.e. it transitions from an local
818    /// transaction id to its remote event id counterpart), by updating the
819    /// internal mappings.
820    pub fn mark_target_as_sent(&mut self, txn_id: OwnedTransactionId, event_id: OwnedEventId) {
821        let from = TimelineEventItemId::TransactionId(txn_id);
822        let to = TimelineEventItemId::EventId(event_id);
823
824        // Update the aggregations in the `related_events` field.
825        if let Some(aggregations) = self.related_events.remove(&from) {
826            // Update the inverted mappings (from aggregation's id, to the new target id).
827            for a in &aggregations {
828                if let Some(prev_target) = self.inverted_map.remove(&a.own_id) {
829                    debug_assert_eq!(prev_target, from);
830                    self.inverted_map.insert(a.own_id.clone(), to.clone());
831                }
832            }
833            // Update the direct mapping of target -> aggregations.
834            self.related_events.entry(to).or_default().extend(aggregations);
835        }
836    }
837
838    /// Update the send state of one of our local aggregations, identified by
839    /// its transaction id, and reflect it on the item it applies to.
840    ///
841    /// Returns `false` if no aggregation has this transaction id.
842    pub fn update_send_state(
843        &mut self,
844        txn_id: OwnedTransactionId,
845        send_state: EventSendState,
846        items: &mut ObservableItemsTransaction<'_>,
847        rules: &RoomVersionRules,
848    ) -> bool {
849        let from = TimelineEventItemId::TransactionId(txn_id);
850
851        let Some(target) = self.inverted_map.get(&from).cloned() else {
852            return false;
853        };
854
855        let sent_event_id =
856            as_variant!(&send_state, EventSendState::Sent { event_id } => event_id.clone());
857
858        if let Some(event_id) = &sent_event_id {
859            let to = TimelineEventItemId::EventId(event_id.clone());
860            let remote_echo_received = self
861                .related_events
862                .get(&target)
863                .is_some_and(|aggs| aggs.iter().any(|agg| agg.own_id == to));
864            if remote_echo_received {
865                // The remote echo got there first: forget the local echo and let the remote
866                // one settle the item.
867                let remote = self.related_events.get_mut(&target).and_then(|aggs| {
868                    aggs.retain(|agg| agg.own_id != from);
869                    aggs.iter().find(|agg| agg.own_id == to).cloned()
870                });
871                self.inverted_map.remove(&from);
872                if let Some(remote) = remote {
873                    find_item_and_apply_aggregation(self, items, &target, remote, rules);
874                }
875                return true;
876            }
877        }
878
879        let updated = {
880            let Some(aggregations) = self.related_events.get_mut(&target) else {
881                return false;
882            };
883            let Some(found) = aggregations.iter_mut().find(|agg| agg.own_id == from) else {
884                return false;
885            };
886
887            found.send_state = Some(send_state);
888
889            if let Some(event_id) = &sent_event_id {
890                found.own_id = TimelineEventItemId::EventId(event_id.clone());
891            }
892
893            found.clone()
894        };
895
896        if let Some(event_id) = sent_event_id {
897            self.inverted_map.remove(&from);
898            self.inverted_map.insert(TimelineEventItemId::EventId(event_id), target.clone());
899        }
900
901        let sent_redaction = matches!(updated.kind, AggregationKind::Redaction)
902            && matches!(updated.send_state, Some(EventSendState::Sent { .. }));
903
904        if sent_redaction {
905            // A sent redaction becomes irreversible: reapply it.
906            find_item_and_apply_aggregation(self, items, &target, updated, rules);
907        } else if let Some((idx, item)) = rfind_event_by_item_id(items, &target) {
908            let siblings = self.related_events.get(&target).map(Vec::as_slice).unwrap_or(&[]);
909            let mut cowed = Cow::Borrowed(&*item);
910            if updated.apply_send_state(siblings, &mut cowed) {
911                let new_item = TimelineItem::new(cowed.into_owned(), item.internal_id.to_owned());
912                items.replace(idx, new_item);
913            }
914        } else {
915            trace!("couldn't find aggregation's target {target:?} to reflect its send state");
916        }
917
918        true
919    }
920
921    /// Returns the id of the event this aggregation relates to, if it's a known
922    /// aggregation.
923    pub fn is_aggregation_of(&self, item: &TimelineEventItemId) -> Option<&TimelineEventItemId> {
924        self.inverted_map.get(item)
925    }
926
927    /// Find the latest reaction with the given key sent by `sender` on
928    /// `target`.
929    pub fn find_reaction(
930        &self,
931        target: &TimelineEventItemId,
932        key: &str,
933        sender: &UserId,
934    ) -> Option<&Aggregation> {
935        self.related_events.get(target)?.iter().rev().find(|agg| {
936            matches!(&agg.kind, AggregationKind::Reaction { key: k, sender: s, .. } if k == key && s == sender)
937        })
938    }
939}
940
941/// Look at all the edits of a given event, and apply the most recent one, if
942/// found.
943///
944/// Returns true if an edit was found and applied, false otherwise.
945fn resolve_edits(
946    aggregations: &[Aggregation],
947    items: &ObservableItemsTransaction<'_>,
948    event: &mut Cow<'_, EventTimelineItem>,
949) -> bool {
950    // A tuple of the best edit, if we have found one and a boolean indicating if
951    // the edit is coming from a local echo. If it's from a local echo, we can't
952    // validate it as we don't have a raw JSON, but this isn't that important as
953    // we're sure we won't send ourselves invalid edits.
954    let mut best_edit: Option<(PendingEdit, bool)> = None;
955    let mut best_edit_pos = None;
956
957    for a in aggregations {
958        if let AggregationKind::Edit(pending_edit) = &a.kind {
959            // One of our own edits is always the most recent, even once sent but not
960            // echoed yet.
961            if a.send_state.is_some() {
962                best_edit = Some((pending_edit.clone(), true));
963                break;
964            }
965
966            match &a.own_id {
967                TimelineEventItemId::TransactionId(_) => {
968                    // A local echo is always the most recent edit: use this one.
969                    best_edit = Some((pending_edit.clone(), true));
970                    break;
971                }
972
973                TimelineEventItemId::EventId(event_id) => {
974                    if let Some(best_edit_pos) = &mut best_edit_pos {
975                        // Find the position of the timeline owning the edit: either the bundled
976                        // item owner if this was a bundled edit, or the edit event itself.
977                        let pos = items.position_by_event_id(
978                            pending_edit.bundled_item_owner.as_ref().unwrap_or(event_id),
979                        );
980
981                        if let Some(pos) = pos {
982                            // If the edit is more recent (higher index) than the previous best
983                            // edit we knew about, use this one.
984                            if pos > *best_edit_pos {
985                                best_edit = Some((pending_edit.clone(), false));
986                                *best_edit_pos = pos;
987                                trace!(?best_edit_pos, edit_id = ?a.own_id, "found better edit");
988                            }
989                        } else {
990                            trace!(edit_id = ?a.own_id, "couldn't find timeline meta for edit event");
991
992                            // The edit event isn't in the timeline, so it might be a bundled
993                            // edit. In this case, record it as the best edit if and only if
994                            // there wasn't any other.
995                            if best_edit.is_none() {
996                                best_edit = Some((pending_edit.clone(), false));
997                                trace!(?best_edit_pos, edit_id = ?a.own_id, "found bundled edit");
998                            }
999                        }
1000                    } else {
1001                        // There wasn't any best edit yet, so record this one as being it, with
1002                        // its position.
1003                        best_edit = Some((pending_edit.clone(), false));
1004                        best_edit_pos = items.position_by_event_id(event_id);
1005                        trace!(?best_edit_pos, edit_id = ?a.own_id, "first best edit");
1006                    }
1007                }
1008            }
1009        }
1010    }
1011
1012    if let Some((edit, is_local_echo)) = best_edit {
1013        if edit_item(event, edit, is_local_echo) {
1014            event.to_mut().edit_send_state = edit_send_state(aggregations);
1015            true
1016        } else {
1017            false
1018        }
1019    } else {
1020        false
1021    }
1022}
1023
1024/// Apply the selected edit to the given EventTimelineItem.
1025///
1026/// Returns true if the edit was applied, false otherwise (because the edit and
1027/// original timeline item types didn't match, for instance).
1028fn edit_item(
1029    item: &mut Cow<'_, EventTimelineItem>,
1030    edit: PendingEdit,
1031    is_local_echo: bool,
1032) -> bool {
1033    // We can receive edits from a local echo, i.e. the edit wasn't yet received
1034    // from the homeserver.
1035    //
1036    // Before we send an edit we check that the event is allowed to be edited and
1037    // that the replacement content is allowed.
1038    //
1039    // We don't have yet a full JSON of the event, so we can't do the validation
1040    // here.
1041    if !is_local_echo {
1042        let Some(original_json) = item.original_json() else {
1043            error!("The original event does not have the JSON field set.");
1044            return false;
1045        };
1046
1047        let Some(edit_json) = &edit.edit_json else {
1048            error!(
1049                "The replacement event of a remotely received edit does not have the JSON field set."
1050            );
1051            return false;
1052        };
1053
1054        match check_validity_of_replacement_events(
1055            original_json,
1056            item.encryption_info(),
1057            edit_json,
1058            edit.encryption_info.as_deref(),
1059        ) {
1060            Ok(content) => content,
1061            Err(e) => {
1062                warn!("Event wasn't replaced due to the replacement event being invalid: {e}");
1063                return false;
1064            }
1065        }
1066    }
1067
1068    let TimelineItemContent::MsgLike(content) = item.content() else {
1069        info!("Edit of message event applies to {:?}, discarding", item.content().debug_string());
1070        return false;
1071    };
1072
1073    let PendingEdit { kind: edit_kind, edit_json, encryption_info, bundled_item_owner: _ } = edit;
1074
1075    match (edit_kind, content) {
1076        (
1077            PendingEditKind::RoomMessage(replacement),
1078            MsgLikeContent { kind: MsgLikeKind::Message(msg), .. },
1079        ) => {
1080            // First combination: it's a message edit for a message. Good.
1081            let mut new_msg = msg.clone();
1082            new_msg.apply_edit(replacement.new_content);
1083
1084            let new_item = item.with_content_and_latest_edit(
1085                TimelineItemContent::MsgLike(content.with_kind(MsgLikeKind::Message(new_msg))),
1086                edit_json,
1087            );
1088            *item = Cow::Owned(new_item);
1089        }
1090
1091        (
1092            PendingEditKind::Poll(replacement),
1093            MsgLikeContent { kind: MsgLikeKind::Poll(poll_state), .. },
1094        ) => {
1095            // Second combination: it's a poll edit for a poll. Good.
1096            if let Some(new_poll_state) = poll_state.edit(replacement.new_content) {
1097                let new_item = item.with_content_and_latest_edit(
1098                    TimelineItemContent::MsgLike(
1099                        content.with_kind(MsgLikeKind::Poll(new_poll_state)),
1100                    ),
1101                    edit_json,
1102                );
1103                *item = Cow::Owned(new_item);
1104            } else {
1105                // The poll has ended, so we can't edit it anymore.
1106                return false;
1107            }
1108        }
1109
1110        (edit_kind, _) => {
1111            // Invalid combination.
1112            info!(
1113                content = item.content().debug_string(),
1114                edit = format!("{:?}", edit_kind),
1115                "Mismatch between edit type and content type",
1116            );
1117            return false;
1118        }
1119    }
1120
1121    if let Some(encryption_info) = encryption_info {
1122        *item = Cow::Owned(item.with_encryption_info(Some(encryption_info)));
1123    }
1124
1125    true
1126}
1127
1128/// Whether two optional send states are of the same kind (ignoring their
1129/// payload, e.g. upload progress).
1130fn same_send_state_kind(a: Option<&EventSendState>, b: Option<&EventSendState>) -> bool {
1131    match (a, b) {
1132        (None, None) => true,
1133        (Some(a), Some(b)) => std::mem::discriminant(a) == std::mem::discriminant(b),
1134        _ => false,
1135    }
1136}
1137
1138/// The send state to expose for an item's edits: a failed edit blocks the
1139/// later ones, so it wins over pending, which wins over sent.
1140fn edit_send_state(aggregations: &[Aggregation]) -> Option<EventSendState> {
1141    let rank = |s: &EventSendState| match s {
1142        EventSendState::SendingFailed { .. } => 2,
1143        EventSendState::NotSentYet { .. } => 1,
1144        EventSendState::Sent { .. } => 0,
1145    };
1146    aggregations
1147        .iter()
1148        .filter(|a| matches!(a.kind, AggregationKind::Edit(_)))
1149        .filter_map(|a| a.send_state.as_ref())
1150        .max_by_key(|s| rank(s))
1151        .cloned()
1152}
1153
1154/// Find an item identified by the target identifier, and apply the aggregation
1155/// onto it.
1156///
1157/// Returns the updated [`EventTimelineItem`] if the aggregation was applied, or
1158/// `None` otherwise.
1159pub(crate) fn find_item_and_apply_aggregation(
1160    aggregations: &Aggregations,
1161    items: &mut ObservableItemsTransaction<'_>,
1162    target: &TimelineEventItemId,
1163    aggregation: Aggregation,
1164    rules: &RoomVersionRules,
1165) -> Option<EventTimelineItem> {
1166    let Some((idx, event_item)) = rfind_event_by_item_id(items, target) else {
1167        trace!("couldn't find aggregation's target {target:?}");
1168        return None;
1169    };
1170
1171    let mut cowed = Cow::Borrowed(&*event_item);
1172    match aggregation.apply(&mut cowed, rules) {
1173        ApplyAggregationResult::UpdatedItem => {
1174            trace!("applied aggregation");
1175            let new_event_item = cowed.into_owned();
1176            let new_item =
1177                TimelineItem::new(new_event_item.clone(), event_item.internal_id.to_owned());
1178            items.replace(idx, new_item);
1179            Some(new_event_item)
1180        }
1181        ApplyAggregationResult::Edit => {
1182            if let Some(aggregations) = aggregations.related_events.get(target)
1183                && resolve_edits(aggregations, items, &mut cowed)
1184            {
1185                let new_event_item = cowed.into_owned();
1186                let new_item =
1187                    TimelineItem::new(new_event_item.clone(), event_item.internal_id.to_owned());
1188                items.replace(idx, new_item);
1189                return Some(new_event_item);
1190            }
1191            None
1192        }
1193        ApplyAggregationResult::LeftItemIntact => {
1194            trace!("applying the aggregation had no effect");
1195            None
1196        }
1197        ApplyAggregationResult::Error(err) => {
1198            warn!("error when applying aggregation: {err}");
1199            None
1200        }
1201    }
1202}
1203
1204/// The result of applying (or unapplying) an aggregation onto a timeline item.
1205enum ApplyAggregationResult {
1206    /// The passed `Cow<EventTimelineItem>` has been cloned and updated.
1207    UpdatedItem,
1208
1209    /// An edit must be included in the edit set and resolved later, using the
1210    /// relative position of the edits.
1211    Edit,
1212
1213    /// The item hasn't been modified after applying the aggregation, because it
1214    /// was likely already applied prior to this.
1215    LeftItemIntact,
1216
1217    /// An error happened while applying the aggregation.
1218    Error(AggregationError),
1219}
1220
1221#[derive(Debug, thiserror::Error)]
1222pub(crate) enum AggregationError {
1223    #[error("trying to end a poll twice")]
1224    PollAlreadyEnded,
1225
1226    #[error("a poll end can't be unapplied")]
1227    CantUndoPollEnd,
1228
1229    #[error("a redaction can't be unapplied")]
1230    CantUndoRedaction,
1231
1232    #[error("a beacon stop can't be unapplied")]
1233    CantUndoBeaconStop,
1234
1235    #[error("a call decline can't be unapplied")]
1236    CantUndoRtcDecline,
1237
1238    #[error(
1239        "trying to apply an aggregation of one type to an invalid target: \
1240         expected {expected}, actual {actual}"
1241    )]
1242    InvalidType { expected: String, actual: String },
1243}