From 8943909f06654261c948327f26519e44d7c0f0d7 Mon Sep 17 00:00:00 2001 From: Ulyssa Date: Fri, 20 Oct 2023 19:32:33 -0700 Subject: [PATCH] Support custom sorting for room and user lists (#170) --- README.md | 2 +- src/base.rs | 131 +++++++++++++- src/config.rs | 91 +++++++++- src/tests.rs | 2 + src/windows/mod.rs | 363 +++++++++++++++++++++++++++----------- src/windows/room/space.rs | 8 +- src/worker.rs | 16 +- 7 files changed, 497 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index ba75049..d2957a9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # iamb -[![Build Status](https://github.com/ulyssa/iamb/workflows/CI/badge.svg)](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+) +[![Build Status](https://github.com/ulyssa/iamb/actions/workflows/ci.yml/badge.svg)](https://github.com/ulyssa/iamb/actions?query=workflow%3ACI+) [![License: Apache 2.0](https://img.shields.io/crates/l/iamb.svg?logo=apache)](https://crates.io/crates/iamb) [![#iamb:0x.badd.cafe](https://img.shields.io/badge/matrix-%23iamb:0x.badd.cafe-blue)](https://matrix.to/#/#iamb:0x.badd.cafe) [![Latest Version](https://img.shields.io/crates/v/iamb.svg?logo=rust)](https://crates.io/crates/iamb) diff --git a/src/base.rs b/src/base.rs index b70c42e..45dfd4b 100644 --- a/src/base.rs +++ b/src/base.rs @@ -202,6 +202,135 @@ bitflags::bitflags! { } } +/// Fields that rooms and spaces can be sorted by. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SortFieldRoom { + Favorite, + LowPriority, + Name, + Alias, + RoomId, +} + +/// Fields that users can be sorted by. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SortFieldUser { + PowerLevel, + UserId, + LocalPart, + Server, +} + +/// Whether to use the default sort direction for a field, or to reverse it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SortOrder { + Ascending, + Descending, +} + +/// One of the columns to sort on. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SortColumn(pub T, pub SortOrder); + +impl<'de> Deserialize<'de> for SortColumn { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(SortRoomVisitor) + } +} + +/// [serde] visitor for deserializing [SortColumn] for rooms and spaces. +struct SortRoomVisitor; + +impl<'de> Visitor<'de> for SortRoomVisitor { + type Value = SortColumn; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid field for sorting rooms") + } + + fn visit_str(self, mut value: &str) -> Result + where + E: SerdeError, + { + if value.is_empty() { + return Err(E::custom("Invalid sort field")); + } + + let order = if value.starts_with('~') { + value = &value[1..]; + SortOrder::Descending + } else { + SortOrder::Ascending + }; + + let field = match value { + "favorite" => SortFieldRoom::Favorite, + "lowpriority" => SortFieldRoom::LowPriority, + "name" => SortFieldRoom::Name, + "alias" => SortFieldRoom::Alias, + "id" => SortFieldRoom::RoomId, + _ => { + let msg = format!("Unknown sort field: {value:?}"); + return Err(E::custom(msg)); + }, + }; + + Ok(SortColumn(field, order)) + } +} + +impl<'de> Deserialize<'de> for SortColumn { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(SortUserVisitor) + } +} + +/// [serde] visitor for deserializing [SortColumn] for users. +struct SortUserVisitor; + +impl<'de> Visitor<'de> for SortUserVisitor { + type Value = SortColumn; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid field for sorting rooms") + } + + fn visit_str(self, mut value: &str) -> Result + where + E: SerdeError, + { + if value.is_empty() { + return Err(E::custom("Invalid field for sorting users")); + } + + let order = if value.starts_with('~') { + value = &value[1..]; + SortOrder::Descending + } else { + SortOrder::Ascending + }; + + let field = match value { + "id" => SortFieldUser::UserId, + "localpart" => SortFieldUser::LocalPart, + "server" => SortFieldUser::Server, + "power" => SortFieldUser::PowerLevel, + _ => { + let msg = format!("Unknown sort field: {value:?}"); + return Err(E::custom(msg)); + }, + }; + + Ok(SortColumn(field, order)) + } +} + /// A room property. #[derive(Clone, Debug, Eq, PartialEq)] pub enum RoomField { @@ -811,7 +940,7 @@ fn emoji_map() -> CompletionMap { #[derive(Default)] pub struct SyncInfo { /// Spaces that the user is a member of. - pub spaces: Vec, + pub spaces: Vec)>>, /// Rooms that the user is a member of. pub rooms: Vec)>>, diff --git a/src/config.rs b/src/config.rs index 86ddec5..d07bf34 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,7 +20,7 @@ use modalkit::tui::{ text::Span, }; -use super::base::{IambId, RoomInfo}; +use super::base::{IambId, RoomInfo, SortColumn, SortFieldRoom, SortFieldUser, SortOrder}; macro_rules! usage { ( $($args: tt)* ) => { @@ -29,6 +29,17 @@ macro_rules! usage { } } +const DEFAULT_MEMBERS_SORT: [SortColumn; 2] = [ + SortColumn(SortFieldUser::PowerLevel, SortOrder::Ascending), + SortColumn(SortFieldUser::UserId, SortOrder::Ascending), +]; + +const DEFAULT_ROOM_SORT: [SortColumn; 3] = [ + SortColumn(SortFieldRoom::Favorite, SortOrder::Ascending), + SortColumn(SortFieldRoom::LowPriority, SortOrder::Ascending), + SortColumn(SortFieldRoom::Name, SortOrder::Ascending), +]; + const DEFAULT_REQ_TIMEOUT: u64 = 120; const COLORS: [Color; 13] = [ @@ -213,6 +224,15 @@ pub struct UserDisplayTunables { pub type UserOverrides = HashMap; +fn merge_sorts(a: SortOverrides, b: SortOverrides) -> SortOverrides { + SortOverrides { + dms: b.dms.or(a.dms), + rooms: b.rooms.or(a.rooms), + spaces: b.spaces.or(a.spaces), + members: b.members.or(a.members), + } +} + fn merge_users(a: Option, b: Option) -> Option { match (a, b) { (Some(a), None) => Some(a), @@ -246,6 +266,33 @@ pub enum UserDisplayStyle { DisplayName, } +#[derive(Clone)] +pub struct SortValues { + pub dms: Vec>, + pub rooms: Vec>, + pub spaces: Vec>, + pub members: Vec>, +} + +#[derive(Clone, Default, Deserialize)] +pub struct SortOverrides { + pub dms: Option>>, + pub rooms: Option>>, + pub spaces: Option>>, + pub members: Option>>, +} + +impl SortOverrides { + pub fn values(self) -> SortValues { + let rooms = self.rooms.unwrap_or_else(|| Vec::from(DEFAULT_ROOM_SORT)); + let dms = self.dms.unwrap_or_else(|| rooms.clone()); + let spaces = self.spaces.unwrap_or_else(|| rooms.clone()); + let members = self.members.unwrap_or_else(|| Vec::from(DEFAULT_MEMBERS_SORT)); + + SortValues { rooms, members, dms, spaces } + } +} + #[derive(Clone)] pub struct TunableValues { pub log_level: Level, @@ -254,6 +301,7 @@ pub struct TunableValues { pub read_receipt_send: bool, pub read_receipt_display: bool, pub request_timeout: u64, + pub sort: SortValues, pub typing_notice_send: bool, pub typing_notice_display: bool, pub users: UserOverrides, @@ -270,6 +318,8 @@ pub struct Tunables { pub read_receipt_send: Option, pub read_receipt_display: Option, pub request_timeout: Option, + #[serde(default)] + pub sort: SortOverrides, pub typing_notice_send: Option, pub typing_notice_display: Option, pub users: Option, @@ -289,6 +339,7 @@ impl Tunables { read_receipt_send: self.read_receipt_send.or(other.read_receipt_send), read_receipt_display: self.read_receipt_display.or(other.read_receipt_display), request_timeout: self.request_timeout.or(other.request_timeout), + sort: merge_sorts(self.sort, other.sort), typing_notice_send: self.typing_notice_send.or(other.typing_notice_send), typing_notice_display: self.typing_notice_display.or(other.typing_notice_display), users: merge_users(self.users, other.users), @@ -306,6 +357,7 @@ impl Tunables { read_receipt_send: self.read_receipt_send.unwrap_or(true), read_receipt_display: self.read_receipt_display.unwrap_or(true), request_timeout: self.request_timeout.unwrap_or(DEFAULT_REQ_TIMEOUT), + sort: self.sort.values(), typing_notice_send: self.typing_notice_send.unwrap_or(true), typing_notice_display: self.typing_notice_display.unwrap_or(true), users: self.users.unwrap_or_default(), @@ -701,6 +753,43 @@ mod tests { assert_eq!(res.username_display, Some(UserDisplayStyle::DisplayName)); } + #[test] + fn test_parse_tunables_sort() { + let res: Tunables = serde_json::from_str( + r#"{"sort": {"members": ["server","~localpart"],"spaces":["~favorite", "alias"]}}"#, + ) + .unwrap(); + assert_eq!( + res.sort.members, + Some(vec![ + SortColumn(SortFieldUser::Server, SortOrder::Ascending), + SortColumn(SortFieldUser::LocalPart, SortOrder::Descending), + ]) + ); + assert_eq!( + res.sort.spaces, + Some(vec![ + SortColumn(SortFieldRoom::Favorite, SortOrder::Descending), + SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), + ]) + ); + assert_eq!(res.sort.rooms, None); + assert_eq!(res.sort.dms, None); + + // Check that we get the right default "rooms" and "dms" values. + let res = res.values(); + assert_eq!(res.sort.members, vec![ + SortColumn(SortFieldUser::Server, SortOrder::Ascending), + SortColumn(SortFieldUser::LocalPart, SortOrder::Descending), + ]); + assert_eq!(res.sort.spaces, vec![ + SortColumn(SortFieldRoom::Favorite, SortOrder::Descending), + SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), + ]); + assert_eq!(res.sort.rooms, Vec::from(DEFAULT_ROOM_SORT)); + assert_eq!(res.sort.dms, Vec::from(DEFAULT_ROOM_SORT)); + } + #[test] fn test_parse_layout() { let user = WindowPath::UserId(user_id!("@user:example.com").to_owned()); diff --git a/src/tests.rs b/src/tests.rs index aa687e3..3ce394a 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -28,6 +28,7 @@ use crate::{ ApplicationSettings, DirectoryValues, ProfileConfig, + SortOverrides, TunableValues, UserColor, UserDisplayStyle, @@ -183,6 +184,7 @@ pub fn mock_tunables() -> TunableValues { read_receipt_send: true, read_receipt_display: true, request_timeout: 120, + sort: SortOverrides::default().values(), typing_notice_send: true, typing_notice_display: true, users: vec![(TEST_USER5.clone(), UserDisplayTunables { diff --git a/src/windows/mod.rs b/src/windows/mod.rs index 42146ae..eb77f62 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -17,7 +17,9 @@ use matrix_sdk::{ ruma::{ events::room::member::MembershipState, events::tag::{TagName, Tags}, + OwnedRoomAliasId, OwnedRoomId, + RoomAliasId, RoomId, }, }; @@ -80,6 +82,10 @@ use crate::base::{ ProgramStore, RoomAction, SendAction, + SortColumn, + SortFieldRoom, + SortFieldUser, + SortOrder, }; use self::{room::RoomState, welcome::WelcomeState}; @@ -125,42 +131,87 @@ fn selected_text(s: &str, selected: bool) -> Text { Text::from(selected_span(s, selected)) } -fn room_cmp(a: &MatrixRoom, b: &MatrixRoom) -> Ordering { - let ca1 = a.canonical_alias(); - let ca2 = b.canonical_alias(); - - let ord = match (ca1, ca2) { +/// Sort `Some` to be less than `None` so that list items with values come before those without. +#[inline] +fn some_cmp(a: Option, b: Option) -> Ordering { + match (a, b) { + (Some(a), Some(b)) => a.cmp(&b), (None, None) => Ordering::Equal, (None, Some(_)) => Ordering::Greater, (Some(_), None) => Ordering::Less, - (Some(ca1), Some(ca2)) => ca1.cmp(&ca2), - }; + } +} + +fn user_cmp(a: &MemberItem, b: &MemberItem, field: &SortFieldUser) -> Ordering { + let a_id = a.member.user_id(); + let b_id = b.member.user_id(); - ord.then_with(|| a.room_id().cmp(b.room_id())) + match field { + SortFieldUser::UserId => a_id.cmp(b_id), + SortFieldUser::LocalPart => a_id.localpart().cmp(b_id.localpart()), + SortFieldUser::Server => a_id.server_name().cmp(b_id.server_name()), + SortFieldUser::PowerLevel => { + // Sort higher power levels towards the top of the list. + b.member.power_level().cmp(&a.member.power_level()) + }, + } } -fn tag_cmp(a: &Option, b: &Option) -> Ordering { - let (fava, lowa) = a - .as_ref() - .map(|tags| { - (tags.contains_key(&TagName::Favorite), tags.contains_key(&TagName::LowPriority)) - }) - .unwrap_or((false, false)); +fn room_cmp(a: &T, b: &T, field: &SortFieldRoom) -> Ordering { + match field { + SortFieldRoom::Favorite => { + let fava = a.has_tag(TagName::Favorite); + let favb = b.has_tag(TagName::Favorite); - let (favb, lowb) = b - .as_ref() - .map(|tags| { - (tags.contains_key(&TagName::Favorite), tags.contains_key(&TagName::LowPriority)) - }) - .unwrap_or((false, false)); + // If a has Favorite and b doesn't, it should sort earlier in room list. + favb.cmp(&fava) + }, + SortFieldRoom::LowPriority => { + let lowa = a.has_tag(TagName::LowPriority); + let lowb = b.has_tag(TagName::LowPriority); - // If a has Favorite and b doesn't, it should sort earlier in room list. - let cmpf = favb.cmp(&fava); + // If a has LowPriority and b doesn't, it should sort later in room list. + lowa.cmp(&lowb) + }, + SortFieldRoom::Name => a.name().cmp(b.name()), + SortFieldRoom::Alias => some_cmp(a.alias(), b.alias()), + SortFieldRoom::RoomId => a.room_id().cmp(b.room_id()), + } +} + +/// Compare two rooms according the configured sort criteria. +fn room_fields_cmp( + a: &T, + b: &T, + fields: &[SortColumn], +) -> Ordering { + for SortColumn(field, order) in fields { + match (room_cmp(a, b, field), order) { + (Ordering::Equal, _) => continue, + (o, SortOrder::Ascending) => return o, + (o, SortOrder::Descending) => return o.reverse(), + } + } - // If a has LowPriority and b doesn't, it should sort later in room list. - let cmpl = lowa.cmp(&lowb); + // Break ties on ascending room id. + room_cmp(a, b, &SortFieldRoom::RoomId) +} - cmpl.then(cmpf) +fn user_fields_cmp( + a: &MemberItem, + b: &MemberItem, + fields: &[SortColumn], +) -> Ordering { + for SortColumn(field, order) in fields { + match (user_cmp(a, b, field), order) { + (Ordering::Equal, _) => continue, + (o, SortOrder::Ascending) => return o, + (o, SortOrder::Descending) => return o.reverse(), + } + } + + // Break ties on ascending user id. + user_cmp(a, b, &SortFieldUser::UserId) } fn append_tags<'a>(tags: &'a Tags, spans: &mut Vec>, style: Style) { @@ -190,6 +241,13 @@ fn append_tags<'a>(tags: &'a Tags, spans: &mut Vec>, style: Style) { spans.push(Span::styled(")", style)); } +trait RoomLikeItem { + fn room_id(&self) -> &RoomId; + fn has_tag(&self, tag: TagName) -> bool; + fn alias(&self) -> Option<&RoomAliasId>; + fn name(&self) -> &str; +} + #[inline] fn room_prompt( room_id: &RoomId, @@ -399,7 +457,8 @@ impl WindowOps for IambWindow { .into_iter() .map(|room_info| DirectItem::new(room_info, store)) .collect::>(); - items.sort(); + let fields = &store.application.settings.tunables.sort.dms; + items.sort_by(|a, b| room_fields_cmp(a, b, fields)); state.set(items); @@ -417,8 +476,13 @@ impl WindowOps for IambWindow { if need_fetch { if let Ok(mems) = store.application.worker.members(room_id.clone()) { - let items = mems.into_iter().map(|m| MemberItem::new(m, room_id.clone())); - state.set(items.collect()); + let mut items = mems + .into_iter() + .map(|m| MemberItem::new(m, room_id.clone())) + .collect::>(); + let fields = &store.application.settings.tunables.sort.members; + items.sort_by(|a, b| user_fields_cmp(a, b, fields)); + state.set(items); *last_fetch = Some(Instant::now()); } } @@ -438,7 +502,8 @@ impl WindowOps for IambWindow { .into_iter() .map(|room_info| RoomItem::new(room_info, store)) .collect::>(); - items.sort(); + let fields = &store.application.settings.tunables.sort.rooms; + items.sort_by(|a, b| room_fields_cmp(a, b, fields)); state.set(items); @@ -449,15 +514,18 @@ impl WindowOps for IambWindow { .render(area, buf, state); }, IambWindow::SpaceList(state) => { - let items = store + let mut items = store .application .sync_info .spaces .clone() .into_iter() - .map(|room| SpaceItem::new(room, store)); - state.set(items.collect()); - state.draw(area, buf, focused, store); + .map(|room| SpaceItem::new(room, store)) + .collect::>(); + let fields = &store.application.settings.tunables.sort.spaces; + items.sort_by(|a, b| room_fields_cmp(a, b, fields)); + + state.set(items); List::new(store) .empty_message("You haven't joined any spaces yet") @@ -662,6 +730,7 @@ impl Window for IambWindow { pub struct RoomItem { room_info: MatrixRoomInfo, name: String, + alias: Option, } impl RoomItem { @@ -671,13 +740,14 @@ impl RoomItem { let info = store.application.get_room_info(room_id.to_owned()); let name = info.name.clone().unwrap_or_default(); + let alias = room.canonical_alias(); info.tags = room_info.deref().1.clone(); - if let Some(alias) = room.canonical_alias() { + if let Some(alias) = &alias { store.application.names.insert(alias.to_string(), room_id.to_owned()); } - RoomItem { room_info, name } + RoomItem { room_info, name, alias } } #[inline] @@ -685,34 +755,31 @@ impl RoomItem { &self.room_info.deref().0 } - #[inline] - fn room_id(&self) -> &RoomId { - self.room().room_id() - } - #[inline] fn tags(&self) -> &Option { &self.room_info.deref().1 } } -impl PartialEq for RoomItem { - fn eq(&self, other: &Self) -> bool { - self.room_id() == other.room_id() +impl RoomLikeItem for RoomItem { + fn name(&self) -> &str { + self.name.as_str() } -} -impl Eq for RoomItem {} + fn alias(&self) -> Option<&RoomAliasId> { + self.alias.as_deref() + } -impl Ord for RoomItem { - fn cmp(&self, other: &Self) -> Ordering { - tag_cmp(self.tags(), other.tags()).then_with(|| room_cmp(self.room(), other.room())) + fn room_id(&self) -> &RoomId { + self.room().room_id() } -} -impl PartialOrd for RoomItem { - fn partial_cmp(&self, other: &Self) -> Option { - self.cmp(other).into() + fn has_tag(&self, tag: TagName) -> bool { + if let Some(tags) = &self.room_info.deref().1 { + tags.contains_key(&tag) + } else { + false + } } } @@ -756,14 +823,16 @@ impl Promptable for RoomItem { pub struct DirectItem { room_info: MatrixRoomInfo, name: String, + alias: Option, } impl DirectItem { fn new(room_info: MatrixRoomInfo, store: &mut ProgramStore) -> Self { - let room_id = room_info.deref().0.room_id().to_owned(); + let room_id = room_info.0.room_id().to_owned(); let name = store.application.get_room_info(room_id).name.clone().unwrap_or_default(); + let alias = room_info.0.canonical_alias(); - DirectItem { room_info, name } + DirectItem { room_info, name, alias } } #[inline] @@ -771,17 +840,34 @@ impl DirectItem { &self.room_info.deref().0 } - #[inline] - fn room_id(&self) -> &RoomId { - self.room().room_id() - } - #[inline] fn tags(&self) -> &Option { &self.room_info.deref().1 } } +impl RoomLikeItem for DirectItem { + fn name(&self) -> &str { + self.name.as_str() + } + + fn alias(&self) -> Option<&RoomAliasId> { + self.alias.as_deref() + } + + fn has_tag(&self, tag: TagName) -> bool { + if let Some(tags) = &self.room_info.deref().1 { + tags.contains_key(&tag) + } else { + false + } + } + + fn room_id(&self) -> &RoomId { + self.room().room_id() + } +} + impl ToString for DirectItem { fn to_string(&self) -> String { return self.name.clone(); @@ -807,26 +893,6 @@ impl ListItem for DirectItem { } } -impl PartialEq for DirectItem { - fn eq(&self, other: &Self) -> bool { - self.room_id() == other.room_id() - } -} - -impl Eq for DirectItem {} - -impl Ord for DirectItem { - fn cmp(&self, other: &Self) -> Ordering { - tag_cmp(self.tags(), other.tags()).then_with(|| room_cmp(self.room(), other.room())) - } -} - -impl PartialOrd for DirectItem { - fn partial_cmp(&self, other: &Self) -> Option { - self.cmp(other).into() - } -} - impl Promptable for DirectItem { fn prompt( &mut self, @@ -840,51 +906,58 @@ impl Promptable for DirectItem { #[derive(Clone)] pub struct SpaceItem { - room: MatrixRoom, + room_info: MatrixRoomInfo, name: String, + alias: Option, } impl SpaceItem { - fn new(room: MatrixRoom, store: &mut ProgramStore) -> Self { - let room_id = room.room_id(); + fn new(room_info: MatrixRoomInfo, store: &mut ProgramStore) -> Self { + let room_id = room_info.0.room_id(); let name = store .application .get_room_info(room_id.to_owned()) .name .clone() .unwrap_or_default(); + let alias = room_info.0.canonical_alias(); - if let Some(alias) = room.canonical_alias() { + if let Some(alias) = &alias { store.application.names.insert(alias.to_string(), room_id.to_owned()); } - SpaceItem { room, name } + SpaceItem { room_info, name, alias } } -} -impl PartialEq for SpaceItem { - fn eq(&self, other: &Self) -> bool { - self.room.room_id() == other.room.room_id() + #[inline] + fn room(&self) -> &MatrixRoom { + &self.room_info.deref().0 } } -impl Eq for SpaceItem {} +impl RoomLikeItem for SpaceItem { + fn name(&self) -> &str { + self.name.as_str() + } -impl Ord for SpaceItem { - fn cmp(&self, other: &Self) -> Ordering { - room_cmp(&self.room, &other.room) + fn room_id(&self) -> &RoomId { + self.room().room_id() } -} -impl PartialOrd for SpaceItem { - fn partial_cmp(&self, other: &Self) -> Option { - self.cmp(other).into() + fn alias(&self) -> Option<&RoomAliasId> { + self.alias.as_deref() + } + + fn has_tag(&self, _: TagName) -> bool { + // I think that spaces can technically have tags, but afaik no client + // exposes them, so we'll just always return false here for now. + false } } impl ToString for SpaceItem { fn to_string(&self) -> String { - return self.room.room_id().to_string(); + return self.room_id().to_string(); } } @@ -894,7 +967,7 @@ impl ListItem for SpaceItem { } fn get_word(&self) -> Option { - self.room.room_id().to_string().into() + self.room_id().to_string().into() } } @@ -905,7 +978,7 @@ impl Promptable for SpaceItem { ctx: &ProgramContext, _: &mut ProgramStore, ) -> EditResult, IambInfo> { - room_prompt(self.room.room_id(), act, ctx) + room_prompt(self.room_id(), act, ctx) } } @@ -1200,3 +1273,93 @@ impl Promptable for MemberItem { } } } + +#[cfg(test)] +mod tests { + use super::*; + use matrix_sdk::ruma::{room_alias_id, server_name}; + + #[derive(Debug, Eq, PartialEq)] + struct TestRoomItem { + room_id: OwnedRoomId, + tags: Vec, + alias: Option, + name: &'static str, + } + + impl RoomLikeItem for &TestRoomItem { + fn room_id(&self) -> &RoomId { + self.room_id.as_ref() + } + + fn has_tag(&self, tag: TagName) -> bool { + self.tags.contains(&tag) + } + + fn alias(&self) -> Option<&RoomAliasId> { + self.alias.as_deref() + } + + fn name(&self) -> &str { + self.name + } + } + + #[test] + fn test_sort_rooms() { + let server = server_name!("example.com"); + + let room1 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![TagName::Favorite], + alias: Some(room_alias_id!("#room1:example.com").to_owned()), + name: "Z", + }; + + let room2 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![], + alias: Some(room_alias_id!("#a:example.com").to_owned()), + name: "Unnamed Room", + }; + + let room3 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![], + alias: None, + name: "Cool Room", + }; + + // Sort by Name ascending. + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Ascending)]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room3, &room2, &room1]); + + // Sort by Name descending. + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[SortColumn(SortFieldRoom::Name, SortOrder::Descending)]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room1, &room2, &room3]); + + // Sort by Favorite and Alias before Name to show order matters. + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[ + SortColumn(SortFieldRoom::Favorite, SortOrder::Ascending), + SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), + SortColumn(SortFieldRoom::Name, SortOrder::Ascending), + ]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room1, &room2, &room3]); + + // Now flip order of Favorite with Descending + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[ + SortColumn(SortFieldRoom::Favorite, SortOrder::Descending), + SortColumn(SortFieldRoom::Alias, SortOrder::Ascending), + SortColumn(SortFieldRoom::Name, SortOrder::Ascending), + ]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room2, &room3, &room1]); + } +} diff --git a/src/windows/room/space.rs b/src/windows/room/space.rs index 11c4a51..b378843 100644 --- a/src/windows/room/space.rs +++ b/src/windows/room/space.rs @@ -22,7 +22,7 @@ use modalkit::{ use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus}; -use crate::windows::RoomItem; +use crate::windows::{room_fields_cmp, RoomItem}; const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5); @@ -120,7 +120,7 @@ impl<'a> StatefulWidget for Space<'a> { match res { Ok(members) => { - let items = members + let mut items = members .into_iter() .filter_map(|id| { let (room, _, tags) = @@ -133,7 +133,9 @@ impl<'a> StatefulWidget for Space<'a> { None } }) - .collect(); + .collect::>(); + let fields = &self.store.application.settings.tunables.sort.rooms; + items.sort_by(|a, b| room_fields_cmp(a, b, fields)); state.list.set(items); state.last_fetch = Some(Instant::now()); diff --git a/src/worker.rs b/src/worker.rs index 0b602ee..2a36dff 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -330,34 +330,30 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore) { for room in client.invited_rooms().into_iter() { let name = room.display_name().await.unwrap_or(DisplayName::Empty).to_string(); + let tags = room.tags().await.unwrap_or_default(); + names.push((room.room_id().to_owned(), name)); if room.is_direct() { - let tags = room.tags().await.unwrap_or_default(); - dms.push(Arc::new((room.into(), tags))); } else if room.is_space() { - spaces.push(room.into()); + spaces.push(Arc::new((room.into(), tags))); } else { - let tags = room.tags().await.unwrap_or_default(); - rooms.push(Arc::new((room.into(), tags))); } } for room in client.joined_rooms().into_iter() { let name = room.display_name().await.unwrap_or(DisplayName::Empty).to_string(); + let tags = room.tags().await.unwrap_or_default(); + names.push((room.room_id().to_owned(), name)); if room.is_direct() { - let tags = room.tags().await.unwrap_or_default(); - dms.push(Arc::new((room.into(), tags))); } else if room.is_space() { - spaces.push(room.into()); + spaces.push(Arc::new((room.into(), tags))); } else { - let tags = room.tags().await.unwrap_or_default(); - rooms.push(Arc::new((room.into(), tags))); } }