Skip to main content

matrix_sdk_ui/timeline/
thread_list_service.rs

1// Copyright 2026 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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/// Each `ThreadListItem` represents one thread root event in the room. The
38/// fields are pre-resolved from the raw homeserver response: the sender's
39/// profile is fetched eagerly and the event content is parsed into a
40/// [`TimelineItemContent`] so that consumers can render the item without any
41/// additional work.
42///
43/// `ThreadListItem`s are accumulated inside
44/// [`super::thread_list_service::ThreadListService`] as pages are fetched via
45/// [`super::thread_list_service::ThreadListService::paginate`].
46#[derive(Clone, Debug)]
47pub struct ThreadListItem {
48    /// The thread root event.
49    pub root_event: ThreadListItemEvent,
50
51    /// The latest event in the thread (i.e. the most recent reply), if
52    /// available.
53    ///
54    /// This is initially populated from the server's bundled thread summary and
55    /// is updated in real time as new events arrive via sync.
56    pub latest_event: Option<ThreadListItemEvent>,
57
58    /// The number of replies in this thread (excluding the root event).
59    ///
60    /// This is initially populated from the server's bundled thread summary and
61    /// is updated in real time as new events arrive via sync.
62    pub num_replies: u32,
63}
64
65/// Information about an event in a thread (either the root or the latest
66/// reply).
67#[derive(Clone, Debug)]
68pub struct ThreadListItemEvent {
69    /// The event ID.
70    pub event_id: OwnedEventId,
71
72    /// The timestamp of the event.
73    pub timestamp: MilliSecondsSinceUnixEpoch,
74
75    /// The sender of the event.
76    pub sender: OwnedUserId,
77
78    /// Whether the event was sent by the current user.
79    pub is_own: bool,
80
81    /// The sender's profile (display name and avatar URL).
82    pub sender_profile: TimelineDetails<Profile>,
83
84    /// The parsed content of the event, if available.
85    ///
86    /// `None` when the event could not be deserialized into a known
87    /// [`TimelineItemContent`] variant (e.g. an unsupported or redacted event
88    /// type).
89    pub content: Option<TimelineItemContent>,
90}
91
92/// The pagination state of a [`ThreadListService`].
93#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
94#[derive(Clone, Debug, Eq, PartialEq)]
95pub enum ThreadListPaginationState {
96    /// The list is idle (not currently loading).
97    Idle {
98        /// Whether the end of the thread list has been reached (no more pages
99        /// to load).
100        end_reached: bool,
101    },
102    /// The list is currently loading the next page.
103    Loading,
104}
105
106/// An error that occurred while using a [`ThreadListService`].
107#[derive(Debug, thiserror::Error)]
108pub enum ThreadListServiceError {
109    /// An error from the underlying Matrix SDK.
110    #[error(transparent)]
111    Sdk(#[from] matrix_sdk::Error),
112}
113
114/// A paginated list of threads for a given room.
115///
116/// `ThreadListService` provides an observable, paginated list of
117/// [`ThreadListItem`]s. It exposes methods to paginate forward through the
118/// thread list as well as subscribe to state changes.
119///
120/// When created, the service automatically starts a background task that
121/// listens to room event cache updates (from `/sync` and other sources).
122/// Whenever a new event belonging to a known thread arrives, the service
123/// updates that thread's `latest_event` and `num_replies` fields in real time,
124/// emitting observable diffs to all subscribers.
125///
126/// # Example
127///
128/// ```no_run
129/// use matrix_sdk::Room;
130/// use matrix_sdk_ui::timeline::thread_list_service::{
131///     ThreadListPaginationState, ThreadListService,
132/// };
133///
134/// # async {
135/// # let room: Room = todo!();
136/// let service = ThreadListService::new(room);
137///
138/// assert_eq!(
139///     service.pagination_state(),
140///     ThreadListPaginationState::Idle { end_reached: false }
141/// );
142///
143/// service.paginate().await.unwrap();
144///
145/// let items = service.items();
146/// # anyhow::Ok(()) };
147/// ```
148pub struct ThreadListService {
149    /// The room whose threads are being listed.
150    room: Room,
151
152    /// The pagination token used to fetch subsequent pages.
153    token: AsyncMutex<PaginationToken>,
154
155    /// The current pagination state.
156    pagination_state: SharedObservable<ThreadListPaginationState>,
157
158    /// The current list of thread items.
159    items: Arc<Mutex<ObservableVector<ThreadListItem>>>,
160
161    /// Handle to the background task listening for event cache updates.
162    /// Dropping this aborts the task.
163    _event_cache_task: BackgroundTaskHandle,
164}
165
166impl ThreadListService {
167    /// Creates a new [`ThreadListService`] for the given room.
168    ///
169    /// This immediately spawns a background task that listens to the room's
170    /// event cache for live updates. The task self-bootstraps by performing the
171    /// async event cache subscription internally.
172    pub fn new(room: Room) -> Self {
173        let items: Arc<Mutex<ObservableVector<ThreadListItem>>> =
174            Arc::new(Mutex::new(ObservableVector::new()));
175
176        let client = room.client();
177
178        let event_cache_task = client
179            .task_monitor()
180            .spawn_infinite_task("thread_list_service::event_cache_listener", {
181                let room = room.clone();
182                let items = items.clone();
183                let client = client.clone();
184
185                async move {
186                    // Obtain the room event cache and a subscriber.
187                    if let Some(event_cache) = client.event_cache()
188                        && let Ok((room_event_cache, _event_cache_drop_handles)) =
189                            event_cache.room(room.room_id()).await
190                        && let Ok((_initial_events, mut subscriber)) =
191                            room_event_cache.subscribe().await
192                    {
193                        trace!("ThreadListService: event cache listener started");
194
195                        Self::event_cache_listener_loop(&room, &mut subscriber, items).await;
196                    } else {
197                        error!(
198                            "ThreadListService: failed to subscribe to room event cache, \
199                                 live updates will not work"
200                        );
201                    }
202                }
203            })
204            .abort_on_drop();
205
206        Self {
207            room,
208            token: AsyncMutex::new(PaginationToken::None),
209            pagination_state: SharedObservable::new(ThreadListPaginationState::Idle {
210                end_reached: false,
211            }),
212            items,
213            _event_cache_task: event_cache_task,
214        }
215    }
216
217    /// Returns the current pagination state.
218    pub fn pagination_state(&self) -> ThreadListPaginationState {
219        self.pagination_state.get()
220    }
221
222    /// Subscribes to pagination state updates.
223    ///
224    /// The returned [`Subscriber`] will emit a new value every time the
225    /// pagination state changes.
226    pub fn subscribe_to_pagination_state_updates(&self) -> Subscriber<ThreadListPaginationState> {
227        self.pagination_state.subscribe()
228    }
229
230    /// Returns the current list of thread items as a snapshot.
231    pub fn items(&self) -> Vec<ThreadListItem> {
232        self.items.lock().iter().cloned().collect()
233    }
234
235    /// Subscribes to updates of the thread item list.
236    ///
237    /// Returns a snapshot of the current items alongside a batched stream of
238    /// [`eyeball_im::VectorDiff`]s that describe subsequent changes.
239    pub fn subscribe_to_items_updates(
240        &self,
241    ) -> (Vector<ThreadListItem>, VectorSubscriberBatchedStream<ThreadListItem>) {
242        self.items.lock().subscribe().into_values_and_batched_stream()
243    }
244
245    /// Fetches the next page of threads, appending the results to the item
246    /// list.
247    ///
248    /// - If the list is already loading or the end has been reached, this
249    ///   method returns immediately with `Ok(())`.
250    /// - On a network/SDK error the pagination state is reset to `Idle {
251    ///   end_reached: false }` and the error is propagated.
252    pub async fn paginate(&self) -> Result<(), ThreadListServiceError> {
253        // Guard: do nothing if we are already loading or have reached the end.
254        {
255            let mut pagination_state = self.pagination_state.write();
256
257            match *pagination_state {
258                ThreadListPaginationState::Idle { end_reached: true }
259                | ThreadListPaginationState::Loading => return Ok(()),
260                _ => {}
261            }
262
263            ObservableWriteGuard::set(&mut pagination_state, ThreadListPaginationState::Loading);
264        }
265
266        let mut pagination_token = self.token.lock().await;
267
268        // Build the options for this page, using the current token if we have
269        // one.
270        let from = match &*pagination_token {
271            PaginationToken::HasMore(token) => Some(token.clone()),
272            _ => None,
273        };
274
275        let opts = ListThreadsOptions { from, ..Default::default() };
276
277        match self.load_thread_list(opts).await {
278            Ok(thread_list) => {
279                // Update the pagination token based on whether there are more
280                // pages.
281                *pagination_token = match &thread_list.prev_batch_token {
282                    Some(token) => PaginationToken::HasMore(token.clone()),
283                    None => PaginationToken::HitEnd,
284                };
285
286                let end_reached = thread_list.prev_batch_token.is_none();
287
288                // Append new items to the observable vector.
289                self.items.lock().append(thread_list.items.into());
290
291                self.pagination_state.set(ThreadListPaginationState::Idle { end_reached });
292
293                Ok(())
294            }
295            Err(err) => {
296                self.pagination_state.set(ThreadListPaginationState::Idle { end_reached: false });
297                Err(ThreadListServiceError::Sdk(err))
298            }
299        }
300    }
301
302    /// Resets the service back to its initial state.
303    ///
304    /// Clears all loaded items, discards the current pagination token, and sets
305    /// the pagination state to `Idle { end_reached: false }`. The next call to
306    /// [`Self::paginate`] will therefore start from the beginning of the thread
307    /// list.
308    pub async fn reset(&self) {
309        let mut pagination_token = self.token.lock().await;
310        *pagination_token = PaginationToken::None;
311
312        self.items.lock().clear();
313
314        self.pagination_state.set(ThreadListPaginationState::Idle { end_reached: false });
315    }
316
317    async fn load_thread_list(&self, opts: ListThreadsOptions) -> Result<ThreadList> {
318        let thread_roots = self.room.list_threads(opts).await?;
319
320        let list_items = join_all(
321            thread_roots
322                .chunk
323                .into_iter()
324                .map(|timeline_event| Self::build_thread_list_item(&self.room, timeline_event))
325                .collect::<Vec<_>>(),
326        )
327        .await
328        .into_iter()
329        .flatten()
330        .collect();
331
332        Ok(ThreadList { items: list_items, prev_batch_token: thread_roots.prev_batch_token })
333    }
334
335    async fn build_thread_list_item(
336        room: &Room,
337        timeline_event: TimelineEvent,
338    ) -> Option<ThreadListItem> {
339        // Extract thread summary info before consuming the event.
340        let thread_summary_with_latest_event = timeline_event.thread_summary_with_latest_event();
341
342        // Build the root event using the same logic as latest events.
343        let root_event = Self::build_event(room, timeline_event).await?;
344
345        // Build `latest_event` and `num_replies`.
346        let mut num_replies = 0;
347        let mut latest_event = None;
348
349        if let Some((thread_summary, latest_timeline_event)) = thread_summary_with_latest_event {
350            num_replies = thread_summary.num_replies;
351            latest_event = Self::build_event(room, latest_timeline_event).await
352        }
353
354        Some(ThreadListItem { root_event, latest_event, num_replies })
355    }
356
357    /// Build a [`ThreadListItemEvent`] from a [`TimelineEvent`].
358    async fn build_event(
359        room: &Room,
360        timeline_event: TimelineEvent,
361    ) -> Option<ThreadListItemEvent> {
362        let event_id = timeline_event.event_id()?.to_owned();
363        let timestamp = timeline_event.timestamp()?;
364        let sender = timeline_event.sender()?;
365        let is_own = room.own_user_id() == sender;
366        let sender_profile =
367            TimelineDetails::from_initial_value(Profile::load(room, &sender).await);
368        let content = TimelineItemContent::from_event(room, timeline_event).await;
369        Some(ThreadListItemEvent { event_id, timestamp, sender, is_own, sender_profile, content })
370    }
371
372    /// The main loop of the event-cache listener task.
373    ///
374    /// Listens for [`RoomEventCacheUpdate`]s and, for each new timeline event
375    /// that belongs to a thread we are tracking, updates the corresponding
376    /// [`ThreadListItem`]'s `latest_event` and `num_replies`.
377    async fn event_cache_listener_loop(
378        room: &Room,
379        subscriber: &mut EventCacheSubscriber<RoomEventCacheUpdate>,
380        items: Arc<Mutex<ObservableVector<ThreadListItem>>>,
381    ) {
382        use tokio::sync::broadcast::error::RecvError;
383
384        loop {
385            let update = match subscriber.recv().await {
386                Ok(update) => update,
387                Err(RecvError::Closed) => {
388                    error!("ThreadListService: event cache channel closed, stopping listener");
389                    break;
390                }
391                Err(RecvError::Lagged(n)) => {
392                    warn!("ThreadListService: lagged behind {n} event cache updates");
393                    continue;
394                }
395            };
396
397            if let RoomEventCacheUpdate::UpdateTimelineEvents(timeline_diffs) = update {
398                let new_events = Self::collect_events_from_diffs(timeline_diffs.diffs);
399
400                for event in new_events {
401                    // Check if this event has a thread relation pointing to a
402                    // known root.
403                    let Some(thread_root) = extract_thread_root(event.raw()) else { continue };
404
405                    // Find the position of this thread root in our list.
406                    let position = {
407                        let guard = items.lock();
408                        guard.iter().position(|item| item.root_event.event_id == thread_root)
409                    };
410
411                    if let Some(index) = position {
412                        // Build the latest event representation from the raw
413                        // event.
414                        if let Some(latest_event) = Self::build_event(room, event).await {
415                            let mut guard = items.lock();
416
417                            // Re-check the position — the vector may have
418                            // changed while we were awaiting the profile lookup
419                            // above.
420                            if index < guard.len()
421                                && guard[index].root_event.event_id == thread_root
422                            {
423                                let mut updated = guard[index].clone();
424                                updated.latest_event = Some(latest_event);
425                                updated.num_replies = updated.num_replies.saturating_add(1);
426                                guard.set(index, updated);
427                            }
428                        }
429                    }
430                }
431            }
432        }
433    }
434
435    /// Extracts all events from a list of [`VectorDiff`]s.
436    fn collect_events_from_diffs(
437        diffs: Vec<VectorDiff<matrix_sdk_base::event_cache::Event>>,
438    ) -> Vec<matrix_sdk_base::event_cache::Event> {
439        let mut events = Vec::new();
440
441        for diff in diffs {
442            match diff {
443                VectorDiff::Append { values } => events.extend(values),
444                VectorDiff::PushBack { value }
445                | VectorDiff::PushFront { value }
446                | VectorDiff::Insert { value, .. }
447                | VectorDiff::Set { value, .. } => events.push(value),
448                VectorDiff::Reset { values } => events.extend(values),
449                // These diffs don't carry new events.
450                VectorDiff::Clear
451                | VectorDiff::PopBack
452                | VectorDiff::PopFront
453                | VectorDiff::Remove { .. }
454                | VectorDiff::Truncate { .. } => {}
455            }
456        }
457
458        events
459    }
460}
461
462/// A structure wrapping a Thread List endpoint response i.e.
463/// [`ThreadListItem`]s and the current pagination token.
464#[derive(Clone, Debug)]
465struct ThreadList {
466    /// The thread-root events that belong to this page of results.
467    pub items: Vec<ThreadListItem>,
468
469    /// Opaque pagination token returned by the homeserver.
470    pub prev_batch_token: Option<String>,
471}
472
473#[cfg(test)]
474mod tests {
475    use std::{pin::pin, time::Duration};
476
477    use assert_matches::assert_matches;
478    use matrix_sdk::test_utils::mocks::MatrixMockServer;
479    use matrix_sdk_test::{async_test, event_factory::EventFactory};
480    use ruma::{
481        event_id,
482        events::{
483            AnyTimelineEvent,
484            room::{
485                encrypted::{
486                    EncryptedEventScheme, MegolmV1AesSha2ContentInit, RoomEncryptedEventContent,
487                },
488                message::RedactedRoomMessageEventContent,
489            },
490        },
491        room_id,
492        serde::Raw,
493        user_id,
494    };
495    use stream_assert::{assert_next_matches, assert_pending};
496
497    use super::{ThreadListPaginationState, ThreadListService};
498    use crate::timeline::{MsgLikeContent, MsgLikeKind, TimelineItemContent};
499
500    #[async_test]
501    async fn test_initial_state() {
502        let server = MatrixMockServer::new().await;
503        let service = make_service(&server).await;
504
505        assert_eq!(
506            service.pagination_state(),
507            ThreadListPaginationState::Idle { end_reached: false }
508        );
509        assert!(service.items().is_empty());
510    }
511
512    #[async_test]
513    async fn test_pagination() {
514        let server = MatrixMockServer::new().await;
515        let client = server.client_builder().build().await;
516        let room_id = room_id!("!a:b.c");
517        let sender_id = user_id!("@alice:b.c");
518
519        let f = EventFactory::new().room(room_id).sender(sender_id);
520
521        let eid1 = event_id!("$1");
522        let eid2 = event_id!("$2");
523
524        server
525            .mock_room_threads()
526            .ok(
527                vec![f.text_msg("Thread root 1").event_id(eid1).into_raw()],
528                Some("next_page_token".to_owned()),
529            )
530            .mock_once()
531            .mount()
532            .await;
533
534        server
535            .mock_room_threads()
536            .match_from("next_page_token")
537            .ok(vec![f.text_msg("Thread root 2").event_id(eid2).into_raw()], None)
538            .mock_once()
539            .mount()
540            .await;
541
542        let room = server.sync_joined_room(&client, room_id).await;
543        let service = ThreadListService::new(room);
544
545        service.paginate().await.expect("first paginate failed");
546
547        assert_eq!(
548            service.pagination_state(),
549            ThreadListPaginationState::Idle { end_reached: false }
550        );
551        assert_eq!(service.items().len(), 1);
552        assert_eq!(service.items()[0].root_event.event_id, eid1);
553
554        service.paginate().await.expect("second paginate failed");
555
556        assert_eq!(
557            service.pagination_state(),
558            ThreadListPaginationState::Idle { end_reached: true }
559        );
560        assert_eq!(service.items().len(), 2);
561        assert_eq!(service.items()[1].root_event.event_id, eid2);
562    }
563
564    #[async_test]
565    async fn test_pagination_end_reached() {
566        let server = MatrixMockServer::new().await;
567        let client = server.client_builder().build().await;
568        let room_id = room_id!("!a:b.c");
569        let sender_id = user_id!("@alice:b.c");
570        let f = EventFactory::new().room(room_id).sender(sender_id);
571        let eid1 = event_id!("$1");
572
573        server
574            .mock_room_threads()
575            .ok(vec![f.text_msg("Thread root").event_id(eid1).into_raw()], None)
576            .mock_once()
577            .mount()
578            .await;
579
580        let room = server.sync_joined_room(&client, room_id).await;
581        let service = ThreadListService::new(room);
582
583        service.paginate().await.expect("paginate failed");
584        assert_eq!(
585            service.pagination_state(),
586            ThreadListPaginationState::Idle { end_reached: true }
587        );
588        assert_eq!(service.items().len(), 1);
589
590        service.paginate().await.expect("second paginate should be a no-op");
591        assert_eq!(service.items().len(), 1);
592        assert_eq!(
593            service.pagination_state(),
594            ThreadListPaginationState::Idle { end_reached: true }
595        );
596    }
597
598    /// Two concurrent calls to [`ThreadListService::paginate`] must not result
599    /// in two concurrent HTTP requests. The second call should detect that a
600    /// pagination is already in progress (state is `Loading`) and return
601    /// immediately without making another network request.
602    #[async_test]
603    async fn test_concurrent_pagination_is_not_possible() {
604        let server = MatrixMockServer::new().await;
605        let client = server.client_builder().build().await;
606        let room_id = room_id!("!a:b.c");
607        let sender_id = user_id!("@alice:b.c");
608        let f = EventFactory::new().room(room_id).sender(sender_id);
609        let eid1 = event_id!("$1");
610
611        // Set up a slow mock response so both `paginate()` calls overlap in
612        // flight. Using `expect(1)` means the test will panic during server
613        // teardown if the endpoint is hit more than once.
614        let chunk: Vec<Raw<AnyTimelineEvent>> =
615            vec![f.text_msg("Thread root").event_id(eid1).into_raw()];
616        server
617            .mock_room_threads()
618            .with_delay(Duration::from_millis(100))
619            .ok(chunk, None)
620            .expect(1)
621            .mount()
622            .await;
623
624        let room = server.sync_joined_room(&client, room_id).await;
625        let service = ThreadListService::new(room);
626
627        // Run two paginations concurrently.
628        let (first, second) = tokio::join!(service.paginate(), service.paginate());
629
630        first.expect("first paginate should succeed");
631        second.expect("second (concurrent) paginate should succeed as a no-op");
632
633        // Only one HTTP request was made, so we have exactly one item.
634        assert_eq!(service.items().len(), 1);
635        assert_eq!(service.items()[0].root_event.event_id, eid1);
636        assert_eq!(
637            service.pagination_state(),
638            ThreadListPaginationState::Idle { end_reached: true }
639        );
640    }
641
642    /// When the server returns an error, [`ThreadListService::paginate`] must
643    /// propagate the error _and_ reset the pagination state back to
644    /// `Idle { end_reached: false }` so that the caller can retry.
645    #[async_test]
646    async fn test_pagination_error() {
647        let server = MatrixMockServer::new().await;
648        let client = server.client_builder().build().await;
649        let room_id = room_id!("!a:b.c");
650
651        server.mock_room_threads().error500().mock_once().mount().await;
652
653        let room = server.sync_joined_room(&client, room_id).await;
654        let service = ThreadListService::new(room);
655
656        // Pagination must surface the server error.
657        service.paginate().await.expect_err("paginate should fail on a 500 response");
658
659        // The state must be reset so the caller can retry; it must _not_ be
660        // stuck in `Loading`.
661        assert_eq!(
662            service.pagination_state(),
663            ThreadListPaginationState::Idle { end_reached: false }
664        );
665
666        // No items should have been added.
667        assert!(service.items().is_empty());
668    }
669
670    #[async_test]
671    async fn test_reset() {
672        let server = MatrixMockServer::new().await;
673        let client = server.client_builder().build().await;
674        let room_id = room_id!("!a:b.c");
675        let sender_id = user_id!("@alice:b.c");
676        let f = EventFactory::new().room(room_id).sender(sender_id);
677        let eid1 = event_id!("$1");
678
679        server
680            .mock_room_threads()
681            .ok(vec![f.text_msg("Thread root").event_id(eid1).into_raw()], None)
682            .expect(2)
683            .mount()
684            .await;
685
686        let room = server.sync_joined_room(&client, room_id).await;
687        let service = ThreadListService::new(room);
688
689        service.paginate().await.expect("first paginate failed");
690        assert_eq!(service.items().len(), 1);
691        assert_eq!(
692            service.pagination_state(),
693            ThreadListPaginationState::Idle { end_reached: true }
694        );
695
696        service.reset().await;
697        assert!(service.items().is_empty());
698        assert_eq!(
699            service.pagination_state(),
700            ThreadListPaginationState::Idle { end_reached: false }
701        );
702
703        service.paginate().await.expect("paginate after reset failed");
704        assert_eq!(service.items().len(), 1);
705    }
706
707    #[async_test]
708    async fn test_pagination_state_subscriber() {
709        let server = MatrixMockServer::new().await;
710        let client = server.client_builder().build().await;
711        let room_id = room_id!("!a:b.c");
712        let sender_id = user_id!("@alice:b.c");
713        let f = EventFactory::new().room(room_id).sender(sender_id);
714        let eid1 = event_id!("$1");
715
716        server
717            .mock_room_threads()
718            .ok(
719                vec![f.text_msg("Thread root").event_id(eid1).into_raw()],
720                Some("next_token".to_owned()),
721            )
722            .mock_once()
723            .mount()
724            .await;
725
726        let room = server.sync_joined_room(&client, room_id).await;
727        let service = ThreadListService::new(room);
728
729        let subscriber = service.subscribe_to_pagination_state_updates();
730        let mut subscriber = pin!(subscriber);
731
732        assert_pending!(subscriber);
733
734        service.paginate().await.expect("paginate failed");
735
736        assert_next_matches!(subscriber, ThreadListPaginationState::Idle { end_reached: false });
737    }
738
739    #[async_test]
740    async fn test_paginated_items_have_num_replies_zero_without_summary() {
741        let server = MatrixMockServer::new().await;
742        let client = server.client_builder().build().await;
743        let room_id = room_id!("!a:b.c");
744        let sender_id = user_id!("@alice:b.c");
745        let f = EventFactory::new().room(room_id).sender(sender_id);
746        let eid1 = event_id!("$1");
747
748        // A thread root without bundled thread summary.
749        server
750            .mock_room_threads()
751            .ok(vec![f.text_msg("Thread root").event_id(eid1).into_raw()], None)
752            .mock_once()
753            .mount()
754            .await;
755
756        let room = server.sync_joined_room(&client, room_id).await;
757        let service = ThreadListService::new(room);
758
759        service.paginate().await.expect("paginate failed");
760
761        let items = service.items();
762        assert_eq!(items.len(), 1);
763        assert_eq!(items[0].num_replies, 0);
764        assert!(items[0].latest_event.is_none());
765    }
766
767    #[async_test]
768    async fn test_paginated_items_have_num_replies_from_bundled_summary() {
769        let server = MatrixMockServer::new().await;
770        let client = server.client_builder().build().await;
771        let room_id = room_id!("!a:b.c");
772        let sender_id = user_id!("@alice:b.c");
773        let f = EventFactory::new().room(room_id).sender(sender_id);
774        let root_id = event_id!("$root");
775        let reply_id = event_id!("$reply");
776
777        // Build a reply event to use as the bundled latest event.
778        // `with_bundled_thread_summary` expects `Raw<AnySyncMessageLikeEvent>`,
779        // so we cast from the more general `Raw<AnySyncTimelineEvent>`.
780        let reply_event =
781            f.text_msg("Reply in thread").event_id(reply_id).into_raw_sync().cast_unchecked();
782
783        // Build a thread root with a bundled thread summary (3 replies).
784        let thread_root = f
785            .text_msg("Thread root")
786            .event_id(root_id)
787            .with_bundled_thread_summary(reply_event, 3, false)
788            .into_raw();
789
790        server.mock_room_threads().ok(vec![thread_root], None).mock_once().mount().await;
791
792        let room = server.sync_joined_room(&client, room_id).await;
793        let service = ThreadListService::new(room);
794
795        service.paginate().await.expect("paginate failed");
796
797        let items = service.items();
798        assert_eq!(items.len(), 1);
799        assert_eq!(items[0].root_event.event_id, root_id);
800        assert_eq!(items[0].num_replies, 3);
801
802        // The latest event should be populated from the bundled summary.
803        let latest = items[0].latest_event.as_ref().expect("should have latest_event");
804        assert_eq!(latest.event_id, reply_id);
805        assert_eq!(latest.sender.as_str(), sender_id.as_str());
806    }
807
808    #[async_test]
809    async fn test_redacted_root_with_encrypted_latest_event() {
810        let server = MatrixMockServer::new().await;
811        let client = server.client_builder().build().await;
812        let room_id = room_id!("!a:b.c");
813        let sender_id = user_id!("@alice:b.c");
814        let f = EventFactory::new().room(room_id).sender(sender_id);
815        let root_id = event_id!("$root");
816        let latest_id = event_id!("$latest");
817
818        // The bundled latest reply is encrypted (E2EE room).
819        let encrypted_latest = f
820            .event(RoomEncryptedEventContent::new(
821                EncryptedEventScheme::MegolmV1AesSha2(
822                    MegolmV1AesSha2ContentInit {
823                        ciphertext: "ciphertext".to_owned(),
824                        sender_key: "sender-key".to_owned(),
825                        device_id: "device-id".to_owned().into(),
826                        session_id: "session-id".to_owned(),
827                    }
828                    .into(),
829                ),
830                None,
831            ))
832            .event_id(latest_id)
833            .into_raw_sync()
834            .cast_unchecked();
835
836        // Redacted thread root, still carrying a bundled thread summary whose
837        // latest event is still encrypted.
838        let thread_root = f
839            .redacted(sender_id, RedactedRoomMessageEventContent::new())
840            .event_id(root_id)
841            .with_bundled_thread_summary(encrypted_latest, 3, false)
842            .into_raw();
843
844        server.mock_room_threads().ok(vec![thread_root], None).mock_once().mount().await;
845
846        let room = server.sync_joined_room(&client, room_id).await;
847        let service = ThreadListService::new(room);
848
849        service.paginate().await.expect("paginate failed");
850
851        let items = service.items();
852        assert_eq!(items.len(), 1);
853
854        // The latest event is still encrypted: it must be surfaced as a UTD,
855        // not as an unsupported/other event.
856        let latest = items[0].latest_event.as_ref().expect("should have latest_event");
857        assert_matches!(
858            latest.content,
859            Some(TimelineItemContent::MsgLike(MsgLikeContent {
860                kind: MsgLikeKind::UnableToDecrypt(_),
861                ..
862            }))
863        );
864    }
865
866    #[async_test]
867    async fn test_redacted_root_still_listed_with_summary() {
868        let server = MatrixMockServer::new().await;
869        let client = server.client_builder().build().await;
870        let room_id = room_id!("!a:b.c");
871        let sender_id = user_id!("@alice:b.c");
872        let f = EventFactory::new().room(room_id).sender(sender_id);
873        let root_id = event_id!("$root");
874        let reply_id = event_id!("$reply");
875
876        let reply_event =
877            f.text_msg("Reply in thread").event_id(reply_id).into_raw_sync().cast_unchecked();
878
879        // Redacted thread root, still carrying a bundled thread summary.
880        let thread_root = f
881            .redacted(sender_id, RedactedRoomMessageEventContent::new())
882            .event_id(root_id)
883            .with_bundled_thread_summary(reply_event, 3, false)
884            .into_raw();
885
886        server.mock_room_threads().ok(vec![thread_root], None).mock_once().mount().await;
887
888        let room = server.sync_joined_room(&client, room_id).await;
889        let service = ThreadListService::new(room);
890
891        service.paginate().await.expect("paginate failed");
892
893        let items = service.items();
894        assert_eq!(items.len(), 1);
895        assert_eq!(items[0].root_event.event_id, root_id);
896        assert_eq!(items[0].num_replies, 3);
897
898        // The redacted root is surfaced as a redacted message.
899        assert!(matches!(
900            items[0].root_event.content,
901            Some(TimelineItemContent::MsgLike(MsgLikeContent { kind: MsgLikeKind::Redacted, .. }))
902        ));
903
904        // The plaintext latest reply is surfaced as a regular message.
905        let latest = items[0].latest_event.as_ref().expect("should have latest_event");
906        assert_eq!(latest.event_id, reply_id);
907        assert!(matches!(
908            latest.content,
909            Some(TimelineItemContent::MsgLike(MsgLikeContent {
910                kind: MsgLikeKind::Message(_),
911                ..
912            }))
913        ));
914    }
915
916    /// Builds a [`ThreadListService`] and makes the room known to the client by
917    /// performing a sync.
918    async fn make_service(server: &MatrixMockServer) -> ThreadListService {
919        let client = server.client_builder().build().await;
920        let room_id = room_id!("!a:b.c");
921        let room = server.sync_joined_room(&client, room_id).await;
922        ThreadListService::new(room)
923    }
924}