Repository: matrix-org/matrix-rust-sdk
Pull request #5046: refactor(timeline): finish the aggregations refactoring
Author: login: bnjbvr
Created: 2025-05-15T18:00:40Z | Merged: 2025-05-20T11:50:02Z

This uses the `Aggregations` manager to take care of redactions and edits, allowing us to get rid of the `pending_edits` in the `TimelineMetadata` \o/ Aggregations are now handled in a single manner, which is nice for consistency.

For redactions, this means we now stash a redaction even if we've seen it before the event it redacts, making sure we won't display something that should've been redacted.

For edits, we now do "the right thing", and will look at the position of each edit event we know about, and use the most "recent" one, if we've found any. This will help handling edits within threads as well (as such, helps with #4869), instead of having us implement some more complicated logic for those.

Part of #4823.


Diff:
diff --git a/crates/matrix-sdk-ui/CHANGELOG.md b/crates/matrix-sdk-ui/CHANGELOG.md
index a17bec1caa6..9140f754d5c 100644
--- a/crates/matrix-sdk-ui/CHANGELOG.md
+++ b/crates/matrix-sdk-ui/CHANGELOG.md
@@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file.
 
 ## [Unreleased] - ReleaseDate
 
+### Refactor
+
+- [**breaking**] [`TimelineItemContent::reactions()`] returns an `Option<&ReactionsByKeyBySender>`
+  instead of `ReactionsByKeyBySender`. This reflects the fact that some timeline items cannot hold
+  reactions at all.
+
 ### Bug Fixes
 
 - Introduce `Timeline` regions, which helps to remove a class of bugs in the
diff --git a/crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs b/crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs
index c2ca45f3771..3534ec38caa 100644
--- a/crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs
+++ b/crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs
@@ -37,17 +37,54 @@
 //! to cater for the first use case, and to never lose any aggregations in the
 //! second use case.
 
-use std::collections::HashMap;
-
-use ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, OwnedUserId};
-use tracing::{trace, warn};
+use std::{borrow::Cow, collections::HashMap};
+
+use as_variant::as_variant;
+use matrix_sdk::deserialized_responses::EncryptionInfo;
+use ruma::{
+    events::{
+        poll::unstable_start::NewUnstablePollStartEventContentWithoutRelation,
+        relation::Replacement, room::message::RoomMessageEventContentWithoutRelation,
+        AnySyncTimelineEvent,
+    },
+    serde::Raw,
+    MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, OwnedUserId, RoomVersionId,
+};
+use tracing::{info, trace, warn};
 
 use super::{rfind_event_by_item_id, ObservableItemsTransaction};
 use crate::timeline::{
-    MsgLikeContent, MsgLikeKind, PollState, ReactionInfo, ReactionStatus, TimelineEventItemId,
-    TimelineItem, TimelineItemContent,
+    EventTimelineItem, MsgLikeContent, MsgLikeKind, PollState, ReactionInfo, ReactionStatus,
+    TimelineEventItemId, TimelineItem, TimelineItemContent,
 };
 
+#[derive(Clone)]
+pub(in crate::timeline) enum PendingEditKind {
+    RoomMessage(Replacement<RoomMessageEventContentWithoutRelation>),
+    Poll(Replacement<NewUnstablePollStartEventContentWithoutRelation>),
+}
+
+impl std::fmt::Debug for PendingEditKind {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::RoomMessage(_) => f.debug_struct("RoomMessage").finish_non_exhaustive(),
+            Self::Poll(_) => f.debug_struct("Poll").finish_non_exhaustive(),
+        }
+    }
+}
+
+#[derive(Clone, Debug)]
+pub(in crate::timeline) struct PendingEdit {
+    /// The kind of edit this is.
+    pub kind: PendingEditKind,
+
+    /// The raw JSON for the edit.
+    pub edit_json: Option<Raw<AnySyncTimelineEvent>>,
+
+    /// The encryption info for this edit.
+    pub encryption_info: Option<EncryptionInfo>,
+}
+
 /// Which kind of aggregation (related event) is this?
 #[derive(Clone, Debug)]
 pub(crate) enum AggregationKind {
@@ -82,6 +119,18 @@ pub(crate) enum AggregationKind {
         /// we can, etc.
         reaction_status: ReactionStatus,
     },
+
+    /// An event has been redacted.
+    Redaction,
+
+    /// An event has been edited.
+    ///
+    /// Note that edits can't be applied in isolation; we need to identify what
+    /// the *latest* edit is, based on the event ordering. As such, they're
+    /// handled exceptionally in `Aggregation::apply` and
+    /// `Aggregation::unapply`, and the callers have the responsibility of
+    /// considering all the edits and applying only the right one.
+    Edit(PendingEdit),
 }
 
 /// An aggregation is an event related to another event (for instance a
@@ -102,18 +151,22 @@ pub(crate) struct Aggregation {
 }
 
 /// Get the poll state from a given [`TimelineItemContent`].
-fn poll_state_from_item(
-    content: &mut TimelineItemContent,
-) -> Result<&mut PollState, AggregationError> {
-    match content {
-        TimelineItemContent::MsgLike(MsgLikeContent {
-            kind: MsgLikeKind::Poll(poll_state),
-            ..
-        }) => Ok(poll_state),
-        _ => Err(AggregationError::InvalidType {
+fn poll_state_from_item<'a>(
+    event: &'a mut Cow<'_, EventTimelineItem>,
+) -> Result<&'a mut PollState, AggregationError> {
+    if event.content().is_poll() {
+        // It was a poll! Now return the state as mutable.
+        let state = as_variant!(
+            event.to_mut().content_mut(),
+            TimelineItemContent::MsgLike(MsgLikeContent { kind: MsgLikeKind::Poll(s), ..}) => s
+        )
+        .expect("it was a poll just above");
+        Ok(state)
+    } else {
+        Err(AggregationError::InvalidType {
             expected: "a poll".to_owned(),
-            actual: content.debug_string().to_owned(),
-        }),
+            actual: event.content().debug_string().to_owned(),
+        })
     }
 }
 
