Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c288212
feat(send_queue): send redactions via the send queue
Johennes Mar 14, 2026
ba8f1bb
feat(timeline): handle local echoes of redactions
Johennes Mar 14, 2026
9fbe8d3
fixup! feat(timeline): handle local echoes of redactions
Johennes Mar 18, 2026
40f42ff
fixup! feat(timeline): handle local echoes of redactions
Johennes Mar 18, 2026
08e764b
fixup! feat(timeline): handle local echoes of redactions
Johennes Mar 18, 2026
d34a773
fixup! feat(timeline): handle local echoes of redactions
Johennes Mar 18, 2026
1d21e85
fixup! feat(send_queue): send redactions via the send queue
Johennes Mar 18, 2026
9719663
fixup! feat(send_queue): send redactions via the send queue
Johennes Mar 18, 2026
75896b2
Merge branch 'main' into johannes/send-queue-redactions
Johennes Mar 18, 2026
de1fae3
fixup! feat(send_queue): send redactions via the send queue
Johennes Mar 20, 2026
5007d2f
fixup! feat(send_queue): send redactions via the send queue
Johennes Mar 20, 2026
112ea05
fixup! feat(send_queue): send redactions via the send queue
Johennes Mar 20, 2026
44bc666
Merge branch 'main' into johannes/send-queue-redactions
Johennes Mar 20, 2026
4560ac8
fixup! feat(timeline): handle local echoes of redactions
Johennes Mar 23, 2026
b2007a6
Merge branch 'main' into johannes/send-queue-redactions
Johennes Mar 23, 2026
a25d0ed
Revert "fixup! feat(timeline): handle local echoes of redactions"
Johennes Mar 30, 2026
8e60190
fixup! feat(timeline): handle local echoes of redactions
Johennes Mar 30, 2026
6a4a0af
Merge branch 'main' into johannes/send-queue-redactions
Johennes Mar 31, 2026
78c4707
fixup! feat(timeline): handle local echoes of redactions
Johennes Mar 31, 2026
fc3fa36
fixup! feat(timeline): handle local echoes of redactions
Johennes Mar 31, 2026
78eb03d
fixup! feat(send_queue): send redactions via the send queue
Johennes Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion crates/matrix-sdk-base/src/store/send_queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ pub enum QueuedRequestKind {
#[serde(default)]
accumulated: Vec<AccumulatedSentMediaInfo>,
},

/// A redaction of another event to send.
Redaction {
/// The ID of the event to redact.
redacts: OwnedEventId,
/// The reason for the event being redacted.
reason: Option<String>,
},
}

