Skip to main content

matrix_sdk/event_cache/caches/
read_receipts.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
15//! # Client-side read receipts computation
16//!
17//! While Matrix servers have the ability to provide basic information about the
18//! unread status of rooms, via [`crate::sync::UnreadNotificationsCount`], it's
19//! not reliable for encrypted rooms. Indeed, the server doesn't have access to
20//! the content of encrypted events, so it can only makes guesses when
21//! estimating unread and highlight counts.
22//!
23//! Instead, this module provides facilities to compute the number of unread
24//! messages, unread notifications (based on the push rules) and unread
25//! highlights in a room.
26//!
27//! Counting unread messages is performed by looking at the latest receipt of
28//! the current user, and inferring which events are following it, according to
29//! the sync ordering.
30//!
31//! For notifications and highlights to be precisely accounted for, we also need
32//! to pay attention to the user's notification settings. Fortunately, this is
33//! also something we need for notifications, so we can reuse this code.
34//!
35//! Of course, not all events are created equal, and some are less interesting
36//! than others, and shouldn't cause a room to be marked unread. This module's
37//! `marks_as_unread` function shows the opiniated set of rules that will filter
38//! out uninterested events.
39//!
40//! The only `pub(crate)` method in that module is `compute_unread_counts`,
41//! which updates the `RoomInfo` in place according to the new counts.
42//!
43//! ## Implementation details: How to get the latest receipt?
44//!
45//! ### Preliminary context
46//!
47//! We reuse a room event cache's linked chunk, and iterate over the events that
48//! are stored in memory.
49//!
50//! ### How-to
51//!
52//! When we call `compute_unread_counts`, that's for one of two reasons (and
53//! maybe both at once, or maybe none at all):
54//! - we received a new receipt
55//! - new events came in.
56//!
57//! A read receipt is considered _active_ if it's been received from sync
58//! *and* it matches a known event in the in-memory linked chunk.
59//!
60//! The *latest active* receipt is the one that's active, with the latest order
61//! (according to the event cache ordering, aka its position in the linked
62//! chunk).
63//!
64//! The problem of keeping a precise read count is thus equivalent to finding
65//! the latest active receipt, and counting interesting events after it.
66//!
67//! When we need to recompute the unread counts, we go through all the linked
68//! chunk's events to select a "better" active receipt, using the following
69//! rules:
70//!
71//! - an event we sent counts as a read receipt (it's called the implicit read
72//!   receipt in the spec),
73//! - an event which is referenced in the read receipt event content (either a
74//!   private or a public read receipt, of type unthreaded or main, to keep
75//!   maximal compatibility with thread-unaware clients),
76//! - a previously stashed read receipt we've received from a read receipt event
77//!   content, but for which we couldn't find the corresponding event. It's
78//!   possible that a read receipt is received before the corresponding event.
79//!
80//! The read receipt that wins is always the one that points to the most recent
81//! in the linked chunk ordering. In other words, the receipt type (as described
82//! above) doesn't matter; it's the relative position in the linked chunk
83//! ordering which does.
84//!
85//! Once we have a new *better active receipt*, we'll save it in the
86//! `RoomReadReceipt` data (stored in `RoomInfo`), and we'll compute the counts,
87//! starting from the event the better active receipt was referring to.
88//!
89//! If we *don't* have a better active receipt, that means that all the events
90//! received in that batch aren't referred to by a known read receipt, _and_ we
91//! didn't get a new better receipt that matched known events. In that case, we
92//! can just consider that all the events are new, and count them as such.
93
94use matrix_sdk_base::{
95    read_receipts::{LatestReadReceipt, RoomReadReceipts},
96    serde_helpers::extract_relation,
97    store::DynStateStore,
98};
99use matrix_sdk_common::{
100    deserialized_responses::TimelineEvent, ring_buffer::RingBuffer,
101    serde_helpers::extract_thread_root,
102};
103use ruma::{
104    EventId, OwnedEventId, OwnedUserId, RoomId, UserId,
105    events::{
106        AnySyncTimelineEvent, MessageLikeEventType,
107        receipt::{ReceiptEventContent, ReceiptThread, ReceiptType},
108        relation::RelationType,
109    },
110    serde::Raw,
111};
112use tracing::{debug, instrument, trace, warn};
113
114use crate::event_cache::{
115    automatic_pagination::AutomaticPagination, caches::event_linked_chunk::EventLinkedChunk,
116};
117
118trait RoomReadReceiptsExt {
119    /// Update the [`RoomReadReceipts`] unread counts according to the new
120    /// event.
121    ///
122    /// Returns whether a new event triggered a new unread/notification/mention.
123    fn process_event(
124        &mut self,
125        event: &TimelineEvent,
126        user_id: &UserId,
127        with_threading_support: bool,
128    );
129
130    fn reset(&mut self);
131
132    /// Try to find the event to which the receipt attaches to, and if found,
133    /// will update the notification count in the room.
134    fn find_and_process_events<'a>(
135        &mut self,
136        receipt_event_id: &EventId,
137        user_id: &UserId,
138        events: impl IntoIterator<Item = &'a TimelineEvent>,
139        with_threading_support: bool,
140    ) -> bool;
141}
142
143impl RoomReadReceiptsExt for RoomReadReceipts {
144    /// Update the [`RoomReadReceipts`] unread counts according to the new
145    /// event.
146    ///
147    /// Returns whether a new event triggered a new unread/notification/mention.
148    #[inline(always)]
149    fn process_event(
150        &mut self,
151        event: &TimelineEvent,
152        user_id: &UserId,
153        with_threading_support: bool,
154    ) {
155        if with_threading_support && extract_thread_root(event.raw()).is_some() {
156            return;
157        }
158
159        if marks_as_unread(event.raw(), user_id) {
160            self.num_unread += 1;
161        }
162
163        let mut has_notify = false;
164        let mut has_mention = false;
165
166        let Some(actions) = event.push_actions() else {
167            return;
168        };
169
170        for action in actions.iter() {
171            if !has_notify && action.should_notify() {
172                self.num_notifications += 1;
173                has_notify = true;
174            }
175            if !has_mention && action.is_highlight() {
176                self.num_mentions += 1;
177                has_mention = true;
178            }
179        }
180    }
181
182    #[inline(always)]
183    fn reset(&mut self) {
184        self.num_unread = 0;
185        self.num_notifications = 0;
186        self.num_mentions = 0;
187    }
188
189    /// Try to find the event to which the receipt attaches to, and if found,
190    /// will update the notification count in the room.
191    #[instrument(skip_all)]
192    fn find_and_process_events<'a>(
193        &mut self,
194        receipt_event_id: &EventId,
195        user_id: &UserId,
196        events: impl IntoIterator<Item = &'a TimelineEvent>,
197        with_threading_support: bool,
198    ) -> bool {
199        let mut counting_receipts = false;
200
201        for event in events {
202            // Sliding sync sometimes sends the same event multiple times, so it can be at
203            // the beginning and end of a batch, for instance. In that case, just reset
204            // every time we see the event matching the receipt.
205            if let Some(event_id) = event.event_id()
206                && event_id == receipt_event_id
207            {
208                // Bingo! Switch over to the counting state, after resetting the
209                // previous counts.
210                trace!("Found the event the receipt was referring to! Starting to count.");
211                self.reset();
212                counting_receipts = true;
213                continue;
214            }
215
216            if counting_receipts {
217                self.process_event(event, user_id, with_threading_support);
218            }
219        }
220
221        counting_receipts
222    }
223}
224
225/// The receipt types we look for, in order of priority (the first ones are more
226/// likely to be ahead in the timeline, so we look for them first).
227const ALL_RECEIPT_TYPES: [ReceiptType; 2] = [ReceiptType::ReadPrivate, ReceiptType::Read];
228
229/// Return a new better (i.e. more recent) receipt based on a search of the
230/// linked chunk.
231///
232/// A receipt is selected if:
233///
234/// - it's an implicit read receipt (i.e. an event we've sent),
235/// - it's holding onto a new read receipt we've just received,
236/// - it was a pending receipt for which we found the event now,
237///
238/// A receipt returned in this function **must** point to an event that is in
239/// the linked chunk.
240fn select_best_receipt(
241    user_id: &UserId,
242    linked_chunk: &EventLinkedChunk,
243    pending_receipts: &mut RingBuffer<OwnedEventId>,
244    new_receipt_event: Option<&ReceiptEventContent>,
245    latest_active: Option<&EventId>,
246    with_threading_support: bool,
247) -> Option<OwnedEventId> {
248    // If we had a new receipt event, add the main/unthreaded receipts it contains
249    // to the pending receipts list. We'll try to chase them later.
250    if let Some(receipt_event) = new_receipt_event {
251        for (event_id, receipts) in &receipt_event.0 {
252            for ty in ALL_RECEIPT_TYPES {
253                if let Some(receipts) = receipts.get(&ty)
254                    && let Some(receipt) = receipts.get(user_id)
255                    && matches!(receipt.thread, ReceiptThread::Main | ReceiptThread::Unthreaded)
256                {
257                    // Add it to the pending receipts list.
258                    trace!(%event_id, "found new receipt (added to pending)");
259                    pending_receipts.push(event_id.clone());
260                }
261            }
262        }
263    }
264
265    // This loop folds two actions at once:
266    // - try to find the most recent receipt, by looking at the events in reverse
267    //   order (i.e. from the most recent to the least recent),
268    // - try to match stashed receipts against known events in the linked chunk, so
269    //   as to shrink the stash of pending receipts.
270    //
271    // We can early exit out of this loop, as soon as there's no more work to do,
272    // i.e., we've found a better receipt, *and* there's no more pending receipt
273    // to try to match against events in the linked chunk.
274
275    let mut receipt = None;
276
277    for (event, event_id) in
278        linked_chunk.revents().filter_map(|(_pos, ev)| Some((ev, ev.event_id()?)))
279    {
280        if receipt.is_none() {
281            // Try to see if the latest active receipt is still the most recent receipt.
282            if latest_active == Some(&event_id) {
283                // The latest active receipt is still the most recent receipt, so keep it.
284                trace!(active = %event_id, "the latest active receipt is still the most recent; stopping search");
285                receipt = Some(event_id.clone());
286            }
287            // Try to find an implicit read receipt (i.e. an event sent by the current
288            // user).
289            //
290            // If the client is enabled with threading support, skip events that are in threads.
291            else if event.sender().as_deref() == Some(user_id)
292                && (!with_threading_support || extract_thread_root(event.raw()).is_none())
293            {
294                trace!(implicit = %event_id, "found an implicit receipt; stopping search");
295                receipt = Some(event_id.clone());
296            }
297        }
298
299        // Early exit condition (see the comment above): we've already found a most
300        // recent receipt, and there's no other pending receipts to match against known
301        // events.
302        if receipt.is_some() && pending_receipts.is_empty() {
303            trace!("exiting loop; found a better receipt, and no more pending receipt to match");
304            break;
305        }
306
307        // Try to match pending receipts to events known in the linked chunk. If we
308        // haven't found any receipt yet, the first matched pending receipt is a better
309        // one!
310        pending_receipts.retain(|pending| {
311            if *pending == event_id {
312                if receipt.is_none() {
313                    trace!(pending = %event_id, "found a pending receipt; stopping search");
314                    receipt = Some(event_id.clone());
315                } else {
316                    trace!(%event_id, "discarding a pending receipt that wasn't selected");
317                }
318
319                // Don't keep the pending receipt in the pending list: we've already identified
320                // a better, more recent receipt at this point (found == Some).
321                false
322            } else {
323                // Keep the receipt, in case the associated event shows up later.
324                true
325            }
326        });
327    }
328
329    receipt
330}
331
332/// Try to find extra read receipts that were in the store but never saved in
333/// the [`RoomReadReceipts`] data structure.
334///
335/// Doesn't return a `Result`, because this is entirely optional; if the store
336/// fails to load these receipts, the worst that can happen is incorrect unread
337/// counts until the next receipt event is received from sync.
338async fn try_find_store_receipts(
339    store: &DynStateStore,
340    user_id: &UserId,
341    room_id: &RoomId,
342    read_receipts: &mut RoomReadReceipts,
343) {
344    for receipt_type in ALL_RECEIPT_TYPES {
345        // Implementation note: we want to prioritize a `Unthreaded` receipt over a
346        // `Main`-threaded one, for better compatibility with thread-unaware clients.
347        for receipt_thread in [ReceiptThread::Unthreaded, ReceiptThread::Main] {
348            if let Ok(Some((event_id, _receipt))) = store
349                .get_user_room_receipt_event(
350                    room_id,
351                    receipt_type.clone(),
352                    receipt_thread.clone(),
353                    user_id,
354                )
355                .await
356            {
357                trace!(%event_id, ?receipt_type, ?receipt_thread, "Found a dormant receipt in the store");
358
359                if read_receipts.latest_active.is_none() {
360                    read_receipts.latest_active =
361                        Some(LatestReadReceipt { event_id: event_id.clone() });
362                } else {
363                    // This loop has already flagged a read receipt as the new `latest_active`.
364                    // Extra read receipts can go to the pending receipts list, as they're lower
365                    // priority, by the implementation notes above.
366                    read_receipts.pending.push(event_id.clone());
367                }
368            }
369        }
370    }
371}
372
373/// Given a set of events coming from sync, for a room, update the
374/// [`RoomReadReceipts`]'s counts of unread messages, notifications and
375/// highlights' in place.
376///
377/// See this module's documentation for more information.
378#[instrument(skip_all, fields(room_id = %room_id))]
379#[allow(clippy::too_many_arguments)]
380pub(crate) async fn compute_unread_counts(
381    user_id: &UserId,
382    room_id: &RoomId,
383    receipt_event: Option<&ReceiptEventContent>,
384    linked_chunk: &EventLinkedChunk,
385    read_receipts: &mut RoomReadReceipts,
386    with_threading_support: bool,
387    automatic_pagination: Option<&AutomaticPagination>,
388    state_store: &DynStateStore,
389) {
390    debug!(?read_receipts, "Starting");
391
392    // If we don't have a latest active receipt for this room, try to reload one
393    // from the state store into the `RoomReadReceipts`.
394    if read_receipts.latest_active.is_none() {
395        try_find_store_receipts(state_store, user_id, room_id, read_receipts).await;
396    }
397
398    let better_receipt = select_best_receipt(
399        user_id,
400        linked_chunk,
401        &mut read_receipts.pending,
402        receipt_event,
403        read_receipts.latest_active.as_ref().map(|latest_active| latest_active.event_id.as_ref()),
404        with_threading_support,
405    );
406
407    if let Some(event_id) = better_receipt {
408        // We've found the id of an event to which the receipt attaches. The associated
409        // event may either come from the new batch of events associated to
410        // this sync, or it may live in the past timeline events we know
411        // about.
412
413        // First, save the event id as the latest one that has a read receipt.
414        trace!(%event_id, "Saving a new active read receipt");
415        read_receipts.latest_active = Some(LatestReadReceipt { event_id: event_id.clone() });
416
417        // The event for the receipt is in the linked chunk, so we'll find it and can
418        // count safely from here.
419        read_receipts.find_and_process_events(
420            &event_id,
421            user_id,
422            linked_chunk.events().map(|(_pos, event)| event),
423            with_threading_support,
424        );
425
426        debug!(?read_receipts, "after finding a better receipt");
427        return;
428    }
429
430    // Request a pagination: we haven't found a better receipt, but we haven't even
431    // found the latest active receipt!
432    if let Some(automatic_pagination) = automatic_pagination {
433        if automatic_pagination.run_once(room_id) {
434            trace!("Requested pagination to find a better receipt");
435        } else {
436            warn!("Failed to request pagination to find a better receipt");
437        }
438    }
439
440    // If we haven't returned at this point, it means we don't have any new "active"
441    // read receipt. So either there was a previous one further in the past, or
442    // none.
443    //
444    // In that case, the number of unreads is *at most* the number of processed
445    // events. Reset the number of unreads, and recount them all.
446    read_receipts.reset();
447
448    for (_pos, event) in linked_chunk.events() {
449        read_receipts.process_event(event, user_id, with_threading_support);
450    }
451
452    debug!(?read_receipts, "no better receipt");
453}
454
455/// Is the event worth marking a room as unread?
456fn marks_as_unread(event: &Raw<AnySyncTimelineEvent>, user_id: &UserId) -> bool {
457    // Parse the sender from the raw event.
458    if event.get_field::<OwnedUserId>("sender").ok().flatten().as_deref() == Some(user_id) {
459        tracing::trace!("not interesting because sent by the current user");
460        return false;
461    }
462
463    let Some(event_type) = event.get_field::<MessageLikeEventType>("type").ok().flatten() else {
464        tracing::trace!(
465            "failed to parse event type for event with id {:?}, skipping it",
466            event.get_field::<OwnedEventId>("event_id").ok().flatten()
467        );
468        return false;
469    };
470
471    match event_type {
472        MessageLikeEventType::Message
473        | MessageLikeEventType::PollStart
474        | MessageLikeEventType::UnstablePollStart
475        | MessageLikeEventType::PollEnd
476        | MessageLikeEventType::UnstablePollEnd
477        | MessageLikeEventType::RoomEncrypted
478        | MessageLikeEventType::RoomMessage
479        | MessageLikeEventType::Sticker => {}
480
481        _ => {
482            tracing::trace!("not interesting because not an interesting message-like");
483            return false;
484        }
485    }
486
487    // Filter out edits.
488    if let Some((RelationType::Replacement, _)) = extract_relation(event) {
489        tracing::trace!("not interesting because edited");
490        return false;
491    }
492
493    // Filter out redacted events.
494    #[derive(serde::Deserialize)]
495    struct UnsignedContent {
496        redacted_because: Option<Raw<AnySyncTimelineEvent>>,
497    }
498
499    // Filter out redactions.
500    if let Ok(Some(UnsignedContent { redacted_because: Some(_redaction) })) =
501        event.get_field::<UnsignedContent>("unsigned")
502    {
503        tracing::trace!("not interesting because redacted");
504        return false;
505    }
506
507    true
508}
509
510#[cfg(test)]
511mod tests {
512    use std::{num::NonZeroUsize, ops::Not as _};
513
514    use matrix_sdk_base::read_receipts::RoomReadReceipts;
515    use matrix_sdk_common::{deserialized_responses::TimelineEvent, ring_buffer::RingBuffer};
516    use matrix_sdk_test::{ALICE, event_factory::EventFactory};
517    use ruma::{
518        EventId, UserId, event_id,
519        events::{
520            receipt::{ReceiptThread, ReceiptType},
521            room::{member::MembershipState, message::MessageType},
522        },
523        owned_event_id,
524        push::{Action, HighlightTweakValue, Tweak},
525        room_id, user_id,
526    };
527
528    use super::marks_as_unread;
529    use crate::event_cache::caches::{
530        event_linked_chunk::EventLinkedChunk,
531        read_receipts::{RoomReadReceiptsExt as _, select_best_receipt},
532    };
533
534    #[test]
535    fn test_room_message_marks_as_unread() {
536        let user_id = user_id!("@alice:example.org");
537        let other_user_id = user_id!("@bob:example.org");
538
539        let f = EventFactory::new();
540
541        // A message from somebody else marks the room as unread...
542        let ev = f.text_msg("A").event_id(event_id!("$ida")).sender(other_user_id).into_raw_sync();
543        assert!(marks_as_unread(&ev, user_id));
544
545        // ... but a message from ourselves doesn't.
546        let ev = f.text_msg("A").event_id(event_id!("$ida")).sender(user_id).into_raw_sync();
547        assert!(marks_as_unread(&ev, user_id).not());
548    }
549
550    #[test]
551    fn test_room_edit_doesnt_mark_as_unread() {
552        let user_id = user_id!("@alice:example.org");
553        let other_user_id = user_id!("@bob:example.org");
554
555        // An edit to a message from somebody else doesn't mark the room as unread.
556        let ev = EventFactory::new()
557            .text_msg("* edited message")
558            .edit(
559                event_id!("$someeventid:localhost"),
560                MessageType::text_plain("edited message").into(),
561            )
562            .event_id(event_id!("$ida"))
563            .sender(other_user_id)
564            .into_raw_sync();
565
566        assert!(marks_as_unread(&ev, user_id).not());
567    }
568
569    #[test]
570    fn test_redaction_doesnt_mark_room_as_unread() {
571        let user_id = user_id!("@alice:example.org");
572        let other_user_id = user_id!("@bob:example.org");
573
574        // A redact of a message from somebody else doesn't mark the room as unread.
575        let ev = EventFactory::new()
576            .redaction(event_id!("$151957878228ssqrj:localhost"))
577            .sender(other_user_id)
578            .event_id(event_id!("$151957878228ssqrJ:localhost"))
579            .into_raw_sync();
580
581        assert!(marks_as_unread(&ev, user_id).not());
582    }
583
584    #[test]
585    fn test_reaction_doesnt_mark_room_as_unread() {
586        let user_id = user_id!("@alice:example.org");
587        let other_user_id = user_id!("@bob:example.org");
588
589        // A reaction from somebody else to a message doesn't mark the room as unread.
590        let ev = EventFactory::new()
591            .reaction(event_id!("$15275047031IXQRj:localhost"), "👍")
592            .sender(other_user_id)
593            .event_id(event_id!("$15275047031IXQRi:localhost"))
594            .into_raw_sync();
595
596        assert!(marks_as_unread(&ev, user_id).not());
597    }
598
599    #[test]
600    fn test_state_event_doesnt_mark_as_unread() {
601        let user_id = user_id!("@alice:example.org");
602        let event_id = event_id!("$1");
603
604        let ev = EventFactory::new()
605            .member(user_id)
606            .membership(MembershipState::Join)
607            .display_name("Alice")
608            .event_id(event_id)
609            .into_raw_sync();
610        assert!(marks_as_unread(&ev, user_id).not());
611
612        let other_user_id = user_id!("@bob:example.org");
613        assert!(marks_as_unread(&ev, other_user_id).not());
614    }
615
616    #[test]
617    fn test_count_unread_and_mentions() {
618        fn make_event(user_id: &UserId, push_actions: Vec<Action>) -> TimelineEvent {
619            let mut ev = EventFactory::new()
620                .text_msg("A")
621                .sender(user_id)
622                .event_id(event_id!("$ida"))
623                .into_event();
624            ev.set_push_actions(push_actions);
625            ev
626        }
627
628        let user_id = user_id!("@alice:example.org");
629        let threading_support = false;
630
631        // An interesting event from oneself doesn't count as a new unread message.
632        let event = make_event(user_id, Vec::new());
633        let mut receipts = RoomReadReceipts::default();
634        receipts.process_event(&event, user_id, threading_support);
635        assert_eq!(receipts.num_unread, 0);
636        assert_eq!(receipts.num_mentions, 0);
637        assert_eq!(receipts.num_notifications, 0);
638
639        // An interesting event from someone else does count as a new unread message.
640        let event = make_event(user_id!("@bob:example.org"), Vec::new());
641        let mut receipts = RoomReadReceipts::default();
642        receipts.process_event(&event, user_id, threading_support);
643        assert_eq!(receipts.num_unread, 1);
644        assert_eq!(receipts.num_mentions, 0);
645        assert_eq!(receipts.num_notifications, 0);
646
647        // Push actions computed beforehand are respected.
648        let event = make_event(user_id!("@bob:example.org"), vec![Action::Notify]);
649        let mut receipts = RoomReadReceipts::default();
650        receipts.process_event(&event, user_id, threading_support);
651        assert_eq!(receipts.num_unread, 1);
652        assert_eq!(receipts.num_mentions, 0);
653        assert_eq!(receipts.num_notifications, 1);
654
655        let event = make_event(
656            user_id!("@bob:example.org"),
657            vec![Action::SetTweak(Tweak::Highlight(HighlightTweakValue::Yes))],
658        );
659        let mut receipts = RoomReadReceipts::default();
660        receipts.process_event(&event, user_id, threading_support);
661        assert_eq!(receipts.num_unread, 1);
662        assert_eq!(receipts.num_mentions, 1);
663        assert_eq!(receipts.num_notifications, 0);
664
665        let event = make_event(
666            user_id!("@bob:example.org"),
667            vec![Action::SetTweak(Tweak::Highlight(HighlightTweakValue::Yes)), Action::Notify],
668        );
669        let mut receipts = RoomReadReceipts::default();
670        receipts.process_event(&event, user_id, threading_support);
671        assert_eq!(receipts.num_unread, 1);
672        assert_eq!(receipts.num_mentions, 1);
673        assert_eq!(receipts.num_notifications, 1);
674
675        // Technically this `push_actions` set would be a bug somewhere else, but let's
676        // make sure to resist against it.
677        let event = make_event(user_id!("@bob:example.org"), vec![Action::Notify, Action::Notify]);
678        let mut receipts = RoomReadReceipts::default();
679        receipts.process_event(&event, user_id, threading_support);
680        assert_eq!(receipts.num_unread, 1);
681        assert_eq!(receipts.num_mentions, 0);
682        assert_eq!(receipts.num_notifications, 1);
683    }
684
685    #[test]
686    fn test_find_and_process_events() {
687        let ev0 = event_id!("$0");
688        let user_id = user_id!("@alice:example.org");
689        let thread_support = false;
690
691        // When provided with no events, we report not finding the event to which the
692        // receipt relates.
693        let mut receipts = RoomReadReceipts::default();
694        assert!(receipts.find_and_process_events(ev0, user_id, &[], thread_support).not());
695        assert_eq!(receipts.num_unread, 0);
696        assert_eq!(receipts.num_notifications, 0);
697        assert_eq!(receipts.num_mentions, 0);
698
699        // When provided with one event, that's not the receipt event, we don't count
700        // it.
701        fn make_event(event_id: &EventId) -> TimelineEvent {
702            EventFactory::new()
703                .text_msg("A")
704                .sender(user_id!("@bob:example.org"))
705                .event_id(event_id)
706                .into()
707        }
708
709        let mut receipts = RoomReadReceipts {
710            num_unread: 42,
711            num_notifications: 13,
712            num_mentions: 37,
713            ..Default::default()
714        };
715        assert!(
716            receipts
717                .find_and_process_events(
718                    ev0,
719                    user_id,
720                    &[make_event(event_id!("$1"))],
721                    thread_support
722                )
723                .not()
724        );
725        assert_eq!(receipts.num_unread, 42);
726        assert_eq!(receipts.num_notifications, 13);
727        assert_eq!(receipts.num_mentions, 37);
728
729        // When provided with one event that's the receipt target, we find it, reset the
730        // count, and since there's nothing else, we stop there and end up with
731        // zero counts.
732        let mut receipts = RoomReadReceipts {
733            num_unread: 42,
734            num_notifications: 13,
735            num_mentions: 37,
736            ..Default::default()
737        };
738        assert!(receipts.find_and_process_events(ev0, user_id, &[make_event(ev0)], thread_support),);
739        assert_eq!(receipts.num_unread, 0);
740        assert_eq!(receipts.num_notifications, 0);
741        assert_eq!(receipts.num_mentions, 0);
742
743        // When provided with multiple events and not the receipt event, we do not count
744        // anything..
745        let mut receipts = RoomReadReceipts {
746            num_unread: 42,
747            num_notifications: 13,
748            num_mentions: 37,
749            ..Default::default()
750        };
751        assert!(
752            receipts
753                .find_and_process_events(
754                    ev0,
755                    user_id,
756                    &[
757                        make_event(event_id!("$1")),
758                        make_event(event_id!("$2")),
759                        make_event(event_id!("$3"))
760                    ],
761                    thread_support
762                )
763                .not()
764        );
765        assert_eq!(receipts.num_unread, 42);
766        assert_eq!(receipts.num_notifications, 13);
767        assert_eq!(receipts.num_mentions, 37);
768
769        // When provided with multiple events including one that's the receipt event, we
770        // find it and count from it.
771        let mut receipts = RoomReadReceipts {
772            num_unread: 42,
773            num_notifications: 13,
774            num_mentions: 37,
775            ..Default::default()
776        };
777        assert!(receipts.find_and_process_events(
778            ev0,
779            user_id,
780            &[
781                make_event(event_id!("$1")),
782                make_event(ev0),
783                make_event(event_id!("$2")),
784                make_event(event_id!("$3"))
785            ],
786            thread_support
787        ));
788        assert_eq!(receipts.num_unread, 2);
789        assert_eq!(receipts.num_notifications, 0);
790        assert_eq!(receipts.num_mentions, 0);
791
792        // Even if duplicates are present in the new events list, the count is correct.
793        let mut receipts = RoomReadReceipts {
794            num_unread: 42,
795            num_notifications: 13,
796            num_mentions: 37,
797            ..Default::default()
798        };
799        assert!(receipts.find_and_process_events(
800            ev0,
801            user_id,
802            &[
803                make_event(ev0),
804                make_event(event_id!("$1")),
805                make_event(ev0),
806                make_event(event_id!("$2")),
807                make_event(event_id!("$3"))
808            ],
809            thread_support
810        ));
811        assert_eq!(receipts.num_unread, 2);
812        assert_eq!(receipts.num_notifications, 0);
813        assert_eq!(receipts.num_mentions, 0);
814    }
815
816    #[test]
817    fn test_compute_unread_counts_with_threading_enabled() {
818        fn make_event(user_id: &UserId, thread_root: &EventId) -> TimelineEvent {
819            EventFactory::new()
820                .text_msg("A")
821                .sender(user_id)
822                .event_id(event_id!("$ida"))
823                .in_thread(thread_root, event_id!("$latest_event"))
824                .into_event()
825        }
826
827        let mut receipts = RoomReadReceipts::default();
828
829        let own_alice = user_id!("@alice:example.org");
830        let bob = user_id!("@bob:example.org");
831
832        let threading_support = true;
833
834        // Threaded messages from myself or other users shouldn't change the
835        // unread counts.
836        receipts.process_event(
837            &make_event(own_alice, event_id!("$some_thread_root")),
838            own_alice,
839            threading_support,
840        );
841        receipts.process_event(
842            &make_event(own_alice, event_id!("$some_other_thread_root")),
843            own_alice,
844            threading_support,
845        );
846
847        receipts.process_event(
848            &make_event(bob, event_id!("$some_thread_root")),
849            own_alice,
850            threading_support,
851        );
852        receipts.process_event(
853            &make_event(bob, event_id!("$some_other_thread_root")),
854            own_alice,
855            threading_support,
856        );
857
858        assert_eq!(receipts.num_unread, 0);
859        assert_eq!(receipts.num_mentions, 0);
860        assert_eq!(receipts.num_notifications, 0);
861
862        // Processing an unthreaded message should still count as unread.
863        receipts.process_event(
864            &EventFactory::new().text_msg("A").sender(bob).event_id(event_id!("$ida")).into_event(),
865            own_alice,
866            threading_support,
867        );
868
869        assert_eq!(receipts.num_unread, 1);
870        assert_eq!(receipts.num_mentions, 0);
871        assert_eq!(receipts.num_notifications, 0);
872    }
873
874    #[test]
875    fn test_select_best_receipt_noop() {
876        let room_id = room_id!("!roomid:example.org");
877        let f = EventFactory::new().room(room_id).sender(*ALICE);
878
879        // Create a non-empty linked chunk, with no messages sent by the current user.
880        let mut lc = EventLinkedChunk::new();
881        lc.push_events(vec![
882            f.text_msg("Event 1").event_id(event_id!("$1")).into_event(),
883            f.text_msg("Event 2").event_id(event_id!("$2")).into_event(),
884            f.text_msg("Event 3").event_id(event_id!("$3")).into_event(),
885        ]);
886
887        let own_user_id = user_id!("@not_alice:example.org");
888
889        // When there are no pending receipts,
890        let mut pending_receipts = RingBuffer::new(NonZeroUsize::new(16).unwrap());
891        // And no new receipt,
892        let new_receipt_event = None;
893        // And no active receipt,
894        let active_receipt = None;
895        let with_threading_support = false;
896
897        // Then there's no best receipt.
898        let receipt = select_best_receipt(
899            own_user_id,
900            &lc,
901            &mut pending_receipts,
902            new_receipt_event,
903            active_receipt,
904            with_threading_support,
905        );
906        assert!(receipt.is_none());
907        // And there are no pending receipts.
908        assert!(pending_receipts.is_empty());
909    }
910
911    #[test]
912    fn test_select_best_receipt_implicit() {
913        let room_id = room_id!("!roomid:example.org");
914        let f = EventFactory::new().room(room_id).sender(*ALICE);
915        let own_user_id = user_id!("@not_alice:example.org");
916
917        // Create a non-empty linked chunk, with one message sent by the current user,
918        // which will act as an implicit read receipt.
919        let mut lc = EventLinkedChunk::new();
920        lc.push_events(vec![
921            f.text_msg("Event 1").event_id(event_id!("$1")).into_event(),
922            f.text_msg("Event 2").event_id(event_id!("$2")).sender(own_user_id).into_event(),
923            f.text_msg("Event 3").event_id(event_id!("$3")).into_event(),
924        ]);
925
926        // When there are no pending receipts,
927        let mut pending_receipts = RingBuffer::new(NonZeroUsize::new(16).unwrap());
928        // And no new receipt,
929        let new_receipt_event = None;
930        // And no active receipt,
931        let active_receipt = None;
932        let with_threading_support = false;
933
934        // Then there's a new best receipt, which is the implicit one.
935        let receipt = select_best_receipt(
936            own_user_id,
937            &lc,
938            &mut pending_receipts,
939            new_receipt_event,
940            active_receipt,
941            with_threading_support,
942        );
943        assert_eq!(receipt.unwrap(), event_id!("$2"));
944        // And there are no pending receipts.
945        assert!(pending_receipts.is_empty());
946    }
947
948    #[test]
949    fn test_select_best_receipt_active_receipt() {
950        let room_id = room_id!("!roomid:example.org");
951        let f = EventFactory::new().room(room_id).sender(*ALICE);
952
953        // Create a non-empty linked chunk, with no messages sent by the current user.
954        let mut lc = EventLinkedChunk::new();
955        lc.push_events(vec![
956            f.text_msg("Event 1").event_id(event_id!("$1")).into_event(),
957            f.text_msg("Event 2").event_id(event_id!("$2")).into_event(),
958            f.text_msg("Event 3").event_id(event_id!("$3")).into_event(),
959        ]);
960
961        let own_user_id = user_id!("@not_alice:example.org");
962
963        // When there are no pending receipts,
964        let mut pending_receipts = RingBuffer::new(NonZeroUsize::new(16).unwrap());
965        // And no new receipt,
966        let new_receipt_event = None;
967        // And an active receipt pointing at $2,
968        let active_receipt = Some(event_id!("$2"));
969        let with_threading_support = false;
970
971        // Then the best receipt is still $2.
972        let receipt = select_best_receipt(
973            own_user_id,
974            &lc,
975            &mut pending_receipts,
976            new_receipt_event,
977            active_receipt,
978            with_threading_support,
979        );
980        assert_eq!(receipt.unwrap(), event_id!("$2"));
981        // And there are no pending receipts.
982        assert!(pending_receipts.is_empty());
983    }
984
985    #[test]
986    fn test_select_best_receipt_new_receipt_event() {
987        let room_id = room_id!("!roomid:example.org");
988        let f = EventFactory::new().room(room_id).sender(*ALICE);
989        let own_user_id = user_id!("@not_alice:example.org");
990
991        // Create a non-empty linked chunk, with no messages sent by the current user.
992        let mut lc = EventLinkedChunk::new();
993        lc.push_events(vec![
994            f.text_msg("Event 1").event_id(event_id!("$1")).into_event(),
995            f.text_msg("Event 2").event_id(event_id!("$2")).into_event(),
996            f.text_msg("Event 3").event_id(event_id!("$3")).into_event(),
997        ]);
998
999        // When there are no pending receipts,
1000        let mut pending_receipts = RingBuffer::new(NonZeroUsize::new(16).unwrap());
1001
1002        // And a new receipt event which points to $2,
1003        let new_receipt_event = Some(
1004            f.read_receipts()
1005                .add(event_id!("$2"), own_user_id, ReceiptType::Read, ReceiptThread::Unthreaded)
1006                .into_content(),
1007        );
1008
1009        // And no active receipt,
1010        let active_receipt = None;
1011        let with_threading_support = false;
1012
1013        // Then there's a new best receipt, which is the explicit one from the event
1014        let receipt = select_best_receipt(
1015            own_user_id,
1016            &lc,
1017            &mut pending_receipts,
1018            new_receipt_event.as_ref(),
1019            active_receipt,
1020            with_threading_support,
1021        );
1022        assert_eq!(receipt.unwrap(), event_id!("$2"));
1023        // And there are no pending receipts.
1024        assert!(pending_receipts.is_empty());
1025    }
1026
1027    #[test]
1028    fn test_select_best_receipt_stashes_pending_receipts() {
1029        let room_id = room_id!("!roomid:example.org");
1030        let f = EventFactory::new().room(room_id).sender(*ALICE);
1031        let own_user_id = user_id!("@not_alice:example.org");
1032
1033        // Create a non-empty linked chunk, with no messages sent by the current user.
1034        let mut lc = EventLinkedChunk::new();
1035        lc.push_events(vec![
1036            f.text_msg("Event 1").event_id(event_id!("$1")).into_event(),
1037            f.text_msg("Event 2").event_id(event_id!("$2")).into_event(),
1038            f.text_msg("Event 3").event_id(event_id!("$3")).into_event(),
1039        ]);
1040
1041        // When there are no pending receipts,
1042        let mut pending_receipts = RingBuffer::new(NonZeroUsize::new(16).unwrap());
1043
1044        // And a new receipt event, for an event we don't know about,
1045        let new_receipt_event = Some(
1046            f.read_receipts()
1047                .add(event_id!("$4"), own_user_id, ReceiptType::Read, ReceiptThread::Unthreaded)
1048                .into_content(),
1049        );
1050
1051        // And no active receipt,
1052        let active_receipt = None;
1053        let with_threading_support = false;
1054
1055        // Then there's no new best receipts.
1056        let receipt = select_best_receipt(
1057            own_user_id,
1058            &lc,
1059            &mut pending_receipts,
1060            new_receipt_event.as_ref(),
1061            active_receipt,
1062            with_threading_support,
1063        );
1064
1065        assert!(receipt.is_none());
1066        // And there's a new pending receipt for $4.
1067        assert_eq!(pending_receipts.len(), 1);
1068        assert_eq!(pending_receipts.get(0).unwrap(), event_id!("$4"));
1069    }
1070
1071    #[test]
1072    fn test_select_best_receipt_matched_pending_receipt() {
1073        let room_id = room_id!("!roomid:example.org");
1074        let f = EventFactory::new().room(room_id).sender(*ALICE);
1075        let own_user_id = user_id!("@not_alice:example.org");
1076
1077        // Create a non-empty linked chunk, with no messages sent by the current user.
1078        let mut lc = EventLinkedChunk::new();
1079        lc.push_events(vec![
1080            f.text_msg("Event 1").event_id(event_id!("$1")).into_event(),
1081            f.text_msg("Event 2").event_id(event_id!("$2")).into_event(),
1082            f.text_msg("Event 3").event_id(event_id!("$3")).into_event(),
1083        ]);
1084
1085        // When there is a pending receipt for $2,
1086        let mut pending_receipts = RingBuffer::new(NonZeroUsize::new(16).unwrap());
1087        pending_receipts.push(owned_event_id!("$2"));
1088
1089        // And no new receipt event,
1090        let new_receipt_event = None;
1091
1092        // And no active receipt,
1093        let active_receipt = None;
1094        let with_threading_support = false;
1095
1096        // Then there's a new best receipt, which is the matched pending receipt.
1097        let receipt = select_best_receipt(
1098            own_user_id,
1099            &lc,
1100            &mut pending_receipts,
1101            new_receipt_event.as_ref(),
1102            active_receipt,
1103            with_threading_support,
1104        );
1105        assert_eq!(receipt.unwrap(), event_id!("$2"));
1106        // And there are no more pending receipts.
1107        assert!(pending_receipts.is_empty());
1108    }
1109
1110    #[test]
1111    fn test_select_best_receipt_mixed() {
1112        let room_id = room_id!("!roomid:example.org");
1113        let f = EventFactory::new().room(room_id).sender(*ALICE);
1114        let own_user_id = user_id!("@not_alice:example.org");
1115
1116        // Create a non-empty linked chunk, with one message sent by the current user,
1117        // which will act as an implicit read receipt.
1118        let mut lc = EventLinkedChunk::new();
1119        lc.push_events(vec![
1120            f.text_msg("Event 1").event_id(event_id!("$1")).into_event(),
1121            f.text_msg("Event 2").event_id(event_id!("$2")).into_event(),
1122            f.text_msg("Event 3").event_id(event_id!("$3")).sender(own_user_id).into_event(),
1123            f.text_msg("Event 4").event_id(event_id!("$4")).into_event(),
1124            f.text_msg("Event 5").event_id(event_id!("$5")).into_event(),
1125        ]);
1126
1127        // When there is a pending receipt for $2, and $6,
1128        let mut pending_receipts = RingBuffer::new(NonZeroUsize::new(16).unwrap());
1129        pending_receipts.push(owned_event_id!("$2"));
1130        pending_receipts.push(owned_event_id!("$6"));
1131
1132        // And a new receipt event pointing at $4 and $6,
1133        let new_receipt_event = Some(
1134            f.read_receipts()
1135                .add(event_id!("$4"), own_user_id, ReceiptType::Read, ReceiptThread::Unthreaded)
1136                .add(event_id!("$7"), own_user_id, ReceiptType::ReadPrivate, ReceiptThread::Main)
1137                .into_content(),
1138        );
1139
1140        // And an active receipt point at $1,
1141        let active_receipt = Some(event_id!("$1"));
1142
1143        let with_threading_support = false;
1144
1145        // Then there's a new best receipt, which is the most advanced in the linked
1146        // chunk: $4.
1147        let receipt = select_best_receipt(
1148            own_user_id,
1149            &lc,
1150            &mut pending_receipts,
1151            new_receipt_event.as_ref(),
1152            active_receipt,
1153            with_threading_support,
1154        );
1155        assert_eq!(receipt.unwrap(), event_id!("$4"));
1156
1157        // Receipt 6 is still pending, and there's a new pending receipt for 7 too. ($2
1158        // has been cleaned because it has been seen).
1159        assert_eq!(pending_receipts.len(), 2);
1160        assert!(pending_receipts.iter().any(|ev| ev == event_id!("$6")));
1161        assert!(pending_receipts.iter().any(|ev| ev == event_id!("$7")));
1162    }
1163}