@@ -131,43 +184,58 @@ impl Aggregation {
     ///
     /// In case of error, returns an error detailing why the aggregation
     /// couldn't be applied.
-    pub fn apply(&self, content: &mut TimelineItemContent) -> ApplyAggregationResult {
+    fn apply(
+        &self,
+        event: &mut Cow<'_, EventTimelineItem>,
+        room_version: &RoomVersionId,
+    ) -> ApplyAggregationResult {
         match &self.kind {
             AggregationKind::PollResponse { sender, timestamp, answers } => {
-                let state = match poll_state_from_item(content) {
-                    Ok(state) => state,
-                    Err(err) => return ApplyAggregationResult::Error(err),
-                };
-                state.add_response(sender.clone(), *timestamp, answers.clone());
-                ApplyAggregationResult::UpdatedItem
+                match poll_state_from_item(event) {
+                    Ok(state) => {
+                        state.add_response(sender.clone(), *timestamp, answers.clone());
+                        ApplyAggregationResult::UpdatedItem
+                    }
+                    Err(err) => ApplyAggregationResult::Error(err),
+                }
             }
 
-            AggregationKind::PollEnd { end_date } => {
-                let poll_state = match poll_state_from_item(content) {
-                    Ok(state) => state,
-                    Err(err) => return ApplyAggregationResult::Error(err),
-                };
-                if !poll_state.end(*end_date) {
-                    return ApplyAggregationResult::Error(AggregationError::PollAlreadyEnded);
+            AggregationKind::Redaction => {
+                if event.content().is_redacted() {
+                    ApplyAggregationResult::LeftItemIntact
+                } else {
+                    let new_item = event.redact(room_version);
+                    *event = Cow::Owned(new_item);
+                    ApplyAggregationResult::UpdatedItem
                 }
-                ApplyAggregationResult::UpdatedItem
             }
 
+            AggregationKind::PollEnd { end_date } => match poll_state_from_item(event) {
+                Ok(state) => {
+                    if !state.end(*end_date) {
+                        return ApplyAggregationResult::Error(AggregationError::PollAlreadyEnded);
+                    }
+                    ApplyAggregationResult::UpdatedItem
+                }
+                Err(err) => ApplyAggregationResult::Error(err),
+            },
+
             AggregationKind::Reaction { key, sender, timestamp, reaction_status } => {
-                let Some(reactions) = content.reactions_mut() else {
-                    // These items don't hold reactions.
+                let Some(reactions) = event.content().reactions() else {
+                    // An item that can't hold any reactions.
                     return ApplyAggregationResult::LeftItemIntact;
                 };
 
-                let previous_reaction = reactions.entry(key.clone()).or_default().insert(
-                    sender.clone(),
-                    ReactionInfo { timestamp: *timestamp, status: reaction_status.clone() },
-                );
+                let previous_reaction = reactions.get(key).and_then(|by_user| by_user.get(sender));
+
+                // If the reaction was already added to the item, we don't need to add it back.
+                //
+                // Search for a previous reaction that would be equivalent.
 
                 let is_same = previous_reaction.is_some_and(|prev| {
                     prev.timestamp == *timestamp
                         && matches!(
-                            (prev.status, reaction_status),
+                            (&prev.status, reaction_status),
                             (ReactionStatus::LocalToLocal(_), ReactionStatus::LocalToLocal(_))
                                 | (
                                     ReactionStatus::LocalToRemote(_),
@@ -183,9 +251,25 @@ impl Aggregation {
                 if is_same {
                     ApplyAggregationResult::LeftItemIntact
                 } else {
+                    let reactions = event
+                        .to_mut()
+                        .content_mut()
+                        .reactions_mut()
+                        .expect("reactions was Some above");
+
+                    reactions.entry(key.clone()).or_default().insert(
+                        sender.clone(),
+                        ReactionInfo { timestamp: *timestamp, status: reaction_status.clone() },
+                    );
+
                     ApplyAggregationResult::UpdatedItem
                 }
             }
+
+            AggregationKind::Edit(_) => {
+                // Let the caller handle the edit.
+                ApplyAggregationResult::Edit
+            }
         }
     }
 
@@ -197,10 +281,10 @@ impl Aggregation {
     ///
     /// In case of error, returns an error detailing why the aggregation
     /// couldn't be unapplied.
-    pub fn unapply(&self, content: &mut TimelineItemContent) -> ApplyAggregationResult {
+    fn unapply(&self, event: &mut Cow<'_, EventTimelineItem>) -> ApplyAggregationResult {
         match &self.kind {
             AggregationKind::PollResponse { sender, timestamp, .. } => {
-                let state = match poll_state_from_item(content) {
+                let state = match poll_state_from_item(event) {
                     Ok(state) => state,
                     Err(err) => return ApplyAggregationResult::Error(err),
                 };
@@ -213,30 +297,48 @@ impl Aggregation {
                 ApplyAggregationResult::Error(AggregationError::CantUndoPollEnd)
             }
 
+            AggregationKind::Redaction => {
+                // Redactions are not reversible.
+                ApplyAggregationResult::Error(AggregationError::CantUndoRedaction)
+            }
+
             AggregationKind::Reaction { key, sender, .. } => {
-                let Some(reactions) = content.reactions_mut() else {
-                    // An item that doesn't hold any reactions.
+                let Some(reactions) = event.content().reactions() else {
+                    // An item that can't hold any reactions.
                     return ApplyAggregationResult::LeftItemIntact;
                 };
 
-                let by_user = reactions.get_mut(key);
-                let previous_entry = if let Some(by_user) = by_user {
-                    let prev = by_user.swap_remove(sender);
-                    // If this was the last reaction, remove the entire map for this key.
-                    if by_user.is_empty() {
-                        reactions.swap_remove(key);
+                // We only need to remove the previous reaction if it was there.
+                //
+                // Search for it.
+
+                let had_entry =
+                    reactions.get(key).and_then(|by_user| by_user.get(sender)).is_some();
+
+                if had_entry {
+                    let reactions = event
+                        .to_mut()
+                        .content_mut()
+                        .reactions_mut()
+                        .expect("reactions was some above");
+                    let by_user = reactions.get_mut(key);
+                    if let Some(by_user) = by_user {
+                        by_user.swap_remove(sender);
+                        // If this was the last reaction, remove the entire map for this key.
+                        if by_user.is_empty() {
+                            reactions.swap_remove(key);
+                        }
                     }
-                    prev
-                } else {
-                    None
-                };
-
-                if previous_entry.is_some() {
                     ApplyAggregationResult::UpdatedItem
                 } else {
                     ApplyAggregationResult::LeftItemIntact
                 }
             }
+
+            AggregationKind::Edit(_) => {
+                // Let the caller handle the edit.
+                ApplyAggregationResult::Edit
+            }
         }
     }
 }
@@ -261,21 +363,46 @@ impl Aggregations {
     /// Add a given aggregation that relates to the [`TimelineItemContent`]
     /// identified by the given [`TimelineEventItemId`].
     pub fn add(&mut self, related_to: TimelineEventItemId, aggregation: Aggregation) {
+        // If the aggregation is a redaction, it invalidates all the other aggregations;
+        // remove them.
+        if matches!(aggregation.kind, AggregationKind::Redaction) {
+            for agg in self.related_events.remove(&related_to).unwrap_or_default() {
+                self.inverted_map.remove(&agg.own_id);
+            }
+        }
+
+        // If there was any redaction among the current aggregation, adding a new one
+        // should be a noop.
+        if let Some(previous_aggregations) = self.related_events.get(&related_to) {
+            if previous_aggregations
+                .iter()
+                .any(|agg| matches!(agg.kind, AggregationKind::Redaction))
+            {
+                return;
+            }
+        }
+
         self.inverted_map.insert(aggregation.own_id.clone(), related_to.clone());
         self.related_events.entry(related_to).or_default().push(aggregation);
     }
 
     /// Is the given id one for a known aggregation to another event?
     ///
-    /// If so, returns the target event identifier as well as the aggregation.
-    /// The aggregation must be unapplied on the corresponding timeline
-    /// item.
-    #[must_use]
+    /// If so, unapplies it by replacing the corresponding related item, if
+    /// needs be.
+    ///
+    /// Returns true if an aggregation was found. This doesn't mean
+    /// the underlying item has been updated, if it was missing from the
+    /// timeline for instance.
+    ///
+    /// May return an error if it found an aggregation, but it couldn't be
+    /// properly applied.
     pub fn try_remove_aggregation(
         &mut self,
         aggregation_id: &TimelineEventItemId,
-    ) -> Option<(&TimelineEventItemId, Aggregation)> {
-        let found = self.inverted_map.get(aggregation_id)?;
+        items: &mut ObservableItemsTransaction<'_>,
+    ) -> Result<bool, AggregationError> {
+        let Some(found) = self.inverted_map.get(aggregation_id) else { return Ok(false) };
 
         // Find and remove the aggregation in the other mapping.
         let aggregation = if let Some(aggregations) = self.related_events.get_mut(found) {
@@ -295,11 +422,48 @@ impl Aggregations {
             None
         };
 
-        if aggregation.is_none() {
+        let Some(aggregation) = aggregation else {
             warn!("incorrect internal state: {aggregation_id:?} was present in the inverted map, not in related-to map.");
+            return Ok(false);
+        };
+
+        if let Some((item_pos, item)) = rfind_event_by_item_id(items, found) {
+            let mut cowed = Cow::Borrowed(&*item);
+            match aggregation.unapply(&mut cowed) {
+                ApplyAggregationResult::UpdatedItem => {
+                    trace!("removed aggregation");
+                    items.replace(
+                        item_pos,
+                        TimelineItem::new(cowed.into_owned(), item.internal_id.to_owned()),
+                    );
+                }
+                ApplyAggregationResult::LeftItemIntact => {}
+                ApplyAggregationResult::Error(err) => {
+                    warn!("error when unapplying aggregation: {err}");
+                }
+                ApplyAggregationResult::Edit => {
+                    // This edit has been removed; try to find another that still applies.
+                    if let Some(aggregations) = self.related_events.get(found) {
+                        if resolve_edits(aggregations, items, &mut cowed) {
+                            items.replace(
+                                item_pos,
+                                TimelineItem::new(cowed.into_owned(), item.internal_id.to_owned()),
+                            );
+                        } else {
+                            // No other edit was found, leave the item as is.
+                            // TODO likely need to change the item to indicate
+                            // it's been un-edited etc.
+                        }
+                    } else {
+                        // No other edits apply.
+                    }
+                }
+            }
+        } else {
+            info!("missing related-to item ({found:?}) for aggregation {aggregation_id:?}");
         }
 
-        Some((found, aggregation?))
+        Ok(true)
     }
 
     /// Apply all the aggregations to a [`TimelineItemContent`].
@@ -307,19 +471,36 @@ impl Aggregations {
     /// Will return an error at the first aggregation that couldn't be applied;
     /// see [`Aggregation::apply`] which explains under which conditions it can
     /// happen.
-    pub fn apply(
+    ///
+    /// Returns a boolean indicating whether at least one aggregation was
+    /// applied.
+    pub fn apply_all(
         &self,
         item_id: &TimelineEventItemId,
-        content: &mut TimelineItemContent,
+        event: &mut Cow<'_, EventTimelineItem>,
+        items: &mut ObservableItemsTransaction<'_>,
+        room_version: &RoomVersionId,
     ) -> Result<(), AggregationError> {
         let Some(aggregations) = self.related_events.get(item_id) else {
             return Ok(());
         };
+
+        let mut has_edits = false;
+
         for a in aggregations {
-            if let ApplyAggregationResult::Error(err) = a.apply(content) {
-                return Err(err);
+            match a.apply(event, room_version) {
+                ApplyAggregationResult::Edit => {
+                    has_edits = true;
+                }
+                ApplyAggregationResult::UpdatedItem | ApplyAggregationResult::LeftItemIntact => {}
+                ApplyAggregationResult::Error(err) => return Err(err),
             }
         }
+
+        if has_edits {
+            resolve_edits(aggregations, items, event);
+        }
+
         Ok(())
     }
 
@@ -349,97 +530,252 @@ impl Aggregations {
     /// updating the internal mappings.
     ///
     /// When an aggregation has been marked as sent, it may need to be reapplied
-    /// to the corresponding [`TimelineItemContent`]; in this case, a
-    /// [`MarkAggregationSentResult::MarkedSent`] result with a set `update`
-    /// will be returned, and must be applied.
+    /// to the corresponding [`TimelineItemContent`]; this is why we're also
+    /// passing the context to apply an aggregation here.
     pub fn mark_aggregation_as_sent(
         &mut self,
         txn_id: OwnedTransactionId,
         event_id: OwnedEventId,
-    ) -> MarkAggregationSentResult {
+        items: &mut ObservableItemsTransaction<'_>,
+        room_version: &RoomVersionId,
+    ) -> bool {
         let from = TimelineEventItemId::TransactionId(txn_id);
         let to = TimelineEventItemId::EventId(event_id.clone());
 
         let Some(target) = self.inverted_map.remove(&from) else {
-            return MarkAggregationSentResult::NotFound;
+            return false;
         };
 
-        let mut target_and_new_aggregation = MarkAggregationSentResult::MarkedSent { update: None };
-
         if let Some(aggregations) = self.related_events.get_mut(&target) {
             if let Some(found) = aggregations.iter_mut().find(|agg| agg.own_id == from) {
                 found.own_id = to.clone();
 
                 match &mut found.kind {
-                    AggregationKind::PollResponse { .. } | AggregationKind::PollEnd { .. } => {
+                    AggregationKind::PollResponse { .. }
+                    | AggregationKind::PollEnd { .. }
+                    | AggregationKind::Edit(..)
+                    | AggregationKind::Redaction => {
                         // Nothing particular to do.
                     }
+
                     AggregationKind::Reaction { reaction_status, .. } => {
                         // Mark the reaction as becoming remote, and signal that update to the
                         // caller.
                         *reaction_status = ReactionStatus::RemoteToRemote(event_id);
-                        target_and_new_aggregation = MarkAggregationSentResult::MarkedSent {
-                            update: Some((target.clone(), found.clone())),
-                        };
+
+                        let found = found.clone();
+                        find_item_and_apply_aggregation(self, items, &target, found, room_version);
                     }
                 }
             }
         }
 
         self.inverted_map.insert(to, target);
+        true
+    }
+}
+
+/// Look at all the edits of a given event, and apply the most recent one, if
+/// found.
+///
+/// Returns true if an edit was found and applied, false otherwise.
+fn resolve_edits(
+    aggregations: &[Aggregation],
+    items: &ObservableItemsTransaction<'_>,
+    event: &mut Cow<'_, EventTimelineItem>,
+) -> bool {
+    let mut best_edit: Option<PendingEdit> = None;
+    let mut best_edit_pos = None;
+
+    for a in aggregations {
+        if let AggregationKind::Edit(pending_edit) = &a.kind {
+            match &a.own_id {
+                TimelineEventItemId::TransactionId(_) => {
+                    // A local echo is always the most recent edit: use this one.
+                    best_edit = Some(pending_edit.clone());
+                    break;
+                }
 
-        target_and_new_aggregation
+                TimelineEventItemId::EventId(event_id) => {
+                    if let Some(best_edit_pos) = &mut best_edit_pos {
+                        let pos = items.position_by_event_id(event_id);
+                        if let Some(pos) = pos {
+                            // If the edit is more recent (higher index) than the previous best
+                            // edit we knew about, use this one.
+                            if pos > *best_edit_pos {
+                                best_edit = Some(pending_edit.clone());
+                                *best_edit_pos = pos;
+                                trace!(?best_edit_pos, edit_id = ?a.own_id, "found better edit");
+                            }
+                        } else {
+                            trace!(edit_id = ?a.own_id, "couldn't find timeline meta for edit event");
+
+                            // The edit event isn't in the timeline, so it might be a bundled
+                            // edit. In this case, record it as the best edit if and only if
+                            // there wasn't any other.
+                            if best_edit.is_none() {
+                                best_edit = Some(pending_edit.clone());
+                                trace!(?best_edit_pos, edit_id = ?a.own_id, "found bundled edit");
+                            }
+                        }
+                    } else {
+                        // There wasn't any best edit yet, so record this one as being it, with
+                        // its position.
+                        best_edit = Some(pending_edit.clone());
+                        best_edit_pos = items.position_by_event_id(event_id);
+                        trace!(?best_edit_pos, edit_id = ?a.own_id, "first best edit");
+                    }
+                }
+            }
+        }
+    }
+
+    if let Some(edit) = best_edit {
+        edit_item(event, edit)
+    } else {
+        false
     }
 }
 
+/// Apply the selected edit to the given EventTimelineItem.
+fn edit_item(item: &mut Cow<'_, EventTimelineItem>, edit: PendingEdit) -> bool {
+    let PendingEdit { kind: edit_kind, edit_json, encryption_info } = edit;
+
+    if let Some(event_json) = &edit_json {
+        let Some(edit_sender) = event_json.get_field::<OwnedUserId>("sender").ok().flatten() else {
+            info!("edit event didn't have a sender; likely a malformed event");
+            return false;
+        };
+
+        if edit_sender != item.sender() {
+            info!(
+                original_sender = %item.sender(),
+                %edit_sender,
+                "Edit event applies to another user's timeline item, discarding"
+            );
+            return false;
+        }
+    }
+
+    let TimelineItemContent::MsgLike(content) = item.content() else {
+        info!("Edit of message event applies to {:?}, discarding", item.content().debug_string(),);
+        return false;
+    };
+
+    match (edit_kind, content) {
+        (
+            PendingEditKind::RoomMessage(replacement),
+            MsgLikeContent { kind: MsgLikeKind::Message(msg), .. },
+        ) => {
+            // First combination: it's a message edit for a message. Good.
+            let mut new_msg = msg.clone();
+            new_msg.apply_edit(replacement.new_content);
+
+            let new_item = item.with_content_and_latest_edit(
+                TimelineItemContent::MsgLike(content.with_kind(MsgLikeKind::Message(new_msg))),
+                edit_json,
+            );
+            *item = Cow::Owned(new_item);
+        }
+
+        (
+            PendingEditKind::Poll(replacement),
+            MsgLikeContent { kind: MsgLikeKind::Poll(poll_state), .. },
+        ) => {
+            // Second combination: it's a poll edit for a poll. Good.
+            if let Some(new_poll_state) = poll_state.edit(replacement.new_content) {
+                let new_item = item.with_content_and_latest_edit(
+                    TimelineItemContent::MsgLike(
+                        content.with_kind(MsgLikeKind::Poll(new_poll_state)),
+                    ),
+                    edit_json,
+                );
+                *item = Cow::Owned(new_item);
+            } else {
+                // The poll has ended, so we can't edit it anymore.
+                return false;
+            }
+        }
+
+        (edit_kind, _) => {
+            // Invalid combination.
+            info!(
+                content = item.content().debug_string(),
+                edit = format!("{:?}", edit_kind),
+                "Mismatch between edit type and content type",
+            );
+            return false;
+        }
+    }
+
+    if let Some(encryption_info) = encryption_info {
+        *item = Cow::Owned(item.with_encryption_info(Some(encryption_info)));
+    }
+
+    true
+}
+
 /// Find an item identified by the target identifier, and apply the aggregation
 /// onto it.
 ///
-/// Returns whether the aggregation has been applied or not (so as to increment
-/// a number of updated result, for instance).
+/// Returns the updated [`EventTimelineItem`] if the aggregation was applied, or
+/// `None` otherwise.
 pub(crate) fn find_item_and_apply_aggregation(
+    aggregations: &Aggregations,
     items: &mut ObservableItemsTransaction<'_>,
     target: &TimelineEventItemId,
     aggregation: Aggregation,
-) {
+    room_version: &RoomVersionId,
+) -> Option<EventTimelineItem> {
     let Some((idx, event_item)) = rfind_event_by_item_id(items, target) else {
         trace!("couldn't find aggregation's target {target:?}");
-        return;
+        return None;
     };
 
-    let mut new_content = event_item.content().clone();
-
-    match aggregation.apply(&mut new_content) {
+    let mut cowed = Cow::Borrowed(&*event_item);
+    match aggregation.apply(&mut cowed, room_version) {
         ApplyAggregationResult::UpdatedItem => {
             trace!("applied aggregation");
-            let new_item = event_item.with_content(new_content);
-            items.replace(idx, TimelineItem::new(new_item, event_item.internal_id.to_owned()));
+            let new_event_item = cowed.into_owned();
+            let new_item =
+                TimelineItem::new(new_event_item.clone(), event_item.internal_id.to_owned());
+            items.replace(idx, new_item);
+            Some(new_event_item)
+        }
+        ApplyAggregationResult::Edit => {
+            if let Some(aggregations) = aggregations.related_events.get(target) {
+                if resolve_edits(aggregations, items, &mut cowed) {
+                    let new_event_item = cowed.into_owned();
+                    let new_item = TimelineItem::new(
+                        new_event_item.clone(),
+                        event_item.internal_id.to_owned(),
+                    );
+                    items.replace(idx, new_item);
+                    return Some(new_event_item);
+                }
+            }
+            None
         }
         ApplyAggregationResult::LeftItemIntact => {
             trace!("applying the aggregation had no effect");
+            None
         }
         ApplyAggregationResult::Error(err) => {
             warn!("error when applying aggregation: {err}");
+            None
         }
     }
 }
 
-/// The result of marking an aggregation as sent.
-pub(crate) enum MarkAggregationSentResult {
-    /// The aggregation has been found, and marked as sent.
-    ///
-    /// Optionally, it can include an [`Aggregation`] `update` to the matching
-    /// [`TimelineItemContent`] item identified by the [`TimelineEventItemId`].
-    MarkedSent { update: Option<(TimelineEventItemId, Aggregation)> },
-    /// The aggregation was unknown to the aggregations manager, aka not found.
-    NotFound,
-}
-
 /// The result of applying (or unapplying) an aggregation onto a timeline item.
-pub(crate) enum ApplyAggregationResult {
-    /// The item has been updated after applying the aggregation.
+enum ApplyAggregationResult {
+    /// The passed `Cow<EventTimelineItem>` has been cloned and updated.
     UpdatedItem,
 
+    /// An edit must be included in the edit set and resolved later, using the
+    /// relative position of the edits.
+    Edit,
+
     /// The item hasn't been modified after applying the aggregation, because it
     /// was likely already applied prior to this.
     LeftItemIntact,
@@ -456,6 +792,9 @@ pub(crate) enum AggregationError {
     #[error("a poll end can't be unapplied")]
     CantUndoPollEnd,
 
+    #[error("a redaction can't be unapplied")]
+    CantUndoRedaction,
+
     #[error("trying to apply an aggregation of one type to an invalid target: expected {expected}, actual {actual}")]
     InvalidType { expected: String, actual: String },
 }
diff --git a/crates/matrix-sdk-ui/src/timeline/controller/decryption_retry_task.rs b/crates/matrix-sdk-ui/src/timeline/controller/decryption_retry_task.rs
index 82989c507b3..b82972ab3d9 100644
--- a/crates/matrix-sdk-ui/src/timeline/controller/decryption_retry_task.rs
+++ b/crates/matrix-sdk-ui/src/timeline/controller/decryption_retry_task.rs
@@ -521,14 +521,16 @@ mod tests {
             origin: RemoteEventOrigin::Sync,
         });
 
+        let content = RoomMessageEventContent::text_plain("hi");
+
         TimelineItem::new(
             TimelineItemKind::Event(EventTimelineItem::new(
                 owned_user_id!("@u:s.to"),
                 TimelineDetails::Pending,
                 timestamp(),
                 TimelineItemContent::message(
-                    RoomMessageEventContent::text_plain("hi"),
-                    None,
+                    content.msgtype,
+                    content.mentions,
                     ReactionsByKeyBySender::default(),
                     None,
                     None,
diff --git a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs
index 238b89c03e9..f0de0e4c180 100644
--- a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs
+++ b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs
@@ -14,18 +14,16 @@
 
 use std::{
     collections::{BTreeSet, HashMap},
-    num::NonZeroUsize,
     sync::Arc,
 };
 
-use matrix_sdk::ring_buffer::RingBuffer;
 use ruma::{EventId, OwnedEventId, OwnedUserId, RoomVersionId};
 use tracing::trace;
 
 use super::{
     super::{subscriber::skip::SkipCount, TimelineItem, TimelineItemKind, TimelineUniqueId},
     read_receipts::ReadReceipts,
-    Aggregations, AllRemoteEvents, ObservableItemsTransaction, PendingEdit,
+    Aggregations, AllRemoteEvents, ObservableItemsTransaction,
 };
 use crate::unable_to_decrypt_hook::UtdHookManager;
 
@@ -77,11 +75,10 @@ pub(in crate::timeline) struct TimelineMetadata {
     pub aggregations: Aggregations,
 
     /// Given an event, what are all the events that are replies to it?
+    ///
+    /// Only works for remote events *and* replies which are remote-echoed.
     pub replies: HashMap<OwnedEventId, BTreeSet<OwnedEventId>>,
 
-    /// Edit events received before the related event they're editing.
-    pub pending_edits: RingBuffer<PendingEdit>,
-
     /// Identifier of the fully-read event, helping knowing where to introduce
     /// the read marker.
     pub fully_read_event: Option<OwnedEventId>,
@@ -100,10 +97,6 @@ pub(in crate::timeline) struct TimelineMetadata {
     pub(super) read_receipts: ReadReceipts,
 }
 
-/// Maximum number of stash pending edits.
-/// SAFETY: 32 is not 0.
-const MAX_NUM_STASHED_PENDING_EDITS: NonZeroUsize = NonZeroUsize::new(32).unwrap();
-
 impl TimelineMetadata {
     pub(in crate::timeline) fn new(
         own_user_id: OwnedUserId,
@@ -117,7 +110,6 @@ impl TimelineMetadata {
             own_user_id,
             next_internal_id: Default::default(),
             aggregations: Default::default(),
-            pending_edits: RingBuffer::new(MAX_NUM_STASHED_PENDING_EDITS),
             replies: Default::default(),
             fully_read_event: Default::default(),
             // It doesn't make sense to set this to false until we fill the `fully_read_event`
@@ -136,7 +128,6 @@ impl TimelineMetadata {
         // ids across timeline clears.
         self.aggregations.clear();
         self.replies.clear();
-        self.pending_edits.clear();
         self.fully_read_event = None;
         // We forgot about the fully read marker right above, so wait for a new one
         // before attempting to update it for each new timeline item.
diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs
index abd3cde88c0..d01ac023fdd 100644
--- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs
+++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs
@@ -59,7 +59,7 @@ pub(super) use self::{
         AllRemoteEvents, ObservableItems, ObservableItemsEntry, ObservableItemsTransaction,
         ObservableItemsTransactionEntry,
     },
-    state::{PendingEdit, PendingEditKind, TimelineState},
+    state::TimelineState,
     state_transaction::TimelineStateTransaction,
 };
 use super::{
@@ -84,7 +84,7 @@ use crate::{
     unable_to_decrypt_hook::UtdHookManager,
 };
 
-mod aggregations;
+pub(in crate::timeline) mod aggregations;
 mod decryption_retry_task;
 mod metadata;
 mod observable_items;
@@ -607,9 +607,7 @@ impl<P: RoomDataProvider, D: Decryptor> TimelineController<P, D> {
         let prev_status = item
             .content()
             .reactions()
-            .get(key)
-            .and_then(|group| group.get(user_id))
-            .map(|reaction_info| reaction_info.status.clone());
+            .and_then(|map| Some(map.get(key)?.get(user_id)?.status.clone()));
 
         let Some(prev_status) = prev_status else {
             match &item.kind {
@@ -685,7 +683,7 @@ impl<P: RoomDataProvider, D: Decryptor> TimelineController<P, D> {
                     return Ok(false);
                 };
 
-                let mut reactions = item.content().reactions().clone();
+                let mut reactions = item.content().reactions().cloned().unwrap_or_default();
                 let reaction_info = reactions.remove_reaction(user_id, key);
 
                 if reaction_info.is_some() {
@@ -708,7 +706,8 @@ impl<P: RoomDataProvider, D: Decryptor> TimelineController<P, D> {
                             rfind_event_by_id(&state.items, &annotated_event_id)
                         {
                             // Re-add the reaction to the mapping.
-                            let mut reactions = item.content().reactions();
+                            let mut reactions =
+                                item.content().reactions().cloned().unwrap_or_default();
                             reactions
                                 .entry(key.to_owned())
                                 .or_default()
@@ -893,45 +892,18 @@ impl<P: RoomDataProvider, D: Decryptor> TimelineController<P, D> {
             // If it was just sent, try to find if it matches a corresponding aggregation,
             // and mark it as sent in that case.
             if let Some(new_event_id) = new_event_id {
-                match txn
-                    .meta
-                    .aggregations
-                    .mark_aggregation_as_sent(txn_id.to_owned(), new_event_id.to_owned())
-                {
-                    MarkAggregationSentResult::MarkedSent { update } => {
-                        trace!("marked aggregation as sent");
-
-                        if let Some((target, aggregation)) = update {
-                            if let Some((item_pos, item)) =
-                                rfind_event_by_item_id(&txn.items, &target)
-                            {
-                                let mut content = item.content().clone();
-                                match aggregation.apply(&mut content) {
-                                    ApplyAggregationResult::UpdatedItem => {
-                                        trace!("reapplied aggregation in the event");
-                                        let internal_id = item.internal_id.to_owned();
-                                        let new_item = item.with_content(content);
-                                        txn.items.replace(
-                                            item_pos,
-                                            TimelineItem::new(new_item, internal_id),
-                                        );
-                                        txn.commit();
-                                    }
-                                    ApplyAggregationResult::LeftItemIntact => {}
-                                    ApplyAggregationResult::Error(err) => {
-                                        warn!("when reapplying aggregation just marked as sent: {err}");
-                                    }
-                                }
-                            }
-                        }
-
-                        // Early return: we've found the event to mark as sent, it was an
-                        // aggregation.
-                        return;
-                    }
-
-                    MarkAggregationSentResult::NotFound => {}
+                if txn.meta.aggregations.mark_aggregation_as_sent(
+                    txn_id.to_owned(),
+                    new_event_id.to_owned(),
+                    &mut txn.items,
+                    &txn.meta.room_version,
+                ) {
+                    trace!("Aggregation marked as sent");
+                    txn.commit();
+                    return;
                 }
+
+                trace!("Sent aggregation was not found");
             }
 
             warn!("Timeline item not found, can't update send state");
@@ -986,37 +958,26 @@ impl<P: RoomDataProvider, D: Decryptor> TimelineController<P, D> {
 
         // Avoid multiple mutable and immutable borrows of the lock guard by explicitly
         // dereferencing it once.
-        let state = &mut *state;
+        let mut txn = state.transaction();
 
         // Look if this was a local aggregation.
-        if let Some((target, aggregation)) = state
-            .meta
-            .aggregations
-            .try_remove_aggregation(&TimelineEventItemId::TransactionId(txn_id.to_owned()))
-        {
-            let Some((item_pos, item)) = rfind_event_by_item_id(&state.items, target) else {
-                warn!("missing target item for a local aggregation");
-                return false;
-            };
-
-            let mut content = item.content().clone();
-            match aggregation.unapply(&mut content) {
-                ApplyAggregationResult::UpdatedItem => {
-                    trace!("removed local reaction to local echo");
-                    let internal_id = item.internal_id.clone();
-                    let new_item = item.with_content(content);
-                    state.items.replace(item_pos, TimelineItem::new(new_item, internal_id));
-                }
-                ApplyAggregationResult::LeftItemIntact => {}
-                ApplyAggregationResult::Error(err) => {
-                    warn!("when undoing local aggregation: {err}");
-                }
+        let found_aggregation = match txn.meta.aggregations.try_remove_aggregation(
+            &TimelineEventItemId::TransactionId(txn_id.to_owned()),
+            &mut txn.items,
+        ) {
+            Ok(val) => val,
+            Err(err) => {
+                warn!("error when discarding local echo for an aggregation: {err}");
+                // The aggregation has been found, it's just that we couldn't discard it.
+                true
             }
+        };
 
-            return true;
+        if found_aggregation {
+            txn.commit();
         }
 
-        false
+        found_aggregation
     }
 
     pub(super) async fn replace_local_echo(
@@ -1056,9 +1017,9 @@ impl<P: RoomDataProvider, D: Decryptor> TimelineController<P, D> {
         // Replace the local-related state (kind) and the content state.
         let new_item = TimelineItem::new(
             prev_item.with_kind(ti_kind).with_content(TimelineItemContent::message(
-                content,
-                None,
-                prev_item.content().reactions(),
+                content.msgtype,
+                content.mentions,
+                prev_item.content().reactions().cloned().unwrap_or_default(),
                 prev_item.content().thread_root(),
                 prev_item.content().in_reply_to(),
                 prev_item.content().thread_summary(),
@@ -1281,7 +1242,13 @@ impl<P: RoomDataProvider, D: Decryptor> TimelineController<P, D> {
         );
 
         tr.meta.aggregations.add(target.clone(), aggregation.clone());
-        find_item_and_apply_aggregation(&mut tr.items, &target, aggregation);
+        find_item_and_apply_aggregation(
+            &tr.meta.aggregations,
+            &mut tr.items,
+            &target,
+            aggregation,
+            &tr.meta.room_version,
+        );
 
         tr.commit();
     }
diff --git a/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs b/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs
index 3541afb2d54..9dc60786d0b 100644
--- a/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs
+++ b/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs
@@ -275,6 +275,11 @@ impl<'observable_items> ObservableItemsTransaction<'observable_items> {
         self.all_remote_events.get_by_event_id(event_id)
     }
 
+    /// Get the position of an event in the events array by its ID.
+    pub fn position_by_event_id(&self, event_id: &EventId) -> Option<usize> {
+        self.all_remote_events.position_by_event_id(event_id)
+    }
+
     /// Replace a timeline item at position `timeline_item_index` by
     /// `timeline_item`.
     pub fn replace(
@@ -1940,6 +1945,17 @@ impl AllRemoteEvents {
         self.0.iter().rev().find(|event_meta| event_meta.event_id == event_id)
     }
 
+    /// Get the position of an event in the events array by its ID.
+    pub fn position_by_event_id(&self, event_id: &EventId) -> Option<usize> {
+        // Reverse the iterator to start looking at the end. Since this will give us the
+        // "reverse" position, reverse the index after finding the event.
+        self.0
+            .iter()
+            .enumerate()
+            .rev()
+            .find_map(|(i, event_meta)| (event_meta.event_id == event_id).then_some(i))
+    }
+
     /// Shift to the right all timeline item indexes that are equal to or
     /// greater than `new_timeline_item_index`.
     fn increment_all_timeline_item_index_after(&mut self, new_timeline_item_index: usize) {
diff --git a/crates/matrix-sdk-ui/src/timeline/controller/state.rs b/crates/matrix-sdk-ui/src/timeline/controller/state.rs
index 306da7b8b56..c998d171fa1 100644
--- a/crates/matrix-sdk-ui/src/timeline/controller/state.rs
+++ b/crates/matrix-sdk-ui/src/timeline/controller/state.rs
@@ -19,14 +19,9 @@ use matrix_sdk::{deserialized_responses::TimelineEvent, send_queue::SendHandle};
 #[cfg(test)]
 use ruma::events::receipt::ReceiptEventContent;
 use ruma::{
-    events::{
-        poll::unstable_start::NewUnstablePollStartEventContentWithoutRelation,
-        relation::Replacement, room::message::RoomMessageEventContentWithoutRelation,
-        AnyMessageLikeEventContent, AnySyncEphemeralRoomEvent, AnySyncTimelineEvent,
-    },
+    events::{AnyMessageLikeEventContent, AnySyncEphemeralRoomEvent},
     serde::Raw,
-    EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, OwnedUserId,
-    RoomVersionId,
+    MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, OwnedUserId, RoomVersionId,
 };
 use tracing::{instrument, trace, warn};
 
@@ -283,36 +278,3 @@ impl TimelineState {
         TimelineStateTransaction::new(&mut self.items, &mut self.meta, self.timeline_focus)
     }
 }
-
-#[derive(Clone)]
-pub(in crate::timeline) enum PendingEditKind {
-    RoomMessage(Replacement<RoomMessageEventContentWithoutRelation>),
-    Poll(Replacement<NewUnstablePollStartEventContentWithoutRelation>),
-}
-
-#[derive(Clone)]
-pub(in crate::timeline) struct PendingEdit {
-    pub kind: PendingEditKind,
-    pub event_json: Raw<AnySyncTimelineEvent>,
-}
-
-impl PendingEdit {
-    pub fn edited_event(&self) -> &EventId {
-        match &self.kind {
-            PendingEditKind::RoomMessage(Replacement { event_id, .. })
-            | PendingEditKind::Poll(Replacement { event_id, .. }) => event_id,
-        }
-    }
-}
-
-#[cfg(not(tarpaulin_include))]
-impl std::fmt::Debug for PendingEdit {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match &self.kind {
-            PendingEditKind::RoomMessage(_) => {
-                f.debug_struct("RoomMessage").finish_non_exhaustive()
-            }
-            PendingEditKind::Poll(_) => f.debug_struct("Poll").finish_non_exhaustive(),
-        }
-    }
-}
diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs
index d781d7c7a0c..40b2747112c 100644
--- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs
+++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-use std::sync::Arc;
+use std::{borrow::Cow, sync::Arc};
 
 use as_variant::as_variant;
 use imbl::Vector;
@@ -20,7 +20,6 @@ use indexmap::IndexMap;
 use matrix_sdk::{
     crypto::types::events::UtdCause,
     deserialized_responses::{EncryptionInfo, UnableToDecryptInfo},
-    ring_buffer::RingBuffer,
     send_queue::SendHandle,
 };
 use ruma::{
@@ -42,14 +41,12 @@ use ruma::{
     EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, OwnedUserId,
     TransactionId,
 };
-use tracing::{debug, error, field::debug, info, instrument, trace, warn};
+use tracing::{debug, error, field::debug, instrument, trace, warn};
 
 use super::{
-    algorithms::{rfind_event_by_id, rfind_event_by_item_id},
     controller::{
-        find_item_and_apply_aggregation, Aggregation, AggregationKind, ApplyAggregationResult,
-        ObservableItemsTransaction, PendingEdit, PendingEditKind, TimelineMetadata,
-        TimelineStateTransaction,
+        find_item_and_apply_aggregation, Aggregation, AggregationKind, ObservableItemsTransaction,
+        PendingEditKind, TimelineMetadata, TimelineStateTransaction,
     },
     date_dividers::DateDividerAdjuster,
     event_item::{
@@ -62,6 +59,7 @@ use super::{
     EncryptedMessage, EventTimelineItem, InReplyToDetails, MsgLikeContent, MsgLikeKind, OtherState,
     ReactionStatus, RepliedToEvent, Sticker, TimelineDetails, TimelineItem, TimelineItemContent,
 };
+use crate::timeline::controller::aggregations::PendingEdit;
 
 /// When adding an event, useful information related to the source of the event.
 pub(super) enum Flow {
@@ -158,8 +156,6 @@ pub(super) enum TimelineAction {
     AddItem {
         /// The content of the item we want to add.
         content: TimelineItemContent,
-        /// The latest edit JSON for this item.
-        edit_json: Option<Raw<AnySyncTimelineEvent>>,
     },
 
     /// Handle an aggregation to another event.
@@ -176,9 +172,9 @@ pub(super) enum TimelineAction {
 }
 
 impl TimelineAction {
-    /// Create a new [`TimelineEventKind::AddItem`] with no edit json.
+    /// Create a new [`TimelineEventKind::AddItem`].
     fn add_item(content: TimelineItemContent) -> Self {
-        Self::AddItem { content, edit_json: None }
+        Self::AddItem { content }
     }
 
     /// Create a new [`TimelineAction`] from a given remote event.
@@ -356,44 +352,18 @@ impl TimelineAction {
             }
 
             AnyMessageLikeEventContent::Sticker(content) => {
-                let mut replied_to_event_id = None;
-                let mut thread_root = None;
-                let in_reply_to_details =
-                    content.relates_to.as_ref().and_then(|relation| match relation {
-                        Relation::Reply { in_reply_to } => {
-                            replied_to_event_id = Some(in_reply_to.event_id.clone());
-                            Some(InReplyToDetails::new(
-                                in_reply_to.event_id.clone(),
-                                timeline_items,
-                            ))
-                        }
-                        Relation::Thread(thread) => {
-                            thread_root = Some(thread.event_id.clone());
-                            thread.in_reply_to.as_ref().map(|in_reply_to| {
-                                replied_to_event_id = Some(in_reply_to.event_id.clone());
-                                InReplyToDetails::new(in_reply_to.event_id.clone(), timeline_items)
-                            })
-                        }
-                        _ => None,
-                    });
+                let (in_reply_to, thread_root) = Self::extract_reply_and_thread_root(
+                    content.relates_to.clone().and_then(|rel| rel.try_into().ok()),
+                    timeline_items,
+                );
 
-                // If this message is a reply to another message, add an entry in the
-                // inverted mapping.
-                if let Some(event_id) = event_id {
-                    if let Some(replied_to_event_id) = replied_to_event_id {
-                        // This is a reply! Add an entry.
-                        meta.replies
-                            .entry(replied_to_event_id)
-                            .or_default()
-                            .insert(event_id.to_owned());
-                    }
-                }
+                Self::mark_response(meta, event_id, in_reply_to.as_ref());
 
                 Self::add_item(TimelineItemContent::MsgLike(MsgLikeContent {
                     kind: MsgLikeKind::Sticker(Sticker { content }),
                     reactions: Default::default(),
                     thread_root,
-                    in_reply_to: in_reply_to_details,
+                    in_reply_to,
                     thread_summary: None,
                 }))
             }
@@ -401,156 +371,96 @@ impl TimelineAction {
             AnyMessageLikeEventContent::UnstablePollStart(UnstablePollStartEventContent::New(
                 c,
             )) => {
-                // Always remove the pending edit, if there's any. The reason is that
-                // if there's an edit in the relations mapping, we want to prefer it
-                // over any other pending edit, since it's more likely to be up to
-                // date, and we don't want to apply another pending edit on top of it.
-                let pending_edit = event_id
-                    .and_then(|event_id| {
-                        TimelineEventHandler::maybe_unstash_pending_edit(
-                            &mut meta.pending_edits,
-                            event_id,
-                        )
-                    })
-                    .and_then(|edit| match edit.kind {
-                        PendingEditKind::Poll(replacement) => {
-                            Some((Some(edit.event_json), replacement.new_content))
-                        }
-                        _ => None,
-                    });
-
-                let (edit_json, edit_content) = relations
-                    .and_then(extract_poll_edit_content)
-                    .map(|content| {
-                        let edit_json = raw_event.and_then(extract_bundled_edit_event_json);
-                        (edit_json, content)
-                    })
-                    .or(pending_edit)
-                    .unzip();
-
-                let mut replied_to_event_id = None;
-                let mut thread_root = None;
-                let in_reply_to_details =
-                    c.relates_to.as_ref().and_then(|relation| match relation {
-                        RelationWithoutReplacement::Reply { in_reply_to } => {
-                            replied_to_event_id = Some(in_reply_to.event_id.clone());
-                            Some(InReplyToDetails::new(
-                                in_reply_to.event_id.clone(),
-                                timeline_items,
-                            ))
-                        }
-                        RelationWithoutReplacement::Thread(thread) => {
-                            thread_root = Some(thread.event_id.clone());
-                            thread.in_reply_to.as_ref().map(|in_reply_to| {
-                                replied_to_event_id = Some(in_reply_to.event_id.clone());
-                                InReplyToDetails::new(in_reply_to.event_id.clone(), timeline_items)
-                            })
+                // Record the bundled edit in the aggregations set, if any.
+                if let Some(target_id) = event_id {
+                    if let Some(new_content) = relations.and_then(extract_poll_edit_content) {
+                        // It is replacing the current event.
+                        if let Some(edit_event_id) = raw_event.and_then(|raw_event| {
+                            raw_event.get_field::<OwnedEventId>("event_id").ok().flatten()
+                        }) {
+                            let edit_json = raw_event.and_then(extract_bundled_edit_event_json);
+                            let aggregation = Aggregation::new(
+                                TimelineEventItemId::EventId(edit_event_id),
+                                AggregationKind::Edit(PendingEdit {
+                                    kind: PendingEditKind::Poll(Replacement::new(
+                                        target_id.to_owned(),
+                                        new_content,
+                                    )),
+                                    edit_json,
+                                    // TODO maybe we could provide it?
+                                    encryption_info: None,
+                                }),
+                            );
+                            meta.aggregations.add(
+                                TimelineEventItemId::EventId(target_id.to_owned()),
+                                aggregation,
+                            );
                         }
-                        _ => None,
-                    });
-
-                // If this message is a reply to another message, add an entry in the
-                // inverted mapping.
-                if let Some(event_id) = event_id {
-                    if let Some(replied_to_event_id) = replied_to_event_id {
-                        // This is a reply! Add an entry.
-                        meta.replies
-                            .entry(replied_to_event_id)
-                            .or_default()
-                            .insert(event_id.to_owned());
                     }
                 }
 
-                let poll_state = PollState::new(c, edit_content);
+                let (in_reply_to, thread_root) =
+                    Self::extract_reply_and_thread_root(c.relates_to.clone(), timeline_items);
 
-                let edit_json = edit_json.flatten();
+                Self::mark_response(meta, event_id, in_reply_to.as_ref());
+
+                let poll_state = PollState::new(c);
 
                 Self::AddItem {
                     content: TimelineItemContent::MsgLike(MsgLikeContent {
                         kind: MsgLikeKind::Poll(poll_state),
                         reactions: Default::default(),
                         thread_root,
-                        in_reply_to: in_reply_to_details,
+                        in_reply_to,
                         thread_summary: None,
                     }),
-                    edit_json,
                 }
             }
 
             AnyMessageLikeEventContent::RoomMessage(msg) => {
-                // Always remove the pending edit, if there's any. The reason is that if
-                // there's an edit in the relations mapping, we want to prefer it over
-                // any other pending edit, since it's more
-                // likely to be up to date, and we
-                // don't want to apply another pending edit on top of it.
-                let pending_edit = event_id
-                    .and_then(|event_id| {
-                        TimelineEventHandler::maybe_unstash_pending_edit(
-                            &mut meta.pending_edits,
-                            event_id,
-                        )
-                    })
-                    .and_then(|edit| match edit.kind {
-                        PendingEditKind::RoomMessage(replacement) => {
-                            Some((Some(edit.event_json), replacement.new_content))
+                // Record the bundled edit in the aggregations set, if any.
+                if let Some(target_id) = event_id {
+                    if let Some(new_content) = relations.and_then(extract_room_msg_edit_content) {
+                        // It is replacing the current event.
+                        if let Some(edit_event_id) = raw_event.and_then(|raw_event| {
+                            raw_event.get_field::<OwnedEventId>("event_id").ok().flatten()
+                        }) {
+                            let edit_json = raw_event.and_then(extract_bundled_edit_event_json);
+                            let aggregation = Aggregation::new(
+                                TimelineEventItemId::EventId(edit_event_id),
+                                AggregationKind::Edit(PendingEdit {
+                                    kind: PendingEditKind::RoomMessage(Replacement::new(
+                                        target_id.to_owned(),
+                                        new_content,
+                                    )),
+                                    edit_json,
+                                    // TODO maybe we could provide it?
+                                    encryption_info: None,
+                                }),
+                            );
+                            meta.aggregations.add(
+                                TimelineEventItemId::EventId(target_id.to_owned()),
+                                aggregation,
+                            );
                         }
-                        _ => None,
-                    });
-
-                let (edit_json, edit_content) = relations
-                    .and_then(extract_room_msg_edit_content)
-                    .map(|content| {
-                        let edit_json = raw_event.and_then(extract_bundled_edit_event_json);
-                        (edit_json, content)
-                    })
-                    .or(pending_edit)
-                    .unzip();
-
-                let mut replied_to_event_id = None;
-                let mut thread_root = None;
-                let in_reply_to_details =
-                    msg.relates_to.as_ref().and_then(|relation| match relation {
-                        Relation::Reply { in_reply_to } => {
-                            replied_to_event_id = Some(in_reply_to.event_id.clone());
-                            Some(InReplyToDetails::new(
-                                in_reply_to.event_id.clone(),
-                                timeline_items,
-                            ))
-                        }
-                        Relation::Thread(thread) => {
-                            thread_root = Some(thread.event_id.clone());
-                            thread.in_reply_to.as_ref().map(|in_reply_to| {
-                                replied_to_event_id = Some(in_reply_to.event_id.clone());
-                                InReplyToDetails::new(in_reply_to.event_id.clone(), timeline_items)
-                            })
-                        }
-                        _ => None,
-                    });
-
-                // If this message is a reply to another message, add an entry in the
-                // inverted mapping.
-                if let Some(event_id) = event_id {
-                    if let Some(replied_to_event_id) = replied_to_event_id {
-                        // This is a reply! Add an entry.
-                        meta.replies
-                            .entry(replied_to_event_id)
-                            .or_default()
-                            .insert(event_id.to_owned());
                     }
                 }
 
-                let edit_json = edit_json.flatten();
+                let (in_reply_to, thread_root) = Self::extract_reply_and_thread_root(
+                    msg.relates_to.and_then(|rel| rel.try_into().ok()),
+                    timeline_items,
+                );
+                Self::mark_response(meta, event_id, in_reply_to.as_ref());
 
                 Self::AddItem {
                     content: TimelineItemContent::message(
-                        msg,
-                        edit_content,
+                        msg.msgtype,
+                        msg.mentions,
                         Default::default(),
                         thread_root,
-                        in_reply_to_details,
+                        in_reply_to,
                         None,
                     ),
-                    edit_json,
                 }
             }
 
@@ -564,6 +474,47 @@ impl TimelineAction {
         })
     }
 
+    fn extract_reply_and_thread_root(
+        relates_to: Option<RelationWithoutReplacement>,
+        timeline_items: &Vector<Arc<TimelineItem>>,
+    ) -> (Option<InReplyToDetails>, Option<OwnedEventId>) {
+        let mut thread_root = None;
+
+        let in_reply_to = relates_to.and_then(|relation| match relation {
+            RelationWithoutReplacement::Reply { in_reply_to } => {
+                Some(InReplyToDetails::new(in_reply_to.event_id, timeline_items))
+            }
+            RelationWithoutReplacement::Thread(thread) => {
+                thread_root = Some(thread.event_id);
+                thread
+                    .in_reply_to
+                    .map(|in_reply_to| InReplyToDetails::new(in_reply_to.event_id, timeline_items))
+            }
+            _ => None,
+        });
+
+        (in_reply_to, thread_root)
+    }
+
+    fn mark_response(
+        meta: &mut TimelineMetadata,
+        event_id: Option<&EventId>,
+        in_reply_to: Option<&InReplyToDetails>,
+    ) {
+        // If this message is a reply to another message, add an entry in the
+        // inverted mapping.
+        if let Some(event_id) = event_id {
+            if let Some(replied_to_event_id) = in_reply_to.as_ref().map(|details| &details.event_id)
+            {
+                // This is a reply! Add an entry.
+                meta.replies
+                    .entry(replied_to_event_id.to_owned())
+                    .or_default()
+                    .insert(event_id.to_owned());
+            }
+        }
+    }
+
     pub(super) fn failed_to_parse(event: FailedToParseEvent, error: serde_json::Error) -> Self {
         let error = Arc::new(error);
         match event {
@@ -682,9 +633,9 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
         let mut added_item = false;
 
         match timeline_action {
-            TimelineAction::AddItem { content, edit_json } => {
+            TimelineAction::AddItem { content } => {
                 if self.ctx.should_add_new_items {
-                    self.add_item(content, edit_json);
+                    self.add_item(content);
                     added_item = true;
                 }
             }
@@ -697,13 +648,19 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
                     self.handle_redaction(related_event);
                 }
                 HandleAggregationKind::Edit { replacement } => {
-                    self.handle_room_message_edit(replacement);
+                    self.handle_edit(
+                        replacement.event_id.clone(),
+                        PendingEditKind::RoomMessage(replacement),
+                    );
                 }
                 HandleAggregationKind::PollResponse { answers } => {
                     self.handle_poll_response(related_event, answers);
                 }
                 HandleAggregationKind::PollEdit { replacement } => {
-                    self.handle_poll_edit(replacement);
+                    self.handle_edit(
+                        replacement.event_id.clone(),
+                        PendingEditKind::Poll(replacement),
+                    );
                 }
                 HandleAggregationKind::PollEnd => {
                     self.handle_poll_end(related_event);
@@ -732,146 +689,33 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
         removed_item
     }
 
-    #[instrument(skip_all, fields(replacement_event_id = ?replacement.event_id))]
-    fn handle_room_message_edit(
-        &mut self,
-        replacement: Replacement<RoomMessageEventContentWithoutRelation>,
-    ) {
-        if let Some((item_pos, item)) = rfind_event_by_id(self.items, &replacement.event_id) {
-            let edit_json = self.ctx.flow.raw_event().cloned();
-            if let Some(new_item) = self.apply_msg_edit(&item, replacement.new_content, edit_json) {
-                trace!("Applied edit");
-
-                let internal_id = item.internal_id.to_owned();
-
-                // Update all events that replied to this message with the edited content.
-                Self::maybe_update_responses(
-                    self.meta,
-                    self.items,
-                    &replacement.event_id,
-                    &new_item,
-                );
-
-                // Update the event itself.
-                self.items.replace(item_pos, TimelineItem::new(new_item, internal_id));
-            }
-        } else if let Flow::Remote { position, raw_event, .. } = &self.ctx.flow {
-            let replaced_event_id = replacement.event_id.clone();
-            let replacement = PendingEdit {
-                kind: PendingEditKind::RoomMessage(replacement),
-                event_json: raw_event.clone(),
-            };
-            self.stash_pending_edit(*position, replaced_event_id, replacement);
-        } else {
-            debug!("Local message edit for a timeline item not found, discarding");
-        }
-    }
-
-    /// Try to stash a pending edit, if it makes sense to do so.
-    #[instrument(skip(self, replacement))]
-    fn stash_pending_edit(
-        &mut self,
-        position: TimelineItemPosition,
-        replaced_event_id: OwnedEventId,
-        replacement: PendingEdit,
-    ) {
-        match position {
-            TimelineItemPosition::Start { .. }
-            | TimelineItemPosition::At { .. }
-            | TimelineItemPosition::UpdateAt { .. } => {
-                // Only insert the edit if there wasn't any other edit
-                // before.
-                //
-                // For a start position, this is the right thing to do, because if there was a
-                // stashed edit, it relates to a more recent one (either appended for a live
-                // sync, or inserted earlier via back-pagination).
-                //
-                // For an update position, if there was a stashed edit, we can't really know
-                // which version is the more recent, without an ordering of the
-                // edit events themselves, so we discard it in that case.
-                if !self
-                    .meta
-                    .pending_edits
-                    .iter()
-                    .any(|edit| edit.edited_event() == replaced_event_id)
-                {
-                    self.meta.pending_edits.push(replacement);
-                    debug!("Timeline item not found, stashing edit");
-                } else {
-                    debug!("Timeline item not found, but there was a previous edit for the event: discarding");
-                }
-            }
-
-            TimelineItemPosition::End { .. } => {
-                // This is a more recent edit, coming either live from sync or from a
-                // forward-pagination: it's fine to overwrite the previous one, if
-                // available.
-                let edits = &mut self.meta.pending_edits;
-                let _ = Self::maybe_unstash_pending_edit(edits, &replaced_event_id);
-                edits.push(replacement);
-                debug!("Timeline item not found, stashing edit");
-            }
-        }
-    }
-
-    /// Look for a pending edit for the given event, and remove it from the list
-    /// and return it, if found.
-    fn maybe_unstash_pending_edit(
-        edits: &mut RingBuffer<PendingEdit>,
-        event_id: &EventId,
-    ) -> Option<PendingEdit> {
-        let pos = edits.iter().position(|edit| edit.edited_event() == event_id)?;
-        trace!(edited_event = %event_id, "unstashed pending edit");
-        Some(edits.remove(pos).unwrap())
-    }
-
-    /// Try applying an edit to an existing [`EventTimelineItem`].
-    ///
-    /// Return a new item if applying the edit succeeded, or `None` if there was
-    /// an error while applying it.
-    fn apply_msg_edit(
-        &self,
-        item: &EventTimelineItem,
-        new_content: RoomMessageEventContentWithoutRelation,
-        edit_json: Option<Raw<AnySyncTimelineEvent>>,
-    ) -> Option<EventTimelineItem> {
-        if self.ctx.sender != item.sender() {
-            info!(
-                original_sender = ?item.sender(), edit_sender = ?self.ctx.sender,
-                "Edit event applies to another user's timeline item, discarding"
-            );
-            return None;
-        }
-
-        let TimelineItemContent::MsgLike(content) = item.content() else {
-            info!(
-                "Edit of message event applies to {:?}, discarding",
-                item.content().debug_string(),
-            );
-            return None;
-        };
-
-        let MsgLikeContent { kind: MsgLikeKind::Message(msg), .. } = content else {
-            info!(
-                "Edit of message event applies to {:?}, discarding",
-                item.content().debug_string(),
-            );
-            return None;
-        };
-
-        let mut new_msg = msg.clone();
-        new_msg.apply_edit(new_content);
+    #[instrument(skip(self, edit_kind))]
+    fn handle_edit(&mut self, edited_event_id: OwnedEventId, edit_kind: PendingEditKind) {
+        let target = TimelineEventItemId::EventId(edited_event_id.clone());
 
-        let mut new_item = item.with_content_and_latest_edit(
-            TimelineItemContent::MsgLike(content.with_kind(MsgLikeKind::Message(new_msg))),
-            edit_json,
+        let encryption_info =
+            as_variant!(&self.ctx.flow, Flow::Remote { encryption_info, .. } => encryption_info.clone()).flatten();
+        let aggregation = Aggregation::new(
+            self.ctx.flow.timeline_item_id(),
+            AggregationKind::Edit(PendingEdit {
+                kind: edit_kind,
+                edit_json: self.ctx.flow.raw_event().cloned(),
+                encryption_info,
+            }),
         );
 
-        if let Flow::Remote { encryption_info, .. } = &self.ctx.flow {
-            new_item = new_item.with_encryption_info(encryption_info.clone());
-        }
+        self.meta.aggregations.add(target.clone(), aggregation.clone());
 
-        Some(new_item)
+        if let Some(new_item) = find_item_and_apply_aggregation(
+            &self.meta.aggregations,
+            self.items,
+            &target,
+            aggregation,
+            &self.meta.room_version,
+        ) {
+            // Update all events that replied to this message with the edited content.
+            Self::maybe_update_responses(self.meta, self.items, &edited_event_id, &new_item);
+        }
     }
 
     /// Apply a reaction to a *remote* event.
@@ -893,6 +737,7 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
                 ReactionStatus::RemoteToRemote(event_id.clone())
             }
         };
+
         let aggregation = Aggregation::new(
             self.ctx.flow.timeline_item_id(),
             AggregationKind::Reaction {
@@ -904,73 +749,13 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
         );
 
         self.meta.aggregations.add(target.clone(), aggregation.clone());
-        find_item_and_apply_aggregation(self.items, &target, aggregation);
-    }
-
-    #[instrument(skip_all, fields(replacement_event_id = ?replacement.event_id))]
-    fn handle_poll_edit(
-        &mut self,
-        replacement: Replacement<NewUnstablePollStartEventContentWithoutRelation>,
-    ) {
-        let Some((item_pos, item)) = rfind_event_by_id(self.items, &replacement.event_id) else {
-            if let Flow::Remote { position, raw_event, .. } = &self.ctx.flow {
-                let replaced_event_id = replacement.event_id.clone();
-                let replacement = PendingEdit {
-                    kind: PendingEditKind::Poll(replacement),
-                    event_json: raw_event.clone(),
-                };
-                self.stash_pending_edit(*position, replaced_event_id, replacement);
-            } else {
-                debug!("Local poll edit for a timeline item not found, discarding");
-            }
-            return;
-        };
-
-        let edit_json = self.ctx.flow.raw_event().cloned();
-
-        let Some(new_item) = self.apply_poll_edit(item.inner, replacement, edit_json) else {
-            return;
-        };
-
-        trace!("Applying poll start edit.");
-        self.items.replace(item_pos, TimelineItem::new(new_item, item.internal_id.to_owned()));
-    }
-
-    fn apply_poll_edit(
-        &self,
-        item: &EventTimelineItem,
-        replacement: Replacement<NewUnstablePollStartEventContentWithoutRelation>,
-        edit_json: Option<Raw<AnySyncTimelineEvent>>,
-    ) -> Option<EventTimelineItem> {
-        if self.ctx.sender != item.sender() {
-            info!(
-                original_sender = ?item.sender(), edit_sender = ?self.ctx.sender,
-                "Edit event applies to another user's timeline item, discarding"
-            );
-            return None;
-        }
-
-        let TimelineItemContent::MsgLike(content) = &item.content() else {
-            info!("Edit of poll event applies to {}, discarding", item.content().debug_string(),);
-            return None;
-        };
-
-        let MsgLikeContent { kind: MsgLikeKind::Poll(poll_state), .. } = content else {
-            info!("Edit of poll event applies to {}, discarding", item.content().debug_string(),);
-            return None;
-        };
-
-        let new_content = match poll_state.edit(replacement.new_content) {
-            Some(edited_poll_state) => TimelineItemContent::MsgLike(
-                content.with_kind(MsgLikeKind::Poll(edited_poll_state)),
-            ),
-            None => {
-                info!("Not applying edit to a poll that's already ended");
-                return None;
-            }
-        };
-
-        Some(item.with_content_and_latest_edit(new_content, edit_json))
+        find_item_and_apply_aggregation(
+            &self.meta.aggregations,
+            self.items,
+            &target,
+            aggregation,
+            &self.meta.room_version,
+        );
     }
 
     fn handle_poll_response(&mut self, poll_event_id: OwnedEventId, answers: Vec<String>) {
@@ -984,7 +769,13 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
             },
         );
         self.meta.aggregations.add(target.clone(), aggregation.clone());
-        find_item_and_apply_aggregation(self.items, &target, aggregation);
+        find_item_and_apply_aggregation(
+            &self.meta.aggregations,
+            self.items,
+            &target,
+            aggregation,
+            &self.meta.room_version,
+        );
     }
 
     fn handle_poll_end(&mut self, poll_event_id: OwnedEventId) {
@@ -994,7 +785,13 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
             AggregationKind::PollEnd { end_date: self.ctx.timestamp },
         );
         self.meta.aggregations.add(target.clone(), aggregation.clone());
-        find_item_and_apply_aggregation(self.items, &target, aggregation);
+        find_item_and_apply_aggregation(
+            &self.meta.aggregations,
+            self.items,
+            &target,
+            aggregation,
+            &self.meta.room_version,
+        );
     }
 
     /// Looks for the redacted event in all the timeline event items, and
@@ -1014,27 +811,22 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
             return;
         }
 
-        // General path: redact another kind of (non-reaction) event.
-        if let Some((idx, item)) = rfind_event_by_id(self.items, &redacted) {
-            if item.as_remote().is_some() {
-                if item.content.is_redacted() {
-                    debug!("event item is already redacted");
-                } else {
-                    let new_item = item.redact(&self.meta.room_version);
-                    let internal_id = item.internal_id.to_owned();
-
-                    // Look for any timeline event that's a reply to the redacted event, and redact
-                    // the replied-to event there as well.
-                    Self::maybe_update_responses(self.meta, self.items, &redacted, &new_item);
+        let target = TimelineEventItemId::EventId(redacted.clone());
+        let aggregation =
+            Aggregation::new(self.ctx.flow.timeline_item_id(), AggregationKind::Redaction);
+        self.meta.aggregations.add(target.clone(), aggregation.clone());
 
-                    self.items.replace(idx, TimelineItem::new(new_item, internal_id));
-                }
-            } else {
-                error!("inconsistent state: redaction received on a non-remote event item");
-            }
-        } else {
-            debug!("Timeline item not found, discarding redaction");
-        };
+        if let Some(new_item) = find_item_and_apply_aggregation(
+            &self.meta.aggregations,
+            self.items,
+            &target,
+            aggregation,
+            &self.meta.room_version,
+        ) {
+            // Look for any timeline event that's a reply to the redacted event, and redact
+            // the replied-to event there as well.
+            Self::maybe_update_responses(self.meta, self.items, &redacted, &new_item);
+        }
     }
 
     /// Attempts to redact an aggregation (e.g. a reaction, a poll response,
@@ -1045,33 +837,15 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
     fn handle_aggregation_redaction(&mut self, aggregation_id: OwnedEventId) -> bool {
         let aggregation_id = TimelineEventItemId::EventId(aggregation_id);
 
-        let Some((target, aggregation)) =
-            self.meta.aggregations.try_remove_aggregation(&aggregation_id)
-        else {
+        match self.meta.aggregations.try_remove_aggregation(&aggregation_id, self.items) {
+            Ok(val) => val,
             // This wasn't a known aggregation that was redacted.
-            return false;
-        };
-
-        if let Some((item_pos, item)) = rfind_event_by_item_id(self.items, target) {
-            let mut content = item.content().clone();
-            match aggregation.unapply(&mut content) {
-                ApplyAggregationResult::UpdatedItem => {
-                    trace!("removed aggregation");
-                    let internal_id = item.internal_id.to_owned();
-                    let new_item = item.with_content(content);
-                    self.items.replace(item_pos, TimelineItem::new(new_item, internal_id));
-                }
-                ApplyAggregationResult::LeftItemIntact => {}
-                ApplyAggregationResult::Error(err) => {
-                    warn!("error when unapplying aggregation: {err}");
-                }
+            Err(err) => {
+                warn!("error while attempting to remove aggregation: {err}");
+                // It could find an aggregation but didn't properly unapply it.
+                true
             }
-        } else {
-            info!("missing related-to item ({target:?}) for aggregation {aggregation_id:?}");
         }
-
-        // In all cases, we noticed this was an aggregation.
-        true
     }
 
     /// Add a new event item in the timeline.
@@ -1086,18 +860,7 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
     ///    `all_remote_events`,
     /// 2. the lastly added or updated remote event must be associated to the
     ///    timeline item being added here.
-    fn add_item(
-        &mut self,
-        mut content: TimelineItemContent,
-        edit_json: Option<Raw<AnySyncTimelineEvent>>,
-    ) {
-        // Apply any pending or stashed aggregations.
-        if let Err(err) =
-            self.meta.aggregations.apply(&self.ctx.flow.timeline_item_id(), &mut content)
-        {
-            warn!("discarding aggregations: {err}");
-        }
-
+    fn add_item(&mut self, content: TimelineItemContent) {
         let sender = self.ctx.sender.to_owned();
         let sender_profile = TimelineDetails::from_initial_value(self.ctx.sender_profile.clone());
         let timestamp = self.ctx.timestamp;
@@ -1134,7 +897,7 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
                     is_highlighted: self.ctx.is_highlighted,
                     encryption_info: encryption_info.clone(),
                     original_json: Some(raw_event.clone()),
-                    latest_edit_json: edit_json,
+                    latest_edit_json: None,
                     origin,
                 }
                 .into()
@@ -1152,6 +915,18 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
             is_room_encrypted,
         );
 
+        // Apply any pending or stashed aggregations.
+        let mut cowed = Cow::Owned(item);
+        if let Err(err) = self.meta.aggregations.apply_all(
+            &self.ctx.flow.timeline_item_id(),
+            &mut cowed,
+            self.items,
+            &self.meta.room_version,
+        ) {
+            warn!("discarding aggregations: {err}");
+        }
+        let item = cowed.into_owned();
+
         match &self.ctx.flow {
             Flow::Local { .. } => {
                 trace!("Adding new local timeline item");
diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/message.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/message.rs
index 727fd68d08b..5125bc73bac 100644
--- a/crates/matrix-sdk-ui/src/timeline/event_item/content/message.rs
+++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/message.rs
@@ -23,8 +23,7 @@ use ruma::{
             UnstablePollStartEventContent,
         },
         room::message::{
-            MessageType, Relation, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
-            SyncRoomMessageEvent,
+            MessageType, Relation, RoomMessageEventContentWithoutRelation, SyncRoomMessageEvent,
         },
         AnySyncMessageLikeEvent, AnySyncTimelineEvent, BundledMessageLikeRelations, Mentions,
     },
@@ -46,14 +45,14 @@ pub struct Message {
 impl Message {
     /// Construct a `Message` from a `m.room.message` event.
     pub(in crate::timeline) fn from_event(
-        c: RoomMessageEventContent,
+        mut msgtype: MessageType,
+        mentions: Option<Mentions>,
         edit: Option<RoomMessageEventContentWithoutRelation>,
         remove_reply_fallback: RemoveReplyFallback,
     ) -> Self {
-        let mut msgtype = c.msgtype;
         msgtype.sanitize(DEFAULT_SANITIZER_MODE, remove_reply_fallback);
 
-        let mut ret = Self { msgtype, edited: false, mentions: c.mentions };
+        let mut ret = Self { msgtype, edited: false, mentions };
 
         if let Some(edit) = edit {
             ret.apply_edit(edit);
diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs
index 700349adf82..889acbc722c 100644
--- a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs
+++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs
@@ -39,10 +39,7 @@ use ruma::{
             history_visibility::RoomHistoryVisibilityEventContent,
             join_rules::RoomJoinRulesEventContent,
             member::{Change, RoomMemberEventContent, SyncRoomMemberEvent},
-            message::{
-                Relation, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
-                SyncRoomMessageEvent,
-            },
+            message::{MessageType, Relation, SyncRoomMessageEvent},
             name::RoomNameEventContent,
             pinned_events::RoomPinnedEventsEventContent,
             power_levels::{RoomPowerLevels, RoomPowerLevelsEventContent},
@@ -53,7 +50,7 @@ use ruma::{
         },
         space::{child::SpaceChildEventContent, parent::SpaceParentEventContent},
         sticker::{StickerEventContent, SyncStickerEvent},
-        AnyFullStateEventContent, AnySyncTimelineEvent, FullStateEventContent,
+        AnyFullStateEventContent, AnySyncTimelineEvent, FullStateEventContent, Mentions,
         MessageLikeEventType, StateEventType,
     },
     html::RemoveReplyFallback,
@@ -202,7 +199,8 @@ impl TimelineItemContent {
 
                 let msglike = MsgLikeContent {
                     kind: MsgLikeKind::Message(Message::from_event(
-                        event_content,
+                        event_content.msgtype,
+                        event_content.mentions,
                         edit,
                         RemoveReplyFallback::Yes,
                     )),
@@ -292,6 +290,13 @@ impl TimelineItemContent {
                 }
             });
 
+        let mut poll = PollState::new(NewUnstablePollStartEventContent::new(
+            event.content.poll_start().clone(),
+        ));
+        if let Some(edit) = edit {
+            poll = poll.edit(edit).expect("the poll can't be ended yet!"); // TODO or can it?
+        }
+
         // We're not interested in aggregations for the latest preview item.
         let reactions = Default::default();
         let thread_root = None;
@@ -299,10 +304,7 @@ impl TimelineItemContent {
         let thread_summary = None;
 
         let msglike = MsgLikeContent {
-            kind: MsgLikeKind::Poll(PollState::new(
-                NewUnstablePollStartEventContent::new(event.content.poll_start().clone()),
-                edit,
-            )),
+            kind: MsgLikeKind::Poll(poll),
             reactions,
             thread_root,
             in_reply_to,
@@ -409,8 +411,8 @@ impl TimelineItemContent {
     // These constructors could also be `From` implementations, but that would
     // allow users to call them directly, which should not be supported
     pub(crate) fn message(
-        c: RoomMessageEventContent,
-        edit: Option<RoomMessageEventContentWithoutRelation>,
+        msgtype: MessageType,
+        mentions: Option<Mentions>,
         reactions: ReactionsByKeyBySender,
         thread_root: Option<OwnedEventId>,
         in_reply_to: Option<InReplyToDetails>,
@@ -420,7 +422,12 @@ impl TimelineItemContent {
             if in_reply_to.is_some() { RemoveReplyFallback::Yes } else { RemoveReplyFallback::No };
 
         Self::MsgLike(MsgLikeContent {
-            kind: MsgLikeKind::Message(Message::from_event(c, edit, remove_reply_fallback)),
+            kind: MsgLikeKind::Message(Message::from_event(
+                msgtype,
+                mentions,
+                None,
+                remove_reply_fallback,
+            )),
             reactions,
             thread_root,
             in_reply_to,
@@ -531,9 +538,9 @@ impl TimelineItemContent {
 
     /// Return the reactions, grouped by key and then by sender, for a given
     /// content.
-    pub fn reactions(&self) -> ReactionsByKeyBySender {
+    pub fn reactions(&self) -> Option<&ReactionsByKeyBySender> {
         match self {
-            TimelineItemContent::MsgLike(msglike) => msglike.reactions.clone(),
+            TimelineItemContent::MsgLike(msglike) => Some(&msglike.reactions),
 
             TimelineItemContent::MembershipChange(..)
             | TimelineItemContent::ProfileChange(..)
@@ -543,7 +550,7 @@ impl TimelineItemContent {
             | TimelineItemContent::CallInvite
             | TimelineItemContent::CallNotify => {
                 // No reactions for these kind of items.
-                Default::default()
+                None
             }
         }
     }
diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/polls.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/polls.rs
index d7dc6d31c05..4634f3638f6 100644
--- a/crates/matrix-sdk-ui/src/timeline/event_item/content/polls.rs
+++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/polls.rs
@@ -50,24 +50,13 @@ pub(in crate::timeline) struct ResponseData {
 }
 
 impl PollState {
-    pub(crate) fn new(
-        content: NewUnstablePollStartEventContent,
-        edit: Option<NewUnstablePollStartEventContentWithoutRelation>,
-    ) -> Self {
-        let mut ret = Self {
+    pub(crate) fn new(content: NewUnstablePollStartEventContent) -> Self {
+        Self {
             start_event_content: content,
             response_data: vec![],
             end_event_timestamp: None,
             has_been_edited: false,
-        };
-
-        if let Some(edit) = edit {
-            // SAFETY: [`Self::edit`] only returns `None` when the poll has ended, not the
-            // case here.
-            ret = ret.edit(edit).unwrap();
         }
-
-        ret
     }
 
     /// Applies an edit to a poll, returns `None` if the poll was already marked
diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/reply.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/reply.rs
index e4fc55a6613..42c68f0fd94 100644
--- a/crates/matrix-sdk-ui/src/timeline/event_item/content/reply.rs
+++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/reply.rs
@@ -145,7 +145,8 @@ impl RepliedToEvent {
 
                     TimelineItemContent::MsgLike(MsgLikeContent {
                         kind: MsgLikeKind::Message(Message::from_event(
-                            c,
+                            c.msgtype,
+                            c.mentions,
                             extract_room_msg_edit_content(event.relations()),
                             RemoveReplyFallback::Yes,
                         )),
@@ -199,7 +200,7 @@ impl RepliedToEvent {
                     let thread_summary = None;
 
                     // TODO: could we provide the bundled edit here?
-                    let poll_state = PollState::new(content, None);
+                    let poll_state = PollState::new(content);
                     TimelineItemContent::MsgLike(MsgLikeContent {
                         kind: MsgLikeKind::Poll(poll_state),
                         reactions,
diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs
index ba17a8f75b4..fdd87a9528f 100644
--- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs
+++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs
@@ -314,6 +314,11 @@ impl EventTimelineItem {
         &self.content
     }
 
+    /// Get a mutable handle to the content of this item.
+    pub(crate) fn content_mut(&mut self) -> &mut TimelineItemContent {
+        &mut self.content
+    }
+
     /// Get the read receipts of this item.
     ///
     /// The key is the ID of a room member and the value are details about the
diff --git a/crates/matrix-sdk-ui/src/timeline/tests/edit.rs b/crates/matrix-sdk-ui/src/timeline/tests/edit.rs
index 4a5a47d858b..add39cc3f6e 100644
--- a/crates/matrix-sdk-ui/src/timeline/tests/edit.rs
+++ b/crates/matrix-sdk-ui/src/timeline/tests/edit.rs
@@ -426,18 +426,16 @@ async fn test_updated_reply_doesnt_lose_latest_edit() {
         )
         .await;
 
-    {
-        // The reply is updated.
-        let item = assert_next_matches!(stream, VectorDiff::Set { index: 1, value } => value);
-        // And still has the latest edit JSON.
-        assert!(item.latest_edit_json().is_some());
-        assert_eq!(item.content().as_message().unwrap().body(), "guten tag");
+    // The original is updated.
+    let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value);
+    // And now has a latest edit JSON.
+    assert!(item.latest_edit_json().is_some());
 
-        // The original is updated.
-        let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value);
-        // And now has a latest edit JSON.
-        assert!(item.latest_edit_json().is_some());
+    // The reply is updated.
+    let item = assert_next_matches!(stream, VectorDiff::Set { index: 1, value } => value);
+    // And still has the latest edit JSON.
+    assert!(item.latest_edit_json().is_some());
+    assert_eq!(item.content().as_message().unwrap().body(), "guten tag");
 
-        assert_pending!(stream);
-    }
+    assert_pending!(stream);
 }
diff --git a/crates/matrix-sdk-ui/src/timeline/tests/event_filter.rs b/crates/matrix-sdk-ui/src/timeline/tests/event_filter.rs
index c118ef300ea..f2b7a66202e 100644
--- a/crates/matrix-sdk-ui/src/timeline/tests/event_filter.rs
+++ b/crates/matrix-sdk-ui/src/timeline/tests/event_filter.rs
@@ -83,7 +83,10 @@ async fn test_default_filter() {
     timeline.handle_live_event(f.reaction(third_event_id, "+1").sender(&BOB)).await;
     timeline.handle_live_event(f.redaction(second_event_id).sender(&BOB)).await;
     let item = assert_next_matches!(stream, VectorDiff::Set { index: 3, value } => value);
-    assert_eq!(item.as_event().unwrap().content().reactions().len(), 1);
+    assert_eq!(
+        item.as_event().unwrap().content().reactions().cloned().unwrap_or_default().len(),
+        1
+    );
 
     // TODO: After adding raw timeline items, check for one here.
 
diff --git a/crates/matrix-sdk-ui/src/timeline/tests/reactions.rs b/crates/matrix-sdk-ui/src/timeline/tests/reactions.rs
index 3ab5c34d268..142e0473e84 100644
--- a/crates/matrix-sdk-ui/src/timeline/tests/reactions.rs
+++ b/crates/matrix-sdk-ui/src/timeline/tests/reactions.rs
@@ -67,7 +67,7 @@ macro_rules! assert_item_update {
 macro_rules! assert_reaction_is_updated {
     ($stream:expr, $event_id:expr, $index:expr, $is_remote_echo:literal) => {{
         let event = assert_item_update!($stream, $event_id, $index);
-        let reactions = event.content().reactions();
+        let reactions = event.content().reactions().cloned().unwrap_or_default();
         let reactions = reactions.get(&REACTION_KEY.to_owned()).unwrap();
         let reaction = reactions.get(*ALICE).unwrap();
         match reaction.status {
@@ -144,7 +144,13 @@ async fn test_redact_reaction_success() {
 
     // Will immediately redact it on the item.
     let event = assert_item_update!(stream, &event_id, item_pos);
-    assert!(event.content().reactions().get(&REACTION_KEY.to_owned()).is_none());
+    assert!(event
+        .content()
+        .reactions()
+        .cloned()
+        .unwrap_or_default()
+        .get(&REACTION_KEY.to_owned())
+        .is_none());
 
     // And send a redaction request for that reaction.
     {
@@ -174,7 +180,7 @@ async fn test_reactions_store_timestamp() {
     timeline.toggle_reaction_local(&item_id, REACTION_KEY).await.unwrap();
 
     let event = assert_reaction_is_updated!(stream, &event_id, msg_pos, false);
-    let reactions = event.content().reactions();
+    let reactions = event.content().reactions().cloned().unwrap_or_default();
     let reactions = reactions.get(&REACTION_KEY.to_owned()).unwrap();
     let timestamp = reactions.values().next().unwrap().timestamp;
 
@@ -209,7 +215,15 @@ async fn test_initial_reaction_timestamp_is_stored() {
         .await;
 
     let items = timeline.controller.items().await;
-    let reactions = items.last().unwrap().as_event().unwrap().content().reactions();
+    let reactions = items
+        .last()
+        .unwrap()
+        .as_event()
+        .unwrap()
+        .content()
+        .reactions()
+        .cloned()
+        .unwrap_or_default();
     let entry = reactions.get(&REACTION_KEY.to_owned()).unwrap();
 
     assert_eq!(entry.values().next().unwrap().timestamp, reaction_timestamp);
@@ -262,13 +276,13 @@ async fn test_reinserted_item_keeps_reactions() {
     // Get the event.
     assert_next_matches_with_timeout!(stream, VectorDiff::PushBack { value: item } => {
         assert_eq!(item.content().as_message().unwrap().body(), "hey");
-        assert!(item.content().reactions().is_empty());
+        assert!(item.content().reactions().cloned().unwrap_or_default().is_empty());
     });
 
     // Get the reaction.
     assert_next_matches_with_timeout!(stream, VectorDiff::Set { index: 0, value: item } => {
         assert_eq!(item.content().as_message().unwrap().body(), "hey");
-        let reactions = item.content().reactions();
+        let reactions = item.content().reactions().cloned().unwrap_or_default();
         assert_eq!(reactions.len(), 1);
         reactions.get(REACTION_KEY).unwrap().get(*ALICE).unwrap();
     });
@@ -295,7 +309,7 @@ async fn test_reinserted_item_keeps_reactions() {
     assert_next_matches_with_timeout!(stream, VectorDiff::Insert { index: 0, value: item } => {
         assert_eq!(item.content().as_message().unwrap().body(), "hey");
         // And it still includes the reaction from Alice.
-        let reactions = item.content().reactions();
+        let reactions = item.content().reactions().cloned().unwrap_or_default();
         assert_eq!(reactions.len(), 1);
         reactions.get(REACTION_KEY).unwrap().get(*ALICE).unwrap();
     });
diff --git a/crates/matrix-sdk-ui/src/timeline/tests/redaction.rs b/crates/matrix-sdk-ui/src/timeline/tests/redaction.rs
index 2ac7eb722a6..c681109cacb 100644
--- a/crates/matrix-sdk-ui/src/timeline/tests/redaction.rs
+++ b/crates/matrix-sdk-ui/src/timeline/tests/redaction.rs
@@ -17,11 +17,14 @@ use assert_matches2::assert_let;
 use eyeball_im::VectorDiff;
 use imbl::vector;
 use matrix_sdk_test::{async_test, ALICE, BOB};
-use ruma::events::{
-    reaction::RedactedReactionEventContent, room::message::OriginalSyncRoomMessageEvent,
-    FullStateEventContent,
+use ruma::{
+    event_id,
+    events::{
+        reaction::RedactedReactionEventContent, room::message::OriginalSyncRoomMessageEvent,
+        FullStateEventContent,
+    },
 };
-use stream_assert::assert_next_matches;
+use stream_assert::{assert_next_matches, assert_pending};
 
 use super::TestTimeline;
 use crate::timeline::{
@@ -81,17 +84,53 @@ async fn test_redact_replied_to_event() {
 
     timeline.handle_live_event(f.redaction(first_item.event_id().unwrap()).sender(&ALICE)).await;
 
+    let first_item_again =
+        assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value);
+    assert!(first_item_again.content().is_redacted());
+    assert_matches!(first_item_again.original_json(), None);
+
     let second_item_again =
         assert_next_matches!(stream, VectorDiff::Set { index: 1, value } => value);
     let msglike = second_item_again.content().as_msglike().unwrap();
     let in_reply_to = msglike.in_reply_to.clone().unwrap();
     assert_let!(TimelineDetails::Ready(replied_to_event) = &in_reply_to.event);
     assert!(replied_to_event.content().is_redacted());
+}
 
-    let first_item_again =
-        assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value);
-    assert!(first_item_again.content().is_redacted());
-    assert_matches!(first_item_again.original_json(), None);
+#[async_test]
+async fn test_redaction_before_event() {
+    let timeline = TestTimeline::new();
+    let mut stream = timeline.subscribe_events().await;
+
+    let f = &timeline.factory;
+
+    let target_event_id = event_id!("$target");
+
+    // We get a reaction to the event, first.
+    timeline.handle_live_event(f.reaction(target_event_id, "😀").sender(&ALICE)).await;
+
+    // Then we see the redaction.
+    timeline.handle_live_event(f.redaction(target_event_id).sender(&ALICE)).await;
+
+    // Then another reaction.
+    timeline.handle_live_event(f.reaction(target_event_id, "👍").sender(&ALICE)).await;
+
+    // Nothing happens yet.
+    assert_pending!(stream);
+
+    // Then we see the original event.
+    timeline
+        .handle_live_event(f.text_msg("Hello, world!").event_id(target_event_id).sender(&ALICE))
+        .await;
+
+    // It is immediately redacted.
+    let first_item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value);
+    assert!(first_item.content().is_redacted());
+    assert_matches!(first_item.original_json(), None);
+    assert_matches!(first_item.latest_edit_json(), None);
+
+    // And the reactions didn't get applied.
+    assert!(first_item.content().reactions().cloned().unwrap_or_default().is_empty());
 }
 
 #[async_test]
@@ -103,13 +142,13 @@ async fn test_reaction_redaction() {
 
     timeline.handle_live_event(f.text_msg("hi!").sender(&ALICE)).await;
     let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value);
-    assert_eq!(item.content().reactions().len(), 0);
+    assert_eq!(item.content().reactions().cloned().unwrap_or_default().len(), 0);
 
     let msg_event_id = item.event_id().unwrap();
 
     timeline.handle_live_event(f.reaction(msg_event_id, "+1").sender(&BOB)).await;
     let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value);
-    assert_eq!(item.content().reactions().len(), 1);
+    assert_eq!(item.content().reactions().cloned().unwrap_or_default().len(), 1);
 
     // TODO: After adding raw timeline items, check for one here
 
@@ -117,7 +156,7 @@ async fn test_reaction_redaction() {
 
     timeline.handle_live_event(f.redaction(reaction_event_id).sender(&BOB)).await;
     let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value);
-    assert_eq!(item.content().reactions().len(), 0);
+    assert_eq!(item.content().reactions().cloned().unwrap_or_default().len(), 0);
 }
 
 #[async_test]
@@ -161,6 +200,6 @@ async fn test_reaction_redaction_timeline_filter() {
     // Redacting the reaction doesn't add a timeline item.
     timeline.handle_live_event(f.redaction(reaction_event_id).sender(&BOB)).await;
     let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value);
-    assert_eq!(item.content().reactions().len(), 0);
+    assert_eq!(item.content().reactions().cloned().unwrap_or_default().len(), 0);
     assert_eq!(timeline.controller.items().await.len(), 2);
 }
diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs b/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs
index f29d5df8925..f28d97635d3 100644
--- a/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs
+++ b/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs
@@ -492,7 +492,14 @@ async fn test_edit_to_replied_updates_reply() {
 
     yield_now().await; // let the send queue handle the edit.
 
-    // The reply events are updated with the edited replied-to content.
+    // The edit happens.
+    assert_next_matches!(timeline_stream, VectorDiff::Set { index: 0, value } => {
+        let msg = value.content().as_message().unwrap();
+        assert_eq!(msg.body(), "hello world");
+        assert!(msg.is_edited());
+    });
+
+    // And then the reply events are updated with the edited replied-to content.
     assert_next_matches!(timeline_stream, VectorDiff::Set { index: 1, value } => {
         let msglike = value.content().as_msglike().unwrap();
         let reply_message = msglike.as_message().unwrap();
@@ -517,13 +524,6 @@ async fn test_edit_to_replied_updates_reply() {
         assert_eq!(replied_to.content().as_message().unwrap().body(), "hello world");
     });
 
-    // And the edit happens.
-    assert_next_matches!(timeline_stream, VectorDiff::Set { index: 0, value } => {
-        let msg = value.content().as_message().unwrap();
-        assert_eq!(msg.body(), "hello world");
-        assert!(msg.is_edited());
-    });
-
     sleep(Duration::from_millis(200)).await;
 }
 
diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/focus_event.rs b/crates/matrix-sdk-ui/tests/integration/timeline/focus_event.rs
index 41dae23e088..cebb9e1d0e4 100644
--- a/crates/matrix-sdk-ui/tests/integration/timeline/focus_event.rs
+++ b/crates/matrix-sdk-ui/tests/integration/timeline/focus_event.rs
@@ -236,7 +236,7 @@ async fn test_focused_timeline_does_not_react() {
 
     let event_item = items[1].as_event().unwrap();
     assert_eq!(event_item.content().as_message().unwrap().body(), "yolo");
-    assert_eq!(event_item.content().reactions().len(), 0);
+    assert_eq!(event_item.content().reactions().cloned().unwrap_or_default().len(), 0);
 
     assert_pending!(timeline_stream);
 
@@ -310,7 +310,7 @@ async fn test_focused_timeline_local_echoes() {
 
     let event_item = items[1].as_event().unwrap();
     assert_eq!(event_item.content().as_message().unwrap().body(), "yolo");
-    assert_eq!(event_item.content().reactions().len(), 0);
+    assert_eq!(event_item.content().reactions().cloned().unwrap_or_default().len(), 0);
 
     sleep(Duration::from_millis(100)).await;
     assert_pending!(timeline_stream);
@@ -328,7 +328,7 @@ async fn test_focused_timeline_local_echoes() {
     // Text hasn't changed.
     assert_eq!(event_item.content().as_message().unwrap().body(), "yolo");
     // But now there's one reaction to the event.
-    let reactions = event_item.content().reactions();
+    let reactions = event_item.content().reactions().cloned().unwrap_or_default();
     assert_eq!(reactions.len(), 1);
     assert!(reactions.get("✨").unwrap().get(client.user_id().unwrap()).is_some());
 
@@ -389,7 +389,7 @@ async fn test_focused_timeline_doesnt_show_local_echoes() {
 
     let event_item = items[1].as_event().unwrap();
     assert_eq!(event_item.content().as_message().unwrap().body(), "yolo");
-    assert_eq!(event_item.content().reactions().len(), 0);
+    assert_eq!(event_item.content().reactions().cloned().unwrap_or_default().len(), 0);
 
     assert_pending!(timeline_stream);
 
diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/media.rs b/crates/matrix-sdk-ui/tests/integration/timeline/media.rs
index dcf446b1d6d..9e4489ba325 100644
--- a/crates/matrix-sdk-ui/tests/integration/timeline/media.rs
+++ b/crates/matrix-sdk-ui/tests/integration/timeline/media.rs
@@ -295,7 +295,7 @@ async fn test_react_to_local_media() {
         assert_eq!(get_filename_and_caption(msg.msgtype()), ("test.bin", None));
 
         // The item starts with no reactions.
-        assert!(item.content().reactions().is_empty());
+        assert!(item.content().reactions().cloned().unwrap_or_default().is_empty());
 
         item.identifier()
     };
@@ -308,7 +308,7 @@ async fn test_react_to_local_media() {
     assert_eq!(get_filename_and_caption(msg.msgtype()), ("test.bin", None));
 
     // There's a reaction for the current user for the given emoji.
-    let reactions = item.content().reactions();
+    let reactions = item.content().reactions().cloned().unwrap_or_default();
     let own_user_id = client.user_id().unwrap();
     reactions.get("🤪").unwrap().get(own_user_id).unwrap();
 
diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs b/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs
index 8e8740db704..47275ac1590 100644
--- a/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs
+++ b/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs
@@ -110,7 +110,7 @@ async fn test_reaction() {
     assert_let!(Some(msg) = event_item.content().as_message());
     assert!(!msg.is_edited());
     assert_eq!(event_item.read_receipts().len(), 2);
-    assert_eq!(event_item.content().reactions().len(), 0);
+    assert_eq!(event_item.content().reactions().cloned().unwrap_or_default().len(), 0);
 
     // Then the reaction is taken into account.
     assert_let!(VectorDiff::Set { index: 0, value: updated_message } = &timeline_updates[2]);
@@ -118,8 +118,9 @@ async fn test_reaction() {
     assert_let!(Some(msg) = event_item.content().as_message());
     assert!(!msg.is_edited());
     assert_eq!(event_item.read_receipts().len(), 2);
-    assert_eq!(event_item.content().reactions().len(), 1);
-    let group = &event_item.content().reactions()["👍"];
+    let reactions = event_item.content().reactions().cloned().unwrap_or_default();
+    assert_eq!(reactions.len(), 1);
+    let group = &reactions["👍"];
     assert_eq!(group.len(), 1);
     let senders: Vec<_> = group.keys().collect();
     assert_eq!(senders.as_slice(), [*BOB]);
@@ -145,7 +146,7 @@ async fn test_reaction() {
     let event_item = updated_message.as_event().unwrap();
     assert_let!(Some(msg) = event_item.content().as_message());
     assert!(!msg.is_edited());
-    assert_eq!(event_item.content().reactions().len(), 0);
+    assert_eq!(event_item.content().reactions().cloned().unwrap_or_default().len(), 0);
 
     assert_pending!(timeline_stream);
 }
diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/reactions.rs b/crates/matrix-sdk-ui/tests/integration/timeline/reactions.rs
index d3bca1c1470..78bbcd36e80 100644
--- a/crates/matrix-sdk-ui/tests/integration/timeline/reactions.rs
+++ b/crates/matrix-sdk-ui/tests/integration/timeline/reactions.rs
@@ -118,7 +118,7 @@ async fn test_abort_before_being_sent() {
 
         assert_let!(VectorDiff::Set { index: 1, value: item } = &timeline_updates[0]);
 
-        let reactions = item.as_event().unwrap().content().reactions();
+        let reactions = item.as_event().unwrap().content().reactions().cloned().unwrap_or_default();
         assert_eq!(reactions.len(), 1);
         assert_matches!(
             &reactions.get("👍").unwrap().get(user_id).unwrap().status,
@@ -138,7 +138,7 @@ async fn test_abort_before_being_sent() {
 
         assert_let!(VectorDiff::Set { index: 1, value: item } = &timeline_updates[0]);
 
-        let reactions = item.as_event().unwrap().content().reactions();
+        let reactions = item.as_event().unwrap().content().reactions().cloned().unwrap_or_default();
         assert_eq!(reactions.len(), 2);
         assert_matches!(
             &reactions.get("👍").unwrap().get(user_id).unwrap().status,
@@ -162,7 +162,7 @@ async fn test_abort_before_being_sent() {
 
         assert_let!(VectorDiff::Set { index: 1, value: item } = &timeline_updates[0]);
 
-        let reactions = item.as_event().unwrap().content().reactions();
+        let reactions = item.as_event().unwrap().content().reactions().cloned().unwrap_or_default();
         assert_eq!(reactions.len(), 1);
         assert_matches!(
             &reactions.get("🥰").unwrap().get(user_id).unwrap().status,
@@ -182,7 +182,7 @@ async fn test_abort_before_being_sent() {
 
         assert_let!(VectorDiff::Set { index: 1, value: item } = &timeline_updates[0]);
 
-        let reactions = item.as_event().unwrap().content().reactions();
+        let reactions = item.as_event().unwrap().content().reactions().cloned().unwrap_or_default();
         assert!(reactions.is_empty());
 
         assert_pending!(stream);
@@ -237,13 +237,16 @@ async fn test_redact_failed() {
 
         let item = item.as_event().unwrap();
         assert_eq!(item.content().as_message().unwrap().body(), "hello");
-        assert!(item.content().reactions().is_empty());
+        assert!(item.content().reactions().cloned().unwrap_or_default().is_empty());
 
         item.identifier()
     };
 
     assert_let!(VectorDiff::Set { index: 0, value: item } = &timeline_updates[1]);
-    assert_eq!(item.as_event().unwrap().content().reactions().len(), 1);
+    assert_eq!(
+        item.as_event().unwrap().content().reactions().cloned().unwrap_or_default().len(),
+        1
+    );
 
     assert_let!(VectorDiff::PushFront { value: date_divider } = &timeline_updates[2]);
     assert!(date_divider.is_date_divider());
@@ -259,11 +262,14 @@ async fn test_redact_failed() {
 
     // The local echo is removed (assuming the redaction works)…
     assert_let!(VectorDiff::Set { index: 1, value: item } = &timeline_updates[0]);
-    assert!(item.as_event().unwrap().content().reactions().is_empty());
+    assert!(item.as_event().unwrap().content().reactions().cloned().unwrap_or_default().is_empty());
 
     // …then added back, after redaction failed.
     assert_let!(VectorDiff::Set { index: 1, value: item } = &timeline_updates[1]);
-    assert_eq!(item.as_event().unwrap().content().reactions().len(), 1);
+    assert_eq!(
+        item.as_event().unwrap().content().reactions().cloned().unwrap_or_default().len(),
+        1
+    );
 
     tokio::time::sleep(Duration::from_millis(150)).await;
     assert_pending!(stream);
@@ -326,7 +332,7 @@ async fn test_local_reaction_to_local_echo() {
         assert_matches!(item.send_state(), Some(EventSendState::NotSentYet));
 
         assert_eq!(item.content().as_message().unwrap().body(), "lol");
-        assert!(item.content().reactions().is_empty());
+        assert!(item.content().reactions().cloned().unwrap_or_default().is_empty());
 
         // Good ol' date divider.
         assert_let!(VectorDiff::PushFront { value: date_divider } = &timeline_updates[1]);
@@ -351,7 +357,7 @@ async fn test_local_reaction_to_local_echo() {
         assert!(item.is_local_echo());
         assert_matches!(item.send_state(), Some(EventSendState::NotSentYet));
 
-        let reactions = item.content().reactions();
+        let reactions = item.content().reactions().cloned().unwrap_or_default();
         assert_eq!(reactions.len(), 1);
         let reaction_info = reactions.get(key1).unwrap().get(user_id).unwrap();
         assert_matches!(&reaction_info.status, ReactionStatus::LocalToLocal(..));
@@ -373,7 +379,7 @@ async fn test_local_reaction_to_local_echo() {
         assert!(item.is_local_echo());
         assert_matches!(item.send_state(), Some(EventSendState::NotSentYet));
 
-        let reactions = item.content().reactions();
+        let reactions = item.content().reactions().cloned().unwrap_or_default();
         assert_eq!(reactions.len(), 2);
         let reaction_info = reactions.get(key2).unwrap().get(user_id).unwrap();
         assert_matches!(&reaction_info.status, ReactionStatus::LocalToLocal(..));
@@ -394,7 +400,7 @@ async fn test_local_reaction_to_local_echo() {
         assert!(item.is_local_echo());
         assert_matches!(item.send_state(), Some(EventSendState::NotSentYet));
 
-        let reactions = item.content().reactions();
+        let reactions = item.content().reactions().cloned().unwrap_or_default();
         assert_eq!(reactions.len(), 1);
         let reaction_info = reactions.get(key1).unwrap().get(user_id).unwrap();
         assert_matches!(&reaction_info.status, ReactionStatus::LocalToLocal(..));
@@ -414,7 +420,7 @@ async fn test_local_reaction_to_local_echo() {
         assert!(item.is_local_echo());
         assert_matches!(item.send_state(), Some(EventSendState::Sent { .. }));
 
-        let reactions = item.content().reactions();
+        let reactions = item.content().reactions().cloned().unwrap_or_default();
         assert_eq!(reactions.len(), 1);
         let reaction_info = reactions.get(key1).unwrap().get(user_id).unwrap();
         // TODO(bnjbvr): why not LocalToRemote here?
@@ -426,7 +432,7 @@ async fn test_local_reaction_to_local_echo() {
 
     // And then the remote echo for the reaction itself.
     assert_let!(VectorDiff::Set { index: 1, value: item } = &timeline_updates[0]);
-    let reactions = item.as_event().unwrap().content().reactions();
+    let reactions = item.as_event().unwrap().content().reactions().cloned().unwrap_or_default();
     assert_eq!(reactions.len(), 1);
     let reaction_info = reactions.get(key1).unwrap().get(user_id).unwrap();
     assert_matches!(&reaction_info.status, ReactionStatus::RemoteToRemote(..));
diff --git a/testing/matrix-sdk-integration-testing/src/tests/timeline.rs b/testing/matrix-sdk-integration-testing/src/tests/timeline.rs
index a8c93d4b187..24e55e6eb8f 100644
--- a/testing/matrix-sdk-integration-testing/src/tests/timeline.rs
+++ b/testing/matrix-sdk-integration-testing/src/tests/timeline.rs
@@ -186,7 +186,7 @@ async fn test_toggling_reaction() -> Result<()> {
         // Local echo is added.
         {
             let event = assert_event_is_updated!(timeline_updates[0], event_id, message_position);
-            let reactions = event.content().reactions();
+            let reactions = event.content().reactions().cloned().unwrap_or_default();
             let reactions = reactions.get(&reaction_key).unwrap();
             let reaction = reactions.get(&user_id).unwrap();
             assert_matches!(reaction.status, ReactionStatus::LocalToRemote(..));
@@ -196,7 +196,7 @@ async fn test_toggling_reaction() -> Result<()> {
         {
             let event = assert_event_is_updated!(timeline_updates[1], event_id, message_position);
 
-            let reactions = event.content().reactions();
+            let reactions = event.content().reactions().cloned().unwrap_or_default();
             let reactions = reactions.get(&reaction_key).unwrap();
             assert_eq!(reactions.keys().count(), 1);
 
@@ -223,7 +223,7 @@ async fn test_toggling_reaction() -> Result<()> {
 
         // The reaction is removed.
         let event = assert_event_is_updated!(timeline_updates[0], event_id, message_position);
-        assert!(event.content().reactions().is_empty());
+        assert!(event.content().reactions().cloned().unwrap_or_default().is_empty());
 
         assert_pending!(stream);
     }


Reviews:
user: login: Hywan
body: It's a big PR with many changes. I've spotted a couple of optimisations. Approving ahead of time to avoid another round a review. The main purpose of this patch is reached I think. Good job!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-19T15:13:59Z
user: login: bnjbvr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T08:44:42Z
user: login: bnjbvr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T10:53:01Z
user: login: bnjbvr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T11:03:45Z
user: login: bnjbvr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T11:18:24Z
user: login: bnjbvr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T11:35:52Z

Review comments:
diff_hunk: @@ -156,31 +161,29 @@ impl Aggregation {
                 if event.content().is_redacted() {
                     ApplyAggregationResult::LeftItemIntact
                 } else {
-                    *event = event.redact(room_version);
+                    let new_item = event.redact(room_version);
+                    *event = Cow::Owned(new_item);
                     ApplyAggregationResult::UpdatedItem
                 }
             }
 
-            AggregationKind::PollEnd { end_date } => {
-                match poll_state_from_item(event.content_mut()) {
-                    Ok(state) => {
-                        if !state.end(*end_date) {
-                            return ApplyAggregationResult::Error(
-                                AggregationError::PollAlreadyEnded,
-                            );
-                        }
-                        ApplyAggregationResult::UpdatedItem
+            AggregationKind::PollEnd { end_date } => match poll_state_from_item(event) {
+                Ok(state) => {
+                    if !state.end(*end_date) {
+                        return ApplyAggregationResult::Error(AggregationError::PollAlreadyEnded);
                     }
-                    Err(err) => ApplyAggregationResult::Error(err),
+                    ApplyAggregationResult::UpdatedItem
                 }
-            }
+                Err(err) => ApplyAggregationResult::Error(err),
+            },
 
             AggregationKind::Reaction { key, sender, timestamp, reaction_status } => {
-                let Some(reactions) = event.content_mut().reactions_mut() else {
+                let Some(reactions) = event.content().reactions() else {
                     // These items don't hold reactions.
                     return ApplyAggregationResult::LeftItemIntact;
                 };
 
+                let mut reactions = reactions.clone();
path: crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs
user: login: Hywan
body: I suspect it is possible to rewrite this part without the clone.

Right now, the code does the following:

* clone all reactions
* find a specific reaction or create it if it doesn't exist, insert a new sender for this reaction
* if this reaction for this sender was present, `previous_reaction` equals `Some(…)`
* we check the condition to compute `is_same`
* if `is_same` is true, we return `LeftItemIntact`, otherwise we get the reactions as a mutable, and update it.

The proposal is the following:

* get the reactions as a mutable directly
* check if the specific reaction exists with `entry` without calling `insert`
* if it returns `None`, we can early return with `LeftItemIntact`, otherwise compute `is_same` and then `insert` if needed

I believe that `get` (or `entry`) + `insert` later is cheaper than a clone here. Something like this:

```rust
let Some(mut reactions) = event.content_mut().reactions_mut() else { … }

let reaction_info = ReactionInfo { timestamp: *timestamp, status: reaction_status.clone() };

match reactions.entry(key.clone()) {
    Entry::Vacant(entry) => {
        entry.insert(sender.clone(), reaction_info);
        ApplyAggregationResult::UpdatedItem
    }
    Entry::Occupied(entry) => {
        let mut prev = entry.get_mut();
        let is_same = prev.timestmp == *timestamp && matches!(…);
        if is_same {
            ApplyAggregationResult::LeftItemIntact
        } else {
            prev.insert(sender.clone(), reaction_info);
            ApplyAggregationResult::UpdatedItem
        }
    }
}
```

Something like this. Thoughts?
        
created_at: 2025-05-19T14:55:28Z
author_association: MEMBER
diff_hunk: @@ -88,6 +118,8 @@ pub(crate) enum AggregationKind {
 
     /// An event has been redacted.
     Redaction,
+
+    Edit(PendingEdit),
path: crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs
user: login: Hywan
body: Documentation please 🙂.
created_at: 2025-05-19T14:57:35Z
author_association: MEMBER
diff_hunk: @@ -377,6 +419,23 @@ impl Aggregations {
                 ApplyAggregationResult::Error(err) => {
                     warn!("error when unapplying aggregation: {err}");
                 }
+                ApplyAggregationResult::Edit => {
+                    // This edit has been removed; try to find another that still applies.
+                    if let Some(aggregations) = self.related_events.get(found) {
+                        if resolve_edits(aggregations, items, &mut cowed) {
+                            items.replace(
+                                item_pos,
+                                TimelineItem::new(cowed.into_owned(), item.internal_id.to_owned()),
+                            );
+                        } else {
+                            // No other edit was found, leave the item as is.
+                            // TODO likely need to change the item to indicate
path: crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs
user: login: Hywan
body: Has it been addressed?
created_at: 2025-05-19T14:59:46Z
author_association: MEMBER
diff_hunk: @@ -492,7 +492,14 @@ async fn test_edit_to_replied_updates_reply() {
 
     yield_now().await; // let the send queue handle the edit.
 
-    // The reply events are updated with the edited replied-to content.
+    // The edit happens.
+    assert_next_matches!(timeline_stream, VectorDiff::Set { index: 0, value } => {
+        let msg = value.content().as_message().unwrap();
+        assert_eq!(msg.body(), "hello world");
+        assert!(msg.is_edited());
+    });
+
+    // And then the reply events are updated with the edited replied-to content.
path: crates/matrix-sdk-ui/tests/integration/timeline/edit.rs
user: login: Hywan
body: It's cool to have these effects in that order. It seems a bit more as expected.
created_at: 2025-05-19T15:01:46Z
author_association: MEMBER
diff_hunk: @@ -491,6 +493,8 @@ impl TimelineAction {
                                         new_content,
                                     )),
                                     edit_json,
+                                    // TODO maybe we could provide it?
path: crates/matrix-sdk-ui/src/timeline/event_handler.rs
user: login: Hywan
body: Has it been addressed?
created_at: 2025-05-19T15:02:15Z
author_association: MEMBER
diff_hunk: @@ -416,6 +416,8 @@ impl TimelineAction {
                                         new_content,
                                     )),
                                     edit_json,
+                                    // TODO maybe we could provide it?
+                                    encryption_info: None,
path: crates/matrix-sdk-ui/src/timeline/event_handler.rs
user: login: Hywan
body: Has it been addressed?
created_at: 2025-05-19T15:02:17Z
author_association: MEMBER
diff_hunk: @@ -79,9 +77,6 @@ pub(in crate::timeline) struct TimelineMetadata {
     /// Given an event, what are all the events that are replies to it?
     pub replies: HashMap<OwnedEventId, BTreeSet<OwnedEventId>>,
 
-    /// Edit events received before the related event they're editing.
-    pub pending_edits: RingBuffer<PendingEdit>,
path: crates/matrix-sdk-ui/src/timeline/controller/metadata.rs
user: login: Hywan
body: 🎉 
created_at: 2025-05-19T15:04:01Z
author_association: MEMBER
diff_hunk: @@ -1947,7 +1947,16 @@ impl AllRemoteEvents {
 
     /// Get the position of an event in the events array by its ID.
     pub fn position_by_event_id(&self, event_id: &EventId) -> Option<usize> {
-        self.0.iter().position(|event_meta| event_meta.event_id == event_id)
+        // Reverse the iterator to start looking at the end. Since this will give us the
+        // "reverse" position, reverse the index after finding the event.
+        //
+        // If the reversed index was 0 in a array of size 1, then it should stay 0, so
+        // that justifies the `- 1` in the formula below.
+        self.0
+            .iter()
+            .rev()
+            .position(|event_meta| event_meta.event_id == event_id)
+            .map(|rev_index| self.0.len() - 1 - rev_index)
path: crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs
user: login: Hywan
body: Could be simplified as:

```rust
self.0
    .iter()
    .enumerate()
    .rev()
    .find_map(|(index, event_meta)| { (event_meta.event_id == event_id).then_some(index))
```

So that you don't need to do the math yourself.

It works because [`Enumerate` implements `DoubleEndedIterator`](https://doc.rust-lang.org/std/iter/struct.Enumerate.html#impl-DoubleEndedIterator-for-Enumerate%3CI%3E).
created_at: 2025-05-19T15:10:49Z
author_association: MEMBER
diff_hunk: @@ -75,6 +75,8 @@ pub(in crate::timeline) struct TimelineMetadata {
     pub aggregations: Aggregations,
 
     /// Given an event, what are all the events that are replies to it?
+    ///
+    /// Only works for remote events *and* replies which are remote-echoed.
path: crates/matrix-sdk-ui/src/timeline/controller/metadata.rs
user: login: Hywan
body: Should we rename `replies` to `remote_replies`? Not convinced, just asking.
created_at: 2025-05-19T15:12:32Z
author_association: MEMBER
diff_hunk: @@ -75,6 +75,8 @@ pub(in crate::timeline) struct TimelineMetadata {
     pub aggregations: Aggregations,
 
     /// Given an event, what are all the events that are replies to it?
+    ///
+    /// Only works for remote events *and* replies which are remote-echoed.
path: crates/matrix-sdk-ui/src/timeline/controller/metadata.rs
user: login: bnjbvr
body: It's unclear in `remote_replies` which side is `remote`; in this case, it's both the original and the reply which must have been remote-echoed, and I think this information is more clearly described from the code comments + types used (only `OwnedEventId`, not `TimelineEventItemId` anywhere). If you don't mind too much, I'll keep this name.
created_at: 2025-05-20T08:44:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -377,6 +419,23 @@ impl Aggregations {
                 ApplyAggregationResult::Error(err) => {
                     warn!("error when unapplying aggregation: {err}");
                 }
+                ApplyAggregationResult::Edit => {
+                    // This edit has been removed; try to find another that still applies.
+                    if let Some(aggregations) = self.related_events.get(found) {
+                        if resolve_edits(aggregations, items, &mut cowed) {
+                            items.replace(
+                                item_pos,
+                                TimelineItem::new(cowed.into_owned(), item.internal_id.to_owned()),
+                            );
+                        } else {
+                            // No other edit was found, leave the item as is.
+                            // TODO likely need to change the item to indicate
path: crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs
user: login: bnjbvr
body: No, but undoing an edit (because it was a local echo that's been cancelled, or it's been redacted) is likely an edge case, and something we're fine supporting in the short term. Will open an issue though :+1: 
created_at: 2025-05-20T10:53:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1947,7 +1947,16 @@ impl AllRemoteEvents {
 
     /// Get the position of an event in the events array by its ID.
     pub fn position_by_event_id(&self, event_id: &EventId) -> Option<usize> {
-        self.0.iter().position(|event_meta| event_meta.event_id == event_id)
+        // Reverse the iterator to start looking at the end. Since this will give us the
+        // "reverse" position, reverse the index after finding the event.
+        //
+        // If the reversed index was 0 in a array of size 1, then it should stay 0, so
+        // that justifies the `- 1` in the formula below.
+        self.0
+            .iter()
+            .rev()
+            .position(|event_meta| event_meta.event_id == event_id)
+            .map(|rev_index| self.0.len() - 1 - rev_index)
path: crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs
user: login: bnjbvr
body: Good idea, thanks!
created_at: 2025-05-20T11:03:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -156,31 +161,29 @@ impl Aggregation {
                 if event.content().is_redacted() {
                     ApplyAggregationResult::LeftItemIntact
                 } else {
-                    *event = event.redact(room_version);
+                    let new_item = event.redact(room_version);
+                    *event = Cow::Owned(new_item);
                     ApplyAggregationResult::UpdatedItem
                 }
             }
 
-            AggregationKind::PollEnd { end_date } => {
-                match poll_state_from_item(event.content_mut()) {
-                    Ok(state) => {
-                        if !state.end(*end_date) {
-                            return ApplyAggregationResult::Error(
-                                AggregationError::PollAlreadyEnded,
-                            );
-                        }
-                        ApplyAggregationResult::UpdatedItem
+            AggregationKind::PollEnd { end_date } => match poll_state_from_item(event) {
+                Ok(state) => {
+                    if !state.end(*end_date) {
+                        return ApplyAggregationResult::Error(AggregationError::PollAlreadyEnded);
                     }
-                    Err(err) => ApplyAggregationResult::Error(err),
+                    ApplyAggregationResult::UpdatedItem
                 }
-            }
+                Err(err) => ApplyAggregationResult::Error(err),
+            },
 
             AggregationKind::Reaction { key, sender, timestamp, reaction_status } => {
-                let Some(reactions) = event.content_mut().reactions_mut() else {
+                let Some(reactions) = event.content().reactions() else {
                     // These items don't hold reactions.
                     return ApplyAggregationResult::LeftItemIntact;
                 };
 
+                let mut reactions = reactions.clone();
path: crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs
user: login: bnjbvr
body: It's possible to do this, but there's one change in the code excerpt you posted that would be needed:

```
let Some(mut reactions) = event.to_mut().content_mut().reactions_mut() else { … }
```

This `to_mut()` eagerly marks the `Cow` as written to, so eagerly clones the underlying content if it was borrowed. In that case, we might be cloning even though the final item isn't actually mutated. So we'd need to do another dance:

- on the read-only version of reaction, check first if there's a similar reaction
- if not, do the mutable transition and add it.

I've done that locally; it's a bit more code, but it's OK and should avoid a few useless clones in some edge cases. 
created_at: 2025-05-20T11:18:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -491,6 +493,8 @@ impl TimelineAction {
                                         new_content,
                                     )),
                                     edit_json,
+                                    // TODO maybe we could provide it?
path: crates/matrix-sdk-ui/src/timeline/event_handler.rs
user: login: bnjbvr
body: It can be done, I'll open a followup PR to keep changes tidy.
created_at: 2025-05-20T11:35:52Z
author_association: CONTRIBUTOR

PR comments:
user: login: bnjbvr
created_at: 2025-05-19T10:56:30Z
body: Now rebased and ready for review! Even though there are many, the changes ought to be digestible in a commit per commit read-through.
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-05-19T11:09:15Z
body: ## [Codecov](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/5046?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org) Report
Attention: Patch coverage is `84.30233%` with `54 lines` in your changes missing coverage. Please review.
> Project coverage is 85.94%. Comparing base [(`e3bcd4d`)](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/commit/e3bcd4d5b2dde00f88813d5866d0c6e1b9c86d83?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org) to head [(`2adfa00`)](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/commit/2adfa008f9d90d7dc1ea441268b2ec7821ba01c8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org).
> Report is 39 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/5046?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org) | Patch % | Lines |
|---|---|---|
| [...rix-sdk-ui/src/timeline/controller/aggregations.rs](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/5046?src=pr&el=tree&filepath=crates%2Fmatrix-sdk-ui%2Fsrc%2Ftimeline%2Fcontroller%2Faggregations.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org#diff-Y3JhdGVzL21hdHJpeC1zZGstdWkvc3JjL3RpbWVsaW5lL2NvbnRyb2xsZXIvYWdncmVnYXRpb25zLnJz) | 82.60% | [28 Missing :warning: ](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/5046?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org) |
| [crates/matrix-sdk-ui/src/timeline/event\_handler.rs](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/5046?src=pr&el=tree&filepath=crates%2Fmatrix-sdk-ui%2Fsrc%2Ftimeline%2Fevent_handler.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org#diff-Y3JhdGVzL21hdHJpeC1zZGstdWkvc3JjL3RpbWVsaW5lL2V2ZW50X2hhbmRsZXIucnM=) | 88.97% | [14 Missing :warning: ](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/5046?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org) |
| [...rates/matrix-sdk-ui/src/timeline/controller/mod.rs](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/5046?src=pr&el=tree&filepath=crates%2Fmatrix-sdk-ui%2Fsrc%2Ftimeline%2Fcontroller%2Fmod.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org#diff-Y3JhdGVzL21hdHJpeC1zZGstdWkvc3JjL3RpbWVsaW5lL2NvbnRyb2xsZXIvbW9kLnJz) | 65.62% | [11 Missing :warning: ](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/5046?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org) |
| [...trix-sdk-ui/src/timeline/event\_item/content/mod.rs](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/5046?src=pr&el=tree&filepath=crates%2Fmatrix-sdk-ui%2Fsrc%2Ftimeline%2Fevent_item%2Fcontent%2Fmod.rs&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org#diff-Y3JhdGVzL21hdHJpeC1zZGstdWkvc3JjL3RpbWVsaW5lL2V2ZW50X2l0ZW0vY29udGVudC9tb2QucnM=) | 92.30% | [1 Missing :warning: ](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/5046?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #5046      +/-   ##
==========================================
+ Coverage   85.85%   85.94%   +0.09%     
==========================================
  Files         325      325              
  Lines       35937    35941       +4     
==========================================
+ Hits        30852    30889      +37     
+ Misses       5085     5052      -33     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/matrix-org/matrix-rust-sdk/pull/5046?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=matrix-org).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE