1use std::sync::Arc;
16
17use eyeball::{ObservableWriteGuard, SharedObservable, Subscriber};
18use eyeball_im::{ObservableVector, VectorDiff, VectorSubscriberBatchedStream};
19use futures_util::future::join_all;
20use imbl::Vector;
21use matrix_sdk::{
22 Result, Room,
23 deserialized_responses::TimelineEvent,
24 event_cache::{RoomEventCacheUpdate, Subscriber as EventCacheSubscriber},
25 locks::Mutex,
26 paginators::PaginationToken,
27 room::ListThreadsOptions,
28 task_monitor::BackgroundTaskHandle,
29};
30use matrix_sdk_common::serde_helpers::extract_thread_root;
31use ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId};
32use tokio::sync::Mutex as AsyncMutex;
33use tracing::{error, trace, warn};
34
35use crate::timeline::{Profile, TimelineDetails, TimelineItemContent, traits::RoomDataProvider};
36
37#[derive(Clone, Debug)]
47pub struct ThreadListItem {
48 pub root_event: ThreadListItemEvent,
50
51 pub latest_event: Option<ThreadListItemEvent>,
57
58 pub num_replies: u32,
63}
64
65#[derive(Clone, Debug)]
68pub struct ThreadListItemEvent {
69 pub event_id: OwnedEventId,
71
72 pub timestamp: MilliSecondsSinceUnixEpoch,
74
75 pub sender: OwnedUserId,
77
78 pub is_own: bool,
80
81 pub sender_profile: TimelineDetails<Profile>,
83
84 pub content: Option<TimelineItemContent>,
90}
91
92#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
94#[derive(Clone, Debug, Eq, PartialEq)]
95pub enum ThreadListPaginationState {
96 Idle {
98 end_reached: bool,
101 },
102 Loading,
104}
105
106#[derive(Debug, thiserror::Error)]
108pub enum ThreadListServiceError {
109 #[error(transparent)]
111 Sdk(#[from] matrix_sdk::Error),
112}
113
114pub struct ThreadListService {
149 room: Room,
151
152 token: AsyncMutex<PaginationToken>,
154
155 pagination_state: SharedObservable<ThreadListPaginationState>,
157
158 items: Arc<Mutex<ObservableVector<ThreadListItem>>>,
160
161 _event_cache_task: BackgroundTaskHandle,
164}
165
166impl ThreadListService {
167 pub fn new(room: Room) -> Self {
173 let items: Arc<Mutex<ObservableVector<ThreadListItem>>> =
174 Arc::new(Mutex::new(ObservableVector::new()));
175
176 if let Err(e) = room.client().event_cache().subscribe() {
179 warn!("ThreadListService: failed to subscribe event cache to sync: {e}");
180 }
181
182 let event_cache_task = room
183 .client()
184 .task_monitor()
185 .spawn_infinite_task("thread_list_service::event_cache_listener", {
186 let room = room.clone();
187 let items = items.clone();
188 async move {
189 let (_event_cache_drop, mut subscriber) = match async {
191 let (room_event_cache, drop_handles) = room.event_cache().await?;
192 let (_, subscriber) = room_event_cache.subscribe().await?;
193 matrix_sdk::event_cache::Result::Ok((drop_handles, subscriber))
194 }
195 .await
196 {
197 Ok(pair) => pair,
198 Err(e) => {
199 error!(
200 "ThreadListService: failed to subscribe to room event cache, \
201 live updates will not work: {e}"
202 );
203 return;
204 }
205 };
206
207 trace!("ThreadListService: event cache listener started");
208
209 Self::event_cache_listener_loop(&room, &mut subscriber, items).await;
210 }
211 })
212 .abort_on_drop();
213
214 Self {
215 room,
216 token: AsyncMutex::new(PaginationToken::None),
217 pagination_state: SharedObservable::new(ThreadListPaginationState::Idle {
218 end_reached: false,
219 }),
220 items,
221 _event_cache_task: event_cache_task,
222 }
223 }
224
225 pub fn pagination_state(&self) -> ThreadListPaginationState {
227 self.pagination_state.get()
228 }
229
230 pub fn subscribe_to_pagination_state_updates(&self) -> Subscriber<ThreadListPaginationState> {
235 self.pagination_state.subscribe()
236 }
237
238 pub fn items(&self) -> Vec<ThreadListItem> {
240 self.items.lock().iter().cloned().collect()
241 }
242
243 pub fn subscribe_to_items_updates(
248 &self,
249 ) -> (Vector<ThreadListItem>, VectorSubscriberBatchedStream<ThreadListItem>) {
250 self.items.lock().subscribe().into_values_and_batched_stream()
251 }
252
253 pub async fn paginate(&self) -> Result<(), ThreadListServiceError> {
261 {
263 let mut pagination_state = self.pagination_state.write();
264
265 match *pagination_state {
266 ThreadListPaginationState::Idle { end_reached: true }
267 | ThreadListPaginationState::Loading => return Ok(()),
268 _ => {}
269 }
270
271 ObservableWriteGuard::set(&mut pagination_state, ThreadListPaginationState::Loading);
272 }
273
274 let mut pagination_token = self.token.lock().await;
275
276 let from = match &*pagination_token {
279 PaginationToken::HasMore(token) => Some(token.clone()),
280 _ => None,
281 };
282
283 let opts = ListThreadsOptions { from, ..Default::default() };
284
285 match self.load_thread_list(opts).await {
286 Ok(thread_list) => {
287 *pagination_token = match &thread_list.prev_batch_token {
290 Some(token) => PaginationToken::HasMore(token.clone()),
291 None => PaginationToken::HitEnd,
292 };
293
294 let end_reached = thread_list.prev_batch_token.is_none();
295
296 self.items.lock().append(thread_list.items.into());
298
299 self.pagination_state.set(ThreadListPaginationState::Idle { end_reached });
300
301 Ok(())
302 }
303 Err(err) => {
304 self.pagination_state.set(ThreadListPaginationState::Idle { end_reached: false });
305 Err(ThreadListServiceError::Sdk(err))
306 }
307 }
308 }
309
310 pub async fn reset(&self) {
317 let mut pagination_token = self.token.lock().await;
318 *pagination_token = PaginationToken::None;
319
320 self.items.lock().clear();
321
322 self.pagination_state.set(ThreadListPaginationState::Idle { end_reached: false });
323 }
324
325 async fn load_thread_list(&self, opts: ListThreadsOptions) -> Result<ThreadList> {
326 let thread_roots = self.room.list_threads(opts).await?;
327
328 let list_items = join_all(
329 thread_roots
330 .chunk
331 .into_iter()
332 .map(|timeline_event| Self::build_thread_list_item(&self.room, timeline_event))
333 .collect::<Vec<_>>(),
334 )
335 .await
336 .into_iter()
337 .flatten()
338 .collect();
339
340 Ok(ThreadList { items: list_items, prev_batch_token: thread_roots.prev_batch_token })
341 }
342
343 async fn build_thread_list_item(
344 room: &Room,
345 timeline_event: TimelineEvent,
346 ) -> Option<ThreadListItem> {
347 let thread_summary_with_latest_event = timeline_event.thread_summary_with_latest_event();
349
350 let root_event = Self::build_event(room, timeline_event).await?;
352
353 let mut num_replies = 0;
355 let mut latest_event = None;
356
357 if let Some((thread_summary, latest_timeline_event)) = thread_summary_with_latest_event {
358 num_replies = thread_summary.num_replies;
359 latest_event = Self::build_event(room, latest_timeline_event).await
360 }
361
362 Some(ThreadListItem { root_event, latest_event, num_replies })
363 }
364
365 async fn build_event(
367 room: &Room,
368 timeline_event: TimelineEvent,
369 ) -> Option<ThreadListItemEvent> {
370 let event_id = timeline_event.event_id()?.to_owned();
371 let timestamp = timeline_event.timestamp()?;
372 let sender = timeline_event.sender()?;
373 let is_own = room.own_user_id() == sender;
374 let sender_profile =
375 TimelineDetails::from_initial_value(Profile::load(room, &sender).await);
376 let content = TimelineItemContent::from_event(room, timeline_event).await;
377 Some(ThreadListItemEvent { event_id, timestamp, sender, is_own, sender_profile, content })
378 }
379
380 async fn event_cache_listener_loop(
386 room: &Room,
387 subscriber: &mut EventCacheSubscriber<RoomEventCacheUpdate>,
388 items: Arc<Mutex<ObservableVector<ThreadListItem>>>,
389 ) {
390 use tokio::sync::broadcast::error::RecvError;
391
392 loop {
393 let update = match subscriber.recv().await {
394 Ok(update) => update,
395 Err(RecvError::Closed) => {
396 error!("ThreadListService: event cache channel closed, stopping listener");
397 break;
398 }
399 Err(RecvError::Lagged(n)) => {
400 warn!("ThreadListService: lagged behind {n} event cache updates");
401 continue;
402 }
403 };
404
405 if let RoomEventCacheUpdate::UpdateTimelineEvents(timeline_diffs) = update {
406 let new_events = Self::collect_events_from_diffs(timeline_diffs.diffs);
407
408 for event in new_events {
409 let Some(thread_root) = extract_thread_root(event.raw()) else { continue };
412
413 let position = {
415 let guard = items.lock();
416 guard.iter().position(|item| item.root_event.event_id == thread_root)
417 };
418
419 if let Some(index) = position {
420 if let Some(latest_event) = Self::build_event(room, event).await {
423 let mut guard = items.lock();
424
425 if index < guard.len()
429 && guard[index].root_event.event_id == thread_root
430 {
431 let mut updated = guard[index].clone();
432 updated.latest_event = Some(latest_event);
433 updated.num_replies = updated.num_replies.saturating_add(1);
434 guard.set(index, updated);
435 }
436 }
437 }
438 }
439 }
440 }
441 }
442
443 fn collect_events_from_diffs(
445 diffs: Vec<VectorDiff<matrix_sdk_base::event_cache::Event>>,
446 ) -> Vec<matrix_sdk_base::event_cache::Event> {
447 let mut events = Vec::new();
448
449 for diff in diffs {
450 match diff {
451 VectorDiff::Append { values } => events.extend(values),
452 VectorDiff::PushBack { value }
453 | VectorDiff::PushFront { value }
454 | VectorDiff::Insert { value, .. }
455 | VectorDiff::Set { value, .. } => events.push(value),
456 VectorDiff::Reset { values } => events.extend(values),
457 VectorDiff::Clear
459 | VectorDiff::PopBack
460 | VectorDiff::PopFront
461 | VectorDiff::Remove { .. }
462 | VectorDiff::Truncate { .. } => {}
463 }
464 }
465
466 events
467 }
468}
469
470#[derive(Clone, Debug)]
473struct ThreadList {
474 pub items: Vec<ThreadListItem>,
476
477 pub prev_batch_token: Option<String>,
479}
480
481#[cfg(test)]
482mod tests {
483 use std::time::Duration;
484
485 use assert_matches::assert_matches;
486 use futures_util::pin_mut;
487 use matrix_sdk::test_utils::mocks::MatrixMockServer;
488 use matrix_sdk_test::{async_test, event_factory::EventFactory};
489 use ruma::{
490 event_id,
491 events::{
492 AnyTimelineEvent,
493 room::{
494 encrypted::{
495 EncryptedEventScheme, MegolmV1AesSha2ContentInit, RoomEncryptedEventContent,
496 },
497 message::RedactedRoomMessageEventContent,
498 },
499 },
500 room_id,
501 serde::Raw,
502 user_id,
503 };
504 use serde_json::json;
505 use stream_assert::{assert_next_matches, assert_pending};
506 use wiremock::ResponseTemplate;
507
508 use super::{ThreadListPaginationState, ThreadListService};
509 use crate::timeline::{MsgLikeContent, MsgLikeKind, TimelineItemContent};
510
511 #[async_test]
512 async fn test_initial_state() {
513 let server = MatrixMockServer::new().await;
514 let service = make_service(&server).await;
515
516 assert_eq!(
517 service.pagination_state(),
518 ThreadListPaginationState::Idle { end_reached: false }
519 );
520 assert!(service.items().is_empty());
521 }
522
523 #[async_test]
524 async fn test_pagination() {
525 let server = MatrixMockServer::new().await;
526 let client = server.client_builder().build().await;
527 let room_id = room_id!("!a:b.c");
528 let sender_id = user_id!("@alice:b.c");
529
530 let f = EventFactory::new().room(room_id).sender(sender_id);
531
532 let eid1 = event_id!("$1");
533 let eid2 = event_id!("$2");
534
535 server
536 .mock_room_threads()
537 .ok(
538 vec![f.text_msg("Thread root 1").event_id(eid1).into_raw()],
539 Some("next_page_token".to_owned()),
540 )
541 .mock_once()
542 .mount()
543 .await;
544
545 server
546 .mock_room_threads()
547 .match_from("next_page_token")
548 .ok(vec![f.text_msg("Thread root 2").event_id(eid2).into_raw()], None)
549 .mock_once()
550 .mount()
551 .await;
552
553 let room = server.sync_joined_room(&client, room_id).await;
554 let service = ThreadListService::new(room);
555
556 service.paginate().await.expect("first paginate failed");
557
558 assert_eq!(
559 service.pagination_state(),
560 ThreadListPaginationState::Idle { end_reached: false }
561 );
562 assert_eq!(service.items().len(), 1);
563 assert_eq!(service.items()[0].root_event.event_id, eid1);
564
565 service.paginate().await.expect("second paginate failed");
566
567 assert_eq!(
568 service.pagination_state(),
569 ThreadListPaginationState::Idle { end_reached: true }
570 );
571 assert_eq!(service.items().len(), 2);
572 assert_eq!(service.items()[1].root_event.event_id, eid2);
573 }
574
575 #[async_test]
576 async fn test_pagination_end_reached() {
577 let server = MatrixMockServer::new().await;
578 let client = server.client_builder().build().await;
579 let room_id = room_id!("!a:b.c");
580 let sender_id = user_id!("@alice:b.c");
581 let f = EventFactory::new().room(room_id).sender(sender_id);
582 let eid1 = event_id!("$1");
583
584 server
585 .mock_room_threads()
586 .ok(vec![f.text_msg("Thread root").event_id(eid1).into_raw()], None)
587 .mock_once()
588 .mount()
589 .await;
590
591 let room = server.sync_joined_room(&client, room_id).await;
592 let service = ThreadListService::new(room);
593
594 service.paginate().await.expect("paginate failed");
595 assert_eq!(
596 service.pagination_state(),
597 ThreadListPaginationState::Idle { end_reached: true }
598 );
599 assert_eq!(service.items().len(), 1);
600
601 service.paginate().await.expect("second paginate should be a no-op");
602 assert_eq!(service.items().len(), 1);
603 assert_eq!(
604 service.pagination_state(),
605 ThreadListPaginationState::Idle { end_reached: true }
606 );
607 }
608
609 #[async_test]
614 async fn test_concurrent_pagination_is_not_possible() {
615 let server = MatrixMockServer::new().await;
616 let client = server.client_builder().build().await;
617 let room_id = room_id!("!a:b.c");
618 let sender_id = user_id!("@alice:b.c");
619 let f = EventFactory::new().room(room_id).sender(sender_id);
620 let eid1 = event_id!("$1");
621
622 let chunk: Vec<Raw<AnyTimelineEvent>> =
626 vec![f.text_msg("Thread root").event_id(eid1).into_raw()];
627 server
628 .mock_room_threads()
629 .respond_with(
630 ResponseTemplate::new(200)
631 .set_body_json(json!({ "chunk": chunk, "next_batch": null }))
632 .set_delay(Duration::from_millis(100)),
633 )
634 .expect(1)
635 .mount()
636 .await;
637
638 let room = server.sync_joined_room(&client, room_id).await;
639 let service = ThreadListService::new(room);
640
641 let (first, second) = tokio::join!(service.paginate(), service.paginate());
643
644 first.expect("first paginate should succeed");
645 second.expect("second (concurrent) paginate should succeed as a no-op");
646
647 assert_eq!(service.items().len(), 1);
649 assert_eq!(service.items()[0].root_event.event_id, eid1);
650 assert_eq!(
651 service.pagination_state(),
652 ThreadListPaginationState::Idle { end_reached: true }
653 );
654 }
655
656 #[async_test]
660 async fn test_pagination_error() {
661 let server = MatrixMockServer::new().await;
662 let client = server.client_builder().build().await;
663 let room_id = room_id!("!a:b.c");
664
665 server.mock_room_threads().error500().mock_once().mount().await;
666
667 let room = server.sync_joined_room(&client, room_id).await;
668 let service = ThreadListService::new(room);
669
670 service.paginate().await.expect_err("paginate should fail on a 500 response");
672
673 assert_eq!(
676 service.pagination_state(),
677 ThreadListPaginationState::Idle { end_reached: false }
678 );
679
680 assert!(service.items().is_empty());
682 }
683
684 #[async_test]
685 async fn test_reset() {
686 let server = MatrixMockServer::new().await;
687 let client = server.client_builder().build().await;
688 let room_id = room_id!("!a:b.c");
689 let sender_id = user_id!("@alice:b.c");
690 let f = EventFactory::new().room(room_id).sender(sender_id);
691 let eid1 = event_id!("$1");
692
693 server
694 .mock_room_threads()
695 .ok(vec![f.text_msg("Thread root").event_id(eid1).into_raw()], None)
696 .expect(2)
697 .mount()
698 .await;
699
700 let room = server.sync_joined_room(&client, room_id).await;
701 let service = ThreadListService::new(room);
702
703 service.paginate().await.expect("first paginate failed");
704 assert_eq!(service.items().len(), 1);
705 assert_eq!(
706 service.pagination_state(),
707 ThreadListPaginationState::Idle { end_reached: true }
708 );
709
710 service.reset().await;
711 assert!(service.items().is_empty());
712 assert_eq!(
713 service.pagination_state(),
714 ThreadListPaginationState::Idle { end_reached: false }
715 );
716
717 service.paginate().await.expect("paginate after reset failed");
718 assert_eq!(service.items().len(), 1);
719 }
720
721 #[async_test]
722 async fn test_pagination_state_subscriber() {
723 let server = MatrixMockServer::new().await;
724 let client = server.client_builder().build().await;
725 let room_id = room_id!("!a:b.c");
726 let sender_id = user_id!("@alice:b.c");
727 let f = EventFactory::new().room(room_id).sender(sender_id);
728 let eid1 = event_id!("$1");
729
730 server
731 .mock_room_threads()
732 .ok(
733 vec![f.text_msg("Thread root").event_id(eid1).into_raw()],
734 Some("next_token".to_owned()),
735 )
736 .mock_once()
737 .mount()
738 .await;
739
740 let room = server.sync_joined_room(&client, room_id).await;
741 let service = ThreadListService::new(room);
742
743 let subscriber = service.subscribe_to_pagination_state_updates();
744 pin_mut!(subscriber);
745
746 assert_pending!(subscriber);
747
748 service.paginate().await.expect("paginate failed");
749
750 assert_next_matches!(subscriber, ThreadListPaginationState::Idle { end_reached: false });
751 }
752
753 #[async_test]
754 async fn test_paginated_items_have_num_replies_zero_without_summary() {
755 let server = MatrixMockServer::new().await;
756 let client = server.client_builder().build().await;
757 let room_id = room_id!("!a:b.c");
758 let sender_id = user_id!("@alice:b.c");
759 let f = EventFactory::new().room(room_id).sender(sender_id);
760 let eid1 = event_id!("$1");
761
762 server
764 .mock_room_threads()
765 .ok(vec![f.text_msg("Thread root").event_id(eid1).into_raw()], None)
766 .mock_once()
767 .mount()
768 .await;
769
770 let room = server.sync_joined_room(&client, room_id).await;
771 let service = ThreadListService::new(room);
772
773 service.paginate().await.expect("paginate failed");
774
775 let items = service.items();
776 assert_eq!(items.len(), 1);
777 assert_eq!(items[0].num_replies, 0);
778 assert!(items[0].latest_event.is_none());
779 }
780
781 #[async_test]
782 async fn test_paginated_items_have_num_replies_from_bundled_summary() {
783 let server = MatrixMockServer::new().await;
784 let client = server.client_builder().build().await;
785 let room_id = room_id!("!a:b.c");
786 let sender_id = user_id!("@alice:b.c");
787 let f = EventFactory::new().room(room_id).sender(sender_id);
788 let root_id = event_id!("$root");
789 let reply_id = event_id!("$reply");
790
791 let reply_event =
795 f.text_msg("Reply in thread").event_id(reply_id).into_raw_sync().cast_unchecked();
796
797 let thread_root = f
799 .text_msg("Thread root")
800 .event_id(root_id)
801 .with_bundled_thread_summary(reply_event, 3, false)
802 .into_raw();
803
804 server.mock_room_threads().ok(vec![thread_root], None).mock_once().mount().await;
805
806 let room = server.sync_joined_room(&client, room_id).await;
807 let service = ThreadListService::new(room);
808
809 service.paginate().await.expect("paginate failed");
810
811 let items = service.items();
812 assert_eq!(items.len(), 1);
813 assert_eq!(items[0].root_event.event_id, root_id);
814 assert_eq!(items[0].num_replies, 3);
815
816 let latest = items[0].latest_event.as_ref().expect("should have latest_event");
818 assert_eq!(latest.event_id, reply_id);
819 assert_eq!(latest.sender.as_str(), sender_id.as_str());
820 }
821
822 #[async_test]
823 async fn test_redacted_root_with_encrypted_latest_event() {
824 let server = MatrixMockServer::new().await;
825 let client = server.client_builder().build().await;
826 let room_id = room_id!("!a:b.c");
827 let sender_id = user_id!("@alice:b.c");
828 let f = EventFactory::new().room(room_id).sender(sender_id);
829 let root_id = event_id!("$root");
830 let latest_id = event_id!("$latest");
831
832 let encrypted_latest = f
834 .event(RoomEncryptedEventContent::new(
835 EncryptedEventScheme::MegolmV1AesSha2(
836 MegolmV1AesSha2ContentInit {
837 ciphertext: "ciphertext".to_owned(),
838 sender_key: "sender-key".to_owned(),
839 device_id: "device-id".to_owned().into(),
840 session_id: "session-id".to_owned(),
841 }
842 .into(),
843 ),
844 None,
845 ))
846 .event_id(latest_id)
847 .into_raw_sync()
848 .cast_unchecked();
849
850 let thread_root = f
853 .redacted(sender_id, RedactedRoomMessageEventContent::new())
854 .event_id(root_id)
855 .with_bundled_thread_summary(encrypted_latest, 3, false)
856 .into_raw();
857
858 server.mock_room_threads().ok(vec![thread_root], None).mock_once().mount().await;
859
860 let room = server.sync_joined_room(&client, room_id).await;
861 let service = ThreadListService::new(room);
862
863 service.paginate().await.expect("paginate failed");
864
865 let items = service.items();
866 assert_eq!(items.len(), 1);
867
868 let latest = items[0].latest_event.as_ref().expect("should have latest_event");
871 assert_matches!(
872 latest.content,
873 Some(TimelineItemContent::MsgLike(MsgLikeContent {
874 kind: MsgLikeKind::UnableToDecrypt(_),
875 ..
876 }))
877 );
878 }
879
880 #[async_test]
881 async fn test_redacted_root_still_listed_with_summary() {
882 let server = MatrixMockServer::new().await;
883 let client = server.client_builder().build().await;
884 let room_id = room_id!("!a:b.c");
885 let sender_id = user_id!("@alice:b.c");
886 let f = EventFactory::new().room(room_id).sender(sender_id);
887 let root_id = event_id!("$root");
888 let reply_id = event_id!("$reply");
889
890 let reply_event =
891 f.text_msg("Reply in thread").event_id(reply_id).into_raw_sync().cast_unchecked();
892
893 let thread_root = f
895 .redacted(sender_id, RedactedRoomMessageEventContent::new())
896 .event_id(root_id)
897 .with_bundled_thread_summary(reply_event, 3, false)
898 .into_raw();
899
900 server.mock_room_threads().ok(vec![thread_root], None).mock_once().mount().await;
901
902 let room = server.sync_joined_room(&client, room_id).await;
903 let service = ThreadListService::new(room);
904
905 service.paginate().await.expect("paginate failed");
906
907 let items = service.items();
908 assert_eq!(items.len(), 1);
909 assert_eq!(items[0].root_event.event_id, root_id);
910 assert_eq!(items[0].num_replies, 3);
911
912 assert!(matches!(
914 items[0].root_event.content,
915 Some(TimelineItemContent::MsgLike(MsgLikeContent { kind: MsgLikeKind::Redacted, .. }))
916 ));
917
918 let latest = items[0].latest_event.as_ref().expect("should have latest_event");
920 assert_eq!(latest.event_id, reply_id);
921 assert!(matches!(
922 latest.content,
923 Some(TimelineItemContent::MsgLike(MsgLikeContent {
924 kind: MsgLikeKind::Message(_),
925 ..
926 }))
927 ));
928 }
929
930 async fn make_service(server: &MatrixMockServer) -> ThreadListService {
933 let client = server.client_builder().build().await;
934 let room_id = room_id!("!a:b.c");
935 let room = server.sync_joined_room(&client, room_id).await;
936 ThreadListService::new(room)
937 }
938}