diff --git a/docs/iamb.1 b/docs/iamb.1 index 44fde6ce..78207fb8 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -123,6 +123,10 @@ Remove your reaction from the selected message. When no arguments are given, remove all of your reactions from the message. .It Sy ":redact [reason]" Redact the selected message with the optional reason. +.It Sy ":pin" +Pin the selected message to the room. +.It Sy ":unpin" +Unpin the selected message from the room. .It Sy ":reply" Reply to the selected message. .It Sy ":cancel" @@ -166,6 +170,9 @@ Send an invitation to a user to join the currently focused room with an optional Leave the currently focused room. .It Sy ":members" View a list of members of the currently focused room. +.It Sy ":pinned" +View a list of pinned messages in the currently focused room. +Select a message to jump to it in the room. .It Sy ":room access set [rule]" Set the join rules for the room to control who can access it. Possible values for the diff --git a/src/base.rs b/src/base.rs index 0006c509..0c7d7608 100644 --- a/src/base.rs +++ b/src/base.rs @@ -102,6 +102,13 @@ pub enum VerifyAction { Emoji, } +/// An action taken against a room's timeline. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TimelineAction { + /// Jump to a loaded message in the scrollback. + GotoEvent(OwnedEventId), +} + /// An action taken against the currently selected message. #[derive(Clone, Debug, Eq, PartialEq)] pub enum MessageAction { @@ -126,6 +133,9 @@ pub enum MessageAction { /// when it is `true`. React(String, bool), + /// Pin a message to the room. + Pin, + /// Redact a message, with an optional reason. /// /// The [bool] argument indicates whether to skip confirmation. @@ -146,6 +156,9 @@ pub enum MessageAction { /// and error when it doesn't recognize it. The second [bool] argument forces it to be /// interpreted literally when it is `true`. Unreact(Option, bool), + + /// Unpin a message from the room. + Unpin, } /// An action taken in the currently selected space. @@ -505,6 +518,9 @@ pub enum RoomAction { /// Open the members window. Members(Box), + /// Open the pinned messages window. + Pinned(Box), + /// Set whether a room is a direct message. SetDirect(bool), @@ -590,6 +606,9 @@ pub enum IambAction { /// Perform an action against the homeserver. Homeserver(HomeserverAction), + /// Perform an action against a room's timeline. + Timeline(TimelineAction), + /// Perform an action over room keys. Keys(KeysAction), @@ -661,6 +680,12 @@ impl From for IambAction { } } +impl From for IambAction { + fn from(act: TimelineAction) -> Self { + IambAction::Timeline(act) + } +} + impl ApplicationAction for IambAction { fn is_edit_sequence(&self, _: &EditContext) -> SequenceStatus { match self { @@ -672,6 +697,7 @@ impl ApplicationAction for IambAction { IambAction::Room(..) => SequenceStatus::Break, IambAction::OpenLink(..) => SequenceStatus::Break, IambAction::Send(..) => SequenceStatus::Break, + IambAction::Timeline(..) => SequenceStatus::Break, IambAction::ToggleScrollbackFocus => SequenceStatus::Break, IambAction::Verify(..) => SequenceStatus::Break, IambAction::VerifyRequest(..) => SequenceStatus::Break, @@ -689,6 +715,7 @@ impl ApplicationAction for IambAction { IambAction::OpenLink(..) => SequenceStatus::Atom, IambAction::Room(..) => SequenceStatus::Atom, IambAction::Send(..) => SequenceStatus::Atom, + IambAction::Timeline(..) => SequenceStatus::Atom, IambAction::ToggleScrollbackFocus => SequenceStatus::Atom, IambAction::Verify(..) => SequenceStatus::Atom, IambAction::VerifyRequest(..) => SequenceStatus::Atom, @@ -706,6 +733,7 @@ impl ApplicationAction for IambAction { IambAction::Room(..) => SequenceStatus::Ignore, IambAction::OpenLink(..) => SequenceStatus::Ignore, IambAction::Send(..) => SequenceStatus::Ignore, + IambAction::Timeline(..) => SequenceStatus::Ignore, IambAction::ToggleScrollbackFocus => SequenceStatus::Ignore, IambAction::Verify(..) => SequenceStatus::Ignore, IambAction::VerifyRequest(..) => SequenceStatus::Ignore, @@ -723,6 +751,7 @@ impl ApplicationAction for IambAction { IambAction::Keys(..) => false, IambAction::Send(..) => false, IambAction::OpenLink(..) => false, + IambAction::Timeline(..) => false, IambAction::ToggleScrollbackFocus => false, IambAction::Verify(..) => false, IambAction::VerifyRequest(..) => false, @@ -743,6 +772,12 @@ impl From for ProgramAction { } } +impl From for ProgramAction { + fn from(act: TimelineAction) -> Self { + IambAction::from(act).into() + } +} + impl From for ProgramAction { fn from(act: IambAction) -> Self { Action::Application(act) @@ -1185,6 +1220,15 @@ pub struct RoomInfo { /// The last time the room was rendered, used to detect if it is currently open. pub draw_last: Option, + + /// The room's pinned events, mirrored from the SDK's room state for rendering. + pub pinned_events: Vec, + + /// Pinned events fetched for the `:pinned` window that aren't in the loaded scrollback. + pub pinned_previews: HashMap, + + /// How many times fetching each pinned event for the `:pinned` window has failed. + pub pinned_failures: HashMap, } impl Default for RoomInfo { @@ -1206,6 +1250,9 @@ impl Default for RoomInfo { users_typing: Default::default(), display_names: Default::default(), draw_last: Default::default(), + pinned_events: Default::default(), + pinned_previews: Default::default(), + pinned_failures: Default::default(), unloaded_edits: Default::default(), unloaded_polls: Default::default(), unloaded_unstable_polls: Default::default(), @@ -1260,6 +1307,56 @@ impl RoomInfo { } } + /// Whether a message is pinned to the room. + pub fn is_pinned(&self, event_id: &EventId) -> bool { + self.pinned_events.iter().any(|id| id == event_id) + } + + /// Get a pinned message, from the scrollback if it's loaded or else from the fetched previews. + pub fn get_pinned(&self, event_id: &EventId) -> Option<&Message> { + self.get_event(event_id).or_else(|| self.pinned_previews.get(event_id)) + } + + /// Whether fetching a pinned event has failed too many times to keep retrying. + pub fn pinned_unavailable(&self, event_id: &EventId) -> bool { + self.pinned_failures + .get(event_id) + .is_some_and(|failures| *failures >= PINNED_FETCH_ATTEMPTS) + } + + /// Record the result of fetching a pinned event for the `:pinned` window. + pub fn insert_pinned(&mut self, event_id: OwnedEventId, msg: Option) { + match msg { + Some(msg) => { + self.pinned_failures.remove(&event_id); + self.pinned_previews.insert(event_id, msg); + }, + None => { + let failures = self.pinned_failures.entry(event_id).or_default(); + *failures = failures.saturating_add(1); + }, + } + } + + /// Pinned events that still need to be fetched for the `:pinned` window. + pub fn missing_pinned(&self) -> Vec { + self.pinned_events + .iter() + .filter(|id| self.get_pinned(id).is_none() && !self.pinned_unavailable(id)) + .cloned() + .collect() + } + + /// Get where a loaded message lives, as its thread root and key. + pub fn get_message_location( + &self, + event_id: &EventId, + ) -> Option<(Option<&EventId>, &MessageKey)> { + let loc = self.keys.get(event_id)?; + + Some((loc.to_thread_root(), loc.to_message_key()?)) + } + pub fn get_receipt_thread(&self, event_id: &EventId) -> Option { match self.keys.get(event_id)? { EventLocation::Message(None, _) | EventLocation::State(_) => Some(ReceiptThread::Main), @@ -2166,6 +2263,9 @@ impl SyncInfo { static MESSAGE_NEED_TTL: u8 = 30; +/// How many failed fetches of a pinned event before the `:pinned` window stops retrying. +const PINNED_FETCH_ATTEMPTS: u8 = 10; + #[derive(Debug, PartialEq)] /// Load messages until the event is loaded or `ttl` loads are exceeded pub struct MessageNeed { @@ -2176,6 +2276,7 @@ pub struct MessageNeed { #[derive(Default, Debug, PartialEq)] pub struct Need { pub members: bool, + pub pinned: bool, pub messages: Option>, } @@ -2191,6 +2292,11 @@ impl RoomNeeds { self.needs.entry(room_id).or_default().members = true; } + /// Mark a room for needing to fetch its pinned events. + pub fn need_pinned(&mut self, room_id: OwnedRoomId) { + self.needs.entry(room_id).or_default().pinned = true; + } + /// Mark a room for needing to load messages. pub fn need_messages(&mut self, room_id: OwnedRoomId) { self.needs.entry(room_id).or_default().messages.get_or_insert_default(); @@ -2269,6 +2375,9 @@ pub struct ChatStore { /// Whether to ring the terminal bell on the next redraw. pub ring_bell: bool, + /// An error raised while drawing, shown in the message bar on the next redraw. + pub draw_error: Option, + /// Whether the application is currently focused pub focused: bool, @@ -2301,6 +2410,7 @@ impl ChatStore { sync_info: Default::default(), draw_curr: None, ring_bell: false, + draw_error: None, focused: true, open_notifications: Default::default(), }; @@ -2366,6 +2476,9 @@ pub enum IambId { /// The `:members` window for a given Matrix room. MemberList(OwnedRoomId), + /// The `:pinned` window for a given Matrix room. + PinnedList(OwnedRoomId), + /// The `:rooms` window. RoomList, @@ -2427,6 +2540,9 @@ impl Display for IambId { IambId::MemberList(room_id) => { write!(f, "iamb://members/{room_id}") }, + IambId::PinnedList(room_id) => { + write!(f, "iamb://room/{room_id}/pinned") + }, IambId::DirectList => f.write_str("iamb://dms"), IambId::RoomList => f.write_str("iamb://rooms"), IambId::SpaceList => f.write_str("iamb://spaces"), @@ -2507,7 +2623,14 @@ impl Visitor<'_> for IambIdVisitor { Ok(IambId::Room(room_id, Some(thread_root))) }, - _ => return Err(E::custom("Invalid members window URL")), + [room_id, "pinned"] => { + let Ok(room_id) = OwnedRoomId::try_from(room_id) else { + return Err(E::custom("Invalid room identifier")); + }; + + Ok(IambId::PinnedList(room_id)) + }, + _ => return Err(E::custom("Invalid iamb window URL")), } }, Some("joining") => { @@ -2655,6 +2778,9 @@ pub enum IambBufferId { /// The `:members` window for a room. MemberList(OwnedRoomId), + /// The `:pinned` window for a room. + PinnedList(OwnedRoomId), + /// The `:rooms` window. RoomList, @@ -2688,6 +2814,7 @@ impl IambBufferId { IambBufferId::Room(room, thread, _) => IambId::Room(room.clone(), thread.clone()), IambBufferId::DirectList => IambId::DirectList, IambBufferId::MemberList(room) => IambId::MemberList(room.clone()), + IambBufferId::PinnedList(room) => IambId::PinnedList(room.clone()), IambBufferId::RoomList => IambId::RoomList, IambBufferId::SpaceList => IambId::SpaceList, IambBufferId::VerifyList => IambId::VerifyList, @@ -3055,10 +3182,64 @@ pub mod tests { assert_eq!(need_load.into_iter().collect::>(), vec![( room_id, - Need { members: true, messages: Some(Vec::new()) } + Need { + members: true, + messages: Some(Vec::new()), + pinned: false + } )],); } + #[test] + fn test_pinned_lookup() { + let mut info = mock_room(); + let unloaded = owned_event_id!("$unloaded"); + + info.pinned_events = vec![MSG3_EVID.clone(), unloaded.clone()]; + + assert!(info.is_pinned(&MSG3_EVID)); + assert!(!info.is_pinned(&MSG4_EVID)); + + // Loaded messages come from the scrollback, so only the unloaded one needs fetching. + assert!(info.get_pinned(&MSG3_EVID).is_some()); + assert_eq!(info.missing_pinned(), vec![unloaded.clone()]); + + info.insert_pinned(unloaded.clone(), mock_message1().into()); + assert!(info.get_pinned(&unloaded).is_some()); + assert!(info.missing_pinned().is_empty()); + + // Failed fetches are retried until they hit the limit. + let broken = owned_event_id!("$broken"); + info.pinned_events.push(broken.clone()); + + for _ in 1..PINNED_FETCH_ATTEMPTS { + info.insert_pinned(broken.clone(), None); + } + assert!(!info.pinned_unavailable(&broken)); + assert_eq!(info.missing_pinned(), vec![broken.clone()]); + + info.insert_pinned(broken.clone(), None); + assert!(info.pinned_unavailable(&broken)); + assert!(info.missing_pinned().is_empty()); + + let (thread, key) = info.get_message_location(&MSG3_EVID).unwrap(); + assert_eq!(thread, None); + assert_eq!(key, &*MSG3_KEY); + assert!(info.get_message_location(&unloaded).is_none()); + } + + #[test] + fn test_pinned_window_id() { + let room_id = TEST_ROOM1_ID.clone(); + let id = IambId::PinnedList(room_id.clone()); + let url = format!("iamb://room/{room_id}/pinned"); + + assert_eq!(id.to_string(), url); + + let parsed: IambId = serde_json::from_str(&format!("{url:?}")).unwrap(); + assert_eq!(parsed, id); + } + #[test] fn test_ambiguous_displaynames() { let mut store = DisplayNameStore::default(); diff --git a/src/commands.rs b/src/commands.rs index ae8c7429..855a7246 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -344,6 +344,39 @@ fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { return Ok(step); } +fn iamb_pinned(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let open = IambAction::Room(RoomAction::Pinned(ctx.clone().into())); + let step = CommandStep::Continue(open.into(), ctx.context.clone()); + + return Ok(step); +} + +fn iamb_pin(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let mact = IambAction::from(MessageAction::Pin); + let step = CommandStep::Continue(mact.into(), ctx.context.clone()); + + return Ok(step); +} + +fn iamb_unpin(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + if !desc.arg.text.is_empty() { + return Result::Err(CommandError::InvalidArgument); + } + + let mact = IambAction::from(MessageAction::Unpin); + let step = CommandStep::Continue(mact.into(), ctx.context.clone()); + + return Ok(step); +} + fn iamb_redact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { let args = desc.arg.strings()?; @@ -1092,6 +1125,12 @@ pub fn add_iamb_commands(cmds: &mut ProgramCommands) { aliases: vec![], f: iamb_react, }); + cmds.add_command(ProgramCommand { name: "pin".into(), aliases: vec![], f: iamb_pin }); + cmds.add_command(ProgramCommand { + name: "pinned".into(), + aliases: vec![], + f: iamb_pinned, + }); cmds.add_command(ProgramCommand { name: "redact".into(), aliases: vec![], @@ -1144,6 +1183,11 @@ pub fn add_iamb_commands(cmds: &mut ProgramCommands) { aliases: vec![], f: iamb_unreact, }); + cmds.add_command(ProgramCommand { + name: "unpin".into(), + aliases: vec![], + f: iamb_unpin, + }); cmds.add_command(ProgramCommand { name: "upload".into(), aliases: vec![], @@ -1787,6 +1831,29 @@ mod tests { assert_eq!(res, Err(CommandError::InvalidArgument)); } + #[test] + fn test_cmd_pin() { + let mut cmds = setup_test_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("pin", ctx.clone()).unwrap(); + let act = IambAction::Message(MessageAction::Pin); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("unpin", ctx.clone()).unwrap(); + let act = IambAction::Message(MessageAction::Unpin); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("pin foo", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("pinned foo", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let res = cmds.input_cmd("unpin foo", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + #[test] fn test_cmd_keys() { let mut cmds = setup_test_commands(); diff --git a/src/completions.rs b/src/completions.rs index 53fca169..f8b1d224 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -623,7 +623,8 @@ fn complete_cmdarg( // These have no arguments "cancel" | "chats" | "dms" | "editor" | "edit" | "forget" | "invites" | "leave" | - "members" | "mentions" | "replied" | "reply" | "rooms" | "spaces" | "welcome" => vec![], + "members" | "mentions" | "pin" | "pinned" | "unpin" | "replied" | "reply" | "rooms" | + "spaces" | "welcome" => vec![], "abo" | "aboveleft" | "bel" | "belowright" | "hor" | "horizontal" | "lefta" | "leftabove" | "rightb" | "rightbelow" | "tab" | "vert" | "vertical" => { @@ -786,6 +787,7 @@ impl Completer for IambCompleter { IambBufferId::DirectList => vec![], IambBufferId::MemberList(_) => vec![], + IambBufferId::PinnedList(_) => vec![], IambBufferId::RoomList => vec![], IambBufferId::SpaceList => vec![], IambBufferId::VerifyList => vec![], diff --git a/src/main.rs b/src/main.rs index 7b22cded..6c25761e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -329,6 +329,10 @@ impl Application { store.application.ring_bell = term.backend_mut().write_all(&[7]).is_err(); } + if let Some(err) = store.application.draw_error.take() { + sstate.push_error(err); + } + if full { term.clear()?; } @@ -627,6 +631,9 @@ impl Application { IambAction::Space(act) => { self.screen.current_window_mut()?.space_command(act, ctx, store).await? }, + IambAction::Timeline(act) => { + self.screen.current_window_mut()?.timeline_command(act, ctx, store).await? + }, IambAction::Room(act) => { let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?; self.action_prepend(acts); diff --git a/src/message/mod.rs b/src/message/mod.rs index 7db336e2..a3497e12 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -234,6 +234,11 @@ impl MessageTimeStamp { Span::styled(time, BOLD_STYLE) } + /// A compact date and time, for places without a date separator line. + pub fn show_datetime(self) -> String { + self.as_datetime().format("%Y-%m-%d %H:%M").to_string() + } + fn show_time(self) -> Span<'static> { let time = self.as_datetime().format("%T"); let time = format!(" [{time}]"); @@ -1291,11 +1296,19 @@ impl Message { fmt.push_spans(space_span(width, style).into(), style, &mut text); } - if self.event.is_edited() { + let pinned = self.event.event_id().is_some_and(|id| info.is_pinned(id)); + let label = match (self.event.is_edited(), pinned) { + (true, true) => Some("(pinned, edited)"), + (true, false) => Some("(edited)"), + (false, true) => Some("(pinned)"), + (false, false) => None, + }; + + if let Some(label) = label { fmt.push_spans( Line::from(vec![ - Span::styled("(edited)", style.fg(Color::Gray)), - space_span(fmt.width().saturating_sub(8), style), + Span::styled(label, style.fg(Color::Gray)), + space_span(fmt.width().saturating_sub(label.len()), style), ]), style, &mut text, diff --git a/src/prelude.rs b/src/prelude.rs index 79a757c0..8e9f5a31 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -87,6 +87,7 @@ pub use crate::base::{ RoomInfo, SendAction, SpaceAction, + TimelineAction, }; pub use crate::config::{Aliases, ApplicationSettings}; pub use crate::message::{Message, MessageEvent, MessageKey, MessageTimeStamp, Messages}; diff --git a/src/windows/mod.rs b/src/windows/mod.rs index 91ad45fc..286d19c1 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -290,6 +290,7 @@ macro_rules! delegate { IambWindow::Room($id) => $e, IambWindow::DirectList($id) => $e, IambWindow::MemberList($id, _, _) => $e, + IambWindow::PinnedList($id, _, _) => $e, IambWindow::RoomList($id) => $e, IambWindow::SpaceList($id) => $e, IambWindow::VerifyList($id) => $e, @@ -305,6 +306,7 @@ macro_rules! delegate { pub enum IambWindow { DirectList(RoomListState), MemberList(MemberListState, OwnedRoomId, Option), + PinnedList(PinnedListState, OwnedRoomId, Option), Room(RoomState), VerifyList(VerifyListState), RoomList(RoomListState), @@ -325,6 +327,19 @@ impl IambWindow { } } + pub async fn timeline_command( + &mut self, + act: TimelineAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + if let IambWindow::Room(w) = self { + w.timeline_command(act, ctx, store).await + } else { + Err(IambError::NoSelectedRoom.into()) + } + } + pub async fn message_command( &mut self, act: MessageAction, @@ -360,6 +375,7 @@ impl IambWindow { let id = match self { IambWindow::Room(state) => state.id(), IambWindow::MemberList(_, room_id, _) => Some(&**room_id), + IambWindow::PinnedList(_, room_id, _) => Some(&**room_id), IambWindow::DirectList(state) => state.get().map(|state| state.room_id()), IambWindow::RoomList(state) => state.get().map(|state| state.room_id()), @@ -393,6 +409,7 @@ impl IambWindow { } pub type MemberListState = ListState; +pub type PinnedListState = ListState; pub type RoomListState = ListState; pub type VerifyListState = ListState; @@ -527,6 +544,33 @@ impl WindowOps for IambWindow { .focus(focused) .render(area, buf, state); }, + IambWindow::PinnedList(state, room_id, last_fetch) => { + let info = store.application.rooms.get_or_default(room_id.clone()); + + // Most recently pinned first. + let items = info + .pinned_events + .iter() + .rev() + .map(|event_id| PinnedItem::new(room_id.clone(), event_id.clone())) + .collect::>(); + + let need_fetch = last_fetch.is_none_or(|i| i.elapsed() >= MEMBER_FETCH_DEBOUNCE); + + if need_fetch && !info.missing_pinned().is_empty() { + store.application.need_load.need_pinned(room_id.clone()); + *last_fetch = Some(Instant::now()); + } + + state.set(items); + state.set_ignorecase(store.application.settings.tunables.ignorecase); + + List::new(store) + .empty_message("No pinned messages in this room") + .empty_alignment(Alignment::Center) + .focus(focused) + .render(area, buf, state); + }, IambWindow::RoomList(state) => { let mut items = sync_info .rooms @@ -680,6 +724,9 @@ impl WindowOps for IambWindow { IambWindow::MemberList(w, room_id, last_fetch) => { IambWindow::MemberList(w.dup(store), room_id.clone(), *last_fetch) }, + IambWindow::PinnedList(w, room_id, last_fetch) => { + IambWindow::PinnedList(w.dup(store), room_id.clone(), *last_fetch) + }, IambWindow::RoomList(w) => Self::RoomList(w.dup(store)), IambWindow::SpaceList(w) => Self::SpaceList(w.dup(store)), IambWindow::VerifyList(w) => w.dup(store).into(), @@ -723,6 +770,7 @@ impl Window for IambWindow { IambWindow::Room(room) => room.window_id(), IambWindow::DirectList(_) => IambId::DirectList, IambWindow::MemberList(_, room_id, _) => IambId::MemberList(room_id.clone()), + IambWindow::PinnedList(_, room_id, _) => IambId::PinnedList(room_id.clone()), IambWindow::RoomList(_) => IambId::RoomList, IambWindow::SpaceList(_) => IambId::SpaceList, IambWindow::VerifyList(_) => IambId::VerifyList, @@ -757,6 +805,16 @@ impl Window for IambWindow { ]; Line::from(v) }, + IambWindow::PinnedList(state, room_id, _) => { + let title = store.application.get_room_title(room_id.as_ref()); + let n = state.len(); + let v = vec![ + bold_span("Pinned Messages "), + Span::styled(format!("({n}): "), bold_style()), + title.into(), + ]; + Line::from(v) + }, } } @@ -783,6 +841,16 @@ impl Window for IambWindow { ]; Line::from(v) }, + IambWindow::PinnedList(state, room_id, _) => { + let title = store.application.get_room_title(room_id.as_ref()); + let n = state.len(); + let v = vec![ + bold_span("Pinned Messages "), + Span::styled(format!("({n}): "), bold_style()), + title.into(), + ]; + Line::from(v) + }, } } @@ -816,6 +884,13 @@ impl Window for IambWindow { return Ok(win); }, + IambId::PinnedList(room_id) => { + let id = IambBufferId::PinnedList(room_id.clone()); + let list = PinnedListState::new(id, vec![]); + let win = IambWindow::PinnedList(list, room_id, None); + + return Ok(win); + }, IambId::RoomList => { let list = RoomListState::new(IambBufferId::RoomList, vec![]); @@ -1188,6 +1263,100 @@ impl Promptable for MemberItem { } } +#[derive(Clone)] +pub struct PinnedItem { + room_id: OwnedRoomId, + event_id: OwnedEventId, +} + +impl PinnedItem { + fn new(room_id: OwnedRoomId, event_id: OwnedEventId) -> Self { + Self { room_id, event_id } + } +} + +impl Display for PinnedItem { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.event_id) + } +} + +impl ListItem for PinnedItem { + fn show( + &self, + selected: bool, + _: &ViewportContext, + store: &mut ProgramStore, + ) -> Text<'_> { + let info = store.application.rooms.get_or_default(self.room_id.clone()); + let settings = &store.application.settings; + + let style = if selected { + Style::default().add_modifier(StyleModifier::REVERSED) + } else { + Style::default() + }; + + let Some(msg) = info.get_pinned(&self.event_id) else { + let text = if info.pinned_unavailable(&self.event_id) { + "Unable to load message" + } else { + "Loading pinned message..." + }; + + return Span::styled(text, style.fg(Color::Gray)).into(); + }; + + let sender = settings.get_user_span(&msg.sender, info); + let sender = Span::styled(sender.content.into_owned(), sender.style.patch(style)); + let time = format!(" [{}]: ", msg.timestamp.show_datetime()); + let body = msg.event.body().lines().next().unwrap_or_default().to_string(); + + Line::from(vec![sender, Span::styled(time, style), Span::styled(body, style)]).into() + } + + fn get_word(&self) -> Option { + self.event_id.to_string().into() + } +} + +impl Promptable for PinnedItem { + fn prompt( + &mut self, + act: &PromptAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) -> EditResult, IambInfo> { + match act { + PromptAction::Submit => { + let info = store.application.rooms.get_or_default(self.room_id.clone()); + let thread = info + .get_message_location(&self.event_id) + .and_then(|(thread, _)| thread) + .map(ToOwned::to_owned); + + let room = IambId::Room(self.room_id.clone(), thread); + let open = WindowAction::Switch(OpenTarget::Application(room)); + let jump = IambAction::from(TimelineAction::GotoEvent(self.event_id.clone())); + + Ok(vec![(open.into(), ctx.clone()), (jump.into(), ctx.clone())]) + }, + PromptAction::Abort(_) => { + let msg = "Cannot abort entry inside a list"; + let err = EditError::Failure(msg.into()); + + Err(err) + }, + PromptAction::Recall(..) => { + let msg = "Cannot recall history inside a list"; + let err = EditError::Failure(msg.into()); + + Err(err) + }, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 23fdf600..81ecb8f2 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -12,6 +12,7 @@ use matrix_sdk::attachment::{AttachmentInfo, BaseImageInfo}; use matrix_sdk::media::{MediaFormat, MediaRequestParameters}; use matrix_sdk::room::reply::{EnforceThread, Reply}; use matrix_sdk::ruma::events::Mentions; +use matrix_sdk::ruma::events::StateEventType; use matrix_sdk::ruma::events::reaction::ReactionEventContent; use matrix_sdk::ruma::events::relation::Annotation; use matrix_sdk::ruma::events::room::message::{ @@ -21,6 +22,7 @@ use matrix_sdk::ruma::events::room::message::{ ReplyWithinThread, TextMessageEventContent, }; +use matrix_sdk::ruma::events::room::pinned_events::RoomPinnedEventsEventContent; use matrix_sdk::send_queue::RoomSendQueueError; use modalkit::editing::history::{self, HistoryList}; use modalkit::editing::store::RegisterError; @@ -30,7 +32,7 @@ use modalkit_ratatui::textbox::{TextBox, TextBoxState}; use ratatui::prelude::Stylize; use regex::Regex; -use crate::base::{DownloadFlags, EchoLocation}; +use crate::base::{DownloadFlags, EchoLocation, RoomFetchStatus}; use crate::config::EncryptionIndicatorLocation; use crate::message::{ MessageId, @@ -42,6 +44,11 @@ use crate::prelude::*; use crate::util::SuspendedTty; use crate::windows::room::scrollback::{Scrollback, ScrollbackState}; +/// How long to wait for a message to load before giving up on jumping to it. +/// +/// History loads one page roughly every two seconds, for up to `MESSAGE_NEED_TTL` pages. +const PENDING_JUMP_TIMEOUT: Duration = Duration::from_secs(120); + /// State needed for rendering [Chat]. pub struct ChatState { room_id: OwnedRoomId, @@ -56,6 +63,9 @@ pub struct ChatState { reply_to: Option, editing: Option, + + /// A message to jump to once it has been loaded, and when the jump was requested. + pending_jump: Option<(OwnedEventId, Instant)>, } impl ChatState { @@ -79,6 +89,7 @@ impl ChatState { reply_to: None, editing: None, + pending_jump: None, } } @@ -122,6 +133,77 @@ impl ChatState { } } + /// Where to put the cursor for a loaded message. + /// + /// A thread reply can't be shown in the main timeline, so that lands on its thread root. + fn jump_target(&self, info: &RoomInfo, event_id: &EventId) -> Option { + let (thread, key) = info.get_message_location(event_id)?; + + match thread { + Some(root) if self.thread().is_none() => info.get_message_key(root).cloned(), + _ => Some(key.clone()), + } + } + + fn jump_to_message( + &mut self, + event_id: OwnedEventId, + store: &mut ProgramStore, + ) -> IambResult { + let info = store.application.rooms.get_or_default(self.room_id.clone()); + + if let Some(key) = self.jump_target(info, &event_id) { + self.pending_jump = None; + self.scrollback.goto_message(key); + self.focus = RoomFocus::Scrollback; + + return Ok(None); + } + + store + .application + .need_load + .need_message(self.room_id.clone(), event_id.clone()); + self.pending_jump = Some((event_id, Instant::now())); + + let msg = "Loading message; will jump to it once it arrives"; + Ok(Some(InfoMessage::from(msg))) + } + + /// Finish a jump that was waiting on its message to load. + fn complete_pending_jump(&mut self, store: &mut ProgramStore) { + let Some((event_id, requested)) = &self.pending_jump else { + return; + }; + + let info = store.application.rooms.get_or_default(self.room_id.clone()); + + if let Some(key) = self.jump_target(info, event_id) { + self.pending_jump = None; + self.scrollback.goto_message(key); + self.focus = RoomFocus::Scrollback; + } else if matches!(info.fetch_id, RoomFetchStatus::Done) || + requested.elapsed() >= PENDING_JUMP_TIMEOUT + { + // The whole history is loaded without it, or it's too far back to find. + self.pending_jump = None; + + let msg = "Unable to jump to message: it's too far back in the room's history"; + store.application.draw_error = Some(msg.into()); + } + } + + pub async fn timeline_command( + &mut self, + act: TimelineAction, + _: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + match act { + TimelineAction::GotoEvent(event_id) => self.jump_to_message(event_id, store), + } + } + pub async fn message_command( &mut self, act: MessageAction, @@ -354,6 +436,69 @@ impl ChatState { Ok(None) }, + MessageAction::Pin | MessageAction::Unpin => { + let pin = act == MessageAction::Pin; + + let event_id = match &msg.event { + MessageEvent::Local(..) => { + let msg = "Cannot pin a message that hasn't been sent yet"; + return Err(UIError::Failure(msg.into())); + }, + MessageEvent::Redacted(..) | MessageEvent::EncryptedRedacted(_) if pin => { + let msg = "Cannot pin a redacted message"; + return Err(UIError::Failure(msg.into())); + }, + event => { + event + .event_id() + .map(ToOwned::to_owned) + .ok_or(IambError::NoSelectedMessage)? + }, + }; + + let room = self.get_joined(&store.application.worker)?; + + let can_pin = room + .power_levels() + .await + .map_err(matrix_sdk::Error::from) + .map_err(IambError::from)? + .user_can_send_state( + &settings.profile.user_id, + StateEventType::RoomPinnedEvents, + ); + + if !can_pin { + return Err(IambError::InsufficientPermission.into()); + } + + // The state event holds the whole list, so rebuild it from the SDK's latest copy. + let mut pinned = room.pinned_event_ids().unwrap_or_default(); + let position = pinned.iter().position(|id| *id == event_id); + + match (pin, position) { + (true, Some(_)) => { + let msg = "This message is already pinned"; + return Err(UIError::Failure(msg.into())); + }, + (false, None) => { + let msg = "This message is not pinned"; + return Err(UIError::Failure(msg.into())); + }, + (true, None) => { + pinned.push(event_id); + }, + (false, Some(idx)) => { + pinned.remove(idx); + }, + } + + room.send_state_event(RoomPinnedEventsEventContent::new(pinned)) + .await + .map_err(IambError::from)?; + + Ok(None) + }, MessageAction::Redact(reason, skip_confirm) => { if !skip_confirm { let msg = "Are you sure you want to redact this message?"; @@ -874,6 +1019,7 @@ impl WindowOps for ChatState { reply_to: None, editing: None, + pending_jump: None, } } @@ -1108,6 +1254,8 @@ impl StatefulWidget for Chat<'_> { type State = ChatState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + state.complete_pending_jump(self.store); + let settings = &self.store.application.settings; // Determine whether we have a description to show for the message bar. diff --git a/src/windows/room/mod.rs b/src/windows/room/mod.rs index cc1825e7..e45f019f 100644 --- a/src/windows/room/mod.rs +++ b/src/windows/room/mod.rs @@ -209,6 +209,13 @@ pub async fn room_command( Ok(vec![(act, cmd.context.clone())]) }, + RoomAction::Pinned(cmd) => { + let id = IambId::PinnedList(id.to_owned()); + let target = OpenTarget::Application(id); + let act = cmd.switch(target); + + Ok(vec![(act, cmd.context.clone())]) + }, RoomAction::SetAccess(rule) => { let Some(room) = store.application.worker.client.get_room(id) else { return Err(IambError::NotJoined.into()); @@ -832,6 +839,19 @@ impl RoomState { return; } + pub async fn timeline_command( + &mut self, + act: TimelineAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + if let RoomState::Chat(chat) = self { + chat.timeline_command(act, ctx, store).await + } else { + Err(IambError::NoSelectedRoom.into()) + } + } + pub async fn message_command( &mut self, act: MessageAction, diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index c122aaf7..6a601041 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -1555,7 +1555,11 @@ mod tests { std::mem::take(&mut store.application.need_load) .into_iter() .collect::>(), - vec![(room_id.clone(), Need { messages: Some(Vec::new()), members: false })] + vec![(room_id.clone(), Need { + messages: Some(Vec::new()), + members: false, + pinned: false + })] ); // Search forward twice to MSG1. diff --git a/src/worker.rs b/src/worker.rs index 29ac49c2..210a9cf9 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -52,6 +52,7 @@ use matrix_sdk::ruma::events::receipt::{ReceiptEventContent, ReceiptType}; use matrix_sdk::ruma::events::room::encryption::RoomEncryptionEventContent; use matrix_sdk::ruma::events::room::member::{MembershipState, OriginalSyncRoomMemberEvent}; use matrix_sdk::ruma::events::room::name::RoomNameEventContent; +use matrix_sdk::ruma::events::room::pinned_events::SyncRoomPinnedEventsEvent; use matrix_sdk::ruma::events::room::redaction::OriginalSyncRoomRedactionEvent; use matrix_sdk::ruma::events::sticker::StickerEventContent; use matrix_sdk::ruma::events::typing::SyncTypingEvent; @@ -179,6 +180,7 @@ async fn update_event_receipts(info: &mut RoomInfo, room: &MatrixRoom, event_id: enum Plan { Messages(OwnedRoomId, Option, Vec), Members(OwnedRoomId), + Pinned(OwnedRoomId, Vec), } async fn load_plans(store: &AsyncProgramStore) -> Vec { @@ -187,6 +189,13 @@ async fn load_plans(store: &AsyncProgramStore) -> Vec { let mut plan = Vec::with_capacity(need_load.rooms() * 2); for (room_id, need) in std::mem::take(need_load).into_iter() { + if need.pinned { + let missing = rooms.get_or_default(room_id.clone()).missing_pinned(); + + if !missing.is_empty() { + plan.push(Plan::Pinned(room_id.to_owned(), missing)); + } + } if let Some(message_need) = need.messages { let info = rooms.get_or_default(room_id.clone()); @@ -227,10 +236,60 @@ async fn run_plan(client: &Client, store: &AsyncProgramStore, plan: Plan, permit let mut locked = store.lock().await; members_insert(room_id, res, locked.deref_mut()); }, + Plan::Pinned(room_id, event_ids) => { + let msgs = pinned_load(client, &room_id, event_ids).await; + let mut locked = store.lock().await; + let info = locked.application.get_room_info(room_id); + + for (event_id, msg) in msgs { + info.insert_pinned(event_id, msg); + } + }, } drop(permit); } +async fn pinned_load( + client: &Client, + room_id: &RoomId, + event_ids: Vec, +) -> Vec<(OwnedEventId, Option)> { + let Some(room) = client.get_room(room_id) else { + return vec![]; + }; + + let mut msgs = vec![]; + + for event_id in event_ids { + let msg = pinned_load_one(&room, room_id, &event_id).await; + msgs.push((event_id, msg)); + } + + msgs +} + +async fn pinned_load_one( + room: &MatrixRoom, + room_id: &RoomId, + event_id: &EventId, +) -> Option { + let ev = room + .load_or_fetch_event(event_id, None) + .await + .inspect_err(|e| warn!(?event_id, "failed to fetch pinned event: {e}")) + .ok()?; + + let msg = match ev.into_raw().deserialize().ok()?.into_full_event(room_id.to_owned()) { + AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => ev.into(), + AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomEncrypted(ev)) => ev.into(), + AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::Sticker(ev)) => ev.into(), + AnyTimelineEvent::MessageLike(_) => return None, + AnyTimelineEvent::State(ev) => Message::from(AnySyncStateEvent::from(ev)), + }; + + Some(msg) +} + async fn load_older_one( client: &Client, room_id: &RoomId, @@ -440,6 +499,7 @@ async fn load_older_forever(client: &Client, store: &AsyncProgramStore) { } async fn refresh_rooms(client: &Client, store: &AsyncProgramStore, first_sync: bool) { + let mut pinned = vec![]; let mut names_and_tags = vec![]; let mut spaces = vec![]; @@ -465,6 +525,7 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore, first_sync: b let name = display.to_string(); let tags = room.tags().await.unwrap_or_default(); + pinned.push((room.room_id().to_owned(), room.pinned_event_ids().unwrap_or_default())); names_and_tags.push((room.room_id().to_owned(), name, tags)); if room.is_direct().await.unwrap_or_default() { @@ -484,6 +545,10 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore, first_sync: b for (room_id, name, tags) in names_and_tags { locked.application.set_room_info(room_id, name, tags); } + + for (room_id, pinned_events) in pinned { + locked.application.get_room_info(room_id).pinned_events = pinned_events; + } } async fn refresh_rooms_forever(client: &Client, store: &AsyncProgramStore) { @@ -1565,6 +1630,20 @@ impl ClientWorker { }, ); + let _ = self.client.add_event_handler( + |_: SyncRoomPinnedEventsEvent, room: MatrixRoom, store: Ctx| { + async move { + // The SDK has already applied the event to its room state by the time + // handlers run, and it also copes with redacted pin lists. + let pinned = room.pinned_event_ids().unwrap_or_default(); + + let mut locked = store.lock().await; + let info = locked.application.get_room_info(room.room_id().to_owned()); + info.pinned_events = pinned; + } + }, + ); + let _ = self.client.add_event_handler( |ev: OriginalSyncRoomMemberEvent, room: MatrixRoom, store: Ctx| { async move {