From a2a708f1ae1dc8d08620f1b302b2cafb159cb40a Mon Sep 17 00:00:00 2001 From: Ulyssa Date: Wed, 28 Feb 2024 09:03:28 -0800 Subject: [PATCH] Indicate and sort on rooms with unread messages (#205) Fixes #83 --- src/base.rs | 55 ++++++++- src/config.rs | 3 +- src/main.rs | 25 +--- src/tests.rs | 1 - src/windows/mod.rs | 204 +++++++++++++++++++++++++++------ src/windows/room/scrollback.rs | 4 +- src/worker.rs | 43 ++++++- 7 files changed, 271 insertions(+), 64 deletions(-) diff --git a/src/base.rs b/src/base.rs index 4fc021a..c0584cb 100644 --- a/src/base.rs +++ b/src/base.rs @@ -53,6 +53,7 @@ use matrix_sdk::{ OwnedRoomId, OwnedUserId, RoomId, + UserId, }, }; @@ -209,11 +210,26 @@ bitflags::bitflags! { /// Fields that rooms and spaces can be sorted by. #[derive(Clone, Debug, Eq, PartialEq)] pub enum SortFieldRoom { + /// Sort rooms by whether they have the Favorite tag. Favorite, + + /// Sort rooms by whether they have the Low Priority tag. LowPriority, + + /// Sort rooms by their room name. Name, + + /// Sort rooms by their canonical room alias. Alias, + + /// Sort rooms by their Matrix room identifier. RoomId, + + /// Sort rooms by whether they have unread messages. + Unread, + + /// Sort rooms by the timestamps of their most recent messages. + Recent, } /// Fields that users can be sorted by. @@ -273,6 +289,8 @@ impl<'de> Visitor<'de> for SortRoomVisitor { let field = match value { "favorite" => SortFieldRoom::Favorite, "lowpriority" => SortFieldRoom::LowPriority, + "recent" => SortFieldRoom::Recent, + "unread" => SortFieldRoom::Unread, "name" => SortFieldRoom::Name, "alias" => SortFieldRoom::Alias, "id" => SortFieldRoom::RoomId, @@ -672,6 +690,22 @@ impl EventLocation { } } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UnreadInfo { + pub(crate) unread: bool, + pub(crate) latest: Option, +} + +impl UnreadInfo { + pub fn is_unread(&self) -> bool { + self.unread + } + + pub fn latest(&self) -> Option<&MessageTimeStamp> { + self.latest.as_ref() + } +} + /// Information about room's the user's joined. #[derive(Default)] pub struct RoomInfo { @@ -697,9 +731,6 @@ pub struct RoomInfo { /// older than the oldest loaded event, that user will not be included. pub user_receipts: HashMap, - /// An event ID for where we should indicate we've read up to. - pub read_till: Option, - /// A map of message identifiers to a map of reaction events. pub reactions: HashMap, @@ -810,6 +841,20 @@ impl RoomInfo { msg.html = msg.event.html(); } + /// Indicates whether this room has unread messages. + pub fn unreads(&self, settings: &ApplicationSettings) -> UnreadInfo { + let last_message = self.messages.last_key_value(); + let last_receipt = self.get_receipt(&settings.profile.user_id); + + match (last_message, last_receipt) { + (Some(((ts, recent), _)), Some(last_read)) => { + UnreadInfo { unread: last_read != recent, latest: Some(*ts) } + }, + (Some(((ts, _), _)), None) => UnreadInfo { unread: false, latest: Some(*ts) }, + (None, _) => UnreadInfo::default(), + } + } + /// Inserts events that couldn't be decrypted into the scrollback. pub fn insert_encrypted(&mut self, msg: RoomEncryptedEvent) { let event_id = msg.event_id().to_owned(); @@ -901,6 +946,10 @@ impl RoomInfo { self.user_receipts.insert(user_id, event_id); } + pub fn get_receipt(&self, user_id: &UserId) -> Option<&OwnedEventId> { + self.user_receipts.get(user_id) + } + fn get_typers(&self) -> &[OwnedUserId] { if let Some((t, users)) = &self.users_typing { if t.elapsed() < Duration::from_secs(4) { diff --git a/src/config.rs b/src/config.rs index a571321..1922817 100644 --- a/src/config.rs +++ b/src/config.rs @@ -32,9 +32,10 @@ const DEFAULT_MEMBERS_SORT: [SortColumn; 2] = [ SortColumn(SortFieldUser::UserId, SortOrder::Ascending), ]; -const DEFAULT_ROOM_SORT: [SortColumn; 3] = [ +const DEFAULT_ROOM_SORT: [SortColumn; 4] = [ SortColumn(SortFieldRoom::Favorite, SortOrder::Ascending), SortColumn(SortFieldRoom::LowPriority, SortOrder::Ascending), + SortColumn(SortFieldRoom::Unread, SortOrder::Ascending), SortColumn(SortFieldRoom::Name, SortOrder::Ascending), ]; diff --git a/src/main.rs b/src/main.rs index ef6c19c..f921672 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,17 +27,10 @@ use std::sync::Arc; use std::time::Duration; use clap::Parser; +use matrix_sdk::ruma::OwnedUserId; use tokio::sync::Mutex as AsyncMutex; use tracing_subscriber::FmtSubscriber; -use matrix_sdk::{ - config::SyncSettings, - ruma::{ - api::client::filter::{FilterDefinition, LazyLoadOptions, RoomEventFilter, RoomFilter}, - OwnedUserId, - }, -}; - use modalkit::crossterm::{ self, cursor::Show as CursorShow, @@ -719,20 +712,6 @@ async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult< } } - // Perform an initial, lazily-loaded sync. - let mut room = RoomEventFilter::default(); - room.lazy_load_options = LazyLoadOptions::Enabled { include_redundant_members: false }; - - let mut room_ev = RoomFilter::default(); - room_ev.state = room; - - let mut filter = FilterDefinition::default(); - filter.room = room_ev; - - let settings = SyncSettings::new().filter(filter.into()); - - worker.client.sync_once(settings).await.map_err(IambError::from)?; - Ok(()) } @@ -744,12 +723,14 @@ fn print_exit(v: T) -> N { async fn run(settings: ApplicationSettings) -> IambResult<()> { // Set up the async worker thread and global store. let worker = ClientWorker::spawn(settings.clone()).await; + let client = worker.client.clone(); let store = ChatStore::new(worker.clone(), settings.clone()); let store = Store::new(store); let store = Arc::new(AsyncMutex::new(store)); worker.init(store.clone()); login(worker, &settings).await.unwrap_or_else(print_exit); + worker::do_first_sync(client, &store).await; fn restore_tty() { let _ = crossterm::terminal::disable_raw_mode(); diff --git a/src/tests.rs b/src/tests.rs index b30c2df..9a4bbeb 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -156,7 +156,6 @@ pub fn mock_room() -> RoomInfo { event_receipts: HashMap::new(), user_receipts: HashMap::new(), - read_till: None, reactions: HashMap::new(), fetching: false, diff --git a/src/windows/mod.rs b/src/windows/mod.rs index d94b91e..9a1629d 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -79,9 +79,11 @@ use crate::base::{ SortFieldRoom, SortFieldUser, SortOrder, + UnreadInfo, }; use self::{room::RoomState, welcome::WelcomeState}; +use crate::message::MessageTimeStamp; pub mod room; pub mod welcome; @@ -124,11 +126,31 @@ fn selected_text(s: &str, selected: bool) -> Text { Text::from(selected_span(s, selected)) } +fn name_and_labels(name: &str, unread: bool, style: Style) -> (Span<'_>, Vec>>) { + let name_style = if unread { + style.add_modifier(StyleModifier::BOLD) + } else { + style + }; + + let name = Span::styled(name, name_style); + let labels = if unread { + vec![vec![Span::styled("Unread", style)]] + } else { + vec![] + }; + + (name, labels) +} + /// 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 { +fn some_cmp(a: Option, b: Option, f: F) -> Ordering +where + F: Fn(&T, &T) -> Ordering, +{ match (a, b) { - (Some(a), Some(b)) => a.cmp(&b), + (Some(a), Some(b)) => f(&a, &b), (None, None) => Ordering::Equal, (None, Some(_)) => Ordering::Greater, (Some(_), None) => Ordering::Less, @@ -167,8 +189,16 @@ fn room_cmp(a: &T, b: &T, field: &SortFieldRoom) -> Ordering { lowa.cmp(&lowb) }, SortFieldRoom::Name => a.name().cmp(b.name()), - SortFieldRoom::Alias => some_cmp(a.alias(), b.alias()), + SortFieldRoom::Alias => some_cmp(a.alias(), b.alias(), Ord::cmp), SortFieldRoom::RoomId => a.room_id().cmp(b.room_id()), + SortFieldRoom::Unread => { + // Sort true (unread) before false (read) + b.is_unread().cmp(&a.is_unread()) + }, + SortFieldRoom::Recent => { + // sort larger timestamps towards the top. + some_cmp(a.recent_ts(), b.recent_ts(), |a, b| b.cmp(a)) + }, } } @@ -243,6 +273,8 @@ fn append_tags<'a>(tags: Vec>>, spans: &mut Vec>, style: S trait RoomLikeItem { fn room_id(&self) -> &RoomId; fn has_tag(&self, tag: TagName) -> bool; + fn is_unread(&self) -> bool; + fn recent_ts(&self) -> Option<&MessageTimeStamp>; fn alias(&self) -> Option<&RoomAliasId>; fn name(&self) -> &str; } @@ -780,6 +812,7 @@ pub struct GenericChatItem { room_info: MatrixRoomInfo, name: String, alias: Option, + unread: UnreadInfo, is_dm: bool, } @@ -788,17 +821,17 @@ impl GenericChatItem { let room = &room_info.deref().0; let room_id = room.room_id(); - let info = store.application.get_room_info(room_id.to_owned()); - + let info = store.application.rooms.get_or_default(room_id.to_owned()); let name = info.name.clone().unwrap_or_default(); let alias = room.canonical_alias(); + let unread = info.unreads(&store.application.settings); info.tags = room_info.deref().1.clone(); if let Some(alias) = &alias { store.application.names.insert(alias.to_string(), room_id.to_owned()); } - GenericChatItem { room_info, name, alias, is_dm } + GenericChatItem { room_info, name, alias, is_dm, unread } } #[inline] @@ -832,6 +865,14 @@ impl RoomLikeItem for GenericChatItem { false } } + + fn recent_ts(&self) -> Option<&MessageTimeStamp> { + self.unread.latest() + } + + fn is_unread(&self) -> bool { + self.unread.is_unread() + } } impl ToString for GenericChatItem { @@ -842,13 +883,16 @@ impl ToString for GenericChatItem { impl ListItem for GenericChatItem { fn show(&self, selected: bool, _: &ViewportContext, _: &mut ProgramStore) -> Text { + let unread = self.unread.is_unread(); let style = selected_style(selected); - let mut spans = vec![Span::styled(self.name.as_str(), style)]; - let mut labels = if self.is_dm { - vec![vec![Span::styled("DM", style)]] + let (name, mut labels) = name_and_labels(&self.name, unread, style); + let mut spans = vec![name]; + + labels.push(if self.is_dm { + vec![Span::styled("DM", style)] } else { - vec![vec![Span::styled("Room", style)]] - }; + vec![Span::styled("Room", style)] + }); if let Some(tags) = &self.tags() { labels.extend(tags.keys().map(|t| tag_to_span(t, style))); @@ -879,6 +923,7 @@ pub struct RoomItem { room_info: MatrixRoomInfo, name: String, alias: Option, + unread: UnreadInfo, } impl RoomItem { @@ -886,16 +931,17 @@ impl RoomItem { let room = &room_info.deref().0; let room_id = room.room_id(); - let info = store.application.get_room_info(room_id.to_owned()); + let info = store.application.rooms.get_or_default(room_id.to_owned()); let name = info.name.clone().unwrap_or_default(); let alias = room.canonical_alias(); + let unread = info.unreads(&store.application.settings); info.tags = room_info.deref().1.clone(); if let Some(alias) = &alias { store.application.names.insert(alias.to_string(), room_id.to_owned()); } - RoomItem { room_info, name, alias } + RoomItem { room_info, name, alias, unread } } #[inline] @@ -929,6 +975,14 @@ impl RoomLikeItem for RoomItem { false } } + + fn recent_ts(&self) -> Option<&MessageTimeStamp> { + self.unread.latest() + } + + fn is_unread(&self) -> bool { + self.unread.is_unread() + } } impl ToString for RoomItem { @@ -939,17 +993,18 @@ impl ToString for RoomItem { impl ListItem for RoomItem { fn show(&self, selected: bool, _: &ViewportContext, _: &mut ProgramStore) -> Text { + let unread = self.unread.is_unread(); + let style = selected_style(selected); + let (name, mut labels) = name_and_labels(&self.name, unread, style); + let mut spans = vec![name]; + if let Some(tags) = &self.tags() { - let style = selected_style(selected); - let mut spans = vec![Span::styled(self.name.as_str(), style)]; - let tags = tags.keys().map(|t| tag_to_span(t, style)).collect(); - - append_tags(tags, &mut spans, style); - - Text::from(Line::from(spans)) - } else { - selected_text(self.name.as_str(), selected) + labels.extend(tags.keys().map(|t| tag_to_span(t, style))); } + + append_tags(labels, &mut spans, style); + + Text::from(Line::from(spans)) } fn get_word(&self) -> Option { @@ -973,15 +1028,20 @@ pub struct DirectItem { room_info: MatrixRoomInfo, name: String, alias: Option, + unread: UnreadInfo, } impl DirectItem { fn new(room_info: MatrixRoomInfo, store: &mut ProgramStore) -> Self { 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, alias } + let info = store.application.rooms.get_or_default(room_id); + let name = info.name.clone().unwrap_or_default(); + let unread = info.unreads(&store.application.settings); + info.tags = room_info.deref().1.clone(); + + DirectItem { room_info, name, alias, unread } } #[inline] @@ -1015,6 +1075,14 @@ impl RoomLikeItem for DirectItem { fn room_id(&self) -> &RoomId { self.room().room_id() } + + fn recent_ts(&self) -> Option<&MessageTimeStamp> { + self.unread.latest() + } + + fn is_unread(&self) -> bool { + self.unread.is_unread() + } } impl ToString for DirectItem { @@ -1025,17 +1093,18 @@ impl ToString for DirectItem { impl ListItem for DirectItem { fn show(&self, selected: bool, _: &ViewportContext, _: &mut ProgramStore) -> Text { + let unread = self.unread.is_unread(); + let style = selected_style(selected); + let (name, mut labels) = name_and_labels(&self.name, unread, style); + let mut spans = vec![name]; + if let Some(tags) = &self.tags() { - let style = selected_style(selected); - let mut spans = vec![Span::styled(self.name.as_str(), style)]; - let tags = tags.keys().map(|t| tag_to_span(t, style)).collect(); - - append_tags(tags, &mut spans, style); - - Text::from(Line::from(spans)) - } else { - selected_text(self.name.as_str(), selected) + labels.extend(tags.keys().map(|t| tag_to_span(t, style))); } + + append_tags(labels, &mut spans, style); + + Text::from(Line::from(spans)) } fn get_word(&self) -> Option { @@ -1103,6 +1172,16 @@ impl RoomLikeItem for SpaceItem { // exposes them, so we'll just always return false here for now. false } + + fn recent_ts(&self) -> Option<&MessageTimeStamp> { + // XXX: this needs to determine the room with most recent message and return its timestamp. + None + } + + fn is_unread(&self) -> bool { + // XXX: this needs to check whether the space contains rooms with unread messages + false + } } impl ToString for SpaceItem { @@ -1433,6 +1512,7 @@ mod tests { tags: Vec, alias: Option, name: &'static str, + unread: UnreadInfo, } impl RoomLikeItem for &TestRoomItem { @@ -1451,6 +1531,14 @@ mod tests { fn name(&self) -> &str { self.name } + + fn recent_ts(&self) -> Option<&MessageTimeStamp> { + self.unread.latest() + } + + fn is_unread(&self) -> bool { + self.unread.is_unread() + } } #[test] @@ -1462,6 +1550,7 @@ mod tests { tags: vec![TagName::Favorite], alias: Some(room_alias_id!("#room1:example.com").to_owned()), name: "Z", + unread: UnreadInfo::default(), }; let room2 = TestRoomItem { @@ -1469,6 +1558,7 @@ mod tests { tags: vec![], alias: Some(room_alias_id!("#a:example.com").to_owned()), name: "Unnamed Room", + unread: UnreadInfo::default(), }; let room3 = TestRoomItem { @@ -1476,6 +1566,7 @@ mod tests { tags: vec![], alias: None, name: "Cool Room", + unread: UnreadInfo::default(), }; // Sort by Name ascending. @@ -1510,4 +1601,51 @@ mod tests { rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); assert_eq!(rooms, vec![&room2, &room3, &room1]); } + + #[test] + fn test_sort_room_recents() { + let server = server_name!("example.com"); + + let room1 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![], + alias: None, + name: "Room 1", + unread: UnreadInfo { unread: false, latest: None }, + }; + + let room2 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![], + alias: None, + name: "Room 2", + unread: UnreadInfo { + unread: false, + latest: Some(MessageTimeStamp::OriginServer(40u32.into())), + }, + }; + + let room3 = TestRoomItem { + room_id: RoomId::new(server).to_owned(), + tags: vec![], + alias: None, + name: "Room 3", + unread: UnreadInfo { + unread: false, + latest: Some(MessageTimeStamp::OriginServer(20u32.into())), + }, + }; + + // Sort by Recent ascending. + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Ascending)]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room2, &room3, &room1]); + + // Sort by Recent descending. + let mut rooms = vec![&room1, &room2, &room3]; + let fields = &[SortColumn(SortFieldRoom::Recent, SortOrder::Descending)]; + rooms.sort_by(|a, b| room_fields_cmp(a, b, fields)); + assert_eq!(rooms, vec![&room1, &room3, &room2]); + } } diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index c072388..2dee293 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -1342,7 +1342,9 @@ impl<'a> StatefulWidget for Scrollback<'a> { state.cursor.timestamp.is_none() { // If the cursor is at the last message, then update the read marker. - info.read_till = info.messages.last_key_value().map(|(k, _)| k.1.clone()); + if let Some((k, _)) = info.messages.last_key_value() { + info.set_receipt(settings.profile.user_id.clone(), k.1.clone()); + } } // Check whether we should load older messages for this room. diff --git a/src/worker.rs b/src/worker.rs index 7cbdf02..ef7e719 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -26,6 +26,7 @@ use matrix_sdk::{ room::{Invited, Messages, MessagesOptions, Room as MatrixRoom, RoomMember}, ruma::{ api::client::{ + filter::{FilterDefinition, LazyLoadOptions, RoomEventFilter, RoomFilter}, room::create_room::v3::{CreationContent, Request as CreateRoomRequest, RoomPreset}, room::Visibility, space::get_hierarchy::v1::Request as SpaceHierarchyRequest, @@ -424,9 +425,8 @@ async fn refresh_rooms_forever(client: &Client, store: &AsyncProgramStore) { let mut interval = tokio::time::interval(Duration::from_secs(5)); loop { - interval.tick().await; - refresh_rooms(client, store).await; + interval.tick().await; } } @@ -438,13 +438,14 @@ async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) { interval.tick().await; let locked = store.lock().await; + let user_id = &locked.application.settings.profile.user_id; let updates = client .joined_rooms() .into_iter() .filter_map(|room| { let room_id = room.room_id().to_owned(); let info = locked.application.rooms.get(&room_id)?; - let new_receipt = info.read_till.as_ref()?; + let new_receipt = info.get_receipt(user_id)?; let old_receipt = sent.get(&room_id); if Some(new_receipt) != old_receipt { Some((room_id, new_receipt.clone())) @@ -469,6 +470,42 @@ async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) { } } +pub async fn do_first_sync(client: Client, store: &AsyncProgramStore) { + // Perform an initial, lazily-loaded sync. + let mut room = RoomEventFilter::default(); + room.lazy_load_options = LazyLoadOptions::Enabled { include_redundant_members: false }; + + let mut room_ev = RoomFilter::default(); + room_ev.state = room; + + let mut filter = FilterDefinition::default(); + filter.room = room_ev; + + let settings = SyncSettings::new().filter(filter.into()); + + if let Err(e) = client.sync_once(settings).await { + tracing::error!(err = e.to_string(), "Failed to perform initial sync; will retry later"); + return; + } + + // Populate sync_info with our initial set of rooms/dms/spaces. + refresh_rooms(&client, store).await; + + // Insert Need::Messages to fetch accurate recent timestamps in the background. + let mut locked = store.lock().await; + let ChatStore { sync_info, need_load, .. } = &mut locked.application; + + for room in sync_info.rooms.iter() { + let room_id = room.as_ref().0.room_id().to_owned(); + need_load.insert(room_id, Need::MESSAGES); + } + + for room in sync_info.dms.iter() { + let room_id = room.as_ref().0.room_id().to_owned(); + need_load.insert(room_id, Need::MESSAGES); + } +} + #[derive(Debug)] pub enum LoginStyle { SessionRestore(Session),