From afe892c7feb34566d56437e0778b8964c21867c8 Mon Sep 17 00:00:00 2001 From: Ulyssa Date: Thu, 26 Jan 2023 15:40:16 -0800 Subject: [PATCH] Support sending and displaying read markers (#11) --- src/base.rs | 31 +++++- src/config.rs | 84 ++++++++++------ src/message/mod.rs | 170 ++++++++++++++++++++++++--------- src/tests.rs | 19 +++- src/windows/room/chat.rs | 12 +-- src/windows/room/scrollback.rs | 8 +- src/worker.rs | 40 +++++++- 7 files changed, 270 insertions(+), 94 deletions(-) diff --git a/src/base.rs b/src/base.rs index 59f6ec2..ee74bb2 100644 --- a/src/base.rs +++ b/src/base.rs @@ -214,6 +214,8 @@ pub type AsyncProgramStore = Arc>; pub type IambResult = UIResult; +pub type Receipts = HashMap>; + #[derive(thiserror::Error, Debug)] pub enum IambError { #[error("Invalid user identifier: {0}")] @@ -286,6 +288,9 @@ pub struct RoomInfo { pub keys: HashMap, pub messages: Messages, + pub receipts: HashMap>, + pub read_till: Option, + pub fetch_id: RoomFetchStatus, pub fetch_last: Option, pub users_typing: Option<(Instant, Vec)>, @@ -316,7 +321,7 @@ impl RoomInfo { MessageEvent::Original(orig) => { orig.content = *new_content; }, - MessageEvent::Local(content) => { + MessageEvent::Local(_, content) => { *content = new_content; }, MessageEvent::Redacted(_) => { @@ -364,7 +369,7 @@ impl RoomInfo { } } - fn get_typing_spans(&self, settings: &ApplicationSettings) -> Spans { + fn get_typing_spans<'a>(&'a self, settings: &'a ApplicationSettings) -> Spans<'a> { let typers = self.get_typers(); let n = typers.len(); @@ -454,6 +459,26 @@ impl ChatStore { .unwrap_or_else(|| "Untitled Matrix Room".to_string()) } + pub async fn set_receipts(&mut self, receipts: Vec<(OwnedRoomId, Receipts)>) { + let mut updates = vec![]; + + for (room_id, receipts) in receipts.into_iter() { + if let Some(info) = self.rooms.get_mut(&room_id) { + info.receipts = receipts; + + if let Some(read_till) = info.read_till.take() { + updates.push((room_id, read_till)); + } + } + } + + for (room_id, read_till) in updates.into_iter() { + if let Some(room) = self.worker.client.get_joined_room(&room_id) { + let _ = room.read_receipt(read_till.as_ref()).await; + } + } + } + pub fn mark_for_load(&mut self, room_id: OwnedRoomId) { self.need_load.insert(room_id); } @@ -588,7 +613,7 @@ impl ApplicationInfo for IambInfo { #[cfg(test)] pub mod tests { use super::*; - use crate::config::{user_style, user_style_from_color}; + use crate::config::user_style_from_color; use crate::tests::*; use modalkit::tui::style::Color; diff --git a/src/config.rs b/src/config.rs index 82cdb9e..c69d4ab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; use std::collections::HashMap; use std::fmt; @@ -52,10 +53,6 @@ pub fn user_style_from_color(color: Color) -> Style { Style::default().fg(color).add_modifier(StyleModifier::BOLD) } -pub fn user_style(user: &str) -> Style { - user_style_from_color(user_color(user)) -} - fn is_profile_char(c: char) -> bool { c.is_ascii_alphanumeric() || c == '.' || c == '-' } @@ -181,14 +178,18 @@ fn merge_users(a: Option, b: Option) -> Option, + pub read_receipt_send: Option, + pub read_receipt_display: Option, + pub typing_notice_send: Option, pub typing_notice_display: Option, pub users: Option, } @@ -196,7 +197,9 @@ pub struct Tunables { impl Tunables { fn merge(self, other: Self) -> Self { Tunables { - typing_notice: self.typing_notice.or(other.typing_notice), + read_receipt_send: self.read_receipt_send.or(other.read_receipt_send), + read_receipt_display: self.read_receipt_display.or(other.read_receipt_display), + 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), } @@ -204,8 +207,10 @@ impl Tunables { fn values(self) -> TunableValues { TunableValues { - typing_notice: self.typing_notice.unwrap_or(true), - typing_notice_display: self.typing_notice.unwrap_or(true), + read_receipt_send: self.read_receipt_send.unwrap_or(true), + read_receipt_display: self.read_receipt_display.unwrap_or(true), + 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(), } } @@ -374,24 +379,41 @@ impl ApplicationSettings { Ok(settings) } + pub fn get_user_char_span<'a>(&self, user_id: &'a UserId) -> Span<'a> { + let (color, c) = self + .tunables + .users + .get(user_id) + .map(|user| { + ( + user.color.as_ref().map(|c| c.0), + user.name.as_ref().and_then(|s| s.chars().next()), + ) + }) + .unwrap_or_default(); + + let color = color.unwrap_or_else(|| user_color(user_id.as_str())); + let style = user_style_from_color(color); + + let c = c.unwrap_or_else(|| user_id.localpart().chars().next().unwrap_or(' ')); + + Span::styled(String::from(c), style) + } + pub fn get_user_span<'a>(&self, user_id: &'a UserId) -> Span<'a> { - if let Some(user) = self.tunables.users.get(user_id) { - let color = if let Some(UserColor(c)) = user.color { - c - } else { - user_color(user_id.as_str()) - }; + let (color, name) = self + .tunables + .users + .get(user_id) + .map(|user| (user.color.as_ref().map(|c| c.0), user.name.clone().map(Cow::Owned))) + .unwrap_or_default(); - let style = user_style_from_color(color); + let user_id = user_id.as_str(); + let color = color.unwrap_or_else(|| user_color(user_id)); + let style = user_style_from_color(color); + let name = name.unwrap_or(Cow::Borrowed(user_id)); - if let Some(name) = &user.name { - Span::styled(name.clone(), style) - } else { - Span::styled(user_id.as_str(), style) - } - } else { - Span::styled(user_id.as_str(), user_style(user_id.as_str())) - } + Span::styled(name, style) } } @@ -461,22 +483,22 @@ mod tests { #[test] fn test_parse_tunables() { let res: Tunables = serde_json::from_str("{}").unwrap(); - assert_eq!(res.typing_notice, None); + assert_eq!(res.typing_notice_send, None); assert_eq!(res.typing_notice_display, None); assert_eq!(res.users, None); - let res: Tunables = serde_json::from_str("{\"typing_notice\": true}").unwrap(); - assert_eq!(res.typing_notice, Some(true)); + let res: Tunables = serde_json::from_str("{\"typing_notice_send\": true}").unwrap(); + assert_eq!(res.typing_notice_send, Some(true)); assert_eq!(res.typing_notice_display, None); assert_eq!(res.users, None); - let res: Tunables = serde_json::from_str("{\"typing_notice\": false}").unwrap(); - assert_eq!(res.typing_notice, Some(false)); + let res: Tunables = serde_json::from_str("{\"typing_notice_send\": false}").unwrap(); + assert_eq!(res.typing_notice_send, Some(false)); assert_eq!(res.typing_notice_display, None); assert_eq!(res.users, None); let res: Tunables = serde_json::from_str("{\"users\": {}}").unwrap(); - assert_eq!(res.typing_notice, None); + assert_eq!(res.typing_notice_send, None); assert_eq!(res.typing_notice_display, None); assert_eq!(res.users, Some(HashMap::new())); @@ -484,7 +506,7 @@ mod tests { "{\"users\": {\"@a:b.c\": {\"color\": \"black\", \"name\": \"Tim\"}}}", ) .unwrap(); - assert_eq!(res.typing_notice, None); + assert_eq!(res.typing_notice_send, None); assert_eq!(res.typing_notice_display, None); let users = vec![(user_id!("@a:b.c").to_owned(), UserDisplayTunables { color: Some(UserColor(Color::Black)), diff --git a/src/message/mod.rs b/src/message/mod.rs index 82e665a..adce084 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -4,6 +4,7 @@ use std::collections::hash_map::DefaultHasher; use std::collections::BTreeMap; use std::convert::TryFrom; use std::hash::{Hash, Hasher}; +use std::slice::Iter; use chrono::{DateTime, NaiveDateTime, Utc}; use unicode_width::UnicodeWidthStr; @@ -25,6 +26,7 @@ use matrix_sdk::ruma::{ }, Redact, }, + EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, @@ -54,20 +56,28 @@ pub type MessageFetchResult = IambResult<(Option, Vec) pub type MessageKey = (MessageTimeStamp, OwnedEventId); pub type Messages = BTreeMap; +const fn span_static(s: &'static str) -> Span<'static> { + Span { + content: Cow::Borrowed(s), + style: Style { + fg: None, + bg: None, + add_modifier: StyleModifier::empty(), + sub_modifier: StyleModifier::empty(), + }, + } +} + const USER_GUTTER: usize = 30; const TIME_GUTTER: usize = 12; +const READ_GUTTER: usize = 5; const MIN_MSG_LEN: usize = 30; const USER_GUTTER_EMPTY: &str = " "; -const USER_GUTTER_EMPTY_SPAN: Span<'static> = Span { - content: Cow::Borrowed(USER_GUTTER_EMPTY), - style: Style { - fg: None, - bg: None, - add_modifier: StyleModifier::empty(), - sub_modifier: StyleModifier::empty(), - }, -}; +const USER_GUTTER_EMPTY_SPAN: Span<'static> = span_static(USER_GUTTER_EMPTY); + +const TIME_GUTTER_EMPTY: &str = " "; +const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY); #[derive(thiserror::Error, Debug)] pub enum TimeStampIntError { @@ -271,10 +281,18 @@ impl PartialOrd for MessageCursor { pub enum MessageEvent { Original(Box), Redacted(Box), - Local(Box), + Local(OwnedEventId, Box), } impl MessageEvent { + pub fn event_id(&self) -> &EventId { + match self { + MessageEvent::Original(ev) => ev.event_id.as_ref(), + MessageEvent::Redacted(ev) => ev.event_id.as_ref(), + MessageEvent::Local(event_id, _) => event_id.as_ref(), + } + } + pub fn body(&self) -> Cow<'_, str> { match self { MessageEvent::Original(ev) => body_cow_content(&ev.content), @@ -292,7 +310,7 @@ impl MessageEvent { Cow::Borrowed("[Redacted]") } }, - MessageEvent::Local(content) => body_cow_content(content), + MessageEvent::Local(_, content) => body_cow_content(content), } } @@ -300,7 +318,7 @@ impl MessageEvent { let content = match self { MessageEvent::Original(ev) => &ev.content, MessageEvent::Redacted(_) => return None, - MessageEvent::Local(content) => content, + MessageEvent::Local(_, content) => content, }; if let MessageType::Text(content) = &content.msgtype { @@ -317,7 +335,7 @@ impl MessageEvent { pub fn redact(&mut self, redaction: SyncRoomRedactionEvent, version: &RoomVersionId) { match self { MessageEvent::Redacted(_) => return, - MessageEvent::Local(_) => return, + MessageEvent::Local(_, _) => return, MessageEvent::Original(ev) => { let redacted = ev.clone().redact(redaction, version); *self = MessageEvent::Redacted(Box::new(redacted)); @@ -354,27 +372,65 @@ fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> { Cow::Borrowed(s) } -enum MessageColumns<'a> { - Three(usize, Option>, Option>), - Two(usize, Option>), - One(usize, Option>), +enum MessageColumns { + /// Four columns: sender, message, timestamp, read receipts. + Four, + + /// Three columns: sender, message, timestamp. + Three, + + /// Two columns: sender, message. + Two, + + /// One column: message with sender on line before the message. + One, } -impl<'a> MessageColumns<'a> { +struct MessageFormatter<'a> { + settings: &'a ApplicationSettings, + cols: MessageColumns, + fill: usize, + user: Option>, + time: Option>, + read: Iter<'a, OwnedUserId>, +} + +impl<'a> MessageFormatter<'a> { fn width(&self) -> usize { - match self { - MessageColumns::Three(fill, _, _) => *fill, - MessageColumns::Two(fill, _) => *fill, - MessageColumns::One(fill, _) => *fill, - } + self.fill } #[inline] fn push_spans(&mut self, spans: Spans<'a>, style: Style, text: &mut Text<'a>) { - match self { - MessageColumns::Three(_, user, time) => { - let user = user.take().unwrap_or(USER_GUTTER_EMPTY_SPAN); - let time = time.take().unwrap_or_else(|| Span::from("")); + match self.cols { + MessageColumns::Four => { + let settings = self.settings; + let user = self.user.take().unwrap_or(USER_GUTTER_EMPTY_SPAN); + let time = self.time.take().unwrap_or(TIME_GUTTER_EMPTY_SPAN); + + let mut line = vec![user]; + line.extend(spans.0); + line.push(time); + + // Show read receipts. + let user_char = + |user: &'a OwnedUserId| -> Span<'a> { settings.get_user_char_span(user) }; + + let a = self.read.next().map(user_char).unwrap_or_else(|| Span::raw(" ")); + let b = self.read.next().map(user_char).unwrap_or_else(|| Span::raw(" ")); + let c = self.read.next().map(user_char).unwrap_or_else(|| Span::raw(" ")); + + line.push(Span::raw(" ")); + line.push(c); + line.push(b); + line.push(a); + line.push(Span::raw(" ")); + + text.lines.push(Spans(line)) + }, + MessageColumns::Three => { + let user = self.user.take().unwrap_or(USER_GUTTER_EMPTY_SPAN); + let time = self.time.take().unwrap_or_else(|| Span::from("")); let mut line = vec![user]; line.extend(spans.0); @@ -382,15 +438,15 @@ impl<'a> MessageColumns<'a> { text.lines.push(Spans(line)) }, - MessageColumns::Two(_, opt) => { - let user = opt.take().unwrap_or(USER_GUTTER_EMPTY_SPAN); + MessageColumns::Two => { + let user = self.user.take().unwrap_or(USER_GUTTER_EMPTY_SPAN); let mut line = vec![user]; line.extend(spans.0); text.lines.push(Spans(line)); }, - MessageColumns::One(_, opt) => { - if let Some(user) = opt.take() { + MessageColumns::One => { + if let Some(user) = self.user.take() { text.lines.push(Spans(vec![user])); } @@ -428,7 +484,7 @@ impl Message { pub fn reply_to(&self) -> Option { let content = match &self.event { - MessageEvent::Local(content) => content, + MessageEvent::Local(_, content) => content, MessageEvent::Original(ev) => &ev.content, MessageEvent::Redacted(_) => return None, }; @@ -454,28 +510,50 @@ impl Message { return style; } - fn get_render_format( - &self, + fn get_render_format<'a>( + &'a self, prev: Option<&Message>, width: usize, - settings: &ApplicationSettings, - ) -> MessageColumns { - if USER_GUTTER + TIME_GUTTER + MIN_MSG_LEN <= width { - let lw = width - USER_GUTTER - TIME_GUTTER; + info: &'a RoomInfo, + settings: &'a ApplicationSettings, + ) -> MessageFormatter<'a> { + if USER_GUTTER + TIME_GUTTER + READ_GUTTER + MIN_MSG_LEN <= width && + settings.tunables.read_receipt_display + { + let cols = MessageColumns::Four; + let fill = width - USER_GUTTER - TIME_GUTTER - READ_GUTTER; let user = self.show_sender(prev, true, settings); let time = self.timestamp.show(); + let read = match info.receipts.get(self.event.event_id()) { + Some(read) => read.iter(), + None => [].iter(), + }; - MessageColumns::Three(lw, user, time) - } else if USER_GUTTER + MIN_MSG_LEN <= width { - let lw = width - USER_GUTTER; + MessageFormatter { settings, cols, fill, user, time, read } + } else if USER_GUTTER + TIME_GUTTER + MIN_MSG_LEN <= width { + let cols = MessageColumns::Three; + let fill = width - USER_GUTTER - TIME_GUTTER; let user = self.show_sender(prev, true, settings); + let time = self.timestamp.show(); + let read = [].iter(); - MessageColumns::Two(lw, user) + MessageFormatter { settings, cols, fill, user, time, read } + } else if USER_GUTTER + MIN_MSG_LEN <= width { + let cols = MessageColumns::Two; + let fill = width - USER_GUTTER; + let user = self.show_sender(prev, true, settings); + let time = None; + let read = [].iter(); + + MessageFormatter { settings, cols, fill, user, time, read } } else { - let lw = width.saturating_sub(2); + let cols = MessageColumns::One; + let fill = width.saturating_sub(2); let user = self.show_sender(prev, false, settings); + let time = None; + let read = [].iter(); - MessageColumns::One(lw, user) + MessageFormatter { settings, cols, fill, user, time, read } } } @@ -485,12 +563,12 @@ impl Message { selected: bool, vwctx: &ViewportContext, info: &'a RoomInfo, - settings: &ApplicationSettings, + settings: &'a ApplicationSettings, ) -> Text<'a> { let width = vwctx.get_width(); let style = self.get_render_style(selected); - let mut fmt = self.get_render_format(prev, width, settings); + let mut fmt = self.get_render_format(prev, width, info, settings); let mut text = Text { lines: vec![] }; let width = fmt.width(); diff --git a/src/tests.rs b/src/tests.rs index 7ae7e31..0776ff2 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -15,13 +15,15 @@ use matrix_sdk::ruma::{ }; use lazy_static::lazy_static; -use modalkit::tui::style::Color; +use modalkit::tui::style::{Color, Style}; use tokio::sync::mpsc::unbounded_channel; use url::Url; use crate::{ base::{ChatStore, ProgramStore, RoomFetchStatus, RoomInfo}, config::{ + user_color, + user_style_from_color, ApplicationSettings, DirectoryValues, ProfileConfig, @@ -60,6 +62,10 @@ lazy_static! { pub static ref MSG5_KEY: MessageKey = (OriginServer(UInt::new(8).unwrap()), MSG5_EVID.clone()); } +pub fn user_style(user: &str) -> Style { + user_style_from_color(user_color(user)) +} + pub fn mock_room1_message( content: RoomMessageEventContent, sender: OwnedUserId, @@ -82,7 +88,7 @@ pub fn mock_room1_message( pub fn mock_message1() -> Message { let content = RoomMessageEventContent::text_plain("writhe"); - let content = MessageEvent::Local(content.into()); + let content = MessageEvent::Local(MSG1_EVID.clone(), content.into()); Message::new(content, TEST_USER1.clone(), MSG1_KEY.0) } @@ -138,10 +144,13 @@ pub fn mock_messages() -> Messages { pub fn mock_room() -> RoomInfo { RoomInfo { name: Some("Watercooler Discussion".into()), + tags: None, keys: mock_keys(), messages: mock_messages(), - tags: None, + + receipts: HashMap::new(), + read_till: None, fetch_id: RoomFetchStatus::NotStarted, fetch_last: None, @@ -159,7 +168,9 @@ pub fn mock_dirs() -> DirectoryValues { pub fn mock_tunables() -> TunableValues { TunableValues { - typing_notice: true, + read_receipt_send: true, + read_receipt_display: true, + typing_notice_send: true, typing_notice_display: true, users: vec![(TEST_USER5.clone(), UserDisplayTunables { color: Some(UserColor(Color::Black)), diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 9ce1205..66f1a8a 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -241,7 +241,7 @@ impl ChatState { let ev = match &msg.event { MessageEvent::Original(ev) => &ev.content, - MessageEvent::Local(ev) => ev.deref(), + MessageEvent::Local(_, ev) => ev.deref(), _ => { let msg = "Cannot edit a redacted message"; let err = UIError::Failure(msg.into()); @@ -275,9 +275,7 @@ impl ChatState { let event_id = match &msg.event { MessageEvent::Original(ev) => ev.event_id.clone(), - MessageEvent::Local(_) => { - self.scrollback.get_key(info).ok_or(IambError::NoSelectedMessage)?.1 - }, + MessageEvent::Local(event_id, _) => event_id.clone(), MessageEvent::Redacted(_) => { let msg = ""; let err = UIError::Failure(msg.into()); @@ -378,8 +376,8 @@ impl ChatState { if show_echo { let user = store.application.settings.profile.user_id.clone(); - let key = (MessageTimeStamp::LocalEcho, event_id); - let msg = MessageEvent::Local(msg.into()); + let key = (MessageTimeStamp::LocalEcho, event_id.clone()); + let msg = MessageEvent::Local(event_id, msg.into()); let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho); info.messages.insert(key, msg); } @@ -415,7 +413,7 @@ impl ChatState { return; } - if !store.application.settings.tunables.typing_notice { + if !store.application.settings.tunables.typing_notice_send { return; } diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index d6436f7..be8b9c8 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -1260,7 +1260,13 @@ impl<'a> StatefulWidget for Scrollback<'a> { y += 1; } - let first_key = info.messages.first_key_value().map(|f| f.0.clone()); + if settings.tunables.read_receipt_send && 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()); + } + + // Check whether we should load older messages for this room. + let first_key = info.messages.first_key_value().map(|(k, _)| k.clone()); if first_key == state.viewctx.corner.timestamp { // If the top of the screen is the older message, load more. self.store.application.mark_for_load(state.room_id.clone()); diff --git a/src/worker.rs b/src/worker.rs index a98399c..2db1b35 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -57,7 +57,7 @@ use matrix_sdk::{ use modalkit::editing::action::{EditInfo, InfoMessage, UIError}; use crate::{ - base::{AsyncProgramStore, IambError, IambResult, VerifyAction}, + base::{AsyncProgramStore, IambError, IambResult, Receipts, VerifyAction}, message::MessageFetchResult, ApplicationSettings, }; @@ -99,6 +99,29 @@ fn oneshot() -> (ClientReply, ClientResponse) { return (reply, response); } +async fn update_receipts(client: &Client) -> Vec<(OwnedRoomId, Receipts)> { + let mut rooms = vec![]; + + for room in client.joined_rooms() { + if let Ok(users) = room.active_members_no_sync().await { + let mut receipts = Receipts::new(); + + for member in users { + let res = room.user_read_receipt(member.user_id()).await; + + if let Ok(Some((event_id, _))) = res { + let user_id = member.user_id().to_owned(); + receipts.entry(event_id).or_default().push(user_id); + } + } + + rooms.push((room.room_id().to_owned(), receipts)); + } + } + + return rooms; +} + pub type FetchedRoom = (MatrixRoom, DisplayName, Option); pub enum WorkerTask { @@ -454,7 +477,7 @@ impl ClientWorker { } async fn init(&mut self, store: AsyncProgramStore) { - self.client.add_event_handler_context(store); + self.client.add_event_handler_context(store.clone()); let _ = self.client.add_event_handler( |ev: SyncTypingEvent, room: MatrixRoom, store: Ctx| { @@ -661,6 +684,19 @@ impl ClientWorker { }, ); + let client = self.client.clone(); + let _ = tokio::spawn(async move { + // Update the displayed read receipts ever 5 seconds. + let mut interval = tokio::time::interval(Duration::from_secs(5)); + + loop { + interval.tick().await; + + let receipts = update_receipts(&client).await; + store.lock().await.application.set_receipts(receipts).await; + } + }); + self.initialized = true; }