mirror of
https://github.com/matrix-org/matrix-rust-sdk.git
synced 2026-08-04 12:02:40 -04:00
2219 lines
82 KiB
Rust
2219 lines
82 KiB
Rust
// 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));
|
||
}
|