Files
matrix-rust-sdk/crates/matrix-sdk-ui/tests/integration/timeline/thread.rs
2026-06-18 10:27:12 +02:00

2219 lines
82 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::{ops::Not as _, time::Duration};
use assert_matches2::{assert_let, assert_matches};
use eyeball_im::VectorDiff;
use futures_util::StreamExt as _;
use matrix_sdk::{
Client, ThreadingSupport, assert_let_timeout,
sleep::sleep,
test_utils::mocks::{
MatrixMockServer, RoomContextResponseTemplate, RoomRelationsResponseTemplate,
},
};
use matrix_sdk_test::{ALICE, BOB, JoinedRoomBuilder, async_test, event_factory::EventFactory};
use matrix_sdk_ui::timeline::{
EventSendState, RoomExt as _, TimelineBuilder, TimelineDetails, TimelineEventFocusThreadMode,
TimelineEventItemId, TimelineFocus, VirtualTimelineItem,
};
use ruma::{
MilliSecondsSinceUnixEpoch,
api::client::receipt::create_receipt::v3::ReceiptType as SendReceiptType,
event_id,
events::{
AnySyncTimelineEvent,
poll::unstable_start::{
NewUnstablePollStartEventContent, UnstablePollAnswer, UnstablePollStartContentBlock,
UnstablePollStartEventContent,
},
receipt::{ReceiptThread, ReceiptType},
room::{
ImageInfo,
message::{
Relation, ReplacementMetadata, RoomMessageEventContent,
RoomMessageEventContentWithoutRelation,
},
},
sticker::{StickerEventContent, StickerMediaSource},
},
owned_event_id, owned_mxc_uri, room_id, user_id,
};
use stream_assert::assert_pending;
use tokio::task::yield_now;
async fn client_with_threading_support(server: &MatrixMockServer) -> Client {
server
.client_builder()
.on_builder(|builder| {
builder.with_threading_support(ThreadingSupport::Enabled { with_subscriptions: false })
})
.build()
.await
}
#[async_test]
async fn test_new_empty_thread() {
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let thread_root_event_id = owned_event_id!("$root");
let room = server.sync_joined_room(&client, room_id).await;
let timeline = TimelineBuilder::new(&room)
.with_focus(TimelineFocus::Thread { root_event_id: thread_root_event_id })
.build()
.await
.unwrap();
let (items, mut timeline_stream) = timeline.subscribe().await;
// At first, there are no items in the thread timeline.
assert!(items.is_empty());
assert_pending!(timeline_stream);
}
#[async_test]
async fn test_thread_backpagination() {
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let sender_id = user_id!("@alice:b.c");
let factory = EventFactory::new().room(room_id).sender(sender_id);
let thread_root_event_id = owned_event_id!("$root");
server
.mock_room_event()
.match_event_id()
.ok(factory
.text_msg("Thread root")
.sender(sender_id)
.event_id(&thread_root_event_id)
.into())
.mock_once()
.mount()
.await;
let batch1 = vec![
factory
.text_msg("Threaded event 4")
.event_id(event_id!("$4"))
.in_thread_reply(&thread_root_event_id, event_id!("$2"))
.into_raw(),
factory
.text_msg("Threaded event 3")
.event_id(event_id!("$3"))
.in_thread(&thread_root_event_id, event_id!("$2"))
.into_raw(),
];
let batch2 = vec![
factory
.text_msg("Threaded event 2")
.event_id(event_id!("$2"))
.in_thread(&thread_root_event_id, event_id!("$1"))
.into_raw(),
factory
.text_msg("Threaded event 1")
.event_id(event_id!("$1"))
.in_thread(&thread_root_event_id, event_id!("$root"))
.into_raw(),
];
server
.mock_room_relations()
.match_target_event(thread_root_event_id.clone())
.ok(RoomRelationsResponseTemplate::default().events(batch1).next_batch("next_batch"))
.mock_once()
.mount()
.await;
server
.mock_room_relations()
.match_target_event(thread_root_event_id.clone())
.match_from("next_batch")
.ok(RoomRelationsResponseTemplate::default().events(batch2))
.mock_once()
.mount()
.await;
let room = server.sync_joined_room(&client, room_id).await;
let timeline = TimelineBuilder::new(&room)
.with_focus(TimelineFocus::Thread { root_event_id: thread_root_event_id.clone() })
.build()
.await
.unwrap();
let (items, mut timeline_stream) = timeline.subscribe().await;
// At first, there are no items at all.
assert!(items.is_empty());
assert_pending!(timeline_stream);
// We start a first pagination.
timeline.paginate_backwards(20).await.unwrap();
// We receive the two events from the first batch, plus a date divider.
assert_let_timeout!(Some(timeline_updates) = timeline_stream.next());
assert_eq!(timeline_updates.len(), 3);
{
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.content().as_message().unwrap().body(), "Threaded event 3");
// In a threaded timeline, threads aren't using the reply fallback, unless
// they're an actual reply to another thread event.
assert_matches!(event_item.content().in_reply_to(), None);
}
{
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.content().as_message().unwrap().body(), "Threaded event 4");
// But this one is an actual reply to another thread event, so it has the
// replied-to event correctly set.
assert_eq!(event_item.content().in_reply_to().unwrap().event_id, event_id!("$2"));
}
let hit_start = timeline.paginate_backwards(100).await.unwrap();
assert!(hit_start);
assert_let_timeout!(Some(timeline_updates) = timeline_stream.next());
// Remove date separator and insert a new one plus the remaining threaded
// events and the thread root.
assert_eq!(timeline_updates.len(), 3);
// Check the timeline diffs
assert_let!(VectorDiff::Insert { index: 1, value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_root_event_id);
assert_matches!(event_item.content().in_reply_to(), None);
assert_let!(VectorDiff::Insert { index: 2, value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), event_id!("$1"));
assert_matches!(event_item.content().in_reply_to(), None);
assert_let!(VectorDiff::Insert { index: 3, value } = &timeline_updates[2]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), event_id!("$2"));
assert_matches!(event_item.content().in_reply_to(), None);
// Check the final items
let items = timeline.items().await;
assert_eq!(items.len(), 6);
assert!(items[0].is_date_divider());
assert_eq!(items[1].as_event().unwrap().content().as_message().unwrap().body(), "Thread root");
assert_eq!(
items[2].as_event().unwrap().content().as_message().unwrap().body(),
"Threaded event 1"
);
assert_eq!(
items[3].as_event().unwrap().content().as_message().unwrap().body(),
"Threaded event 2"
);
assert_eq!(
items[4].as_event().unwrap().content().as_message().unwrap().body(),
"Threaded event 3"
);
assert_eq!(
items[5].as_event().unwrap().content().as_message().unwrap().body(),
"Threaded event 4"
);
}
#[async_test]
async fn test_extract_bundled_thread_summary() {
// A sync event that includes a bundled thread summary receives a
// `ThreadSummary` in the associated timeline content.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let room = server.sync_joined_room(&client, room_id).await;
let timeline = room.timeline().await.unwrap();
let (initial_items, mut stream) = timeline.subscribe().await;
assert!(initial_items.is_empty());
let f = EventFactory::new().room(room_id).sender(&ALICE);
let thread_event_id = event_id!("$thread_root");
let latest_event_id = event_id!("$latest_event");
let event = f
.text_msg("thready thread mcthreadface")
.with_bundled_thread_summary(
f.text_msg("the last one!").event_id(latest_event_id).into(),
42,
false,
)
.event_id(thread_event_id);
server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(event)).await;
assert_let_timeout!(Some(timeline_updates) = stream.next());
// Message+day divider.
assert_eq!(timeline_updates.len(), 2);
// Check the timeline diffs.
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
assert_let!(Some(summary) = event_item.content().thread_summary());
// We get the latest event from the bundled thread summary.
assert!(summary.latest_event.is_ready());
assert_let!(TimelineDetails::Ready(latest_event) = summary.latest_event);
assert_eq!(latest_event.content.as_message().unwrap().body(), "the last one!");
assert_eq!(latest_event.sender, *ALICE);
assert!(latest_event.sender_profile.is_unavailable());
// We get the count from the bundled thread summary.
assert_eq!(summary.num_replies, 42);
// The read receipts haven't been filled, because we didn't have such
// information for the current user.
assert_eq!(summary.public_read_receipt_event_id, None);
assert_eq!(summary.private_read_receipt_event_id, None);
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[1]);
assert!(value.is_date_divider());
assert_pending!(stream);
}
#[async_test]
async fn test_new_thread_reply_causes_thread_summary_update() {
// A new thread reply received in sync will cause the thread root's thread
// summary to be updated.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let room = server.sync_joined_room(&client, room_id).await;
let timeline = room.timeline().await.unwrap();
let (initial_items, mut stream) = timeline.subscribe().await;
assert!(initial_items.is_empty());
// Start with a simple message, with no bundled thread info.
let f = EventFactory::new().room(room_id).sender(&ALICE);
let thread_event_id = event_id!("$thread_root");
let event = f.text_msg("thready thread mcthreadface").event_id(thread_event_id);
server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(event)).await;
assert_let_timeout!(Some(timeline_updates) = stream.next());
// Message+day divider.
assert_eq!(timeline_updates.len(), 2);
// Sanity check the timeline diffs.
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
assert!(event_item.content().thread_summary().is_none());
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[1]);
assert!(value.is_date_divider());
// When I receive a threaded reply to this event,
let reply_event_id = event_id!("$thread_reply");
let event = f
.text_msg("thread reply")
.sender(&BOB)
.in_thread(thread_event_id, thread_event_id)
.event_id(reply_event_id);
server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(event)).await;
// The timeline sees the reply.
//
// TODO: maybe we should include the thread summaries if and only if the live
// timeline is configured to exclude thread replies, aka, it requires
// thread-focused timelines to consult the thread replies.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 3);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), reply_event_id);
assert!(event_item.content().thread_summary().is_none());
assert_eq!(event_item.content().thread_root().as_deref(), Some(thread_event_id));
// First, the replied-to event (thread root) doesn't have any thread summary
// info.
let replied_to_details = value.as_event().unwrap().content().in_reply_to().unwrap().event;
assert_let!(TimelineDetails::Ready(replied_to_event) = replied_to_details);
assert!(replied_to_event.content.thread_summary().is_none());
// Since the replied-to item (the thread root) has been updated, all replies get
// updated too, including the item we just pushed.
assert_let!(VectorDiff::Set { index: 2, value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), reply_event_id);
let replied_to_details = value.as_event().unwrap().content().in_reply_to().unwrap().event;
assert_let!(TimelineDetails::Ready(replied_to_event) = replied_to_details);
// Spoiling a bit here…
assert!(replied_to_event.content.thread_summary().is_some());
// And finally, the thread root event receives a thread summary.
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[2]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
assert!(event_item.content().thread_root().is_none());
// The thread summary contains the detailed information about the latest event.
assert_let!(Some(summary) = event_item.content().thread_summary());
assert!(summary.latest_event.is_ready());
assert_let!(TimelineDetails::Ready(latest_event) = summary.latest_event);
assert_eq!(latest_event.content.as_message().unwrap().body(), "thread reply");
assert_eq!(latest_event.sender, *BOB);
assert!(latest_event.sender_profile.is_unavailable());
// The thread summary contains the number of replies.
assert_eq!(summary.num_replies, 1);
// The read receipts haven't been filled, because we didn't have such
// information for the current user.
assert_eq!(summary.public_read_receipt_event_id, None);
assert_eq!(summary.private_read_receipt_event_id, None);
assert_pending!(stream);
// A new thread reply updates the number of replies in the thread.
let another_reply_event_id = event_id!("$another_thread_reply");
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id).add_timeline_event(
f.text_msg("another thread reply")
.sender(&BOB)
.in_thread(thread_event_id, reply_event_id)
.event_id(another_reply_event_id),
),
)
.await;
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 4);
// (Read receipt from Bob moves.)
assert_let!(VectorDiff::Set { index: 2, .. } = &timeline_updates[0]);
// Then we're seeing the new thread reply.
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), another_reply_event_id);
assert!(event_item.content().thread_summary().is_none());
assert_eq!(event_item.content().thread_root().as_deref(), Some(thread_event_id));
// Then the first thread reply is updated with the up-to-date thread summary in
// the replied-to event.
assert_let!(VectorDiff::Set { index: 2, value } = &timeline_updates[2]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), reply_event_id);
let replied_to_details = value.as_event().unwrap().content().in_reply_to().unwrap().event;
assert_let!(TimelineDetails::Ready(replied_to_event) = replied_to_details);
// Spoiling a bit here…
assert_eq!(replied_to_event.content.thread_summary().unwrap().num_replies, 2);
// Then, we receive an update for the thread root itself, which now has an
// up-to-date thread summary.
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[3]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
assert!(event_item.content().thread_root().is_none());
assert_let!(Some(summary) = event_item.content().thread_summary());
// The latest event has been updated.
assert!(summary.latest_event.is_ready());
assert_let!(TimelineDetails::Ready(latest_event) = summary.latest_event);
assert_eq!(latest_event.content.as_message().unwrap().body(), "another thread reply");
assert_eq!(latest_event.sender, *BOB);
assert!(latest_event.sender_profile.is_unavailable());
// The number of replies has been updated.
assert_eq!(summary.num_replies, 2);
// The read receipts haven't been filled, because we didn't have such
// information for the current user.
assert_eq!(summary.public_read_receipt_event_id, None);
assert_eq!(summary.private_read_receipt_event_id, None);
}
#[async_test]
async fn test_thread_msg_edit_reflects_in_summary() {
// A new message edit of a threaded reply received in sync (but after we already
// had a thread) will cause the thread root's thread summary to be updated
// with the latest content.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let room = server.sync_joined_room(&client, room_id).await;
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Live { hide_threaded_events: true })
.build()
.await
.unwrap();
let (initial_items, mut stream) = timeline.subscribe().await;
assert!(initial_items.is_empty());
// Start with a message (with no bundled thread info), and a threaded reply to
// it.
let f = EventFactory::new().room(room_id).sender(&ALICE);
let thread_event_id = event_id!("$thread_root");
let reply_event_id = event_id!("$thread_reply");
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(
f.text_msg("thready thread mcthreadface").event_id(thread_event_id),
)
.add_timeline_event(
f.text_msg("threaded reply")
.sender(&BOB)
.in_thread(thread_event_id, thread_event_id)
.event_id(reply_event_id),
),
)
.await;
assert_let_timeout!(Some(timeline_updates) = stream.next());
// Thread root + new implicit read receipt + new thread summary + day divider.
// TODO: could we optimize this, to have only a single timeline update?
assert_eq!(timeline_updates.len(), 4);
// Sanity check the timeline diffs.
{
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
// At first, the summary isn't here.
assert!(event_item.content().thread_summary().is_none());
// And it only has the read receipt of its author.
assert_eq!(event_item.read_receipts().len(), 1);
// Then, a read-receipt "set" because Bob having sent a reply means he's seen
// the thread root.
assert_let!(VectorDiff::Set { index: 0, value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
assert!(event_item.content().thread_summary().is_none());
// Now, with Bob's read receipt.
assert_eq!(event_item.read_receipts().len(), 2);
// The day divider is added.
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[2]);
assert!(value.is_date_divider());
// Eventually the summary comes in.
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[3]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
// Now there is a summary!
assert_let!(Some(summary) = event_item.content().thread_summary());
assert_let!(TimelineDetails::Ready(latest_event) = summary.latest_event);
assert_eq!(
latest_event.identifier,
TimelineEventItemId::EventId(reply_event_id.to_owned())
);
assert_eq!(latest_event.content.as_message().unwrap().body(), "threaded reply");
assert_eq!(summary.num_replies, 1);
}
// When I receive an edit to that event, via sync,
let edit_reply_event_id = event_id!("$thread_reply_edit");
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id).add_timeline_event(
f.text_msg("*edited* threaded reply")
.edit(
reply_event_id,
RoomMessageEventContentWithoutRelation::text_plain("edited threaded reply"),
)
.sender(&BOB)
.event_id(edit_reply_event_id),
),
)
.await;
// The root message is updated.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 1);
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
assert_let!(Some(summary) = event_item.content().thread_summary());
// The latest event has been updated.
assert_let!(TimelineDetails::Ready(latest_event) = summary.latest_event);
assert_eq!(
latest_event.identifier,
TimelineEventItemId::EventId(edit_reply_event_id.to_owned())
);
assert_eq!(latest_event.content.as_message().unwrap().body(), "edited threaded reply");
// Still only one reply; it's been edited.
assert_eq!(summary.num_replies, 1);
// That's all, folks!
assert_pending!(stream);
}
#[async_test]
async fn test_thread_poll_edit_reflects_in_summary() {
// A new poll edit of a threaded reply received in sync (at the same time as the
// original poll) will cause the thread root's thread summary to be updated
// with the latest content.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let room = server.sync_joined_room(&client, room_id).await;
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Live { hide_threaded_events: true })
.build()
.await
.unwrap();
let (initial_items, mut stream) = timeline.subscribe().await;
assert!(initial_items.is_empty());
// Have all the events, including the edit, arrive in the same sync.
let f = EventFactory::new().room(room_id).sender(&ALICE);
let thread_event_id = event_id!("$thread_root");
let reply_event_id = event_id!("$thread_reply");
let edit_reply_event_id = event_id!("$thread_reply_edit");
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(
f.text_msg("thready thread mcthreadface").event_id(thread_event_id),
)
.add_timeline_event(
f.poll_start(
"What's your favorite colour? Red, green, or blue?",
"What's your favorite colour?",
vec!["Red", "Green", "Blue"],
)
.sender(&BOB)
.in_thread(thread_event_id, thread_event_id)
.event_id(reply_event_id),
)
.add_timeline_event(
f.poll_edit(
reply_event_id,
// TODO: allow changing the fallback text too, in the event factory?
//"What's your favourite colour? Red, green, blue, or yellow?",
"What's your favourite colour?",
vec!["Red", "Green", "Blue", "Yellow"],
)
.sender(&BOB)
.event_id(edit_reply_event_id),
),
)
.await;
assert_let_timeout!(Some(timeline_updates) = stream.next());
// Thread root+new implicit read receipt + new thread summary + day divider.
// TODO: could we optimize this, to have only a single timeline update?
assert_eq!(timeline_updates.len(), 4);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
// At first, the summary isn't here.
assert!(event_item.content().thread_summary().is_none());
// And it only has the read receipt of its author.
assert_eq!(event_item.read_receipts().len(), 1);
// Then, a read-receipt "set" because Bob having sent a reply means he's seen
// the thread root.
assert_let!(VectorDiff::Set { index: 0, value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
assert!(event_item.content().thread_summary().is_none());
// Now, with Bob's read receipt.
assert_eq!(event_item.read_receipts().len(), 2);
// The day divider is added.
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[2]);
assert!(value.is_date_divider());
// Eventually the summary comes in.
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[3]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
// Now there is a summary!
assert_let!(Some(summary) = event_item.content().thread_summary());
assert_let!(TimelineDetails::Ready(latest_event) = summary.latest_event);
assert_eq!(
latest_event.identifier,
TimelineEventItemId::EventId(edit_reply_event_id.to_owned())
);
let poll_results = latest_event.content.as_poll().unwrap().results();
assert_eq!(poll_results.question, "What's your favourite colour?");
assert_eq!(poll_results.answers.len(), 4);
assert_eq!(summary.num_replies, 1);
// That's all, folks!
assert_pending!(stream);
}
#[async_test]
async fn test_thread_filtering_for_sync() {
// Make sure that:
// - a live timeline that shows threaded events will show them
// - a live timeline that hides threaded events *will* hide them (and only keep
// the summary)
// - a thread timeline will show the threaded events
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let sender_id = user_id!("@alice:b.c");
let thread_root_event_id = owned_event_id!("$root");
let room = server.sync_joined_room(&client, room_id).await;
let filtered_timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Live { hide_threaded_events: true })
.build()
.await
.unwrap();
let (_, mut filtered_timeline_stream) = filtered_timeline.subscribe().await;
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Live { hide_threaded_events: false })
.build()
.await
.unwrap();
let (_, mut timeline_stream) = timeline.subscribe().await;
let thread_timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread { root_event_id: thread_root_event_id.clone() })
.build()
.await
.unwrap();
let (_, mut thread_timeline_stream) = thread_timeline.subscribe().await;
let factory = EventFactory::new();
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(
factory
.text_msg("Thread root")
.sender(sender_id)
.event_id(&thread_root_event_id),
)
.add_timeline_event(
factory
.text_msg("Within thread")
.sender(sender_id)
.event_id(event_id!("$threaded_event"))
.in_thread(&thread_root_event_id, &thread_root_event_id),
),
)
.await;
// A live timeline hiding in-thread events should only contain the date
// separator and the thread root.
{
assert_let_timeout!(Some(timeline_updates) = filtered_timeline_stream.next());
assert_eq!(timeline_updates.len(), 3);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.content().as_message().unwrap().body(), "Thread root");
assert_matches!(event_item.content().thread_summary(), None);
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[1]);
assert!(value.is_date_divider());
// The item gets a thread summary.
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[2]);
assert_matches!(value.as_event().unwrap().content().thread_summary(), Some(_));
assert_pending!(filtered_timeline_stream);
}
// A non-filtered live timeline should contain all the items.
{
assert_let_timeout!(Some(timeline_updates) = timeline_stream.next());
assert_eq!(timeline_updates.len(), 6);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.content().as_message().unwrap().body(), "Thread root");
assert_matches!(event_item.content().thread_summary(), None);
assert!(event_item.read_receipts().is_empty().not());
// The read receipt from the author moves to the second item.
assert_let!(VectorDiff::Set { index: 0, value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_matches!(event_item.content().thread_summary(), None);
assert!(event_item.read_receipts().is_empty());
// The threaded event is pushed to the timeline.
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[2]);
assert_eq!(
value.as_event().unwrap().content().as_message().unwrap().body(),
"Within thread"
);
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[3]);
assert!(value.is_date_divider());
// The thread event is a reply (because of the reply fallback), and since its
// replied-to timeline item has been updated, it also gets updated.
assert_let!(VectorDiff::Set { index: 2, value } = &timeline_updates[4]);
assert_eq!(
value.as_event().unwrap().content().as_message().unwrap().body(),
"Within thread"
);
// Then the thread summary is updated on the thread root.
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[5]);
assert_matches!(value.as_event().unwrap().content().thread_summary(), Some(_));
assert_pending!(timeline_stream);
}
// The threaded timeline should only contain the thread root and the threaded
// event.
{
assert_let_timeout!(Some(timeline_updates) = thread_timeline_stream.next());
assert_eq!(timeline_updates.len(), 4);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.content().as_message().unwrap().body(), "Thread root");
// The read receipt from the author moves to the second item.
assert_let!(VectorDiff::Set { index: 0, value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_matches!(event_item.content().thread_summary(), None);
assert!(event_item.read_receipts().is_empty());
// The threaded event is pushed to the timeline.
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[2]);
assert_eq!(
value.as_event().unwrap().content().as_message().unwrap().body(),
"Within thread"
);
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[3]);
assert!(value.is_date_divider());
assert_pending!(timeline_stream);
}
}
#[async_test]
async fn test_thread_timeline_gets_related_events_from_sync() {
// If a thread timeline receives a sync event related to an in-thread event, it
// gets updated.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let sender_id = user_id!("@alice:b.c");
let thread_root_event_id = owned_event_id!("$root");
let threaded_event_id = event_id!("$threaded_event");
let room = server.sync_joined_room(&client, room_id).await;
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread { root_event_id: thread_root_event_id.clone() })
.build()
.await
.unwrap();
let (initial_items, mut stream) = timeline.subscribe().await;
// At first, the timeline is empty.
assert!(initial_items.is_empty());
assert_pending!(stream);
// After a sync with an in-thread event, the timeline receives an update.
let f = EventFactory::new();
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id).add_timeline_event(
f.text_msg("Within thread")
.sender(sender_id)
.event_id(threaded_event_id)
.in_thread(&thread_root_event_id, threaded_event_id),
),
)
.await;
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 2);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id(), Some(threaded_event_id));
assert!(event_item.content().reactions().unwrap().is_empty());
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[1]);
assert!(value.is_date_divider());
assert_pending!(stream);
// When we get a reaction for the in-thread event, from sync, the timeline gets
// updated, even though the reaction doesn't mention the thread directly.
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(f.reaction(threaded_event_id, "👍").sender(sender_id)),
)
.await;
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 1);
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), threaded_event_id);
assert!(event_item.content().reactions().unwrap().is_empty().not());
// If I open another timeline on the same thread, I still see the related event.
let other_timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread { root_event_id: thread_root_event_id })
.build()
.await
.unwrap();
let (initial_items, _thread_stream) = other_timeline.subscribe().await;
assert_eq!(initial_items.len(), 2);
// The date divider.
assert!(initial_items[0].is_date_divider());
// The threaded event with the reaction.
let event_item = initial_items[1].as_event().unwrap();
assert_eq!(event_item.event_id(), Some(threaded_event_id));
assert!(event_item.content().reactions().unwrap().is_empty().not());
}
#[async_test]
async fn test_thread_timeline_gets_local_echoes() {
// If a thread timeline receives a local echo of an in-thread event, it
// gets updated. If the event is a reaction, it gets updated too.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let thread_root_event_id = owned_event_id!("$root");
let threaded_event_id = event_id!("$threaded_event");
let room = server.sync_joined_room(&client, room_id).await;
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread { root_event_id: thread_root_event_id.clone() })
.build()
.await
.unwrap();
let (initial_items, mut stream) = timeline.subscribe().await;
// At first, the timeline is empty.
assert!(initial_items.is_empty());
assert_pending!(stream);
// Start the timeline with an in-thread event.
let f = EventFactory::new();
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id).add_timeline_event(
f.text_msg("hello world")
.sender(*ALICE)
.event_id(threaded_event_id)
.in_thread(&thread_root_event_id, threaded_event_id)
.server_ts(MilliSecondsSinceUnixEpoch::now()),
),
)
.await;
// Sanity check: I receive the event and the date divider.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 2);
// If I send a local echo for an in-thread event, the timeline receives an
// update.
let sent_event_id = event_id!("$sent_msg");
server.mock_room_state_encryption().plain().mount().await;
let (event_receiver, mock_builder) =
server.mock_room_send().ok_with_capture(sent_event_id, *ALICE);
mock_builder.mock_once().mount().await;
timeline.send(RoomMessageEventContent::text_plain("hello to you too!").into()).await.unwrap();
// I get the local echo for the in-thread event.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 1);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert!(event_item.is_local_echo());
assert_matches!(event_item.send_state(), Some(EventSendState::NotSentYet { .. }));
assert!(event_item.event_id().is_none());
// The thread information is properly filled.
let msglike = event_item.content().as_msglike().unwrap();
assert_eq!(msglike.thread_root.as_ref(), Some(&thread_root_event_id));
assert!(msglike.in_reply_to.is_none());
// Then the local echo morphs into a sent local echo.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 1);
// The local event is updated.
assert_let!(VectorDiff::Set { index: 2, value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id(), Some(sent_event_id));
assert!(event_item.is_local_echo());
assert_let!(Some(EventSendState::Sent { event_id }) = event_item.send_state());
assert_eq!(event_id, sent_event_id);
assert!(event_item.content().reactions().unwrap().is_empty());
// Then nothing else.
assert_pending!(stream);
// The raw event includes the correctly reply-to fallback.
{
let raw_event = event_receiver.await.unwrap();
let event = raw_event.deserialize().unwrap();
assert_let!(
AnySyncTimelineEvent::MessageLike(
ruma::events::AnySyncMessageLikeEvent::RoomMessage(event)
) = event
);
let event = event.as_original().unwrap();
assert_let!(Some(Relation::Thread(thread)) = event.content.relates_to.clone());
assert_eq!(thread.event_id, thread_root_event_id);
assert!(thread.is_falling_back);
// The reply-to fallback is set to the latest in-thread event, not the thread
// root.
assert_eq!(thread.in_reply_to.unwrap().event_id, threaded_event_id);
}
// If I send a reaction for the in-thread event, the timeline gets updated, even
// though the reaction doesn't mention the thread directly.
server.mock_room_send().ok(event_id!("$reaction_id")).mock_once().mount().await;
timeline.toggle_reaction(&event_item.identifier(), "👍").await.unwrap();
// Then I get the reaction as a local echo first.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 1);
assert_let!(VectorDiff::Set { index: 2, value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), sent_event_id);
assert!(event_item.content().reactions().unwrap().is_empty().not());
// Then as a remote echo.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert!(!timeline_updates.is_empty());
// Sometimes, a double `VectorDiff::Set` is received because of another update.
// Let's make the test reliable.
for timeline_update in timeline_updates {
assert_let!(VectorDiff::Set { index: 2, value } = timeline_update);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), sent_event_id);
assert!(event_item.content().reactions().unwrap().is_empty().not());
}
// Then we're done.
assert_pending!(stream);
}
#[async_test]
async fn test_thread_timeline_can_send_edit() {
// If I send an edit to a threaded timeline, it just works (aka the system to
// set the threaded relationship doesn't kick in, since there's already a
// relationship).
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let thread_root_event_id = owned_event_id!("$root");
let threaded_event_id = event_id!("$threaded_event");
let room = server.sync_joined_room(&client, room_id).await;
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread { root_event_id: thread_root_event_id.clone() })
.build()
.await
.unwrap();
let (initial_items, mut stream) = timeline.subscribe().await;
// At first, the timeline is empty.
assert!(initial_items.is_empty());
assert_pending!(stream);
// Start the timeline with an in-thread event.
let f = EventFactory::new();
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id).add_timeline_event(
f.text_msg("hello world")
.sender(*ALICE)
.event_id(threaded_event_id)
.in_thread(&thread_root_event_id, threaded_event_id)
.server_ts(MilliSecondsSinceUnixEpoch::now()),
),
)
.await;
// Sanity check: I receive the event and the date divider.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 2);
// If I send an edit to an in-thread event, the timeline receives an update.
// Note: it's a bit far fetched to send an edit without using
// `Timeline::edit()`, but since it's possible…
let sent_event_id = event_id!("$sent_msg");
server.mock_room_state_encryption().plain().mount().await;
// No mock_once here: this endpoint may or may not be called, depending on
// timing of the end of the test.
server.mock_room_send().ok(sent_event_id).mount().await;
timeline
.send(
RoomMessageEventContent::text_plain("bonjour monde")
.make_replacement(ReplacementMetadata::new(threaded_event_id.to_owned(), None))
.into(),
)
.await
.unwrap();
// I get the local echo for the in-thread event.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 1);
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
// The thread information is (still) present.
let msglike = event_item.content().as_msglike().unwrap();
assert_eq!(msglike.thread_root.as_ref(), Some(&thread_root_event_id));
assert!(msglike.in_reply_to.is_none());
// Text is eagerly updated.
assert_eq!(msglike.as_message().unwrap().body(), "bonjour monde");
// Then we're done.
assert_pending!(stream);
}
#[async_test]
async fn test_send_sticker_thread() {
// If I send a sticker to a threaded timeline, it just works (aka the system to
// set the threaded relationship does kick in).
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let thread_root_event_id = owned_event_id!("$root");
let threaded_event_id = event_id!("$threaded_event");
let room = server.sync_joined_room(&client, room_id).await;
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread { root_event_id: thread_root_event_id.clone() })
.build()
.await
.unwrap();
let (initial_items, mut stream) = timeline.subscribe().await;
// At first, the timeline is empty.
assert!(initial_items.is_empty());
assert_pending!(stream);
// Start the timeline with an in-thread event.
let f = EventFactory::new();
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id).add_timeline_event(
f.text_msg("hello world")
.sender(*ALICE)
.event_id(threaded_event_id)
.in_thread(&thread_root_event_id, threaded_event_id)
.server_ts(MilliSecondsSinceUnixEpoch::now()),
),
)
.await;
// Sanity check: I receive the event and the date divider.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 2);
server.mock_room_state_encryption().plain().mount().await;
let sent_event_id = event_id!("$sent_msg");
server.mock_room_send().ok(sent_event_id).mount().await;
let media_src = owned_mxc_uri!("mxc://example.com/1");
timeline
.send(
StickerEventContent::new("sticker!".to_owned(), ImageInfo::new(), media_src.clone())
.into(),
)
.await
.unwrap();
// I get the local echo for the in-thread event.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 1);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
// The content matches what we expect.
let sticker_item = event_item.content().as_sticker().unwrap();
let content = sticker_item.content();
assert_eq!(content.body, "sticker!");
assert_let!(StickerMediaSource::Plain(plain) = content.source.clone());
assert_eq!(plain, media_src);
// Then we're done.
assert_pending!(stream);
}
#[async_test]
async fn test_send_poll_thread() {
// If I send a poll to a threaded timeline, it just works (aka the system to
// set the threaded relationship does kick in).
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let thread_root_event_id = owned_event_id!("$root");
let threaded_event_id = event_id!("$threaded_event");
let room = server.sync_joined_room(&client, room_id).await;
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread { root_event_id: thread_root_event_id.clone() })
.build()
.await
.unwrap();
let (initial_items, mut stream) = timeline.subscribe().await;
// At first, the timeline is empty.
assert!(initial_items.is_empty());
assert_pending!(stream);
// Start the timeline with an in-thread event.
let f = EventFactory::new();
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id).add_timeline_event(
f.text_msg("hello world")
.sender(*ALICE)
.event_id(threaded_event_id)
.in_thread(&thread_root_event_id, threaded_event_id)
.server_ts(MilliSecondsSinceUnixEpoch::now()),
),
)
.await;
// Sanity check: I receive the event and the date divider.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 2);
server.mock_room_state_encryption().plain().mount().await;
let sent_event_id = event_id!("$sent_msg");
server.mock_room_send().ok(sent_event_id).mount().await;
timeline
.send(
UnstablePollStartEventContent::New(NewUnstablePollStartEventContent::plain_text(
"let's vote",
UnstablePollStartContentBlock::new(
"what day is it today?",
vec![
UnstablePollAnswer::new("0", "monday"),
UnstablePollAnswer::new("1", "friday"),
]
.try_into()
.unwrap(),
),
))
.into(),
)
.await
.unwrap();
// I get the local echo for the in-thread event.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 1);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
// The content is a poll.
assert!(event_item.content().is_poll());
// Then we're done.
assert_pending!(stream);
}
#[async_test]
async fn test_sending_read_receipt_with_no_events_doesnt_unset_read_flag() {
// If a thread timeline has no events, then marking it as read doesn't unset the
// unread flag on the room.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let thread_root_event_id = owned_event_id!("$root");
// Start with a room manually marked as unread.
let f = EventFactory::new();
let room = server
.sync_room(&client, JoinedRoomBuilder::new(room_id).add_account_data(f.marked_unread(true)))
.await;
// Create a threaded timeline, with no events in it.
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread { root_event_id: thread_root_event_id.clone() })
.build()
.await
.unwrap();
let (initial_items, mut stream) = timeline.subscribe().await;
// Sanity check: the timeline is empty.
assert!(initial_items.is_empty());
assert_pending!(stream);
// Try to mark the timeline as read.
// This should not unset the unread flag on the room (if it tried to do so, the
// test would fail with a 404, because the endpoint hasn't been set).
let marked_as_read = timeline.mark_as_read(SendReceiptType::Read).await.unwrap();
assert!(marked_as_read.not());
}
#[async_test]
async fn test_read_receipts() {
// Threaded read receipts are correctly handled in a thread timeline.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let room_id = room_id!("!a:b.c");
let thread_root = owned_event_id!("$root");
let receipt_thread = ReceiptThread::Thread(thread_root.clone());
// Start with an empty room.
let room = server.sync_joined_room(&client, room_id).await;
// Create a threaded timeline, with no events in it.
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread { root_event_id: thread_root.clone() })
.build()
.await
.unwrap();
let (initial_items, mut stream) = timeline.subscribe().await;
// Sanity check: the timeline is empty.
assert!(initial_items.is_empty());
assert_pending!(stream);
// Receive two events from the server, one of which is a read receipt.
let f = EventFactory::new();
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(
f.text_msg("hey to you too!")
.sender(*ALICE)
.in_thread(&thread_root, &thread_root)
.event_id(event_id!("$1")),
)
.add_timeline_event(
f.text_msg("how's it going?")
.sender(*ALICE)
.in_thread(&thread_root, event_id!("$1"))
.event_id(event_id!("$2")),
)
.add_timeline_event(
f.text_msg("good, u?")
.sender(*BOB)
.in_thread(&thread_root, event_id!("$2"))
.event_id(event_id!("$3")),
),
)
.await;
// Sanity check: we receive the three events, plus a date divider.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 5);
// Second event has Alice's implicit read receipt, third event has Bob's
// implicit read receipt.
{
// Alice's event gets pushed back, first, with its own implicit read receipt.
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let ev0 = value.as_event().unwrap();
assert_eq!(ev0.event_id(), Some(event_id!("$1")));
let rr = ev0.read_receipts();
assert_eq!(rr.len(), 1);
assert_eq!(rr[*ALICE].thread, receipt_thread);
// But then, as we're about to push another event from Alice, its read receipt
// disappears from the first event.
// XXX wouldn't it be nice that we didn't have this update?
assert_let!(VectorDiff::Set { index: 0, value } = &timeline_updates[1]);
let ev0 = value.as_event().unwrap();
assert_eq!(ev0.event_id(), Some(event_id!("$1")));
assert!(ev0.read_receipts().is_empty());
// Second event has Alice's implicit read receipt.
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[2]);
let ev1 = value.as_event().unwrap();
assert_eq!(ev1.event_id(), Some(event_id!("$2")));
let rr = ev1.read_receipts();
assert_eq!(rr.len(), 1);
assert_eq!(rr[*ALICE].thread, receipt_thread);
// Third event has Bob's implicit read receipt.
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[3]);
let ev2 = value.as_event().unwrap();
assert_eq!(ev2.event_id(), Some(event_id!("$3")));
let rr = ev2.read_receipts();
assert_eq!(rr.len(), 1);
assert_eq!(rr[*BOB].thread, receipt_thread);
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[4]);
assert!(value.is_date_divider());
}
// Receive a read receipt update:
// - an explicit read receipt for Alice on $3, which will move their read
// receipt to the latest event.
// - an explicit read receipt for Bob on $3, which will not do anything (because
// of the implicit read receipt)
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id).add_receipt(
f.read_receipts()
.add(event_id!("$3"), *ALICE, ReceiptType::Read, receipt_thread.clone())
.add(event_id!("$3"), *BOB, ReceiptType::Read, receipt_thread.clone())
.into_event(),
),
)
.await;
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 2);
// Alice's previous event is updated, and loses its read receipt.
{
assert_let!(VectorDiff::Set { index: 2, value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id(), Some(event_id!("$2")));
assert!(event_item.read_receipts().is_empty());
}
// Bob's event is updated, and it gets a read receipt for Alice.
{
assert_let!(VectorDiff::Set { index: 3, value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id(), Some(event_id!("$3")));
let rr = event_item.read_receipts();
assert_eq!(rr.len(), 2);
assert_eq!(rr[*ALICE].thread, receipt_thread);
assert_eq!(rr[*BOB].thread, receipt_thread);
}
}
#[async_test]
async fn test_initial_read_receipts_are_correctly_populated() {
// If there are initial read receipts in the store, they should be correctly
// populated in the timeline.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
client.event_cache().subscribe().unwrap();
let room_id = room_id!("!a:b.c");
let thread_root = owned_event_id!("$root");
let receipt_thread = ReceiptThread::Thread(thread_root.clone());
// Start with a room that has an event with some initial read receipts.
//
// It is sync'd *before* the timeline is created, so the timeline will have to
// load the receipts from the store.
let f = EventFactory::new();
let room = server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(
f.text_msg("hey to you too!")
.sender(*ALICE)
.in_thread(&thread_root, &thread_root)
.event_id(event_id!("$1")),
)
.add_receipt(
f.read_receipts()
.add(event_id!("$1"), *BOB, ReceiptType::Read, receipt_thread.clone())
.into_event(),
),
)
.await;
// Create a threaded timeline.
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread { root_event_id: thread_root.clone() })
.build()
.await
.unwrap();
let (mut initial_items, mut stream) = timeline.subscribe().await;
if initial_items.is_empty() {
assert_let_timeout!(Some(timeline_updates) = stream.next());
for up in timeline_updates {
up.apply(&mut initial_items);
}
}
// After stabilizing the timeline, we should see the initial read receipts
// set as intended.
assert_eq!(initial_items.len(), 2);
let ev = initial_items[1].as_event().unwrap();
assert_eq!(ev.event_id(), Some(event_id!("$1")));
let rr = ev.read_receipts();
assert_eq!(rr.len(), 2);
assert_eq!(rr[*ALICE].thread, receipt_thread);
assert_eq!(rr[*BOB].thread, receipt_thread);
}
#[async_test]
async fn test_initial_read_receipts_compatibility_mode() {
// If there are initial read receipts in the store, that are using the
// "unthreaded" kind of receipt, then they're used as "main" receipts as
// well.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
client.event_cache().subscribe().unwrap();
let room_id = room_id!("!a:b.c");
// Start with a room that has an event with some initial read receipts.
//
// It is sync'd *before* the timeline is created, so the timeline will have to
// load the receipts from the store.
let f = EventFactory::new();
let room = server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(
f.text_msg("hello in main timeline").sender(*BOB).event_id(event_id!("$0")),
)
.add_timeline_event(
f.text_msg("hello back in main timeline")
.sender(*ALICE)
.event_id(event_id!("$1")),
)
.add_receipt(
f.read_receipts()
.add(event_id!("$1"), *BOB, ReceiptType::Read, ReceiptThread::Unthreaded)
.into_event(),
),
)
.await;
// Create a main timeline.
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Live { hide_threaded_events: true })
.build()
.await
.unwrap();
let (mut initial_items, mut stream) = timeline.subscribe().await;
// Wait for the initial items.
if initial_items.is_empty() {
assert_let_timeout!(Some(timeline_updates) = stream.next());
for up in timeline_updates {
up.apply(&mut initial_items);
}
}
// After stabilizing the timeline, we should see the initial read receipts
// set as intended.
assert_eq!(initial_items.len(), 3);
assert!(initial_items[0].is_date_divider());
{
let ev = initial_items[1].as_event().unwrap();
assert_eq!(ev.event_id(), Some(event_id!("$0")));
let rr = ev.read_receipts();
assert!(rr.is_empty());
}
{
let ev = initial_items[2].as_event().unwrap();
assert_eq!(ev.event_id(), Some(event_id!("$1")));
let rr = ev.read_receipts();
assert_eq!(rr.len(), 2);
assert!(rr.get(*ALICE).is_some());
assert!(rr.get(*BOB).is_some());
}
}
#[async_test]
async fn test_send_read_receipts() {
// Threaded read receipts can be sent from a thread timeline. Trying to send a
// read receipt on an event that had one is a no-op.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
client.event_cache().subscribe().unwrap();
let user_id = client.user_id().unwrap();
let room_id = room_id!("!a:b.c");
let thread_root = owned_event_id!("$root");
let receipt_thread = ReceiptThread::Thread(thread_root.clone());
// Start with a room with some events.
let f = EventFactory::new();
let room = server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(
f.text_msg("hey to you too!")
.sender(*ALICE)
.in_thread(&thread_root, &thread_root)
.event_id(event_id!("$1")),
)
.add_timeline_event(
f.text_msg("how's it going?")
.sender(user_id)
.in_thread(&thread_root, event_id!("$1"))
.event_id(event_id!("$2")),
)
.add_timeline_event(
f.text_msg("good and you?")
.sender(*BOB)
.in_thread(&thread_root, event_id!("$2"))
.event_id(event_id!("$3")),
)
.add_timeline_event(
f.text_msg("u there?")
.sender(*BOB)
.in_thread(&thread_root, event_id!("$3"))
.event_id(event_id!("$4")),
),
)
.await;
// Create a threaded timeline.
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread { root_event_id: thread_root.clone() })
.build()
.await
.unwrap();
let (mut initial_items, mut stream) = timeline.subscribe().await;
// Either the initial timeline is not empty, or it will soon receive an update
// from the event cache.
if initial_items.is_empty() {
assert_let_timeout!(Some(timeline_updates) = stream.next());
for up in timeline_updates {
up.apply(&mut initial_items);
}
}
// Now that the timeline is populated, we can check the initial read receipts.
// Note: 0 is the index of the date divider.
let ev = initial_items[1].as_event().unwrap();
assert_eq!(ev.event_id(), Some(event_id!("$1")));
let rr = ev.read_receipts();
assert_eq!(rr.len(), 1);
assert_eq!(rr[*ALICE].thread, receipt_thread);
// The timeline doesn't include the read receipt for the current user, but this
// is where it would be, if it did.
let ev = initial_items[2].as_event().unwrap();
assert_eq!(ev.event_id(), Some(event_id!("$2")));
assert!(ev.read_receipts().is_empty());
// Since the room has no `m.fully_read` event, the read marker falls back to
// the normal read receipt.
let ev = initial_items[3].as_virtual().unwrap();
assert!(matches!(ev, VirtualTimelineItem::ReadMarker));
let ev = initial_items[4].as_event().unwrap();
assert_eq!(ev.event_id(), Some(event_id!("$3")));
assert!(ev.read_receipts().is_empty());
let ev = initial_items[5].as_event().unwrap();
assert_eq!(ev.event_id(), Some(event_id!("$4")));
let rr = ev.read_receipts();
assert_eq!(rr.len(), 1);
assert_eq!(rr[*BOB].thread, receipt_thread);
// If the user tries to send a read receipt for an event sent before one of
// theirs, it is a no-op.
let did_send =
timeline.send_single_receipt(SendReceiptType::Read, owned_event_id!("$1")).await.unwrap();
assert!(did_send.not());
// If the user tries to send a read receipt for their own event, it is a no-op.
let did_send =
timeline.send_single_receipt(SendReceiptType::Read, owned_event_id!("$2")).await.unwrap();
assert!(did_send.not());
// But they can send it to a following event.
server
.mock_send_receipt(SendReceiptType::Read)
.match_thread(ReceiptThread::Thread(thread_root.clone()))
.match_event_id(event_id!("$3"))
.ok()
.mock_once()
.mount()
.await;
let did_send =
timeline.send_single_receipt(SendReceiptType::Read, owned_event_id!("$3")).await.unwrap();
assert!(did_send);
// At this point, the read receipts aren't optimistically updated.
assert_pending!(stream);
// Simulate a remote echo for the read receipt.
{
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id).add_receipt(
f.read_receipts()
.add(event_id!("$3"), user_id, ReceiptType::Read, receipt_thread.clone())
.into_event(),
),
)
.await;
// The timeline receives an update for the read receipt, but it won't move it,
// since it doesn't signal our own read receipt.
yield_now().await;
assert_pending!(stream);
}
// And the user can mark the whole thread as read, which will send a read
// receipt on the last event.
server
.mock_send_receipt(SendReceiptType::Read)
.match_thread(ReceiptThread::Thread(thread_root.clone()))
.match_event_id(event_id!("$4"))
.ok()
.mock_once()
.mount()
.await;
let did_send = timeline.mark_as_read(SendReceiptType::Read).await.unwrap();
assert!(did_send);
// Simulate a remote echo for the read receipt.
{
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id).add_receipt(
f.read_receipts()
.add(event_id!("$4"), user_id, ReceiptType::Read, receipt_thread.clone())
.into_event(),
),
)
.await;
// The timeline receives an update for the read receipt, but it won't move it,
// since it doesn't signal our own read receipt.
yield_now().await;
assert_pending!(stream);
}
// Trying to mark the thread as read again is a no-op.
let did_send = timeline.mark_as_read(SendReceiptType::Read).await.unwrap();
assert!(did_send.not());
}
#[async_test]
async fn test_permalink_doesnt_listen_to_thread_sync() {
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
let event_cache = client.event_cache();
event_cache.subscribe().unwrap();
let room_id = room_id!("!a:b.c");
let room = server.sync_joined_room(&client, room_id).await;
let f = EventFactory::new().sender(&ALICE).room(room_id);
let thread_root = event_id!("$thread_root");
let event = f
.text_msg("hey to you too")
.event_id(event_id!("$target"))
.in_thread(thread_root, thread_root)
.into_event();
server
.mock_room_event_context()
.ok(RoomContextResponseTemplate::new(event))
.mock_once()
.mount()
.await;
let timeline = TimelineBuilder::new(&room)
.with_focus(TimelineFocus::Event {
target: owned_event_id!("$target"),
num_context_events: 2,
thread_mode: TimelineEventFocusThreadMode::Automatic { hide_threaded_events: true },
})
.build()
.await
.unwrap();
// Sanity check.
assert!(timeline.is_threaded());
// Subscribe to the timeline changes.
let (initial_items, mut stream) = timeline.subscribe().await;
// Initially, it contains a date divider and the target event only.
assert_eq!(initial_items.len(), 2);
assert!(initial_items[0].is_date_divider());
assert_eq!(initial_items[1].as_event().unwrap().event_id(), Some(event_id!("$target")));
assert_pending!(stream);
// If a back-pagination happens in the thread event cache, it should NOT be
// reflected in the focused timeline.
server
.mock_room_relations()
.match_target_event(thread_root.to_owned())
.ok(RoomRelationsResponseTemplate::default()
.events(vec![
f.text_msg("a new threaded event")
.event_id(event_id!("$new_threaded"))
.in_thread(thread_root, event_id!("$new_threaded")),
])
// Include a next-batch token to not have to mock /event for the root.
.next_batch("next-batch"))
.mock_once()
.mount()
.await;
let (thread_event_cache, _drop_guards) =
event_cache.thread(room_id, thread_root).await.unwrap();
let outcome = thread_event_cache.pagination().run_backwards_once(42).await.unwrap();
assert!(outcome.reached_start.not());
sleep(Duration::from_millis(100)).await;
// The stream should still be pending!
assert_pending!(stream);
}
#[async_test]
async fn test_redacted_replied_to_is_updated() {
// When we're in a thread, and the thread has one thread reply, and another
// in-thread reply to the first threaded reply; if the first threaded reply
// is redacted, the second in-thread reply should have its replied-to
// information updated correctly.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
client.event_cache().subscribe().unwrap();
let room_id = room_id!("!a:b.c");
let f = EventFactory::new().sender(&ALICE).room(room_id);
let thread_root = event_id!("$thread_root");
let first_reply = event_id!("$first_reply");
let second_reply = event_id!("$second_reply");
let room = server.sync_joined_room(&client, room_id).await;
let timeline = TimelineBuilder::new(&room)
.with_focus(TimelineFocus::Thread { root_event_id: thread_root.to_owned() })
.build()
.await
.unwrap();
// Subscribe to the timeline changes.
let (initial_items, mut stream) = timeline.subscribe().await;
assert!(initial_items.is_empty());
assert_pending!(stream);
// After syncing the initial thread content,
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(
f.text_msg("hey to you too!")
.event_id(first_reply)
.in_thread(thread_root, thread_root),
)
.add_timeline_event(
f.text_msg("how are you doing?")
.event_id(second_reply)
.in_thread_reply(thread_root, first_reply),
),
)
.await;
// The timeline sees the new events.
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 3);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let ev1 = value.as_event().unwrap();
assert_eq!(ev1.event_id(), Some(first_reply));
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[1]);
let ev2 = value.as_event().unwrap();
assert_eq!(ev2.event_id(), Some(second_reply));
let msglike = ev2.content().as_msglike().unwrap();
let in_reply_to = msglike.in_reply_to.as_ref().unwrap();
assert_eq!(in_reply_to.event_id, first_reply);
assert_let!(TimelineDetails::Ready(replied_to) = &in_reply_to.event);
assert!(replied_to.content.is_message());
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[2]);
assert!(value.is_date_divider());
assert_pending!(stream);
// When the first reply is redacted,
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(f.redaction(first_reply).event_id(event_id!("$redaction"))),
)
.await;
// The timeline sees the redaction as a removal,
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 3);
// The first reply is being redacted by the `m.room.redaction` event the thread
// timeline receives.
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[0]);
let ev1 = value.as_event().unwrap();
assert_eq!(ev1.event_id(), Some(first_reply));
// The second reply is being updated because the event it replies to has been
// updated.
assert_let!(VectorDiff::Set { index: 2, value } = &timeline_updates[1]);
let ev2 = value.as_event().unwrap();
assert_eq!(ev2.event_id(), Some(second_reply));
let msglike = ev2.content().as_msglike().unwrap();
let in_reply_to = msglike.in_reply_to.as_ref().unwrap();
assert_eq!(in_reply_to.event_id, first_reply);
assert_let!(TimelineDetails::Ready(replied_to_event) = &in_reply_to.event);
assert!(replied_to_event.content.is_redacted());
// Finally, the first reply is being removed.
assert_let!(VectorDiff::Remove { index: 1 } = &timeline_updates[2]);
assert_pending!(stream);
}
#[async_test]
async fn test_redaction_affects_thread_summary() {
// When an in-thread event is being redacted, the thread summary of the root
// will be correctly updated.
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
client.event_cache().subscribe().unwrap();
let room_id = room_id!("!a:b.c");
let f = EventFactory::new().room(room_id).sender(&ALICE);
let thread_root = event_id!("$thread_root");
let thread_reply = event_id!("$thread_reply");
// Start with an initial sync with a thread root and a threaded reply.
let room = server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(f.text_msg("thread root").event_id(thread_root))
.add_timeline_event(
f.text_msg("thread reply")
.in_thread(thread_root, thread_root)
.event_id(thread_reply),
),
)
.await;
// Create a main timeline.
let timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Live { hide_threaded_events: true })
.build()
.await
.unwrap();
let (mut initial_items, mut stream) = timeline.subscribe().await;
// Wait for the timeline's state to stabilize.
if initial_items.is_empty() {
assert_let_timeout!(Some(timeline_updates) = stream.next());
for up in timeline_updates {
up.apply(&mut initial_items);
}
}
assert_eq!(initial_items.len(), 2);
assert!(initial_items[0].is_date_divider());
// The root has a summary.
let event_item = initial_items[1].as_event().unwrap();
assert_eq!(event_item.event_id(), Some(thread_root));
let summary = event_item.content().as_msglike().unwrap().thread_summary.as_ref().unwrap();
assert_eq!(summary.num_replies, 1);
assert_let!(TimelineDetails::Ready(embedded) = &summary.latest_event);
assert_eq!(embedded.identifier, TimelineEventItemId::EventId(thread_reply.to_owned()));
assert_pending!(stream);
// We receive a redaction over sync for the one reply to the thread root.
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_timeline_event(f.redaction(thread_reply).event_id(event_id!("$redaction"))),
)
.await;
// The thread summary has disappeared!
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 1);
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id(), Some(thread_root));
assert!(event_item.content().as_msglike().unwrap().thread_summary.is_none());
}
#[async_test]
async fn test_main_timeline_has_receipts_in_thread_summaries() {
// The initial read receipts for thread events are filled, as part of the
// `ThreadSummary`, for main timeline:
// - at start
// - upon update of the read receipts events
let server = MatrixMockServer::new().await;
let client = client_with_threading_support(&server).await;
client.event_cache().subscribe().unwrap();
let own_user = client.user_id().unwrap();
// Sync some initial read receipts, one for the main timeline (that won't be
// used) and another one for a threaded timeline, that will be used later.
let room_id = room_id!("!a:b.c");
let f = EventFactory::new().room(room_id).sender(&ALICE);
let thread_event_id = event_id!("$thread_root");
let latest_event_id = event_id!("$latest_event");
let room = server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id)
.add_receipt(
// Add a receipt for the latest event of the thread.
f.read_receipts()
.add(
latest_event_id,
own_user,
ReceiptType::Read,
ReceiptThread::Thread(thread_event_id.to_owned()),
)
.into_event(),
)
.add_timeline_event(
// And the thread root itself.
f.text_msg("thready thread mcthreadface")
.with_bundled_thread_summary(
f.text_msg("the last one!").event_id(latest_event_id).into(),
42,
false,
)
.event_id(thread_event_id),
),
)
.await;
let timeline = room.timeline().await.unwrap();
let (mut initial_items, mut stream) = timeline.subscribe().await;
// Wait for the initial items.
if initial_items.is_empty() {
assert_let_timeout!(Some(timeline_updates) = stream.next());
for up in timeline_updates {
up.apply(&mut initial_items);
}
}
// Let's take a look at the items, now that they've been loaded: there will be
// the day divider and the thread root itself.
let items = timeline.items().await;
assert_eq!(items.len(), 2);
// First, the day divider.
let value = &items[0];
assert!(value.is_date_divider());
// Second, the event with the thread summary that has some read receipts.
let value = &items[1];
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
assert_let!(Some(summary) = event_item.content().thread_summary());
// We get the latest event from the bundled thread summary, and it's loaded.
assert!(summary.latest_event.is_ready());
// The public read receipt event id is filled (but the private isn't).
assert_eq!(summary.public_read_receipt_event_id.as_deref(), Some(latest_event_id));
assert_eq!(summary.private_read_receipt_event_id, None);
// Now, a new read receipt is received for the same thread (we haven't received
// the thread event yet).
let new_latest_event_id = event_id!("$new_latest_event_id");
server
.sync_room(
&client,
JoinedRoomBuilder::new(room_id).add_receipt(
// Add a receipt for the latest event of the thread.
f.read_receipts()
.add(
new_latest_event_id,
own_user,
ReceiptType::ReadPrivate,
ReceiptThread::Thread(thread_event_id.to_owned()),
)
.into_event(),
),
)
.await;
assert_let_timeout!(Some(timeline_updates) = stream.next());
assert_eq!(timeline_updates.len(), 1);
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.event_id().unwrap(), thread_event_id);
assert_let!(Some(summary) = event_item.content().thread_summary());
// Now, the private read receipt is *also* filled.
assert_eq!(summary.public_read_receipt_event_id.as_deref(), Some(latest_event_id));
assert_eq!(summary.private_read_receipt_event_id.as_deref(), Some(new_latest_event_id));
}