From 8be4e10606187c6838e154a61f55a96f3d64ba23 Mon Sep 17 00:00:00 2001 From: Matt <22nightchicken@gmail.com> Date: Sun, 13 Sep 2026 16:20:54 -0500 Subject: [PATCH 1/6] pinned event state creation and handling --- docs/iamb.1 | 4 +++ src/base.rs | 15 ++++++++++ src/commands.rs | 48 ++++++++++++++++++++++++++++++++ src/completions.rs | 2 ++ src/message/mod.rs | 14 ++++++++-- src/windows/room/chat.rs | 59 ++++++++++++++++++++++++++++++++++++++++ src/worker.rs | 21 ++++++++++++++ 7 files changed, 160 insertions(+), 3 deletions(-) diff --git a/docs/iamb.1 b/docs/iamb.1 index 0fd07e60..66954ef0 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -115,6 +115,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" diff --git a/src/base.rs b/src/base.rs index 2aa84be9..8990d714 100644 --- a/src/base.rs +++ b/src/base.rs @@ -110,6 +110,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. @@ -130,6 +133,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. @@ -1139,6 +1145,9 @@ 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, } impl Default for RoomInfo { @@ -1160,6 +1169,7 @@ impl Default for RoomInfo { users_typing: Default::default(), display_names: Default::default(), draw_last: Default::default(), + pinned_events: Default::default(), unloaded_edits: Default::default(), } } @@ -1212,6 +1222,11 @@ 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 the reactions and their counts for a message. pub fn get_reactions(&self, event_id: &EventId) -> Vec<(&str, usize, &Option)> { if let Some(reacts) = self.reactions.get(event_id) { diff --git a/src/commands.rs b/src/commands.rs index 05a39b07..57ca52f1 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -331,6 +331,28 @@ fn iamb_unreact(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { 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()?; @@ -1056,6 +1078,7 @@ 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: "redact".into(), aliases: vec![], @@ -1103,6 +1126,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![], @@ -1707,6 +1735,26 @@ mod tests { assert_eq!(res, Err(CommandError::InvalidArgument)); } + #[test] + fn test_cmd_pin() { + let mut cmds = setup_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("unpin foo", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + #[test] fn test_cmd_keys() { let mut cmds = setup_commands(); diff --git a/src/completions.rs b/src/completions.rs index 85ebb67d..d0caa22a 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -578,6 +578,8 @@ fn complete_cmdarg( "logout" => complete_iamb_logout(args, store), + "pin" | "unpin" => vec![], + "react" if args.len() == 1 => complete_emoji(&args[0], store), "react" => vec![], diff --git a/src/message/mod.rs b/src/message/mod.rs index 3575310c..a19d8519 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -1156,11 +1156,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("(edited) (pinned)"), + (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/windows/room/chat.rs b/src/windows/room/chat.rs index e8c64cb4..07723b04 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -11,9 +11,11 @@ use matrix_sdk::attachment::AttachmentConfig; use matrix_sdk::attachment::{AttachmentInfo, BaseImageInfo}; use matrix_sdk::media::{MediaFormat, MediaRequestParameters}; use matrix_sdk::room::reply::{EnforceThread, Reply}; +use matrix_sdk::ruma::events::StateEventType; use matrix_sdk::ruma::events::reaction::ReactionEventContent; use matrix_sdk::ruma::events::relation::{Annotation, Replacement}; use matrix_sdk::ruma::events::room::message::{AddMentions, ForwardThread, ReplyWithinThread}; +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; @@ -358,6 +360,63 @@ 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?"; diff --git a/src/worker.rs b/src/worker.rs index 31555af5..05d3fe3a 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -42,6 +42,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; @@ -396,6 +397,7 @@ async fn load_older_forever(client: &Client, store: &AsyncProgramStore) { async fn refresh_rooms(client: &Client, store: &AsyncProgramStore, first_sync: bool) { let mut names = vec![]; + let mut pinned = vec![]; let mut spaces = vec![]; let mut rooms = vec![]; @@ -421,6 +423,7 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore, first_sync: b let tags = room.tags().await.unwrap_or_default(); names.push((room.room_id().to_owned(), name)); + pinned.push((room.room_id().to_owned(), room.pinned_event_ids().unwrap_or_default())); if room.is_direct().await.unwrap_or_default() { dms.push(Arc::new((room, tags))); @@ -439,6 +442,10 @@ async fn refresh_rooms(client: &Client, store: &AsyncProgramStore, first_sync: b for (room_id, name) in names { locked.application.set_room_name(&room_id, &name); } + + 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) { @@ -1246,6 +1253,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 { From 42f17e332e92203c3faa752aa123e4dff4867d50 Mon Sep 17 00:00:00 2001 From: Matt <22nightchicken@gmail.com> Date: Sun, 13 Sep 2026 20:09:39 -0500 Subject: [PATCH 2/6] working pinned reciepts and jumping, limited history scrollback can go about ~1500 messages back that are not loaded, time out on jump is 120 seconds --- docs/iamb.1 | 3 + src/base.rs | 107 ++++++++++++++++++++++- src/commands.rs | 19 +++++ src/completions.rs | 3 +- src/message/mod.rs | 5 ++ src/windows/mod.rs | 150 +++++++++++++++++++++++++++++++++ src/windows/room/chat.rs | 77 ++++++++++++++++- src/windows/room/mod.rs | 12 +++ src/windows/room/scrollback.rs | 6 +- src/worker.rs | 52 ++++++++++++ 10 files changed, 430 insertions(+), 4 deletions(-) diff --git a/docs/iamb.1 b/docs/iamb.1 index 66954ef0..23213560 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -162,6 +162,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 8990d714..c81a6d80 100644 --- a/src/base.rs +++ b/src/base.rs @@ -110,6 +110,9 @@ pub enum MessageAction { /// when it is `true`. React(String, bool), + /// Jump to a loaded message in the scrollback. + Jump(OwnedEventId), + /// Pin a message to the room. Pin, @@ -495,6 +498,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), @@ -1148,6 +1154,9 @@ pub struct RoomInfo { /// 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, } impl Default for RoomInfo { @@ -1170,6 +1179,7 @@ impl Default for RoomInfo { display_names: Default::default(), draw_last: Default::default(), pinned_events: Default::default(), + pinned_previews: Default::default(), unloaded_edits: Default::default(), } } @@ -1227,6 +1237,30 @@ impl RoomInfo { 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)) + } + + /// 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()) + .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()?)) + } + /// Get the reactions and their counts for a message. pub fn get_reactions(&self, event_id: &EventId) -> Vec<(&str, usize, &Option)> { if let Some(reacts) = self.reactions.get(event_id) { @@ -1808,6 +1842,7 @@ pub struct MessageNeed { #[derive(Default, Debug, PartialEq)] pub struct Need { pub members: bool, + pub pinned: bool, pub messages: Option>, } @@ -1823,6 +1858,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(); @@ -1987,6 +2027,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, @@ -2021,6 +2064,9 @@ impl Display for IambId { IambId::MemberList(room_id) => { write!(f, "iamb://members/{room_id}") }, + IambId::PinnedList(room_id) => { + write!(f, "iamb://pinned/{room_id}") + }, IambId::DirectList => f.write_str("iamb://dms"), IambId::RoomList => f.write_str("iamb://rooms"), IambId::SpaceList => f.write_str("iamb://spaces"), @@ -2118,6 +2164,21 @@ impl Visitor<'_> for IambIdVisitor { Ok(IambId::MemberList(room_id)) }, + Some("pinned") => { + let Some(path) = url.path_segments() else { + return Err(E::custom("Invalid pinned window URL")); + }; + + let &[room_id] = path.collect::>().as_slice() else { + return Err(E::custom("Invalid pinned window URL")); + }; + + let Ok(room_id) = OwnedRoomId::try_from(room_id) else { + return Err(E::custom("Invalid room identifier")); + }; + + Ok(IambId::PinnedList(room_id)) + }, Some("dms") => { if url.path() != "" { return Err(E::custom("iamb://dms takes no path")); @@ -2227,6 +2288,9 @@ pub enum IambBufferId { /// The `:members` window for a room. MemberList(OwnedRoomId), + /// The `:pinned` window for a room. + PinnedList(OwnedRoomId), + /// The `:rooms` window. RoomList, @@ -2257,6 +2321,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, @@ -2447,10 +2512,50 @@ 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.pinned_previews.insert(unloaded.clone(), mock_message1()); + assert!(info.get_pinned(&unloaded).is_some()); + 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://pinned/{room_id}"); + + 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 57ca52f1..391e787a 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -331,6 +331,17 @@ 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); @@ -1079,6 +1090,11 @@ pub fn add_iamb_commands(cmds: &mut ProgramCommands) { 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![], @@ -1751,6 +1767,9 @@ mod tests { 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)); } diff --git a/src/completions.rs b/src/completions.rs index d0caa22a..9f530165 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -578,7 +578,7 @@ fn complete_cmdarg( "logout" => complete_iamb_logout(args, store), - "pin" | "unpin" => vec![], + "pin" | "pinned" | "unpin" => vec![], "react" if args.len() == 1 => complete_emoji(&args[0], store), "react" => vec![], @@ -750,6 +750,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/message/mod.rs b/src/message/mod.rs index a19d8519..8afe40ca 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -229,6 +229,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}]"); diff --git a/src/windows/mod.rs b/src/windows/mod.rs index 57eb92e4..357654f2 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -300,6 +300,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, @@ -314,6 +315,7 @@ macro_rules! delegate { pub enum IambWindow { DirectList(DirectListState), MemberList(MemberListState, OwnedRoomId, Option), + PinnedList(PinnedListState, OwnedRoomId, Option), Room(RoomState), VerifyList(VerifyListState), RoomList(RoomListState), @@ -368,6 +370,7 @@ impl IambWindow { let id = match self { IambWindow::Room(state) => Some(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()), @@ -402,6 +405,7 @@ impl IambWindow { pub type DirectListState = ListState; pub type MemberListState = ListState; +pub type PinnedListState = ListState; pub type RoomListState = ListState; pub type ChatListState = ListState; pub type UnreadListState = ListState; @@ -557,6 +561,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 = store .application @@ -739,6 +770,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) => w.dup(store).into(), IambWindow::SpaceList(w) => w.dup(store).into(), IambWindow::VerifyList(w) => w.dup(store).into(), @@ -781,6 +815,7 @@ impl Window for IambWindow { IambWindow::Room(room) => IambId::Room(room.id().to_owned(), room.thread().cloned()), 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, @@ -817,6 +852,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) + }, } } @@ -842,6 +887,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) + }, } } @@ -866,6 +921,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![]); @@ -1522,6 +1584,94 @@ 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 { + return Span::styled("Loading pinned message...", 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(MessageAction::Jump(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 07723b04..ca0c20b5 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -24,7 +24,7 @@ use modalkit_ratatui::PromptActions; use modalkit_ratatui::textbox::{TextBox, TextBoxState}; use ratatui::prelude::Stylize; -use crate::base::{DownloadFlags, EchoLocation}; +use crate::base::{DownloadFlags, EchoLocation, RoomFetchStatus}; use crate::config::EncryptionIndicatorLocation; use crate::message::{ MessageId, @@ -36,6 +36,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, @@ -50,6 +55,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 { @@ -73,6 +81,7 @@ impl ChatState { reply_to: None, editing: None, + pending_jump: None, } } @@ -116,12 +125,74 @@ 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; + } + } + pub async fn message_command( &mut self, act: MessageAction, _: ProgramContext, store: &mut ProgramStore, ) -> IambResult { + // Jumping doesn't act on the selected message, so it works in an empty scrollback too. + if let MessageAction::Jump(event_id) = act { + return self.jump_to_message(event_id, store); + } + let client = &store.application.worker.client; let settings = &store.application.settings; @@ -470,6 +541,7 @@ impl ChatState { Ok(None) }, + MessageAction::Jump(_) => unreachable!("jumps are handled before selecting a message"), MessageAction::Replied => { let Some(reply) = msg.reply_to() else { let msg = "Selected message is not a reply"; @@ -875,6 +947,7 @@ impl WindowOps for ChatState { reply_to: None, editing: None, + pending_jump: None, } } @@ -1109,6 +1182,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 84be7ff0..9b24a915 100644 --- a/src/windows/room/mod.rs +++ b/src/windows/room/mod.rs @@ -203,6 +203,18 @@ pub async fn room_command( Ok(vec![(act, cmd.context.clone())]) }, + RoomAction::Pinned(mut cmd) => { + let id = IambId::PinnedList(id.to_owned()); + let target = OpenTarget::Application(id); + let cmd = cmd.default_relation(MoveDir1D::Next); + + let act = match store.application.settings.tunables.members_split { + Some(dir) => cmd.default_axis(dir.to_axis()).window(target, None), + None => 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()); diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index c0a77284..715f1f61 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -1543,7 +1543,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 05d3fe3a..1e6acdff 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -163,6 +163,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 { @@ -171,6 +172,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()); @@ -211,10 +219,54 @@ 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); + info.pinned_previews.extend(msgs); + }, } drop(permit); } +async fn pinned_load( + client: &Client, + room_id: &RoomId, + event_ids: Vec, +) -> Vec<(OwnedEventId, Message)> { + let Some(room) = client.get_room(room_id) else { + return vec![]; + }; + + let mut msgs = vec![]; + + for event_id in event_ids { + let ev = match room.load_or_fetch_event(&event_id, None).await { + Ok(ev) => ev, + Err(e) => { + warn!(?event_id, "failed to fetch pinned event: {e}"); + continue; + }, + }; + + let Ok(ev) = ev.into_raw().deserialize() else { + continue; + }; + + let msg = match ev.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(_) => continue, + AnyTimelineEvent::State(ev) => Message::from(AnySyncStateEvent::from(ev)), + }; + + msgs.push((event_id, msg)); + } + + msgs +} + async fn load_older_one( client: &Client, room_id: &RoomId, From b9c1f3204a6bf820184db8080f04bce2f117ad7d Mon Sep 17 00:00:00 2001 From: Matt <22nightchicken@gmail.com> Date: Sun, 13 Sep 2026 21:13:50 -0500 Subject: [PATCH 3/6] fixed loading errors and hangup, added message when events could not be found --- src/base.rs | 50 ++++++++++++++++++++++++++++++++++++-- src/main.rs | 4 ++++ src/windows/mod.rs | 8 ++++++- src/windows/room/chat.rs | 3 +++ src/worker.rs | 52 +++++++++++++++++++++++----------------- 5 files changed, 92 insertions(+), 25 deletions(-) diff --git a/src/base.rs b/src/base.rs index c81a6d80..4e4a6adc 100644 --- a/src/base.rs +++ b/src/base.rs @@ -1157,6 +1157,9 @@ pub struct RoomInfo { /// 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 { @@ -1180,6 +1183,7 @@ impl Default for RoomInfo { draw_last: Default::default(), pinned_events: Default::default(), pinned_previews: Default::default(), + pinned_failures: Default::default(), unloaded_edits: Default::default(), } } @@ -1242,11 +1246,32 @@ impl RoomInfo { 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()) + .filter(|id| self.get_pinned(id).is_none() && !self.pinned_unavailable(id)) .cloned() .collect() } @@ -1832,6 +1857,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 { @@ -1941,6 +1969,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, @@ -1972,6 +2003,7 @@ impl ChatStore { sync_info: Default::default(), draw_curr: None, ring_bell: false, + draw_error: None, focused: true, open_notifications: Default::default(), } @@ -2534,10 +2566,24 @@ pub mod tests { assert!(info.get_pinned(&MSG3_EVID).is_some()); assert_eq!(info.missing_pinned(), vec![unloaded.clone()]); - info.pinned_previews.insert(unloaded.clone(), mock_message1()); + 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); diff --git a/src/main.rs b/src/main.rs index f15c3b6b..23175540 100644 --- a/src/main.rs +++ b/src/main.rs @@ -243,6 +243,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()?; } diff --git a/src/windows/mod.rs b/src/windows/mod.rs index 357654f2..3f297211 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -1619,7 +1619,13 @@ impl ListItem for PinnedItem { }; let Some(msg) = info.get_pinned(&self.event_id) else { - return Span::styled("Loading pinned message...", style.fg(Color::Gray)).into(); + 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); diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index ca0c20b5..e674c698 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -179,6 +179,9 @@ impl ChatState { { // 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()); } } diff --git a/src/worker.rs b/src/worker.rs index 1e6acdff..791d8204 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -223,7 +223,10 @@ async fn run_plan(client: &Client, store: &AsyncProgramStore, plan: Plan, permit 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); - info.pinned_previews.extend(msgs); + + for (event_id, msg) in msgs { + info.insert_pinned(event_id, msg); + } }, } drop(permit); @@ -233,7 +236,7 @@ async fn pinned_load( client: &Client, room_id: &RoomId, event_ids: Vec, -) -> Vec<(OwnedEventId, Message)> { +) -> Vec<(OwnedEventId, Option)> { let Some(room) = client.get_room(room_id) else { return vec![]; }; @@ -241,32 +244,37 @@ async fn pinned_load( let mut msgs = vec![]; for event_id in event_ids { - let ev = match room.load_or_fetch_event(&event_id, None).await { - Ok(ev) => ev, - Err(e) => { - warn!(?event_id, "failed to fetch pinned event: {e}"); - continue; - }, - }; - - let Ok(ev) = ev.into_raw().deserialize() else { - continue; - }; - - let msg = match ev.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(_) => continue, - AnyTimelineEvent::State(ev) => Message::from(AnySyncStateEvent::from(ev)), - }; - + 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 = match room.load_or_fetch_event(event_id, None).await { + Ok(ev) => ev, + Err(e) => { + warn!(?event_id, "failed to fetch pinned event: {e}"); + return None; + }, + }; + + 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, From 2daea5c732b0c6dde637571b75609bdb91e2af08 Mon Sep 17 00:00:00 2001 From: Matt <22nightchicken@gmail.com> Date: Mon, 14 Sep 2026 07:43:46 -0500 Subject: [PATCH 4/6] Restore StateEventType import lost in merge Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Nrj5Gd7i4TyBo1E7eVDLD1 --- src/windows/room/chat.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index d6251dd0..06625e6f 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::{ From 19cfa44a55f2680dbaf9d017cdf4b9e375f39470 Mon Sep 17 00:00:00 2001 From: Ulyssa Date: Sun, 20 Sep 2026 03:40:08 -0400 Subject: [PATCH 5/6] Use a new `TimelineAction` and some cleanup --- src/base.rs | 57 +++++++++++++++++++++++++--------------- src/completions.rs | 5 ++-- src/main.rs | 3 +++ src/message/mod.rs | 2 +- src/prelude.rs | 1 + src/windows/mod.rs | 15 ++++++++++- src/windows/room/chat.rs | 31 +++++++++++++++------- src/windows/room/mod.rs | 21 ++++++++++----- src/worker.rs | 12 ++++----- 9 files changed, 97 insertions(+), 50 deletions(-) diff --git a/src/base.rs b/src/base.rs index 39082179..bb6cd422 100644 --- a/src/base.rs +++ b/src/base.rs @@ -86,6 +86,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 { @@ -110,9 +117,6 @@ pub enum MessageAction { /// when it is `true`. React(String, bool), - /// Jump to a loaded message in the scrollback. - Jump(OwnedEventId), - /// Pin a message to the room. Pin, @@ -586,6 +590,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), @@ -654,6 +661,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 { @@ -665,6 +678,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, @@ -681,6 +695,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, @@ -697,6 +712,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, @@ -713,6 +729,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, @@ -732,6 +749,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) @@ -2115,7 +2138,7 @@ impl Display for IambId { write!(f, "iamb://members/{room_id}") }, IambId::PinnedList(room_id) => { - write!(f, "iamb://pinned/{room_id}") + write!(f, "iamb://room/{room_id}/pinned") }, IambId::DirectList => f.write_str("iamb://dms"), IambId::RoomList => f.write_str("iamb://rooms"), @@ -2196,7 +2219,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("members") => { @@ -2214,21 +2244,6 @@ impl Visitor<'_> for IambIdVisitor { Ok(IambId::MemberList(room_id)) }, - Some("pinned") => { - let Some(path) = url.path_segments() else { - return Err(E::custom("Invalid pinned window URL")); - }; - - let &[room_id] = path.collect::>().as_slice() else { - return Err(E::custom("Invalid pinned window URL")); - }; - - let Ok(room_id) = OwnedRoomId::try_from(room_id) else { - return Err(E::custom("Invalid room identifier")); - }; - - Ok(IambId::PinnedList(room_id)) - }, Some("dms") => { if url.path() != "" { return Err(E::custom("iamb://dms takes no path")); @@ -2612,7 +2627,7 @@ pub mod tests { fn test_pinned_window_id() { let room_id = TEST_ROOM1_ID.clone(); let id = IambId::PinnedList(room_id.clone()); - let url = format!("iamb://pinned/{room_id}"); + let url = format!("iamb://room/{room_id}/pinned"); assert_eq!(id.to_string(), url); diff --git a/src/completions.rs b/src/completions.rs index b43ca1ef..593f4acc 100644 --- a/src/completions.rs +++ b/src/completions.rs @@ -579,8 +579,6 @@ fn complete_cmdarg( "logout" => complete_iamb_logout(args, store), - "pin" | "pinned" | "unpin" => vec![], - "react" if args.len() == 1 => complete_emoji(&args[0], store), "react" => vec![], @@ -614,7 +612,8 @@ fn complete_cmdarg( // These have no arguments "cancel" | "chats" | "dms" | "editor" | "edit" | "forget" | "leave" | "members" | - "mentions" | "replied" | "reply" | "rooms" | "spaces" | "welcome" => vec![], + "mentions" | "pin" | "pinned" | "unpin" | "replied" | "reply" | "rooms" | "spaces" | + "welcome" => vec![], "abo" | "aboveleft" | "bel" | "belowright" | "hor" | "horizontal" | "lefta" | "leftabove" | "rightb" | "rightbelow" | "tab" | "vert" | "vertical" => { diff --git a/src/main.rs b/src/main.rs index 35ec9afc..2e35b180 100644 --- a/src/main.rs +++ b/src/main.rs @@ -627,6 +627,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 7234a506..32e4a647 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -1204,7 +1204,7 @@ impl Message { 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("(edited) (pinned)"), + (true, true) => Some("(pinned, edited)"), (true, false) => Some("(edited)"), (false, true) => Some("(pinned)"), (false, false) => None, diff --git a/src/prelude.rs b/src/prelude.rs index 407575c7..26a87923 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -86,6 +86,7 @@ pub use crate::base::{ RoomInfo, SendAction, SpaceAction, + TimelineAction, }; pub use crate::config::ApplicationSettings; pub use crate::message::{Message, MessageEvent, MessageKey, MessageTimeStamp, Messages}; diff --git a/src/windows/mod.rs b/src/windows/mod.rs index 6160aa5c..0e93c524 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -335,6 +335,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, @@ -1661,7 +1674,7 @@ impl Promptable for PinnedItem { let room = IambId::Room(self.room_id.clone(), thread); let open = WindowAction::Switch(OpenTarget::Application(room)); - let jump = IambAction::from(MessageAction::Jump(self.event_id.clone())); + let jump = IambAction::from(TimelineAction::GotoEvent(self.event_id.clone())); Ok(vec![(open.into(), ctx.clone()), (jump.into(), ctx.clone())]) }, diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 06625e6f..8fa8eb99 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -193,17 +193,23 @@ impl ChatState { } } - pub async fn message_command( + pub async fn timeline_command( &mut self, - act: MessageAction, + act: TimelineAction, _: ProgramContext, store: &mut ProgramStore, ) -> IambResult { - // Jumping doesn't act on the selected message, so it works in an empty scrollback too. - if let MessageAction::Jump(event_id) = act { - return self.jump_to_message(event_id, store); + match act { + TimelineAction::GotoEvent(event_id) => self.jump_to_message(event_id, store), } + } + pub async fn message_command( + &mut self, + act: MessageAction, + _: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { let client = &store.application.worker.client; let settings = &store.application.settings; @@ -421,9 +427,13 @@ impl ChatState { 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)?; + event => { + event + .event_id() + .map(ToOwned::to_owned) + .ok_or(IambError::NoSelectedMessage)? + }, + }; let room = self.get_joined(&store.application.worker)?; @@ -454,7 +464,9 @@ impl ChatState { let msg = "This message is not pinned"; return Err(UIError::Failure(msg.into())); }, - (true, None) => pinned.push(event_id), + (true, None) => { + pinned.push(event_id); + }, (false, Some(idx)) => { pinned.remove(idx); }, @@ -519,7 +531,6 @@ impl ChatState { Ok(None) }, - MessageAction::Jump(_) => unreachable!("jumps are handled before selecting a message"), MessageAction::Replied => { let Some(reply) = msg.reply_to() else { let msg = "Selected message is not a reply"; diff --git a/src/windows/room/mod.rs b/src/windows/room/mod.rs index 9b24a915..78787df4 100644 --- a/src/windows/room/mod.rs +++ b/src/windows/room/mod.rs @@ -203,15 +203,10 @@ pub async fn room_command( Ok(vec![(act, cmd.context.clone())]) }, - RoomAction::Pinned(mut cmd) => { + RoomAction::Pinned(cmd) => { let id = IambId::PinnedList(id.to_owned()); let target = OpenTarget::Application(id); - let cmd = cmd.default_relation(MoveDir1D::Next); - - let act = match store.application.settings.tunables.members_split { - Some(dir) => cmd.default_axis(dir.to_axis()).window(target, None), - None => cmd.switch(target), - }; + let act = cmd.switch(target); Ok(vec![(act, cmd.context.clone())]) }, @@ -777,6 +772,18 @@ impl RoomState { return; } + pub async fn timeline_command( + &mut self, + act: TimelineAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + match self { + RoomState::Chat(chat) => chat.timeline_command(act, ctx, store).await, + RoomState::Space(_) => Err(IambError::NoSelectedRoom.into()), + } + } + pub async fn message_command( &mut self, act: MessageAction, diff --git a/src/worker.rs b/src/worker.rs index bd09ab4f..7db30887 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -258,13 +258,11 @@ async fn pinned_load_one( room_id: &RoomId, event_id: &EventId, ) -> Option { - let ev = match room.load_or_fetch_event(event_id, None).await { - Ok(ev) => ev, - Err(e) => { - warn!(?event_id, "failed to fetch pinned event: {e}"); - return None; - }, - }; + 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(), From 7a4921481b05004a53bdd9d51be4be6c6a4f1a9f Mon Sep 17 00:00:00 2001 From: Ulyssa Date: Sun, 20 Sep 2026 03:55:54 -0400 Subject: [PATCH 6/6] fix tests --- src/commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands.rs b/src/commands.rs index 16588a0a..855a7246 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1833,7 +1833,7 @@ mod tests { #[test] fn test_cmd_pin() { - let mut cmds = setup_commands(); + let mut cmds = setup_test_commands(); let ctx = EditContext::default(); let res = cmds.input_cmd("pin", ctx.clone()).unwrap();