matrix_sdk_ui/room_list_service/sorters/
recency.rs

1// Copyright 2024 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::cmp::Ordering;
16
17use super::{RoomListItem, Sorter};
18
19fn cmp<F>(scores: F, left: &RoomListItem, right: &RoomListItem) -> Ordering
20where
21    F: Fn(&RoomListItem, &RoomListItem) -> (Option<Score>, Option<Score>),
22{
23    let (a, b) = scores(left, right);
24    cmp_impl(a, b)
25}
26
27fn cmp_impl(a: Option<Score>, b: Option<Score>) -> Ordering {
28    match (a, b) {
29        (Some(left), Some(right)) => left.cmp(&right).reverse(),
30        (Some(_), None) => Ordering::Less,
31        (None, Some(_)) => Ordering::Greater,
32        (None, None) => Ordering::Equal,
33    }
34}
35
36/// Create a new sorter that will sort two [`RoomListItem`] by “recency score”,
37/// i.e. by comparing their [`RoomInfo::latest_event_value`]'s timestamp value,
38/// or their [`RoomInfo::recency_stamp`] value. The `Room` with the newest
39/// “recency score” comes first, i.e. newest < oldest.
40///
41/// [`RoomInfo::recency_stamp`]: matrix_sdk_base::RoomInfo::recency_stamp
42/// [`RoomInfo::latest_event_value`]: matrix_sdk_base::RoomInfo::latest_event_value
43pub fn new_sorter() -> impl Sorter {
44    |left, right| -> Ordering { cmp(extract_scores, left, right) }
45}
46
47/// The term _score_ is used here to avoid any confusion with a _timestamp_ (a
48/// `u64` from the latest event), or a _recency stamp_ (a `u64` from the recency
49/// stamp of the room). This type hides `u64` for the sake of semantics.
50type Score = u64;
51
52/// Extract the recency _scores_ from either the
53/// [`RoomInfo::latest_event_value`] or from [`RoomInfo::recency_stamp`].
54///
55/// We must be very careful to return data of the same nature: either a
56/// _score_ from the [`LatestEventValue`]'s timestamp, or from the
57/// [`RoomInfo::recency_stamp`], but we **must never** mix both. The
58/// `RoomInfo::recency_stamp` is not a timestamp, while `LatestEventValue` uses
59/// a timestamp.
60///
61/// [`RoomInfo::recency_stamp`]: matrix_sdk_base::RoomInfo::recency_stamp
62/// [`RoomInfo::latest_event_value`]: matrix_sdk_base::RoomInfo::latest_event_value
63fn extract_scores(left: &RoomListItem, right: &RoomListItem) -> (Option<Score>, Option<Score>) {
64    // Warning 1.
65    //
66    // Be careful. This method is called **a lot** in the context of a sorter. Using
67    // `Room::latest_event` would be dramatic as it returns a clone of the
68    // `LatestEventValue`. It's better to use the more specific method
69    // `Room::latest_event_timestamp`, where the value is cached in
70    // `RoomListItem::cached_latest_event_timestamp`.
71
72    // Warning 2.
73    //
74    // A `RoomListItem` must have a unique score when sorting. Its `Score`
75    // must always be the same while sorting the rooms. Thus, the following
76    // rules must apply:
77    //
78    // - Nominal case: Two rooms with a latest event can be compared together based
79    //   on their latest event's timestamp,
80    // - Case #1: If a room has a latest event, but the other doesn't have one, the
81    //   first room has a score but the other doesn't have one,
82    // - Case #2: If none of the room has a latest event, we fallback to the recency
83    //   stamp for both rooms.
84    //
85    // The most important aspect is: if room returns its latest event's
86    // timestamp or its recency stamp, _once_, it must return it every time
87    // it's compared to another room, if possible, whatever the room is,
88    // otherwise it must return `None`.
89
90    // Nominal case and case #1.
91    if left.cached_latest_event_timestamp.is_some() || right.cached_latest_event_timestamp.is_some()
92    {
93        (
94            left.cached_latest_event_timestamp.map(|ts| ts.get().into()),
95            right.cached_latest_event_timestamp.map(|ts| ts.get().into()),
96        )
97    }
98    // Case #2.
99    else {
100        (left.cached_recency_stamp.map(Into::into), right.cached_recency_stamp.map(Into::into))
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use matrix_sdk::{
107        RoomRecencyStamp,
108        latest_events::{LatestEventValue, LocalLatestEventValue, RemoteLatestEventValue},
109        store::SerializableEventContent,
110        test_utils::logged_in_client_with_server,
111    };
112    use matrix_sdk_base::RoomInfoNotableUpdateReasons;
113    use matrix_sdk_test::async_test;
114    use proptest::prelude::*;
115    use ruma::{
116        MilliSecondsSinceUnixEpoch,
117        events::{AnyMessageLikeEventContent, room::message::RoomMessageEventContent},
118        room_id,
119        serde::Raw,
120    };
121    use serde_json::json;
122
123    use super::{super::super::filters::new_rooms, *};
124
125    fn none() -> LatestEventValue {
126        LatestEventValue::None
127    }
128
129    fn remote(origin_server_ts: u64) -> LatestEventValue {
130        LatestEventValue::Remote(RemoteLatestEventValue::from_plaintext(
131            Raw::from_json_string(
132                json!({
133                    "content": RoomMessageEventContent::text_plain("raclette"),
134                    "type": "m.room.message",
135                    "event_id": "$ev0",
136                    "room_id": "!r0",
137                    "origin_server_ts": origin_server_ts,
138                    "sender": "@mnt_io:matrix.org",
139                })
140                .to_string(),
141            )
142            .unwrap(),
143        ))
144    }
145
146    fn local_is_sending(origin_server_ts: u32) -> LatestEventValue {
147        LatestEventValue::LocalIsSending(LocalLatestEventValue {
148            timestamp: MilliSecondsSinceUnixEpoch(origin_server_ts.into()),
149            content: SerializableEventContent::new(&AnyMessageLikeEventContent::RoomMessage(
150                RoomMessageEventContent::text_plain("raclette"),
151            ))
152            .unwrap(),
153        })
154    }
155
156    fn local_cannot_be_sent(origin_server_ts: u32) -> LatestEventValue {
157        LatestEventValue::LocalCannotBeSent(LocalLatestEventValue {
158            timestamp: MilliSecondsSinceUnixEpoch(origin_server_ts.into()),
159            content: SerializableEventContent::new(&AnyMessageLikeEventContent::RoomMessage(
160                RoomMessageEventContent::text_plain("raclette"),
161            ))
162            .unwrap(),
163        })
164    }
165
166    fn set_latest_event_value(room: &mut RoomListItem, latest_event_value: LatestEventValue) {
167        let mut room_info = room.clone_info();
168        room_info.set_latest_event(latest_event_value);
169        room.set_room_info(room_info, RoomInfoNotableUpdateReasons::LATEST_EVENT);
170        room.refresh_cached_data();
171    }
172
173    fn set_recency_stamp(room: &mut RoomListItem, recency_stamp: RoomRecencyStamp) {
174        let mut room_info = room.clone_info();
175        room_info.update_recency_stamp(recency_stamp);
176        room.set_room_info(room_info, RoomInfoNotableUpdateReasons::RECENCY_STAMP);
177        room.refresh_cached_data();
178    }
179
180    #[async_test]
181    async fn test_extract_scores_with_none() {
182        let (client, server) = logged_in_client_with_server().await;
183        let [mut room_a, mut room_b] =
184            new_rooms([room_id!("!a:b.c"), room_id!("!d:e.f")], &client, &server).await;
185
186        set_recency_stamp(&mut room_a, 1.into());
187        set_recency_stamp(&mut room_b, 2.into());
188
189        // Both rooms have a `LatestEventValue::None`.
190        //
191        // Because there is no latest event, the recency stamp MUST BE USED.
192        {
193            set_latest_event_value(&mut room_a, none());
194            set_latest_event_value(&mut room_b, none());
195
196            assert_eq!(extract_scores(&room_a, &room_b), (Some(1), Some(2)));
197        }
198
199        // `room_a` has `None`, `room_b` has something else.
200        //
201        // One of the room has a latest event, so the recency stamp MUST BE IGNORED.
202        {
203            set_latest_event_value(&mut room_a, none());
204            set_latest_event_value(&mut room_b, remote(3));
205
206            assert_eq!(extract_scores(&room_a, &room_b), (None, Some(3)));
207        }
208
209        // `room_b` has `None`, `room_a` has something else.
210        //
211        // One of the room has a latest event, so the recency stamp MUST BE IGNORED.
212        {
213            set_latest_event_value(&mut room_a, remote(3));
214            set_latest_event_value(&mut room_b, none());
215
216            assert_eq!(extract_scores(&room_a, &room_b), (Some(3), None));
217        }
218    }
219
220    #[async_test]
221    async fn test_extract_scores_with_remote_or_local() {
222        let (client, server) = logged_in_client_with_server().await;
223        let [mut room_a, mut room_b] =
224            new_rooms([room_id!("!a:b.c"), room_id!("!d:e.f")], &client, &server).await;
225
226        set_recency_stamp(&mut room_a, 1.into());
227        set_recency_stamp(&mut room_b, 2.into());
228
229        // `room_a` and `room_b` has either `Remote` or `Local*`.
230        //
231        // Both rooms have a latest event, so the recency stamp MUST BE IGNORED.
232        {
233            for latest_event_value_a in [remote(3), local_is_sending(3), local_cannot_be_sent(3)] {
234                for latest_event_value_b in
235                    [remote(4), local_is_sending(4), local_cannot_be_sent(4)]
236                {
237                    set_latest_event_value(&mut room_a, latest_event_value_a.clone());
238                    set_latest_event_value(&mut room_b, latest_event_value_b);
239
240                    assert_eq!(extract_scores(&room_a, &room_b), (Some(3), Some(4)));
241                }
242            }
243        }
244    }
245
246    #[async_test]
247    async fn test_with_two_scores() {
248        let (client, server) = logged_in_client_with_server().await;
249        let [room_a, room_b] =
250            new_rooms([room_id!("!a:b.c"), room_id!("!d:e.f")], &client, &server).await;
251
252        // `room_a` has an older recency stamp than `room_b`.
253        {
254            // `room_a` has a smaller score than `room_b`, i.e. it must come
255            // after `room_b`.
256            assert_eq!(
257                cmp(|_left, _right| (Some(1), Some(2)), &room_a, &room_b),
258                Ordering::Greater
259            );
260        }
261
262        // `room_b` has a smaller score than `room_a`.
263        {
264            // `room_a` has a greater score than `room_b`, i.e. it must come
265            // before `room_b`.
266            assert_eq!(cmp(|_left, _right| (Some(2), Some(1)), &room_a, &room_b), Ordering::Less);
267        }
268
269        // `room_a` has the same score than `room_b`.
270        {
271            assert_eq!(cmp(|_left, _right| (Some(1), Some(1)), &room_a, &room_b), Ordering::Equal);
272        }
273    }
274
275    #[async_test]
276    async fn test_with_one_score() {
277        let (client, server) = logged_in_client_with_server().await;
278        let [room_a, room_b] =
279            new_rooms([room_id!("!a:b.c"), room_id!("!d:e.f")], &client, &server).await;
280
281        // `room_a` has a score, but `room_b` has none: `room_a` must come
282        // before `room_b`.
283        {
284            assert_eq!(cmp(|_left, _right| (Some(1), None), &room_a, &room_b), Ordering::Less);
285        }
286
287        // `room_a` has no score, but `room_b` has one: `room_a` must come after
288        // `room_b`.
289        {
290            assert_eq!(cmp(|_left, _right| (None, Some(1)), &room_a, &room_b), Ordering::Greater);
291        }
292    }
293
294    #[async_test]
295    async fn test_with_zero_score() {
296        let (client, server) = logged_in_client_with_server().await;
297        let [room_a, room_b] =
298            new_rooms([room_id!("!a:b.c"), room_id!("!d:e.f")], &client, &server).await;
299
300        // `room_a` and `room_b` has no score: they are equal.
301        {
302            assert_eq!(cmp(|_left, _right| (None, None), &room_a, &room_b), Ordering::Equal);
303        }
304    }
305
306    prop_compose! {
307        fn arb_score()(score in any::<Option<u64>>()) -> Option<Score> {
308            score
309        }
310    }
311
312    // Property tests to ensure that our comparison implementation for the `Score`
313    // is total[1]. If it wasn't so, a call to `sort_by()` with the given
314    // `cmp()` method could panic.
315    //
316    // [1]: https://en.wikipedia.org/wiki/Total_order
317    proptest! {
318        // Test that the ordering is reflexive, that is, each `Score` needs to be equal to itself.
319        // a <= a.
320        #![proptest_config(ProptestConfig::with_cases(10_000))]
321        #[test]
322        fn test_cmp_reflexive(score in arb_score()) {
323            let result = cmp_impl(score, score);
324            assert!(result == Ordering::Less || result == Ordering::Equal);
325        }
326
327        // Test that the ordering is transitive, that is, if a <= b and b <= c then a <= c.
328        #[test]
329        fn test_cmp_transitive(a in arb_score(), b in arb_score(), c in arb_score()) {
330            use Ordering::*;
331
332            let a_b_comparison = cmp_impl(a, b);
333            let b_c_comparison = cmp_impl(b, c);
334            let a_c_comparison = cmp_impl(a, c);
335
336            if matches!(a_b_comparison, Less | Equal) && matches!(b_c_comparison, Less | Equal) {
337                assert!(a_c_comparison == Less || a_c_comparison == Equal);
338            }
339        }
340
341        // Test that the ordering is antisymetric, that is, if a <= b and b <= a then a = b.
342        #[test]
343        fn test_cmp_antisymetric(a in arb_score(), b in arb_score()) {
344            use Ordering::*;
345
346            let a_b_comparison = cmp_impl(a, b);
347            let b_a_comparison = cmp_impl(b, a);
348
349            eprintln!("a.cmp(b) = {a_b_comparison:?}");
350            eprintln!("b.cmp(a) = {b_a_comparison:?}");
351
352            if matches!(a_b_comparison, Less | Equal) && matches!(b_a_comparison, Less | Equal) {
353                assert!(a == b);
354            }
355        }
356
357        // Test that the ordering is total, that is, we have either a <= b or b <= a.
358        #[test]
359        fn test_cmp_reverse(a in arb_score(), b in arb_score()) {
360            use Ordering::*;
361
362            let a_b_comparison = cmp_impl(a, b);
363            let b_a_comparison = cmp_impl(b, a);
364
365            if a_b_comparison == Less {
366                assert_eq!(b_a_comparison, Greater);
367            } else if a_b_comparison == Greater {
368                assert_eq!(b_a_comparison, Less);
369            } else {
370                assert_eq!(a_b_comparison, Equal);
371                assert_eq!(b_a_comparison, Equal);
372            }
373        }
374    }
375}