impl From<SerializableEventContent> for QueuedRequestKind {
Expand Down Expand Up @@ -421,12 +429,27 @@ pub enum SentRequestKey {

/// The parent transaction returned an uploaded resource URL.
Media(SentMediaInfo),

/// The parent transaction returned a redaction event when it succeeded.
Redaction {
/// The event ID returned by the server.
event_id: OwnedEventId,

/// The ID of the redacted event.
redacts: OwnedEventId,

/// The reason for the event being redacted.
reason: Option<String>,
},
}

impl SentRequestKey {
/// Converts the current parent key into an event id, if possible.
pub fn into_event_id(self) -> Option<OwnedEventId> {
as_variant!(self, Self::Event { event_id, .. } => event_id)
match self {
Self::Event { event_id, .. } | Self::Redaction { event_id, .. } => Some(event_id),
_ => None,
}
}

/// Converts the current parent key into information about a sent media, if
Expand Down
2 changes: 2 additions & 0 deletions crates/matrix-sdk-ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file.

### Bug Fixes

- Handle local echoes of redactions in the timeline.
([#6250](https://github.com/matrix-org/matrix-rust-sdk/pull/6250))
- Include secondary relations when re-initializing a threaded timeline after a lag.
([#6209](https://github.com/matrix-org/matrix-rust-sdk/pull/6209))
- Ensure that the display name of a `Room` in a `NotificationStatus` coming
Expand Down
40 changes: 30 additions & 10 deletions crates/matrix-sdk-ui/src/timeline/controller/aggregations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,12 @@ pub(crate) enum AggregationKind {
},

/// An event has been redacted.
Redaction,
Redaction {
/// Whether this aggregation results from the local echo of a redaction.
/// Local echoes of redactions are applied reversibly whereas remote
/// echoes of redactions are applied irreversibly.
is_local: bool,
},

/// An event has been edited.
///
Expand Down Expand Up @@ -237,11 +242,15 @@ impl Aggregation {
}
}

AggregationKind::Redaction => {
if event.content().is_redacted() {
AggregationKind::Redaction { is_local } => {
let is_local_redacted =
event.content().is_redacted() && event.unredacted_content.is_some();
let is_remote_redacted =
event.content().is_redacted() && event.unredacted_content.is_none();
if *is_local && is_local_redacted || !*is_local && is_remote_redacted {
ApplyAggregationResult::LeftItemIntact
} else {
let new_item = event.redact(&rules.redaction);
let new_item = event.redact(&rules.redaction, *is_local);
*event = Cow::Owned(new_item);
ApplyAggregationResult::UpdatedItem
}
Expand Down Expand Up @@ -352,9 +361,20 @@ impl Aggregation {
ApplyAggregationResult::Error(AggregationError::CantUndoPollEnd)
}

AggregationKind::Redaction => {
// Redactions are not reversible.
ApplyAggregationResult::Error(AggregationError::CantUndoRedaction)
AggregationKind::Redaction { is_local } => {
if *is_local {
if event.unredacted_content.is_some() {
// Unapply local redaction.
*event = Cow::Owned(event.unredact());
ApplyAggregationResult::UpdatedItem
} else {
// Event isn't locally redacted. Nothing to do.
ApplyAggregationResult::LeftItemIntact
}
} else {
// Remote redactions are not reversible.
ApplyAggregationResult::Error(AggregationError::CantUndoRedaction)
}
}

AggregationKind::Reaction { key, sender, .. } => {
Expand Down Expand Up @@ -477,7 +497,7 @@ impl Aggregations {
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) {
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);
}
Expand All @@ -488,7 +508,7 @@ impl Aggregations {
if let Some(previous_aggregations) = self.related_events.get(&related_to)
&& previous_aggregations
.iter()
.any(|agg| matches!(agg.kind, AggregationKind::Redaction))
.any(|agg| matches!(agg.kind, AggregationKind::Redaction { .. }))
{
return;
}
Expand Down Expand Up @@ -698,7 +718,7 @@ impl Aggregations {
AggregationKind::PollResponse { .. }
| AggregationKind::PollEnd { .. }
| AggregationKind::Edit(..)
| AggregationKind::Redaction
| AggregationKind::Redaction { .. }
| AggregationKind::BeaconUpdate { .. }
| AggregationKind::BeaconStop { .. } => {
// Nothing particular to do.
Expand Down
45 changes: 45 additions & 0 deletions crates/matrix-sdk-ui/src/timeline/controller/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,23 @@ impl<P: RoomDataProvider> TimelineController<P> {
LocalEchoContent::React { key, send_handle, applies_to } => {
self.handle_local_reaction(key, send_handle, applies_to).await;
}

LocalEchoContent::Redaction { redacts, send_error, .. } => {
self.handle_local_redaction(echo.transaction_id.clone(), redacts).await;

if let Some(send_error) = send_error {
self.update_event_send_state(
&echo.transaction_id,
EventSendState::SendingFailed {
error: Arc::new(matrix_sdk::Error::SendQueueWedgeError(Box::new(
send_error,
))),
is_recoverable: false,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this bool be based on a field of send_error somehow?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I think not. This is actually copied from the LocalEchoContent::Event match-arm a few lines above. It seems that send_error is of type QueueWedgeError which appears to always be unrecoverable.

/// Represents a failed to send unrecoverable error of an event sent via the
/// send queue.

},
)
.await;
}
}
}
}

Expand Down Expand Up @@ -1308,6 +1325,34 @@ impl<P: RoomDataProvider> TimelineController<P> {
tr.commit();
}

/// Applies a local echo of a redaction.
pub(super) async fn handle_local_redaction(
&self,
txn_id: OwnedTransactionId,
redacts: OwnedEventId,
) {
let mut state = self.state.write().await;
let mut tr = state.transaction();

let target = TimelineEventItemId::EventId(redacts);

let aggregation = Aggregation::new(
TimelineEventItemId::TransactionId(txn_id),
AggregationKind::Redaction { is_local: true },
);

tr.meta.aggregations.add(target.clone(), aggregation.clone());
find_item_and_apply_aggregation(
&tr.meta.aggregations,
&mut tr.items,
&target,
aggregation,
&tr.meta.room_version_rules,
);

tr.commit();
}

/// Handle a single room send queue update.
pub(crate) async fn handle_room_send_queue_update(&self, update: RoomSendQueueUpdate) {
match update {
Expand Down
8 changes: 6 additions & 2 deletions crates/matrix-sdk-ui/src/timeline/event_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -834,8 +834,12 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
}

let target = TimelineEventItemId::EventId(redacted.clone());
let aggregation =
Aggregation::new(self.ctx.flow.timeline_item_id(), AggregationKind::Redaction);
let aggregation = Aggregation::new(
self.ctx.flow.timeline_item_id(),
AggregationKind::Redaction {
is_local: false, // We can only get here for remote echoes of redactions.
},
);
self.meta.aggregations.add(target.clone(), aggregation.clone());

find_item_and_apply_aggregation(
Expand Down
33 changes: 31 additions & 2 deletions crates/matrix-sdk-ui/src/timeline/event_item/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,13 @@ pub struct EventTimelineItem {
pub(super) forwarder_profile: Option<TimelineDetails<Profile>>,
/// The timestamp of the event.
pub(super) timestamp: MilliSecondsSinceUnixEpoch,
/// The content of the event.
/// The content of the event. Might be redacted if a redaction for this
/// event is currently being sent or has been received from the server.
pub(super) content: TimelineItemContent,
/// If a redaction for this event is currently being sent but the server
/// hasn't yet acknowledged it via its remote echo, the original content
/// before redaction. Otherwise, None.
pub(super) unredacted_content: Option<TimelineItemContent>,
Comment on lines +85 to +89
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize I forgot to ask the previous time, but could we store the unredacted_content in MsgLikeKind::Redacted, or would it make the code super hard to read, or expose an internal implementation detail to the consumers of these APIs? I think it would be nice in that it would make it impossible to represent impossible states, but maybe it complicates things a bit too much, WDYT?

Copy link
Copy Markdown
Contributor Author

@Johennes Johennes Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I'm probably entirely misunderstanding but does unredacted_content not necessarily need to hold just a normal TimelineItemContent with any MsgLikeKind because we want to be able to restore the original content upon unredaction?

Copy link
Copy Markdown
Contributor Author

@Johennes Johennes Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or wait, you mean move unredacted_content from here into MsgLikeKind::Redacted? But then we'd have a TimelineItemContent inside of MsgLikeKind?

Sorry, I'm most likely just being dense. 🙈

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sorry, my comment was about moving the unredacted content field inside MsgLikeKind::Redacted, if that's possible? We might need to box it, to avoid a recursive type here…

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I see. I gave it a shot in 4560ac8 but I'm not sure how well it turned out. A part of the change bled through to the FFI layer and it looks like the recursive type might cause some more issues there since neither Box nor an enum inside an Arc seem to work over FFI. I've left things unchanged there for now meaning local redactions will still look like remote redactions over the FFI.

/// The kind of event timeline item, local or remote.
pub(super) kind: EventTimelineItemKind,
/// Whether or not the event belongs to an encrypted room.
Expand Down Expand Up @@ -135,6 +140,7 @@ impl EventTimelineItem {
forwarder_profile,
timestamp,
content,
unredacted_content: None,
kind,
is_room_encrypted,
}
Expand Down Expand Up @@ -478,7 +484,7 @@ impl EventTimelineItem {
}

/// Create a clone of the current item, with content that's been redacted.
pub(super) fn redact(&self, rules: &RedactionRules) -> Self {
pub(super) fn redact(&self, rules: &RedactionRules, is_local: bool) -> Self {
let content = self.content.redact(rules);
let kind = match &self.kind {
EventTimelineItemKind::Local(l) => EventTimelineItemKind::Local(l.clone()),
Expand All @@ -491,6 +497,29 @@ impl EventTimelineItem {
forwarder_profile: self.forwarder_profile.clone(),
timestamp: self.timestamp,
content,
unredacted_content: is_local.then(|| self.content.clone()),
kind,
is_room_encrypted: self.is_room_encrypted,
}
}

/// Create a clone of the current item, with content restored from the
/// item's unredacted_content field (if it was previously set by a call to
/// the `redact(...)` method).
pub(super) fn unredact(&self) -> Self {
let Some(content) = &self.unredacted_content else { return self.clone() };
let kind = match &self.kind {
EventTimelineItemKind::Local(l) => EventTimelineItemKind::Local(l.clone()),
EventTimelineItemKind::Remote(r) => EventTimelineItemKind::Remote(r.redact()),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment that we can't unredact a remote item, that's why we're calling redact() here? (if that's the correct reasoning behind it)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, actually I think you've found a bug here. I just copied this from the redact method above. I'm now realizing that RemoteEventTimelineItem::redact also clears some things irreversibly that we may want to revert if a local redaction of such an item is reverted. I guess we may need an unredacted_kind member?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ouch. Makes sense to add such fields. Maybe we can group all the unredacted_ fields together, as an Option<UnredactedItem> or so? (If you can add a that shows what would have been missing, that'd be great!)

};
Self {
sender: self.sender.clone(),
sender_profile: self.sender_profile.clone(),
forwarder: self.forwarder.clone(),
forwarder_profile: self.forwarder_profile.clone(),
timestamp: self.timestamp,
content: content.clone(),
unredacted_content: None,
kind,
is_room_encrypted: self.is_room_encrypted,
}
Expand Down
6 changes: 6 additions & 0 deletions crates/matrix-sdk-ui/src/timeline/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ impl TestTimeline {
txn_id
}

async fn handle_local_redaction(&self, redacts: OwnedEventId) -> OwnedTransactionId {
let txn_id = TransactionId::new();
self.controller.handle_local_redaction(txn_id.clone(), redacts).await;
txn_id
}

async fn handle_back_paginated_event(&self, event: Raw<AnyTimelineEvent>) {
let timeline_event = TimelineEvent::from_plaintext(event.cast());
self.controller
Expand Down
32 changes: 32 additions & 0 deletions crates/matrix-sdk-ui/src/timeline/tests/redaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use ruma::{
StateEventContentChange, reaction::RedactedReactionEventContent,
room::message::OriginalSyncRoomMessageEvent,
},
owned_event_id,
};
use stream_assert::{assert_next_matches, assert_pending};

Expand Down Expand Up @@ -203,3 +204,34 @@ async fn test_reaction_redaction_timeline_filter() {
assert_eq!(item.content().reactions().cloned().unwrap_or_default().len(), 0);
assert_eq!(timeline.controller.items().await.len(), 2);
}

#[async_test]
async fn test_local_and_remote_echo_of_redaction() {
let timeline = TestTimeline::new();
let mut stream = timeline.subscribe_events().await;

let f = &timeline.factory;

// Send a message.
let event_id = owned_event_id!("$1");
timeline
.handle_live_event(f.text_msg("Hello, world!").sender(&ALICE).event_id(&event_id))
.await;
let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value);
assert!(!item.content().is_redacted());
assert!(item.unredacted_content.is_none());

// Now redact the message. We first emit the local echo of the redaction event.
// The timeline event should be marked as being under redaction.
timeline.handle_local_redaction(event_id.clone()).await;
let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value);
assert!(item.content().is_redacted());
assert!(item.unredacted_content.is_some());

// Then comes the remote echo of the redaction event. The timeline event should
// now be redacted.
timeline.handle_live_event(f.redaction(&event_id).sender(&ALICE)).await;
let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value);
assert!(item.content().is_redacted());
assert!(item.unredacted_content.is_none());
}
4 changes: 4 additions & 0 deletions crates/matrix-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.

### Features

- Enable sending redaction events through the send queue via `RoomSendQueue::send_redaction`.
This includes local echoes for redaction events through the new `LocalEchoContent::Redaction`
variant.
([#6250](https://github.com/matrix-org/matrix-rust-sdk/pull/6250))
- The `beacon_info` start event ([MSC3672](https://github.com/matrix-org/matrix-spec-proposals/pull/3672))
is now included when computing the latest event for a room, so live location sharing
sessions can be surfaced as a room's most recent activity.
Expand Down
5 changes: 5 additions & 0 deletions crates/matrix-sdk/src/event_cache/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,11 @@ async fn handle_thread_subscriber_send_queue_update(
// Nothing to do, reactions don't count as a thread
// subscription.
}

LocalEchoContent::Redaction { .. } => {
// Nothing to do, redactions don't count as a thread
// subscription.
}
}
return true;
}
Expand Down
Loading
Loading