fix(thread): Only apply valid edits in the thread summary

This commit is contained in:
Damir Jelić
2026-04-16 10:35:35 +02:00
parent 9e242abda9
commit 1e74c48351
2 changed files with 130 additions and 4 deletions
@@ -24,6 +24,7 @@ use eyeball::SharedObservable;
use eyeball_im::VectorDiff;
use matrix_sdk_base::{
RoomInfoNotableUpdateReasons, StateChanges, apply_redaction,
check_validity_of_replacement_events,
deserialized_responses::{ThreadSummary, ThreadSummaryStatus},
event_cache::{
Event, Gap,
@@ -1122,12 +1123,28 @@ impl<'a> RoomEventCacheStateLockWriteGuard<'a> {
// If there's an edit to the latest event in the thread, use the latest edit
// event id as the latest event id for the thread summary.
if let Some(event_id) = latest_event_id.as_ref()
&& let Some((_, edits)) = self
&& let Some((original_event, edits)) = self
.find_event_with_relations(event_id, Some(vec![RelationType::Replacement]))
.await?
&& let Some(latest_edit) = edits.last()
{
latest_event_id = latest_edit.event_id();
let latest_valid_edit = edits.into_iter().rfind(|edit| {
let original_json = original_event.raw();
let original_encryption_info = original_event.encryption_info();
let replacement_json = edit.raw();
let replacement_encryption_info = edit.encryption_info();
check_validity_of_replacement_events(
original_json,
original_encryption_info.map(|v| &**v),
replacement_json,
replacement_encryption_info.map(|v| &**v),
)
.is_ok()
});
if let Some(latest_valid_edit) = latest_valid_edit {
latest_event_id = latest_valid_edit.event_id();
}
}
self.maybe_update_thread_summary(thread_root, latest_event_id, post_processing_origin)
@@ -12,11 +12,14 @@ use matrix_sdk::{
assert_event_matches_msg,
mocks::{MatrixMockServer, RoomRelationsResponseTemplate},
},
timeout::timeout,
};
use matrix_sdk_test::{ALICE, JoinedRoomBuilder, async_test, event_factory::EventFactory};
use ruma::{
OwnedEventId, OwnedRoomId, event_id,
events::{AnySyncTimelineEvent, Mentions},
events::{
AnySyncTimelineEvent, Mentions, room::message::RoomMessageEventContentWithoutRelation,
},
push::{ConditionalPushRule, Ruleset},
room_id,
serde::Raw,
@@ -970,3 +973,109 @@ async fn test_redact_touches_threads() {
}
}
}
#[async_test]
async fn test_edits_touches_threads() {
// We start with a thread with some replies, then receive an edit and an invalid
// edit for the replies over sync. We observe that valid edits update the
// thread linked chunks as well as the thread summary on the thread root
// event. Invalid ones don't update the state.
let s = thread_subscription_test_setup().await;
let f = s.factory;
let event_cache = s.client.event_cache();
event_cache.subscribe().unwrap();
let thread_root_id = s.thread_root;
let room = s.server.sync_joined_room(&s.client, &s.room_id).await;
let (room_event_cache, _drop_handles) = room.event_cache().await.unwrap();
let (thread_events, mut thread_stream) =
room_event_cache.subscribe_to_thread(thread_root_id.to_owned()).await.unwrap();
// Receive a thread root, and a threaded reply.
s.server
.sync_room(
&s.client,
JoinedRoomBuilder::new(&s.room_id)
.add_timeline_event(f.text_msg("Talking to myself.").event_id(&thread_root_id))
.add_timeline_event(s.events[0].clone())
.add_timeline_event(s.events[1].clone()),
)
.await;
wait_for_initial_events(thread_events, &mut thread_stream).await;
let (room_events, mut room_stream) = room_event_cache.subscribe().await.unwrap();
// A valid edit for the first reply comes through sync.
let valid_edit_event_id = event_id!("$valid_edit");
s.server
.sync_room(
&s.client,
JoinedRoomBuilder::new(&s.room_id).add_timeline_event(
f.text_msg("Nobody speaks English anymore.").event_id(valid_edit_event_id).edit(
&room_events[2].event_id().unwrap(),
RoomMessageEventContentWithoutRelation::text_plain("edited text"),
),
),
)
.await;
// Edits are not emitted over the thread subscriber, the timeline uses the
// normal room stream to handle those.
//
// So we're going to look only at the room stream and see if the thread summary
// gets correctly updated.
{
// The room stream receives an update.
assert_let_timeout!(
Ok(RoomEventCacheUpdate::UpdateTimelineEvents(TimelineVectorDiffs { diffs, .. })) =
room_stream.recv()
);
assert_eq!(diffs.len(), 2);
// The edit gets appended to the stream.
assert_let!(VectorDiff::Append { values: new_events } = &diffs[0]);
assert_eq!(new_events.len(), 1);
assert_eq!(new_events[0].event_id().as_deref(), Some(valid_edit_event_id));
// The thread summary is updated.
{
assert_let!(VectorDiff::Set { index: 0, value: new_root } = &diffs[1]);
assert_eq!(new_root.event_id().as_ref(), Some(&thread_root_id));
let summary = new_root.thread_summary.summary().unwrap();
assert_eq!(summary.latest_reply.as_deref(), Some(valid_edit_event_id));
assert_eq!(summary.num_replies, 2);
}
}
// An invalid edit for the second reply comes through sync.
let invalid_edit_id = event_id!("$invalid_edit");
s.server
.sync_room(
&s.client,
JoinedRoomBuilder::new(&s.room_id).add_timeline_event(
f.text_msg("Nobody speaks english anymore.").event_id(invalid_edit_id).edit(
&room_events[2].event_id().unwrap(),
RoomMessageEventContentWithoutRelation::text_plain("edited text"),
),
),
)
.await;
// It's a bit hard to know when the update should have been ready. This makes it
// hard to prove that no update happened.
let result = timeout(room_stream.recv(), Duration::from_secs(1)).await;
assert!(result.is_err(), "The room stream should have timed out as the edit was invnalid");
let room_events = room_event_cache.events().await.unwrap();
let first = room_events.first().unwrap();
let thread_summary = first.thread_summary.summary().unwrap();
// The latest reply should still be our valid event, not our invalid one.
assert_eq!(thread_summary.latest_reply.as_deref(), Some(valid_edit_event_id));
}