Skip to content

Commit 5ae7d0f

Browse files
committed
feat(timeline): Timeline::send() automatically includes the thread relationship for thread foci
1 parent 6f067d5 commit 5ae7d0f

File tree

4 files changed

+197
-95
lines changed

4 files changed

+197
-95
lines changed

crates/matrix-sdk-ui/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file.
66

77
## [Unreleased] - ReleaseDate
88

9+
### Features
10+
11+
- `Timeline::send()` will now automatically fill the thread relationship, if the timeline has a
12+
thread focus, and the sent event doesn't have a prefilled `relates_to` field (i.e. a relationship).
13+
([5427](https://github.com/matrix-org/matrix-rust-sdk/pull/5427))
14+
915
### Refactor
1016

1117
- [**breaking**] The MSRV has been bumped to Rust 1.88.

crates/matrix-sdk-ui/src/timeline/mod.rs

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ use matrix_sdk::{
3333
deserialized_responses::TimelineEvent,
3434
event_cache::{EventCacheDropHandles, RoomEventCache},
3535
executor::JoinHandle,
36-
room::{Receipts, Room, edit::EditedContent, reply::Reply},
36+
room::{
37+
Receipts, Room,
38+
edit::EditedContent,
39+
reply::{EnforceThread, Reply},
40+
},
3741
send_queue::{RoomSendQueueError, SendHandle},
3842
};
3943
use mime::Mime;
@@ -46,7 +50,7 @@ use ruma::{
4650
poll::unstable_start::{NewUnstablePollStartEventContent, UnstablePollStartEventContent},
4751
receipt::{Receipt, ReceiptThread},
4852
room::{
49-
message::RoomMessageEventContentWithoutRelation,
53+
message::{ReplyWithinThread, RoomMessageEventContentWithoutRelation},
5054
pinned_events::RoomPinnedEventsEventContent,
5155
},
5256
},
@@ -270,18 +274,65 @@ impl Timeline {
270274
/// If sending the message fails, the local echo item will change its
271275
/// `send_state` to [`EventSendState::SendingFailed`].
272276
///
277+
/// This will do the right thing in the presence of threads:
278+
/// - if this timeline is not focused on a thread, then it will send the
279+
/// event as is.
280+
/// - if this is a threaded timeline, and the event to send is a room
281+
/// message without a relationship, it will automatically mark it as a
282+
/// thread reply with the correct reply fallback, and send it.
283+
///
273284
/// # Arguments
274285
///
275286
/// * `content` - The content of the message event.
276-
///
277-
/// [`MessageLikeUnsigned`]: ruma::events::MessageLikeUnsigned
278-
/// [`SyncMessageLikeEvent`]: ruma::events::SyncMessageLikeEvent
279287
#[instrument(skip(self, content), fields(room_id = ?self.room().room_id()))]
280-
pub async fn send(
281-
&self,
282-
content: AnyMessageLikeEventContent,
283-
) -> Result<SendHandle, RoomSendQueueError> {
284-
self.room().send_queue().send(content).await
288+
pub async fn send(&self, content: AnyMessageLikeEventContent) -> Result<SendHandle, Error> {
289+
// If this is a room event we're sending in a threaded timeline, we add the
290+
// thread relation ourselves.
291+
if let AnyMessageLikeEventContent::RoomMessage(ref room_msg_content) = content
292+
&& room_msg_content.relates_to.is_none()
293+
&& let Some(thread_root) = self.controller.thread_root()
294+
{
295+
// The latest event id is used for the reply-to fallback, for clients which
296+
// don't handle threads. It should be correctly set to the latest
297+
// event in the thread, which the timeline instance might or might
298+
// not know about; in this case, we do a best effort of filling it, and resort
299+
// to using the thread root if we don't know about any event.
300+
//
301+
// Note: we could trigger a back-pagination if the timeline is empty, and wait
302+
// for the results, if the timeline is too often empty.
303+
let latest_event_id = self
304+
.controller
305+
.items()
306+
.await
307+
.iter()
308+
.rev()
309+
.find_map(|item| {
310+
if let TimelineItemKind::Event(event) = item.kind() {
311+
event.event_id().map(ToOwned::to_owned)
312+
} else {
313+
None
314+
}
315+
})
316+
.unwrap_or(thread_root);
317+
318+
let content = self
319+
.room()
320+
.make_reply_event(
321+
// Note: this `.into()` gets rid of the relation, but we've checked previously
322+
// that the `relates_to` field wasn't set.
323+
room_msg_content.clone().into(),
324+
Reply {
325+
event_id: latest_event_id,
326+
enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),
327+
},
328+
)
329+
.await?;
330+
331+
Ok(self.room().send_queue().send(content.into()).await?)
332+
} else {
333+
// Otherwise, we send the message as is.
334+
Ok(self.room().send_queue().send(content).await?)
335+
}
285336
}
286337

287338
/// Send a reply to the given event.

crates/matrix-sdk-ui/tests/integration/timeline/thread.rs

Lines changed: 119 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ use eyeball_im::VectorDiff;
1919
use futures_util::StreamExt as _;
2020
use matrix_sdk::{
2121
assert_let_timeout,
22-
room::reply::{EnforceThread, Reply},
2322
test_utils::mocks::{MatrixMockServer, RoomRelationsResponseTemplate},
2423
};
2524
use matrix_sdk_test::{
@@ -32,8 +31,9 @@ use ruma::{
3231
api::client::receipt::create_receipt::v3::ReceiptType as SendReceiptType,
3332
event_id,
3433
events::{
34+
AnySyncTimelineEvent,
3535
receipt::{ReceiptThread, ReceiptType},
36-
room::message::{ReplyWithinThread, RoomMessageEventContentWithoutRelation},
36+
room::message::{Relation, ReplacementMetadata, RoomMessageEventContent},
3737
},
3838
owned_event_id, room_id, user_id,
3939
};
@@ -732,17 +732,12 @@ async fn test_thread_timeline_gets_local_echoes() {
732732
// update.
733733
let sent_event_id = event_id!("$sent_msg");
734734
server.mock_room_state_encryption().plain().mount().await;
735-
server.mock_room_send().ok(sent_event_id).mock_once().mount().await;
736-
timeline
737-
.send_reply(
738-
RoomMessageEventContentWithoutRelation::text_plain("hello to you too!"),
739-
Reply {
740-
event_id: threaded_event_id.to_owned(),
741-
enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),
742-
},
743-
)
744-
.await
745-
.unwrap();
735+
736+
let (event_receiver, mock_builder) =
737+
server.mock_room_send().ok_with_capture(sent_event_id, *ALICE);
738+
mock_builder.mock_once().mount().await;
739+
740+
timeline.send(RoomMessageEventContent::text_plain("hello to you too!").into()).await.unwrap();
746741

747742
// I get the local echo for the in-thread event.
748743
assert_let_timeout!(Some(timeline_updates) = stream.next());
@@ -753,19 +748,41 @@ async fn test_thread_timeline_gets_local_echoes() {
753748
assert!(event_item.is_local_echo());
754749
assert!(event_item.event_id().is_none());
755750

751+
// The thread information is properly filled.
752+
let msglike = event_item.content().as_msglike().unwrap();
753+
assert_eq!(msglike.thread_root.as_ref(), Some(&thread_root_event_id));
754+
assert!(msglike.in_reply_to.is_none());
755+
756756
// Then the local echo morphs into a sent local echo.
757757
assert_let_timeout!(Some(timeline_updates) = stream.next());
758758
assert_eq!(timeline_updates.len(), 1);
759759

760760
assert_let!(VectorDiff::Set { index: 2, value } = &timeline_updates[0]);
761761
let event_item = value.as_event().unwrap();
762762
assert_eq!(event_item.event_id(), Some(sent_event_id));
763-
assert_eq!(event_item.event_id(), Some(sent_event_id));
764763
assert!(event_item.content().reactions().unwrap().is_empty());
765764

766765
// Then nothing else.
767766
assert_pending!(stream);
768767

768+
// The raw event includes the correctly reply-to fallback.
769+
{
770+
let raw_event = event_receiver.await.unwrap();
771+
let event = raw_event.deserialize().unwrap();
772+
assert_let!(
773+
AnySyncTimelineEvent::MessageLike(
774+
ruma::events::AnySyncMessageLikeEvent::RoomMessage(event)
775+
) = event
776+
);
777+
let event = event.as_original().unwrap();
778+
assert_let!(Some(Relation::Thread(thread)) = event.content.relates_to.clone());
779+
assert_eq!(thread.event_id, thread_root_event_id);
780+
assert!(thread.is_falling_back);
781+
// The reply-to fallback is set to the latest in-thread event, not the thread
782+
// root.
783+
assert_eq!(thread.in_reply_to.unwrap().event_id, threaded_event_id);
784+
}
785+
769786
// If I send a reaction for the in-thread event, the timeline gets updated, even
770787
// though the reaction doesn't mention the thread directly.
771788
server.mock_room_send().ok(event_id!("$reaction_id")).mock_once().mount().await;
@@ -791,6 +808,94 @@ async fn test_thread_timeline_gets_local_echoes() {
791808
assert_pending!(stream);
792809
}
793810

811+
#[async_test]
812+
async fn test_thread_timeline_can_send_edit() {
813+
// If I send an edit to a threaded timeline, it just works (aka the system to
814+
// set the threaded relationship doesn't kick in, since there's already a
815+
// relationship).
816+
817+
let server = MatrixMockServer::new().await;
818+
let client = server.client_builder().build().await;
819+
820+
let room_id = room_id!("!a:b.c");
821+
let thread_root_event_id = owned_event_id!("$root");
822+
let threaded_event_id = event_id!("$threaded_event");
823+
824+
let room = server.sync_joined_room(&client, room_id).await;
825+
826+
let timeline = room
827+
.timeline_builder()
828+
.with_focus(TimelineFocus::Thread { root_event_id: thread_root_event_id.clone() })
829+
.build()
830+
.await
831+
.unwrap();
832+
833+
let (initial_items, mut stream) = timeline.subscribe().await;
834+
835+
// At first, the timeline is empty.
836+
assert!(initial_items.is_empty());
837+
assert_pending!(stream);
838+
839+
// Start the timeline with an in-thread event.
840+
let f = EventFactory::new();
841+
server
842+
.sync_room(
843+
&client,
844+
JoinedRoomBuilder::new(room_id).add_timeline_event(
845+
f.text_msg("hello world")
846+
.sender(*ALICE)
847+
.event_id(threaded_event_id)
848+
.in_thread(&thread_root_event_id, threaded_event_id)
849+
.server_ts(MilliSecondsSinceUnixEpoch::now()),
850+
),
851+
)
852+
.await;
853+
854+
// Sanity check: I receive the event and the date divider.
855+
assert_let_timeout!(Some(timeline_updates) = stream.next());
856+
assert_eq!(timeline_updates.len(), 2);
857+
858+
// If I send an edit to an in-thread event, the timeline receives an update.
859+
// Note: it's a bit far fetched to send an edit without using
860+
// `Timeline::edit()`, but since it's possible…
861+
let sent_event_id = event_id!("$sent_msg");
862+
server.mock_room_state_encryption().plain().mount().await;
863+
864+
// No mock_once here: this endpoint may or may not be called, depending on
865+
// timing of the end of the test.
866+
server.mock_room_send().ok(sent_event_id).mount().await;
867+
868+
timeline
869+
.send(
870+
RoomMessageEventContent::text_plain("bonjour monde")
871+
.make_replacement(
872+
ReplacementMetadata::new(threaded_event_id.to_owned(), None),
873+
None,
874+
)
875+
.into(),
876+
)
877+
.await
878+
.unwrap();
879+
880+
// I get the local echo for the in-thread event.
881+
assert_let_timeout!(Some(timeline_updates) = stream.next());
882+
assert_eq!(timeline_updates.len(), 1);
883+
884+
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[0]);
885+
let event_item = value.as_event().unwrap();
886+
887+
// The thread information is (still) present.
888+
let msglike = event_item.content().as_msglike().unwrap();
889+
assert_eq!(msglike.thread_root.as_ref(), Some(&thread_root_event_id));
890+
assert!(msglike.in_reply_to.is_none());
891+
892+
// Text is eagerly updated.
893+
assert_eq!(msglike.as_message().unwrap().body(), "bonjour monde");
894+
895+
// Then we're done.
896+
assert_pending!(stream);
897+
}
898+
794899
#[async_test]
795900
async fn test_sending_read_receipt_with_no_events_doesnt_unset_read_flag() {
796901
// If a thread timeline has no events, then marking it as read doesn't unset the

0 commit comments

Comments
 (0)