From 344a96a80f3d0efab2bc8fe87eeeca268ff324db Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Mon, 29 Jan 2024 14:23:05 +0100 Subject: [PATCH 01/12] feat: Introduce the `Filter` trait alias. This patch introduces the `matrix_sdk_ui::room_list_service::filters::Filter` trait alias. This patch also cleans up a little bit the filters by renaming some methods for the sake of consistency across all the existing filters. --- .../src/room_list_service/filters/all.rs | 4 ++- .../room_list_service/filters/all_non_left.rs | 14 ++++++-- .../filters/fuzzy_match_room_name.rs | 34 +++++++++---------- .../src/room_list_service/filters/mod.rs | 9 +++++ .../src/room_list_service/filters/none.rs | 4 +-- .../filters/normalized_match_room_name.rs | 26 +++++++------- 6 files changed, 55 insertions(+), 36 deletions(-) diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/all.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/all.rs index f12c613df..69b0f847c 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/all.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/all.rs @@ -1,7 +1,9 @@ use matrix_sdk::RoomListEntry; +use super::Filter; + /// Create a new filter that will accept all filled or invalidated entries. -pub fn new_filter() -> impl Fn(&RoomListEntry) -> bool { +pub fn new_filter() -> impl Filter { |room_list_entry| -> bool { matches!(room_list_entry, RoomListEntry::Filled(_) | RoomListEntry::Invalidated(_)) } diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/all_non_left.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/all_non_left.rs index 34d834884..01fe9bf25 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/all_non_left.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/all_non_left.rs @@ -1,11 +1,19 @@ use matrix_sdk::{Client, RoomListEntry}; use matrix_sdk_base::RoomState; -struct NonLeftRoomMatcher Option> { +use super::Filter; + +struct NonLeftRoomMatcher +where + F: Fn(&RoomListEntry) -> Option, +{ get_state: F, } -impl Option> NonLeftRoomMatcher { +impl NonLeftRoomMatcher +where + F: Fn(&RoomListEntry) -> Option, +{ fn matches(&self, room: &RoomListEntry) -> bool { if !matches!(room, RoomListEntry::Filled(_) | RoomListEntry::Invalidated(_)) { return false; @@ -21,7 +29,7 @@ impl Option> NonLeftRoomMatcher { /// Create a new filter that will accept all filled or invalidated entries, but /// filters out left rooms. -pub fn new_filter(client: &Client) -> impl Fn(&RoomListEntry) -> bool { +pub fn new_filter(client: &Client) -> impl Filter { let client = client.clone(); let matcher = NonLeftRoomMatcher { diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/fuzzy_match_room_name.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/fuzzy_match_room_name.rs index 1a0e2d634..9e8e672fd 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/fuzzy_match_room_name.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/fuzzy_match_room_name.rs @@ -1,7 +1,7 @@ pub use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher as _}; -use matrix_sdk::{Client, RoomListEntry}; +use matrix_sdk::Client; -use super::normalize_string; +use super::{normalize_string, Filter}; struct FuzzyMatcher { matcher: SkimMatcherV2, @@ -19,7 +19,7 @@ impl FuzzyMatcher { self } - fn fuzzy_match(&self, subject: &str) -> bool { + fn matches(&self, subject: &str) -> bool { // No pattern means there is a match. let Some(pattern) = self.pattern.as_ref() else { return true }; @@ -31,7 +31,7 @@ impl FuzzyMatcher { /// /// Rooms are fetched from the `Client`. The pattern and the room names are /// normalized with `normalize_string`. -pub fn new_filter(client: &Client, pattern: &str) -> impl Fn(&RoomListEntry) -> bool { +pub fn new_filter(client: &Client, pattern: &str) -> impl Filter { let searcher = FuzzyMatcher::new().with_pattern(pattern); let client = client.clone(); @@ -41,7 +41,7 @@ pub fn new_filter(client: &Client, pattern: &str) -> impl Fn(&RoomListEntry) -> let Some(room) = client.get_room(room_id) else { return false }; let Some(room_name) = room.name() else { return false }; - searcher.fuzzy_match(&room_name) + searcher.matches(&room_name) } } @@ -55,14 +55,14 @@ mod tests { fn test_no_pattern() { let matcher = FuzzyMatcher::new(); - assert!(matcher.fuzzy_match("hello")); + assert!(matcher.matches("hello")); } #[test] fn test_empty_pattern() { let matcher = FuzzyMatcher::new(); - assert!(matcher.fuzzy_match("hello")); + assert!(matcher.matches("hello")); } #[test] @@ -70,10 +70,10 @@ mod tests { let matcher = FuzzyMatcher::new(); let matcher = matcher.with_pattern("mtx"); - assert!(matcher.fuzzy_match("matrix")); + assert!(matcher.matches("matrix")); let matcher = matcher.with_pattern("mxt"); - assert!(matcher.fuzzy_match("matrix").not()); + assert!(matcher.matches("matrix").not()); } #[test] @@ -81,10 +81,10 @@ mod tests { let matcher = FuzzyMatcher::new(); let matcher = matcher.with_pattern("mtx"); - assert!(matcher.fuzzy_match("MaTrIX")); + assert!(matcher.matches("MaTrIX")); let matcher = matcher.with_pattern("mxt"); - assert!(matcher.fuzzy_match("MaTrIX").not()); + assert!(matcher.matches("MaTrIX").not()); } #[test] @@ -92,12 +92,12 @@ mod tests { let matcher = FuzzyMatcher::new(); let matcher = matcher.with_pattern("mtx"); - assert!(matcher.fuzzy_match("matrix")); - assert!(matcher.fuzzy_match("Matrix")); + assert!(matcher.matches("matrix")); + assert!(matcher.matches("Matrix")); let matcher = matcher.with_pattern("Mtx"); - assert!(matcher.fuzzy_match("matrix").not()); - assert!(matcher.fuzzy_match("Matrix")); + assert!(matcher.matches("matrix").not()); + assert!(matcher.matches("Matrix")); } #[test] @@ -110,10 +110,10 @@ mod tests { assert_eq!(matcher.pattern, Some("ubete".to_owned())); // Second, assert that the subject is normalized too. - assert!(matcher.fuzzy_match("un bel été")); + assert!(matcher.matches("un bel été")); // Another concrete test. let matcher = matcher.with_pattern("stf"); - assert!(matcher.fuzzy_match("Ștefan")); + assert!(matcher.matches("Ștefan")); } } diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs index b1beefa85..99e85c231 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs @@ -7,10 +7,19 @@ mod normalized_match_room_name; pub use all::new_filter as new_filter_all; pub use all_non_left::new_filter as new_filter_all_non_left; pub use fuzzy_match_room_name::new_filter as new_filter_fuzzy_match_room_name; +use matrix_sdk::RoomListEntry; pub use none::new_filter as new_filter_none; pub use normalized_match_room_name::new_filter as new_filter_normalized_match_room_name; use unicode_normalization::{char::is_combining_mark, UnicodeNormalization}; +/// A trait “alias” that represents a _filter_. +/// +/// A filter is simply a function that receives a `&RoomListEntry` and returns a +/// `bool`. +pub trait Filter: Fn(&RoomListEntry) -> bool {} + +impl Filter for F where F: Fn(&RoomListEntry) -> bool {} + /// Normalize a string, i.e. decompose it into NFD (Normalization Form D, i.e. a /// canonical decomposition, see http://www.unicode.org/reports/tr15/) and /// filter out the combining marks. diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/none.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/none.rs index a500fe7de..99e97be9c 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/none.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/none.rs @@ -1,7 +1,7 @@ -use matrix_sdk::RoomListEntry; +use super::Filter; /// Create a new filter that will reject all entries. -pub fn new_filter() -> impl Fn(&RoomListEntry) -> bool { +pub fn new_filter() -> impl Filter { |_room_list_entry| -> bool { false } } diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/normalized_match_room_name.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/normalized_match_room_name.rs index 7ee8fab5b..c0a49602d 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/normalized_match_room_name.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/normalized_match_room_name.rs @@ -1,6 +1,6 @@ -use matrix_sdk::{Client, RoomListEntry}; +use matrix_sdk::Client; -use super::normalize_string; +use super::{normalize_string, Filter}; struct NormalizedMatcher { pattern: Option, @@ -17,7 +17,7 @@ impl NormalizedMatcher { self } - fn normalized_match(&self, subject: &str) -> bool { + fn matches(&self, subject: &str) -> bool { // No pattern means there is a match. let Some(pattern) = self.pattern.as_ref() else { return true }; @@ -31,7 +31,7 @@ impl NormalizedMatcher { /// /// Rooms are fetched from the `Client`. The pattern and the room names are /// normalized with `normalize_string`. -pub fn new_filter(client: &Client, pattern: &str) -> impl Fn(&RoomListEntry) -> bool { +pub fn new_filter(client: &Client, pattern: &str) -> impl Filter { let searcher = NormalizedMatcher::new().with_pattern(pattern); let client = client.clone(); @@ -41,7 +41,7 @@ pub fn new_filter(client: &Client, pattern: &str) -> impl Fn(&RoomListEntry) -> let Some(room) = client.get_room(room_id) else { return false }; let Some(room_name) = room.name() else { return false }; - searcher.normalized_match(&room_name) + searcher.matches(&room_name) } } @@ -55,14 +55,14 @@ mod tests { fn test_no_pattern() { let matcher = NormalizedMatcher::new(); - assert!(matcher.normalized_match("hello")); + assert!(matcher.matches("hello")); } #[test] fn test_empty_pattern() { let matcher = NormalizedMatcher::new(); - assert!(matcher.normalized_match("hello")); + assert!(matcher.matches("hello")); } #[test] @@ -70,10 +70,10 @@ mod tests { let matcher = NormalizedMatcher::new(); let matcher = matcher.with_pattern("matrix"); - assert!(matcher.normalized_match("matrix")); + assert!(matcher.matches("matrix")); let matcher = matcher.with_pattern("matrxi"); - assert!(matcher.normalized_match("matrix").not()); + assert!(matcher.matches("matrix").not()); } #[test] @@ -81,10 +81,10 @@ mod tests { let matcher = NormalizedMatcher::new(); let matcher = matcher.with_pattern("matrix"); - assert!(matcher.normalized_match("MaTrIX")); + assert!(matcher.matches("MaTrIX")); let matcher = matcher.with_pattern("matrxi"); - assert!(matcher.normalized_match("MaTrIX").not()); + assert!(matcher.matches("MaTrIX").not()); } #[test] @@ -97,10 +97,10 @@ mod tests { assert_eq!(matcher.pattern, Some("un ete".to_owned())); // Second, assert that the subject is normalized too. - assert!(matcher.normalized_match("un été magnifique")); + assert!(matcher.matches("un été magnifique")); // Another concrete test. let matcher = matcher.with_pattern("stefan"); - assert!(matcher.normalized_match("Ștefan")); + assert!(matcher.matches("Ștefan")); } } From 3b068b592d657aa3e404607a0e209c31440f7bee Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Mon, 29 Jan 2024 14:33:59 +0100 Subject: [PATCH 02/12] chore: Rename `all_non_left` to `non_left`. This patch renames the room list filter `all_non_left` to `non_left`. --- bindings/matrix-sdk-ffi/src/room_list.rs | 6 +++--- crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs | 3 +-- .../filters/{all_non_left.rs => non_left.rs} | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) rename crates/matrix-sdk-ui/src/room_list_service/filters/{all_non_left.rs => non_left.rs} (96%) diff --git a/bindings/matrix-sdk-ffi/src/room_list.rs b/bindings/matrix-sdk-ffi/src/room_list.rs index d1cd3c9a7..005819d46 100644 --- a/bindings/matrix-sdk-ffi/src/room_list.rs +++ b/bindings/matrix-sdk-ffi/src/room_list.rs @@ -14,7 +14,7 @@ use matrix_sdk::{ }; use matrix_sdk_ui::{ room_list_service::filters::{ - new_filter_all, new_filter_all_non_left, new_filter_fuzzy_match_room_name, new_filter_none, + new_filter_all, new_filter_fuzzy_match_room_name, new_filter_non_left, new_filter_none, new_filter_normalized_match_room_name, }, timeline::default_event_filter, @@ -395,7 +395,7 @@ impl RoomListDynamicEntriesController { match kind { Kind::All => self.inner.set_filter(new_filter_all()), - Kind::AllNonLeft => self.inner.set_filter(new_filter_all_non_left(&self.client)), + Kind::NonLeft => self.inner.set_filter(new_filter_non_left(&self.client)), Kind::None => self.inner.set_filter(new_filter_none()), Kind::NormalizedMatchRoomName { pattern } => { self.inner.set_filter(new_filter_normalized_match_room_name(&self.client, &pattern)) @@ -418,7 +418,7 @@ impl RoomListDynamicEntriesController { #[derive(uniffi::Enum)] pub enum RoomListEntriesDynamicFilterKind { All, - AllNonLeft, + NonLeft, None, NormalizedMatchRoomName { pattern: String }, FuzzyMatchRoomName { pattern: String }, diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs index 99e85c231..6d3cb56ee 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs @@ -1,13 +1,12 @@ mod all; -mod all_non_left; mod fuzzy_match_room_name; mod none; mod normalized_match_room_name; pub use all::new_filter as new_filter_all; -pub use all_non_left::new_filter as new_filter_all_non_left; pub use fuzzy_match_room_name::new_filter as new_filter_fuzzy_match_room_name; use matrix_sdk::RoomListEntry; +pub use non_left::new_filter as new_filter_non_left; pub use none::new_filter as new_filter_none; pub use normalized_match_room_name::new_filter as new_filter_normalized_match_room_name; use unicode_normalization::{char::is_combining_mark, UnicodeNormalization}; diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/all_non_left.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/non_left.rs similarity index 96% rename from crates/matrix-sdk-ui/src/room_list_service/filters/all_non_left.rs rename to crates/matrix-sdk-ui/src/room_list_service/filters/non_left.rs index 01fe9bf25..4a7a5f04f 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/all_non_left.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/non_left.rs @@ -49,7 +49,7 @@ mod tests { use matrix_sdk_base::RoomState; use ruma::room_id; - use crate::room_list_service::filters::all_non_left::NonLeftRoomMatcher; + use super::NonLeftRoomMatcher; #[test] fn test_all_non_left_kind_of_room_list_entry() { From fe2ca341797357048ab86502ea57fa3c087be9ac Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Mon, 29 Jan 2024 15:06:18 +0100 Subject: [PATCH 03/12] feat: Implement `logical_all`, `_any` and `_not` filters. This patch implements 3 new filters: `logical_all`, `logical_any` and `logical_not`. --- .../room_list_service/filters/logical_all.rs | 73 +++++++++++++++++++ .../room_list_service/filters/logical_any.rs | 73 +++++++++++++++++++ .../room_list_service/filters/logical_not.rs | 39 ++++++++++ .../src/room_list_service/filters/mod.rs | 7 ++ 4 files changed, 192 insertions(+) create mode 100644 crates/matrix-sdk-ui/src/room_list_service/filters/logical_all.rs create mode 100644 crates/matrix-sdk-ui/src/room_list_service/filters/logical_any.rs create mode 100644 crates/matrix-sdk-ui/src/room_list_service/filters/logical_not.rs diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/logical_all.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/logical_all.rs new file mode 100644 index 000000000..c61c3dbe1 --- /dev/null +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/logical_all.rs @@ -0,0 +1,73 @@ +use super::Filter; + +/// Create a new filter that will run multiple filters. It returns `false` if at +/// least one of the filter returns `false`. +pub fn new_filter(filters: Vec>) -> impl Filter { + move |room_list_entry| -> bool { filters.iter().all(|filter| filter(room_list_entry)) } +} + +#[cfg(test)] +mod tests { + use std::ops::Not; + + use matrix_sdk::RoomListEntry; + use ruma::room_id; + + use super::new_filter; + + #[test] + fn test_one_filter() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); + + { + let filter = |_: &_| true; + let all = new_filter(vec![Box::new(filter)]); + + assert!(all(&room_list_entry)); + } + + { + let filter = |_: &_| false; + let all = new_filter(vec![Box::new(filter)]); + + assert!(all(&room_list_entry).not()); + } + } + + #[test] + fn test_two_filters() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); + + { + let filter1 = |_: &_| true; + let filter2 = |_: &_| true; + let all = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + + assert!(all(&room_list_entry)); + } + + { + let filter1 = |_: &_| true; + let filter2 = |_: &_| false; + let all = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + + assert!(all(&room_list_entry).not()); + } + + { + let filter1 = |_: &_| false; + let filter2 = |_: &_| true; + let all = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + + assert!(all(&room_list_entry).not()); + } + + { + let filter1 = |_: &_| false; + let filter2 = |_: &_| false; + let all = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + + assert!(all(&room_list_entry).not()); + } + } +} diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/logical_any.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/logical_any.rs new file mode 100644 index 000000000..315411e5c --- /dev/null +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/logical_any.rs @@ -0,0 +1,73 @@ +use super::Filter; + +/// Create a new filter that will run multiple filters. It returns `true` if at +/// least one of the filter returns `true`. +pub fn new_filter(filters: Vec>) -> impl Filter { + move |room_list_entry| -> bool { filters.iter().any(|filter| filter(room_list_entry)) } +} + +#[cfg(test)] +mod tests { + use std::ops::Not; + + use matrix_sdk::RoomListEntry; + use ruma::room_id; + + use super::new_filter; + + #[test] + fn test_one_filter() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); + + { + let filter = |_: &_| true; + let any = new_filter(vec![Box::new(filter)]); + + assert!(any(&room_list_entry)); + } + + { + let filter = |_: &_| false; + let any = new_filter(vec![Box::new(filter)]); + + assert!(any(&room_list_entry).not()); + } + } + + #[test] + fn test_two_filters() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); + + { + let filter1 = |_: &_| true; + let filter2 = |_: &_| true; + let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + + assert!(any(&room_list_entry)); + } + + { + let filter1 = |_: &_| true; + let filter2 = |_: &_| false; + let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + + assert!(any(&room_list_entry)); + } + + { + let filter1 = |_: &_| false; + let filter2 = |_: &_| true; + let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + + assert!(any(&room_list_entry)); + } + + { + let filter1 = |_: &_| false; + let filter2 = |_: &_| false; + let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + + assert!(any(&room_list_entry).not()); + } + } +} diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/logical_not.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/logical_not.rs new file mode 100644 index 000000000..417915d81 --- /dev/null +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/logical_not.rs @@ -0,0 +1,39 @@ +use std::ops::Not; + +use super::Filter; + +/// Create a new filter that will negate the inner filter. It returns `false` if +/// the inner filter returns `true`, otherwise it returns `true`. +pub fn new_filter(filter: impl Filter) -> impl Filter { + move |room_list_entry| -> bool { filter(room_list_entry).not() } +} + +#[cfg(test)] +mod tests { + use std::ops::Not; + + use matrix_sdk::RoomListEntry; + use ruma::room_id; + + use super::new_filter; + + #[test] + fn test_true() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); + + let filter = |_: &_| true; + let not = new_filter(filter); + + assert!(not(&room_list_entry).not()); + } + + #[test] + fn test_false() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); + + let filter = |_: &_| false; + let not = new_filter(filter); + + assert!(not(&room_list_entry)); + } +} diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs index 6d3cb56ee..85a653cdf 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs @@ -1,10 +1,17 @@ mod all; mod fuzzy_match_room_name; +mod logical_all; +mod logical_any; +mod logical_not; +mod non_left; mod none; mod normalized_match_room_name; pub use all::new_filter as new_filter_all; pub use fuzzy_match_room_name::new_filter as new_filter_fuzzy_match_room_name; +pub use logical_all::new_filter as new_filter_logical_all; +pub use logical_any::new_filter as new_filter_logical_any; +pub use logical_not::new_filter as new_filter_logical_not; use matrix_sdk::RoomListEntry; pub use non_left::new_filter as new_filter_non_left; pub use none::new_filter as new_filter_none; From 435d74a67a7659f2cbca9dce7b493f663e95f642 Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Mon, 5 Feb 2024 12:54:57 +0100 Subject: [PATCH 04/12] chore(ui): Remove the `all` room list filter. This patch removes the `all` room list filter. It's not used anymore since we have `non_left` which is more correct. --- bindings/matrix-sdk-ffi/src/room_list.rs | 4 +-- .../src/room_list_service/filters/all.rs | 29 ------------------- .../src/room_list_service/filters/mod.rs | 2 -- .../tests/integration/room_list_service.rs | 4 +-- 4 files changed, 3 insertions(+), 36 deletions(-) delete mode 100644 crates/matrix-sdk-ui/src/room_list_service/filters/all.rs diff --git a/bindings/matrix-sdk-ffi/src/room_list.rs b/bindings/matrix-sdk-ffi/src/room_list.rs index 005819d46..ff07e143f 100644 --- a/bindings/matrix-sdk-ffi/src/room_list.rs +++ b/bindings/matrix-sdk-ffi/src/room_list.rs @@ -14,7 +14,7 @@ use matrix_sdk::{ }; use matrix_sdk_ui::{ room_list_service::filters::{ - new_filter_all, new_filter_fuzzy_match_room_name, new_filter_non_left, new_filter_none, + new_filter_fuzzy_match_room_name, new_filter_non_left, new_filter_none, new_filter_normalized_match_room_name, }, timeline::default_event_filter, @@ -394,7 +394,6 @@ impl RoomListDynamicEntriesController { use RoomListEntriesDynamicFilterKind as Kind; match kind { - Kind::All => self.inner.set_filter(new_filter_all()), Kind::NonLeft => self.inner.set_filter(new_filter_non_left(&self.client)), Kind::None => self.inner.set_filter(new_filter_none()), Kind::NormalizedMatchRoomName { pattern } => { @@ -417,7 +416,6 @@ impl RoomListDynamicEntriesController { #[derive(uniffi::Enum)] pub enum RoomListEntriesDynamicFilterKind { - All, NonLeft, None, NormalizedMatchRoomName { pattern: String }, diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/all.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/all.rs deleted file mode 100644 index 69b0f847c..000000000 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/all.rs +++ /dev/null @@ -1,29 +0,0 @@ -use matrix_sdk::RoomListEntry; - -use super::Filter; - -/// Create a new filter that will accept all filled or invalidated entries. -pub fn new_filter() -> impl Filter { - |room_list_entry| -> bool { - matches!(room_list_entry, RoomListEntry::Filled(_) | RoomListEntry::Invalidated(_)) - } -} - -#[cfg(test)] -mod tests { - use std::ops::Not; - - use matrix_sdk::RoomListEntry; - use ruma::room_id; - - use super::new_filter; - - #[test] - fn test_all_kind_of_room_list_entry() { - let all = new_filter(); - - assert!(all(&RoomListEntry::Empty).not()); - assert!(all(&RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()))); - assert!(all(&RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned()))); - } -} diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs index 85a653cdf..52b1bdc04 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs @@ -1,4 +1,3 @@ -mod all; mod fuzzy_match_room_name; mod logical_all; mod logical_any; @@ -7,7 +6,6 @@ mod non_left; mod none; mod normalized_match_room_name; -pub use all::new_filter as new_filter_all; pub use fuzzy_match_room_name::new_filter as new_filter_fuzzy_match_room_name; pub use logical_all::new_filter as new_filter_logical_all; pub use logical_any::new_filter as new_filter_logical_any; diff --git a/crates/matrix-sdk-ui/tests/integration/room_list_service.rs b/crates/matrix-sdk-ui/tests/integration/room_list_service.rs index ab035dabe..87b58897d 100644 --- a/crates/matrix-sdk-ui/tests/integration/room_list_service.rs +++ b/crates/matrix-sdk-ui/tests/integration/room_list_service.rs @@ -12,7 +12,7 @@ use matrix_sdk_base::sync::UnreadNotificationsCount; use matrix_sdk_test::async_test; use matrix_sdk_ui::{ room_list_service::{ - filters::{new_filter_all, new_filter_fuzzy_match_room_name, new_filter_none}, + filters::{new_filter_fuzzy_match_room_name, new_filter_non_left, new_filter_none}, Error, Input, InputResult, RoomListEntry, RoomListLoadingState, State, SyncIndicator, ALL_ROOMS_LIST_NAME as ALL_ROOMS, INVITES_LIST_NAME as INVITES, VISIBLE_ROOMS_LIST_NAME as VISIBLE_ROOMS, @@ -1822,7 +1822,7 @@ async fn test_dynamic_entries_stream() -> Result<(), Error> { }; // Now, let's change again the dynamic filter! - dynamic_entries.set_filter(new_filter_all()); + dynamic_entries.set_filter(new_filter_non_left(&client)); // Assert the dynamic entries. assert_entries_batch! { From 61d3f8d1d90ad237dea83f579319a74e36d6b73d Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Mon, 5 Feb 2024 14:13:32 +0100 Subject: [PATCH 05/12] feat(ui): Rename `logical_` filters to ``. This patch removes the `logical_` prefix of some filters. --- .../filters/{logical_all.rs => all.rs} | 4 ++-- .../filters/{logical_any.rs => any.rs} | 4 ++-- .../src/room_list_service/filters/mod.rs | 12 ++++++------ .../filters/{logical_not.rs => not.rs} | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) rename crates/matrix-sdk-ui/src/room_list_service/filters/{logical_all.rs => all.rs} (94%) rename crates/matrix-sdk-ui/src/room_list_service/filters/{logical_any.rs => any.rs} (94%) rename crates/matrix-sdk-ui/src/room_list_service/filters/{logical_not.rs => not.rs} (79%) diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/logical_all.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/all.rs similarity index 94% rename from crates/matrix-sdk-ui/src/room_list_service/filters/logical_all.rs rename to crates/matrix-sdk-ui/src/room_list_service/filters/all.rs index c61c3dbe1..8470e6c98 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/logical_all.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/all.rs @@ -1,8 +1,8 @@ -use super::Filter; +use super::{super::room_list::BoxedFilterFn, Filter}; /// Create a new filter that will run multiple filters. It returns `false` if at /// least one of the filter returns `false`. -pub fn new_filter(filters: Vec>) -> impl Filter { +pub fn new_filter(filters: Vec) -> impl Filter { move |room_list_entry| -> bool { filters.iter().all(|filter| filter(room_list_entry)) } } diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/logical_any.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/any.rs similarity index 94% rename from crates/matrix-sdk-ui/src/room_list_service/filters/logical_any.rs rename to crates/matrix-sdk-ui/src/room_list_service/filters/any.rs index 315411e5c..c0bb09424 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/logical_any.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/any.rs @@ -1,8 +1,8 @@ -use super::Filter; +use super::{super::room_list::BoxedFilterFn, Filter}; /// Create a new filter that will run multiple filters. It returns `true` if at /// least one of the filter returns `true`. -pub fn new_filter(filters: Vec>) -> impl Filter { +pub fn new_filter(filters: Vec) -> impl Filter { move |room_list_entry| -> bool { filters.iter().any(|filter| filter(room_list_entry)) } } diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs index 52b1bdc04..9387a9169 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs @@ -1,19 +1,19 @@ +mod all; +mod any; mod fuzzy_match_room_name; -mod logical_all; -mod logical_any; -mod logical_not; mod non_left; mod none; mod normalized_match_room_name; +mod not; +pub use all::new_filter as new_filter_all; +pub use any::new_filter as new_filter_any; pub use fuzzy_match_room_name::new_filter as new_filter_fuzzy_match_room_name; -pub use logical_all::new_filter as new_filter_logical_all; -pub use logical_any::new_filter as new_filter_logical_any; -pub use logical_not::new_filter as new_filter_logical_not; use matrix_sdk::RoomListEntry; pub use non_left::new_filter as new_filter_non_left; pub use none::new_filter as new_filter_none; pub use normalized_match_room_name::new_filter as new_filter_normalized_match_room_name; +pub use not::new_filter as new_filter_not; use unicode_normalization::{char::is_combining_mark, UnicodeNormalization}; /// A trait “alias” that represents a _filter_. diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/logical_not.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/not.rs similarity index 79% rename from crates/matrix-sdk-ui/src/room_list_service/filters/logical_not.rs rename to crates/matrix-sdk-ui/src/room_list_service/filters/not.rs index 417915d81..b0a63d736 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/logical_not.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/not.rs @@ -1,10 +1,10 @@ use std::ops::Not; -use super::Filter; +use super::{super::room_list::BoxedFilterFn, Filter}; /// Create a new filter that will negate the inner filter. It returns `false` if /// the inner filter returns `true`, otherwise it returns `true`. -pub fn new_filter(filter: impl Filter) -> impl Filter { +pub fn new_filter(filter: BoxedFilterFn) -> impl Filter { move |room_list_entry| -> bool { filter(room_list_entry).not() } } @@ -21,7 +21,7 @@ mod tests { fn test_true() { let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); - let filter = |_: &_| true; + let filter = Box::new(|_: &_| true); let not = new_filter(filter); assert!(not(&room_list_entry).not()); @@ -31,7 +31,7 @@ mod tests { fn test_false() { let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); - let filter = |_: &_| false; + let filter = Box::new(|_: &_| false); let not = new_filter(filter); assert!(not(&room_list_entry)); From d699b2fa709346ee0cbfa647cb7c6a3b0fd7327c Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Mon, 5 Feb 2024 14:14:24 +0100 Subject: [PATCH 06/12] feat(ui,ffi): Implement the `all` and `any` filters on FFI. This patch implements the `all` and `any` filters in `matrix-sdk-ffi`. The `not` filter cannot be implemented because recursive enum isn't supported by UniFFI (see https://github.com/mozilla/uniffi-rs/issues/396). --- bindings/matrix-sdk-ffi/src/room_list.rs | 52 +++++++++++++------ .../src/room_list_service/room_list.rs | 12 ++--- .../tests/integration/room_list_service.rs | 8 +-- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/bindings/matrix-sdk-ffi/src/room_list.rs b/bindings/matrix-sdk-ffi/src/room_list.rs index ff07e143f..5c6731bef 100644 --- a/bindings/matrix-sdk-ffi/src/room_list.rs +++ b/bindings/matrix-sdk-ffi/src/room_list.rs @@ -13,9 +13,12 @@ use matrix_sdk::{ RoomListEntry as MatrixRoomListEntry, }; use matrix_sdk_ui::{ - room_list_service::filters::{ - new_filter_fuzzy_match_room_name, new_filter_non_left, new_filter_none, - new_filter_normalized_match_room_name, + room_list_service::{ + filters::{ + new_filter_all, new_filter_any, new_filter_fuzzy_match_room_name, new_filter_non_left, + new_filter_none, new_filter_normalized_match_room_name, + }, + BoxedFilterFn, }, timeline::default_event_filter, }; @@ -391,18 +394,8 @@ impl RoomListDynamicEntriesController { #[uniffi::export] impl RoomListDynamicEntriesController { fn set_filter(&self, kind: RoomListEntriesDynamicFilterKind) -> bool { - use RoomListEntriesDynamicFilterKind as Kind; - - match kind { - Kind::NonLeft => self.inner.set_filter(new_filter_non_left(&self.client)), - Kind::None => self.inner.set_filter(new_filter_none()), - Kind::NormalizedMatchRoomName { pattern } => { - self.inner.set_filter(new_filter_normalized_match_room_name(&self.client, &pattern)) - } - Kind::FuzzyMatchRoomName { pattern } => { - self.inner.set_filter(new_filter_fuzzy_match_room_name(&self.client, &pattern)) - } - } + let FilterWrapper(filter) = FilterWrapper::from(&self.client, kind); + self.inner.set_filter(filter) } fn add_one_page(&self) { @@ -416,12 +409,41 @@ impl RoomListDynamicEntriesController { #[derive(uniffi::Enum)] pub enum RoomListEntriesDynamicFilterKind { + All { filters: Vec }, + Any { filters: Vec }, NonLeft, None, NormalizedMatchRoomName { pattern: String }, FuzzyMatchRoomName { pattern: String }, } +/// Custom internal type to transform a `RoomListEntriesDynamicFilterKind` into +/// a `BoxedFilterFn`. +struct FilterWrapper(BoxedFilterFn); + +impl FilterWrapper { + fn from(client: &matrix_sdk::Client, value: RoomListEntriesDynamicFilterKind) -> Self { + use RoomListEntriesDynamicFilterKind as Kind; + + match value { + Kind::All { filters } => Self(Box::new(new_filter_all( + filters.into_iter().map(|filter| FilterWrapper::from(client, filter).0).collect(), + ))), + Kind::Any { filters } => Self(Box::new(new_filter_any( + filters.into_iter().map(|filter| FilterWrapper::from(client, filter).0).collect(), + ))), + Kind::NonLeft => Self(Box::new(new_filter_non_left(client))), + Kind::None => Self(Box::new(new_filter_none())), + Kind::NormalizedMatchRoomName { pattern } => { + Self(Box::new(new_filter_normalized_match_room_name(client, &pattern))) + } + Kind::FuzzyMatchRoomName { pattern } => { + Self(Box::new(new_filter_fuzzy_match_room_name(client, &pattern))) + } + } + } +} + #[derive(uniffi::Object)] pub struct RoomListItem { inner: Arc, diff --git a/crates/matrix-sdk-ui/src/room_list_service/room_list.rs b/crates/matrix-sdk-ui/src/room_list_service/room_list.rs index 405514978..93d1e6763 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/room_list.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/room_list.rs @@ -26,7 +26,7 @@ use matrix_sdk::{ RoomListEntry, SlidingSync, SlidingSyncList, }; -use super::{Error, State}; +use super::{filters::Filter, Error, State}; /// A `RoomList` represents a list of rooms, from a /// [`RoomListService`](super::RoomListService). @@ -199,7 +199,8 @@ pub enum RoomListLoadingState { }, } -type BoxedFilterFn = Box bool + Send + Sync>; +/// Type alias for a boxed filter function. +pub type BoxedFilterFn = Box; /// Controller for the [`RoomList`] dynamic entries. /// @@ -226,17 +227,14 @@ impl RoomListDynamicEntriesController { /// /// If the associated stream has been dropped, returns `false` to indicate /// the operation didn't have an effect. - pub fn set_filter( - &self, - filter: impl Fn(&RoomListEntry) -> bool + Send + Sync + 'static, - ) -> bool { + pub fn set_filter(&self, filter: BoxedFilterFn) -> bool { if Arc::strong_count(&self.filter) == 1 { // there is no other reference to the boxed filter fn, setting it // would be pointless (no new references can be created from self, // either) false } else { - self.filter.set(Box::new(filter)); + self.filter.set(filter); true } } diff --git a/crates/matrix-sdk-ui/tests/integration/room_list_service.rs b/crates/matrix-sdk-ui/tests/integration/room_list_service.rs index 87b58897d..276e54280 100644 --- a/crates/matrix-sdk-ui/tests/integration/room_list_service.rs +++ b/crates/matrix-sdk-ui/tests/integration/room_list_service.rs @@ -1643,7 +1643,7 @@ async fn test_dynamic_entries_stream() -> Result<(), Error> { assert_pending!(dynamic_entries_stream); // Now, let's define a filter. - dynamic_entries.set_filter(new_filter_fuzzy_match_room_name(&client, "mat ba")); + dynamic_entries.set_filter(Box::new(new_filter_fuzzy_match_room_name(&client, "mat ba"))); // Assert the dynamic entries. assert_entries_batch! { @@ -1799,7 +1799,7 @@ async fn test_dynamic_entries_stream() -> Result<(), Error> { assert_pending!(dynamic_entries_stream); // Now, let's change the dynamic entries! - dynamic_entries.set_filter(new_filter_fuzzy_match_room_name(&client, "hell")); + dynamic_entries.set_filter(Box::new(new_filter_fuzzy_match_room_name(&client, "hell"))); // Assert the dynamic entries. assert_entries_batch! { @@ -1811,7 +1811,7 @@ async fn test_dynamic_entries_stream() -> Result<(), Error> { assert_pending!(dynamic_entries_stream); // Now, let's change again the dynamic filter! - dynamic_entries.set_filter(new_filter_none()); + dynamic_entries.set_filter(Box::new(new_filter_none())); // Assert the dynamic entries. assert_entries_batch! { @@ -1822,7 +1822,7 @@ async fn test_dynamic_entries_stream() -> Result<(), Error> { }; // Now, let's change again the dynamic filter! - dynamic_entries.set_filter(new_filter_non_left(&client)); + dynamic_entries.set_filter(Box::new(new_filter_non_left(&client))); // Assert the dynamic entries. assert_entries_batch! { From e94fd2a7df9b9f6ebbdef63422402f2998363bb1 Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Mon, 5 Feb 2024 14:56:32 +0100 Subject: [PATCH 07/12] feat(ui,ffi): Implement the `unread` room list filter. This patch implements the `unread` room list filter. --- bindings/matrix-sdk-ffi/src/room_list.rs | 4 +- crates/matrix-sdk-base/src/lib.rs | 2 +- .../src/room_list_service/filters/mod.rs | 2 + .../src/room_list_service/filters/unread.rs | 153 ++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 crates/matrix-sdk-ui/src/room_list_service/filters/unread.rs diff --git a/bindings/matrix-sdk-ffi/src/room_list.rs b/bindings/matrix-sdk-ffi/src/room_list.rs index 5c6731bef..b647cdcc0 100644 --- a/bindings/matrix-sdk-ffi/src/room_list.rs +++ b/bindings/matrix-sdk-ffi/src/room_list.rs @@ -16,7 +16,7 @@ use matrix_sdk_ui::{ room_list_service::{ filters::{ new_filter_all, new_filter_any, new_filter_fuzzy_match_room_name, new_filter_non_left, - new_filter_none, new_filter_normalized_match_room_name, + new_filter_none, new_filter_normalized_match_room_name, new_filter_unread, }, BoxedFilterFn, }, @@ -412,6 +412,7 @@ pub enum RoomListEntriesDynamicFilterKind { All { filters: Vec }, Any { filters: Vec }, NonLeft, + Unread, None, NormalizedMatchRoomName { pattern: String }, FuzzyMatchRoomName { pattern: String }, @@ -433,6 +434,7 @@ impl FilterWrapper { filters.into_iter().map(|filter| FilterWrapper::from(client, filter).0).collect(), ))), Kind::NonLeft => Self(Box::new(new_filter_non_left(client))), + Kind::Unread => Self(Box::new(new_filter_unread(client))), Kind::None => Self(Box::new(new_filter_none())), Kind::NormalizedMatchRoomName { pattern } => { Self(Box::new(new_filter_normalized_match_room_name(client, &pattern))) diff --git a/crates/matrix-sdk-base/src/lib.rs b/crates/matrix-sdk-base/src/lib.rs index 83e18c562..dd5218ef3 100644 --- a/crates/matrix-sdk-base/src/lib.rs +++ b/crates/matrix-sdk-base/src/lib.rs @@ -31,7 +31,7 @@ pub mod latest_event; pub mod media; mod rooms; -mod read_receipts; +pub mod read_receipts; pub use read_receipts::PreviousEventsProvider; #[cfg(feature = "experimental-sliding-sync")] mod sliding_sync; diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs index 9387a9169..9686d1a68 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs @@ -5,6 +5,7 @@ mod non_left; mod none; mod normalized_match_room_name; mod not; +mod unread; pub use all::new_filter as new_filter_all; pub use any::new_filter as new_filter_any; @@ -15,6 +16,7 @@ pub use none::new_filter as new_filter_none; pub use normalized_match_room_name::new_filter as new_filter_normalized_match_room_name; pub use not::new_filter as new_filter_not; use unicode_normalization::{char::is_combining_mark, UnicodeNormalization}; +pub use unread::new_filter as new_filter_unread; /// A trait “alias” that represents a _filter_. /// diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/unread.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/unread.rs new file mode 100644 index 000000000..d78911bc8 --- /dev/null +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/unread.rs @@ -0,0 +1,153 @@ +use matrix_sdk::{Client, RoomListEntry}; +use matrix_sdk_base::read_receipts::RoomReadReceipts; + +use super::Filter; + +type IsMarkedUnread = bool; + +struct UnreadRoomMatcher +where + F: Fn(&RoomListEntry) -> Option<(RoomReadReceipts, IsMarkedUnread)>, +{ + read_receipts_and_unread: F, +} + +impl UnreadRoomMatcher +where + F: Fn(&RoomListEntry) -> Option<(RoomReadReceipts, IsMarkedUnread)>, +{ + fn matches(&self, room_list_entry: &RoomListEntry) -> bool { + if !matches!(room_list_entry, RoomListEntry::Filled(_) | RoomListEntry::Invalidated(_)) { + return false; + } + + let Some((read_receipts, is_marked_unread)) = + (self.read_receipts_and_unread)(room_list_entry) + else { + return false; + }; + + read_receipts.num_notifications > 0 || is_marked_unread + } +} + +/// Create a new filter that will accept all filled or invalidated entries, but +/// filters out rooms that have no unread notifications (different from unread +/// messages), or is not marked as unread. +pub fn new_filter(client: &Client) -> impl Filter { + let client = client.clone(); + + let matcher = UnreadRoomMatcher { + read_receipts_and_unread: move |room| { + let room_id = room.as_room_id()?; + let room = client.get_room(room_id)?; + + Some((room.read_receipts(), room.is_marked_unread())) + }, + }; + + move |room_list_entry| -> bool { matcher.matches(room_list_entry) } +} + +#[cfg(test)] +mod tests { + use std::ops::Not; + + use matrix_sdk::RoomListEntry; + use matrix_sdk_base::read_receipts::RoomReadReceipts; + use ruma::room_id; + + use super::UnreadRoomMatcher; + + #[test] + fn test_has_unread_notifications() { + for is_marked_as_unread in [true, false] { + let matcher = UnreadRoomMatcher { + read_receipts_and_unread: |_| { + let mut read_receipts = RoomReadReceipts::default(); + read_receipts.num_unread = 42; + read_receipts.num_notifications = 42; + + Some((read_receipts, is_marked_as_unread)) + }, + }; + + assert!(matcher.matches(&RoomListEntry::Empty).not()); + assert!(matcher.matches(&RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()))); + assert!( + matcher.matches(&RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned())) + ); + } + } + + #[test] + fn test_has_unread_messages_but_no_unread_notifications_and_is_not_marked_as_unread() { + let matcher = UnreadRoomMatcher { + read_receipts_and_unread: |_| { + let mut read_receipts = RoomReadReceipts::default(); + read_receipts.num_unread = 42; + read_receipts.num_notifications = 0; + + Some((read_receipts, false)) + }, + }; + + assert!(matcher.matches(&RoomListEntry::Empty).not()); + assert!(matcher.matches(&RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned())).not()); + assert!(matcher + .matches(&RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned())) + .not()); + } + + #[test] + fn test_has_unread_messages_but_no_unread_notifications_and_is_marked_as_unread() { + let matcher = UnreadRoomMatcher { + read_receipts_and_unread: |_| { + let mut read_receipts = RoomReadReceipts::default(); + read_receipts.num_unread = 42; + read_receipts.num_notifications = 0; + + Some((read_receipts, true)) + }, + }; + + assert!(matcher.matches(&RoomListEntry::Empty).not()); + assert!(matcher.matches(&RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()))); + assert!(matcher.matches(&RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned()))); + } + + #[test] + fn test_has_no_unread_notifications_and_is_not_marked_as_unread() { + let matcher = UnreadRoomMatcher { + read_receipts_and_unread: |_| Some((RoomReadReceipts::default(), false)), + }; + + assert!(matcher.matches(&RoomListEntry::Empty).not()); + assert!(matcher.matches(&RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned())).not()); + assert!(matcher + .matches(&RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned())) + .not()); + } + + #[test] + fn test_has_no_unread_notifications_and_is_marked_as_unread() { + let matcher = UnreadRoomMatcher { + read_receipts_and_unread: |_| Some((RoomReadReceipts::default(), true)), + }; + + assert!(matcher.matches(&RoomListEntry::Empty).not()); + assert!(matcher.matches(&RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()))); + assert!(matcher.matches(&RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned()))); + } + + #[test] + fn test_read_receipts_cannot_be_found() { + let matcher = UnreadRoomMatcher { read_receipts_and_unread: |_| None }; + + assert!(matcher.matches(&RoomListEntry::Empty).not()); + assert!(matcher.matches(&RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned())).not()); + assert!(matcher + .matches(&RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned())) + .not()); + } +} From 2c688fd40f3df227eed4925b675ebe8f0dab1bb5 Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Mon, 5 Feb 2024 15:13:08 +0100 Subject: [PATCH 08/12] chore(ui): Remove the `get_` prefix of an internal filter type. This patch renames `NonLeftRoomMatcher::get_state` to `::state`. This is more Rust idiomatic. --- .../src/room_list_service/filters/non_left.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/non_left.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/non_left.rs index 4a7a5f04f..b31750772 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/non_left.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/non_left.rs @@ -7,7 +7,7 @@ struct NonLeftRoomMatcher where F: Fn(&RoomListEntry) -> Option, { - get_state: F, + state: F, } impl NonLeftRoomMatcher @@ -19,7 +19,7 @@ where return false; } - if let Some(state) = (self.get_state)(room) { + if let Some(state) = (self.state)(room) { state != RoomState::Left } else { false @@ -33,7 +33,7 @@ pub fn new_filter(client: &Client) -> impl Filter { let client = client.clone(); let matcher = NonLeftRoomMatcher { - get_state: move |room| { + state: move |room| { let room_id = room.as_room_id()?; let room = client.get_room(room_id)?; Some(room.state()) @@ -54,19 +54,19 @@ mod tests { #[test] fn test_all_non_left_kind_of_room_list_entry() { // When we can't figure out the room state, nothing matches. - let matcher = NonLeftRoomMatcher { get_state: |_| None }; + let matcher = NonLeftRoomMatcher { state: |_| None }; assert!(!matcher.matches(&RoomListEntry::Empty)); assert!(!matcher.matches(&RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()))); assert!(!matcher.matches(&RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned()))); // When a room has been left, it doesn't match. - let matcher = NonLeftRoomMatcher { get_state: |_| Some(RoomState::Left) }; + let matcher = NonLeftRoomMatcher { state: |_| Some(RoomState::Left) }; assert!(!matcher.matches(&RoomListEntry::Empty)); assert!(!matcher.matches(&RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()))); assert!(!matcher.matches(&RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned()))); // When a room has been joined, it does match (unless it's empty). - let matcher = NonLeftRoomMatcher { get_state: |_| Some(RoomState::Joined) }; + let matcher = NonLeftRoomMatcher { state: |_| Some(RoomState::Joined) }; assert!(!matcher.matches(&RoomListEntry::Empty)); assert!(matcher.matches(&RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()))); assert!(matcher.matches(&RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned()))); From f950e67b3936214baa208986fb69a96efff9a7dd Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Mon, 5 Feb 2024 15:49:30 +0100 Subject: [PATCH 09/12] feat(base): Implement `Room::direct_targets_length`. This patch implements `Room::direct_targets_length`. It avoids to call `Room::is_direct` if and only if we don't care about the room's state and we don't want an async call, and if we don't want to pay the cost of `Room::direct_targets` which clones the `HashSet` as an alternative way to get a similar information than `Room::is_direct`. --- crates/matrix-sdk-base/src/rooms/normal.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/matrix-sdk-base/src/rooms/normal.rs b/crates/matrix-sdk-base/src/rooms/normal.rs index 877e21a12..1381f182e 100644 --- a/crates/matrix-sdk-base/src/rooms/normal.rs +++ b/crates/matrix-sdk-base/src/rooms/normal.rs @@ -347,6 +347,12 @@ impl Room { self.inner.read().base_info.dm_targets.clone() } + /// If this room is a direct message, returns the number of members that + /// we're sharing the room with. + pub fn direct_targets_length(&self) -> usize { + self.inner.read().base_info.dm_targets.len() + } + /// Is the room encrypted. pub fn is_encrypted(&self) -> bool { self.inner.read().is_encrypted() From 5baf078c4b7df5f44f05307bbb16fd1fcab9ccff Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Mon, 5 Feb 2024 16:06:41 +0100 Subject: [PATCH 10/12] feat(ui,ffi): Implement the `category` room list filter. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This patch implements the `category` room list filter. It introduces a new type: `RoomCategory`, to ensure that “group” and “people” are mutually exclusives. --- bindings/matrix-sdk-ffi/src/room_list.rs | 22 ++- .../src/room_list_service/filters/category.rs | 181 ++++++++++++++++++ .../src/room_list_service/filters/mod.rs | 2 + 3 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 crates/matrix-sdk-ui/src/room_list_service/filters/category.rs diff --git a/bindings/matrix-sdk-ffi/src/room_list.rs b/bindings/matrix-sdk-ffi/src/room_list.rs index b647cdcc0..1911a924a 100644 --- a/bindings/matrix-sdk-ffi/src/room_list.rs +++ b/bindings/matrix-sdk-ffi/src/room_list.rs @@ -15,8 +15,9 @@ use matrix_sdk::{ use matrix_sdk_ui::{ room_list_service::{ filters::{ - new_filter_all, new_filter_any, new_filter_fuzzy_match_room_name, new_filter_non_left, - new_filter_none, new_filter_normalized_match_room_name, new_filter_unread, + new_filter_all, new_filter_any, new_filter_category, new_filter_fuzzy_match_room_name, + new_filter_non_left, new_filter_none, new_filter_normalized_match_room_name, + new_filter_unread, RoomCategory, }, BoxedFilterFn, }, @@ -413,11 +414,27 @@ pub enum RoomListEntriesDynamicFilterKind { Any { filters: Vec }, NonLeft, Unread, + Category { expect: RoomListFilterCategory }, None, NormalizedMatchRoomName { pattern: String }, FuzzyMatchRoomName { pattern: String }, } +#[derive(uniffi::Enum)] +pub enum RoomListFilterCategory { + Group, + People, +} + +impl From for RoomCategory { + fn from(value: RoomListFilterCategory) -> Self { + match value { + RoomListFilterCategory::Group => Self::Group, + RoomListFilterCategory::People => Self::People, + } + } +} + /// Custom internal type to transform a `RoomListEntriesDynamicFilterKind` into /// a `BoxedFilterFn`. struct FilterWrapper(BoxedFilterFn); @@ -435,6 +452,7 @@ impl FilterWrapper { ))), Kind::NonLeft => Self(Box::new(new_filter_non_left(client))), Kind::Unread => Self(Box::new(new_filter_unread(client))), + Kind::Category { expect } => Self(Box::new(new_filter_category(client, expect.into()))), Kind::None => Self(Box::new(new_filter_none())), Kind::NormalizedMatchRoomName { pattern } => { Self(Box::new(new_filter_normalized_match_room_name(client, &pattern))) diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/category.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/category.rs new file mode 100644 index 000000000..6fc07eb8a --- /dev/null +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/category.rs @@ -0,0 +1,181 @@ +use matrix_sdk::{Client, RoomListEntry}; + +use super::Filter; + +/// An enum to represent whether a room is about “people” (1 or 2 users) or +/// “group” (more than 2 users). +/// +/// This is implemented this way so that it's impossible to filter by “group” +/// and by “people” at the same time: these criteria are mutually +/// exclusive by design per filter. +#[derive(Copy, Clone, PartialEq)] +pub enum RoomCategory { + Group, + People, +} + +type DirectTargetsLength = usize; + +struct CategoryRoomMatcher +where + F: Fn(&RoomListEntry) -> Option, +{ + /// _Direct targets_ mean the number of users in a direct room, except us. + /// So if it returns 1, it means there are 2 users in the direct room. + number_of_direct_targets: F, +} + +impl CategoryRoomMatcher +where + F: Fn(&RoomListEntry) -> Option, +{ + fn matches(&self, room_list_entry: &RoomListEntry, expected_kind: RoomCategory) -> bool { + if !matches!(room_list_entry, RoomListEntry::Filled(_) | RoomListEntry::Invalidated(_)) { + return false; + } + + let kind = match (self.number_of_direct_targets)(room_list_entry) { + // If 1, we are sure it's a direct room between two users. It's the strict + // definition of the `People` category, all good. + Some(1) => RoomCategory::People, + + // If smaller than 1, we are not sure it's a direct room, it's then a `Group`. + // If greater than 1, we are sure it's a direct room but not between + // two users, so it's a `Group` based on our expectation. + Some(_) => RoomCategory::Group, + + // Don't know. + None => return false, + }; + + kind == expected_kind + } +} + +/// Create a new filter that will accept all filled or invalidated entries, but +/// filters out rooms that have no unread messages. +pub fn new_filter(client: &Client, expected_kind: RoomCategory) -> impl Filter { + let client = client.clone(); + + let matcher = CategoryRoomMatcher { + number_of_direct_targets: move |room| { + let room_id = room.as_room_id()?; + let room = client.get_room(room_id)?; + + Some(room.direct_targets_length()) + }, + }; + + move |room_list_entry| -> bool { matcher.matches(room_list_entry, expected_kind) } +} + +#[cfg(test)] +mod tests { + use std::ops::Not; + + use matrix_sdk::RoomListEntry; + use ruma::room_id; + + use super::{CategoryRoomMatcher, RoomCategory}; + + #[test] + fn test_kind_is_group() { + let matcher = CategoryRoomMatcher { number_of_direct_targets: |_| Some(42) }; + + // Expect `People`. + { + let expected_kind = RoomCategory::People; + + assert!(matcher.matches(&RoomListEntry::Empty, expected_kind).not()); + assert!( + matcher + .matches( + &RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned(),), + expected_kind, + ) + .not() + ); + assert!(matcher + .matches( + &RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned()), + expected_kind + ) + .not()); + } + + // Expect `Group`. + { + let expected_kind = RoomCategory::Group; + + assert!(matcher.matches(&RoomListEntry::Empty, expected_kind).not()); + assert!(matcher.matches( + &RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned(),), + expected_kind, + )); + assert!(matcher.matches( + &RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned()), + expected_kind, + )); + } + } + + #[test] + fn test_kind_is_people() { + let matcher = CategoryRoomMatcher { number_of_direct_targets: |_| Some(1) }; + + // Expect `People`. + { + let expected_kind = RoomCategory::People; + + assert!(matcher.matches(&RoomListEntry::Empty, expected_kind).not()); + assert!(matcher.matches( + &RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()), + expected_kind, + )); + assert!(matcher.matches( + &RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned()), + expected_kind + )); + } + + // Expect `Group`. + { + let expected_kind = RoomCategory::Group; + + assert!(matcher.matches(&RoomListEntry::Empty, expected_kind).not()); + assert!( + matcher + .matches( + &RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned(),), + expected_kind, + ) + .not() + ); + assert!(matcher + .matches( + &RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned()), + expected_kind, + ) + .not()); + } + } + + #[test] + fn test_room_kind_cannot_be_found() { + let matcher = CategoryRoomMatcher { number_of_direct_targets: |_| None }; + + assert!(matcher.matches(&RoomListEntry::Empty, RoomCategory::Group).not()); + assert!(matcher + .matches( + &RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()), + RoomCategory::Group + ) + .not()); + assert!(matcher + .matches( + &RoomListEntry::Invalidated(room_id!("!r0:bar.org").to_owned()), + RoomCategory::Group + ) + .not()); + } +} diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs index 9686d1a68..794a2ddff 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs @@ -1,5 +1,6 @@ mod all; mod any; +mod category; mod fuzzy_match_room_name; mod non_left; mod none; @@ -9,6 +10,7 @@ mod unread; pub use all::new_filter as new_filter_all; pub use any::new_filter as new_filter_any; +pub use category::{new_filter as new_filter_category, RoomCategory}; pub use fuzzy_match_room_name::new_filter as new_filter_fuzzy_match_room_name; use matrix_sdk::RoomListEntry; pub use non_left::new_filter as new_filter_non_left; From dc89c00a8c7fccf2d5c594c4889d0d8ffebf92f9 Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Wed, 7 Feb 2024 09:19:06 +0100 Subject: [PATCH 11/12] doc(sdk): Fix typos. --- crates/matrix-sdk-base/src/read_receipts.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/matrix-sdk-base/src/read_receipts.rs b/crates/matrix-sdk-base/src/read_receipts.rs index 8cb5e2356..43cab3355 100644 --- a/crates/matrix-sdk-base/src/read_receipts.rs +++ b/crates/matrix-sdk-base/src/read_receipts.rs @@ -15,9 +15,9 @@ //! # Client-side read receipts computation //! //! While Matrix servers have the ability to provide basic information about the -//! unread status of rooms, via [`matrix_sdk::ruma::UnreadNotificationCounts`], -//! it's not reliable for encrypted rooms. Indeed, the server doesn't have -//! access to the content of encrypted events, so it can only makes guesses when +//! unread status of rooms, via [`crate::sync::UnreadNotificationsCount`], it's +//! not reliable for encrypted rooms. Indeed, the server doesn't have access to +//! the content of encrypted events, so it can only makes guesses when //! estimating unread and highlight counts. //! //! Instead, this module provides facilities to compute the number of unread @@ -36,8 +36,8 @@ //! `marks_as_unread` function shows the opiniated set of rules that will filter //! out uninterested events. //! -//! The only public method in that module is [`compute_unread_counts`], which -//! updates the `RoomInfo` in place according to the new counts. +//! The only `pub(crate)` method in that module is `compute_unread_counts`, +//! which updates the `RoomInfo` in place according to the new counts. //! //! ## Implementation details: How to get the latest receipt? //! From fce1140ad11c08b50ee90315325dfb6b1c7d64c8 Mon Sep 17 00:00:00 2001 From: Ivan Enderlin Date: Thu, 8 Feb 2024 14:09:13 +0100 Subject: [PATCH 12/12] test(ui): Split tests and improve documentation. --- .../src/room_list_service/filters/any.rs | 84 ++++++++++--------- .../src/room_list_service/filters/category.rs | 17 ++-- 2 files changed, 57 insertions(+), 44 deletions(-) diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/any.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/any.rs index c0bb09424..dd92ddf73 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/any.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/any.rs @@ -16,58 +16,66 @@ mod tests { use super::new_filter; #[test] - fn test_one_filter() { + fn test_one_filter_is_true() { let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); - { - let filter = |_: &_| true; - let any = new_filter(vec![Box::new(filter)]); + let filter = |_: &_| true; + let any = new_filter(vec![Box::new(filter)]); - assert!(any(&room_list_entry)); - } - - { - let filter = |_: &_| false; - let any = new_filter(vec![Box::new(filter)]); - - assert!(any(&room_list_entry).not()); - } + assert!(any(&room_list_entry)); } #[test] - fn test_two_filters() { + fn test_one_filter_is_false() { let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); - { - let filter1 = |_: &_| true; - let filter2 = |_: &_| true; - let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + let filter = |_: &_| false; + let any = new_filter(vec![Box::new(filter)]); - assert!(any(&room_list_entry)); - } + assert!(any(&room_list_entry).not()); + } - { - let filter1 = |_: &_| true; - let filter2 = |_: &_| false; - let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + #[test] + fn test_two_filters_with_true_true() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); - assert!(any(&room_list_entry)); - } + let filter1 = |_: &_| true; + let filter2 = |_: &_| true; + let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); - { - let filter1 = |_: &_| false; - let filter2 = |_: &_| true; - let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + assert!(any(&room_list_entry)); + } - assert!(any(&room_list_entry)); - } + #[test] + fn test_two_filters_with_true_false() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); - { - let filter1 = |_: &_| false; - let filter2 = |_: &_| false; - let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + let filter1 = |_: &_| true; + let filter2 = |_: &_| false; + let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); - assert!(any(&room_list_entry).not()); - } + assert!(any(&room_list_entry)); + } + + #[test] + fn test_two_filters_with_false_true() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); + + let filter1 = |_: &_| false; + let filter2 = |_: &_| true; + let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + + assert!(any(&room_list_entry)); + } + + #[test] + fn test_two_filters_with_false_false() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); + + let filter1 = |_: &_| false; + let filter2 = |_: &_| false; + let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + + assert!(any(&room_list_entry).not()); } } diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/category.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/category.rs index 6fc07eb8a..f7041ca14 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/category.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/category.rs @@ -2,8 +2,12 @@ use matrix_sdk::{Client, RoomListEntry}; use super::Filter; -/// An enum to represent whether a room is about “people” (1 or 2 users) or -/// “group” (more than 2 users). +/// An enum to represent whether a room is about “people” (strictly 2 users) or +/// “group” (1 or more than 2 users). +/// +/// Ideally, we would only want to rely on the +/// [`matrix_sdk::BaseRoom::is_direct`] method, but the rules are a little bit +/// different for this high-level UI API. /// /// This is implemented this way so that it's impossible to filter by “group” /// and by “people” at the same time: these criteria are mutually @@ -52,9 +56,10 @@ where } } -/// Create a new filter that will accept all filled or invalidated entries, but -/// filters out rooms that have no unread messages. -pub fn new_filter(client: &Client, expected_kind: RoomCategory) -> impl Filter { +/// Create a new filter that will accept all filled or invalidated entries, and +/// if the associated rooms fit in the `expected_category`. The category is +/// defined by [`RoomCategory`], see this type to learn more. +pub fn new_filter(client: &Client, expected_category: RoomCategory) -> impl Filter { let client = client.clone(); let matcher = CategoryRoomMatcher { @@ -66,7 +71,7 @@ pub fn new_filter(client: &Client, expected_kind: RoomCategory) -> impl Filter { }, }; - move |room_list_entry| -> bool { matcher.matches(room_list_entry, expected_kind) } + move |room_list_entry| -> bool { matcher.matches(room_list_entry, expected_category) } } #[cfg(test)]