diff --git a/bindings/matrix-sdk-ffi/src/room_list.rs b/bindings/matrix-sdk-ffi/src/room_list.rs index d1cd3c9a7a7..1911a924ae2 100644 --- a/bindings/matrix-sdk-ffi/src/room_list.rs +++ b/bindings/matrix-sdk-ffi/src/room_list.rs @@ -13,9 +13,13 @@ use matrix_sdk::{ RoomListEntry as MatrixRoomListEntry, }; 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_normalized_match_room_name, + room_list_service::{ + filters::{ + 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, }, timeline::default_event_filter, }; @@ -391,19 +395,8 @@ impl RoomListDynamicEntriesController { #[uniffi::export] impl RoomListDynamicEntriesController { fn set_filter(&self, kind: RoomListEntriesDynamicFilterKind) -> bool { - use RoomListEntriesDynamicFilterKind as Kind; - - 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::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) { @@ -417,13 +410,60 @@ impl RoomListDynamicEntriesController { #[derive(uniffi::Enum)] pub enum RoomListEntriesDynamicFilterKind { - All, - AllNonLeft, + All { filters: Vec }, + 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); + +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::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))) + } + 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-base/src/lib.rs b/crates/matrix-sdk-base/src/lib.rs index 83e18c5622e..dd5218ef351 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-base/src/read_receipts.rs b/crates/matrix-sdk-base/src/read_receipts.rs index 8cb5e2356ff..43cab3355a0 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? //! diff --git a/crates/matrix-sdk-base/src/rooms/normal.rs b/crates/matrix-sdk-base/src/rooms/normal.rs index 96be0c34b34..240382b040d 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() 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 f12c613df20..8470e6c982b 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,10 +1,9 @@ -use matrix_sdk::RoomListEntry; +use super::{super::room_list::BoxedFilterFn, Filter}; -/// Create a new filter that will accept all filled or invalidated entries. -pub fn new_filter() -> impl Fn(&RoomListEntry) -> bool { - |room_list_entry| -> bool { - matches!(room_list_entry, RoomListEntry::Filled(_) | RoomListEntry::Invalidated(_)) - } +/// 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)] @@ -17,11 +16,58 @@ mod tests { use super::new_filter; #[test] - fn test_all_kind_of_room_list_entry() { - let all = new_filter(); + 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(&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()))); + assert!(all(&room_list_entry).not()); + } } } 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 new file mode 100644 index 00000000000..dd92ddf73f7 --- /dev/null +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/any.rs @@ -0,0 +1,81 @@ +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 { + 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_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)]); + + assert!(any(&room_list_entry)); + } + + #[test] + fn test_one_filter_is_false() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); + + let filter = |_: &_| false; + let any = new_filter(vec![Box::new(filter)]); + + assert!(any(&room_list_entry).not()); + } + + #[test] + fn test_two_filters_with_true_true() { + 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)); + } + + #[test] + fn test_two_filters_with_true_false() { + let room_list_entry = RoomListEntry::Filled(room_id!("!r0:bar.org").to_owned()); + + let filter1 = |_: &_| true; + let filter2 = |_: &_| false; + let any = new_filter(vec![Box::new(filter1), Box::new(filter2)]); + + 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 new file mode 100644 index 00000000000..f7041ca14a3 --- /dev/null +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/category.rs @@ -0,0 +1,186 @@ +use matrix_sdk::{Client, RoomListEntry}; + +use super::Filter; + +/// 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 +/// 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, 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 { + 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_category) } +} + +#[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/fuzzy_match_room_name.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/fuzzy_match_room_name.rs index 1a0e2d634e6..9e8e672fd04 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 b1beefa850d..794a2ddff41 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,15 +1,32 @@ mod all; -mod all_non_left; +mod any; +mod category; mod fuzzy_match_room_name; +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 all_non_left::new_filter as new_filter_all_non_left; +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; 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_. +/// +/// 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 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 75% 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 34d8348848f..b31750772cb 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 @@ -1,17 +1,25 @@ use matrix_sdk::{Client, RoomListEntry}; use matrix_sdk_base::RoomState; -struct NonLeftRoomMatcher Option> { - get_state: F, +use super::Filter; + +struct NonLeftRoomMatcher +where + F: Fn(&RoomListEntry) -> Option, +{ + 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; } - if let Some(state) = (self.get_state)(room) { + if let Some(state) = (self.state)(room) { state != RoomState::Left } else { false @@ -21,11 +29,11 @@ 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 { - get_state: move |room| { + state: move |room| { let room_id = room.as_room_id()?; let room = client.get_room(room_id)?; Some(room.state()) @@ -41,24 +49,24 @@ 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() { // 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()))); 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 a500fe7de86..99e97be9c24 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 7ee8fab5b0a..c0a49602de6 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")); } } diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/not.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/not.rs new file mode 100644 index 00000000000..b0a63d73689 --- /dev/null +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/not.rs @@ -0,0 +1,39 @@ +use std::ops::Not; + +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: BoxedFilterFn) -> 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 = Box::new(|_: &_| 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 = Box::new(|_: &_| false); + let not = new_filter(filter); + + assert!(not(&room_list_entry)); + } +} 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 00000000000..d78911bc899 --- /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()); + } +} 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 405514978bd..93d1e67636b 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 ab035dabebd..276e5428094 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, @@ -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_all()); + dynamic_entries.set_filter(Box::new(new_filter_non_left(&client))); // Assert the dynamic entries. assert_entries_batch! {