diff --git a/docs/iamb.1 b/docs/iamb.1 index 38070a2..3e31ce2 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -159,6 +159,8 @@ Create and point the given alias to the room. Delete the provided alias from the room's alternative alias list. .It Sy ":room alias show" Show alternative aliases to the room, if any are set. +.It Sy ":room id show" +Show the Matrix identifier for the room. .It Sy ":room canon set [alias]" Set the room's canonical alias to the one provided, and make the previous one an alternative alias. .It Sy ":room canon unset [alias]" @@ -173,6 +175,14 @@ Unban a user from this room with an optional reason. Kick a user from this room with an optional reason. .El +.Sh "SPACE COMMANDS" +.Bl -tag -width Ds +.It Sy ":space child set [room_id]" +Add a room to the currently focused space. +.It Sy ":space child remove" +Remove the selected room from the currently focused space. +.El + .Sh "WINDOW COMMANDS" .Bl -tag -width Ds .It Sy ":horizontal [cmd]" diff --git a/src/base.rs b/src/base.rs index 62ba83c..43df297 100644 --- a/src/base.rs +++ b/src/base.rs @@ -177,6 +177,19 @@ pub enum MessageAction { Unreact(Option, bool), } +/// An action taken in the currently selected space. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SpaceAction { + /// Add a room or update metadata. + /// + /// The [`Option`] argument is the order parameter. + /// The [`bool`] argument indicates whether the room is suggested. + SetChild(OwnedRoomId, Option, bool), + + /// Remove the selected room. + RemoveChild, +} + /// The type of room being created. #[derive(Clone, Debug, Eq, PartialEq)] pub enum CreateRoomType { @@ -379,6 +392,9 @@ pub enum RoomField { /// The room name. Name, + /// The room id. + Id, + /// A room tag. Tag(TagName), @@ -497,6 +513,9 @@ pub enum IambAction { /// Perform an action on the currently selected message. Message(MessageAction), + /// Perform an action on the current space. + Space(SpaceAction), + /// Open a URL. OpenLink(String), @@ -538,6 +557,12 @@ impl From for IambAction { } } +impl From for IambAction { + fn from(act: SpaceAction) -> Self { + IambAction::Space(act) + } +} + impl From for IambAction { fn from(act: RoomAction) -> Self { IambAction::Room(act) @@ -557,6 +582,7 @@ impl ApplicationAction for IambAction { IambAction::Homeserver(..) => SequenceStatus::Break, IambAction::Keys(..) => SequenceStatus::Break, IambAction::Message(..) => SequenceStatus::Break, + IambAction::Space(..) => SequenceStatus::Break, IambAction::Room(..) => SequenceStatus::Break, IambAction::OpenLink(..) => SequenceStatus::Break, IambAction::Send(..) => SequenceStatus::Break, @@ -572,6 +598,7 @@ impl ApplicationAction for IambAction { IambAction::Homeserver(..) => SequenceStatus::Atom, IambAction::Keys(..) => SequenceStatus::Atom, IambAction::Message(..) => SequenceStatus::Atom, + IambAction::Space(..) => SequenceStatus::Atom, IambAction::OpenLink(..) => SequenceStatus::Atom, IambAction::Room(..) => SequenceStatus::Atom, IambAction::Send(..) => SequenceStatus::Atom, @@ -587,6 +614,7 @@ impl ApplicationAction for IambAction { IambAction::Homeserver(..) => SequenceStatus::Ignore, IambAction::Keys(..) => SequenceStatus::Ignore, IambAction::Message(..) => SequenceStatus::Ignore, + IambAction::Space(..) => SequenceStatus::Ignore, IambAction::Room(..) => SequenceStatus::Ignore, IambAction::OpenLink(..) => SequenceStatus::Ignore, IambAction::Send(..) => SequenceStatus::Ignore, @@ -601,6 +629,7 @@ impl ApplicationAction for IambAction { IambAction::ClearUnreads => false, IambAction::Homeserver(..) => false, IambAction::Message(..) => false, + IambAction::Space(..) => false, IambAction::Room(..) => false, IambAction::Keys(..) => false, IambAction::Send(..) => false, @@ -618,6 +647,12 @@ impl From for ProgramAction { } } +impl From for ProgramAction { + fn from(act: SpaceAction) -> Self { + IambAction::from(act).into() + } +} + impl From for ProgramAction { fn from(act: IambAction) -> Self { Action::Application(act) @@ -713,10 +748,22 @@ pub enum IambError { #[error("Current window is not a room or space")] NoSelectedRoomOrSpace, + /// A failure due to not having a room or space item selected in a list. + #[error("No room or space currently selected in list")] + NoSelectedRoomOrSpaceItem, + /// A failure due to not having a room selected. #[error("Current window is not a room")] NoSelectedRoom, + /// A failure due to not having a space selected. + #[error("Current window is not a space")] + NoSelectedSpace, + + /// A failure due to not having sufficient permission to perform an action in a room. + #[error("You do not have the permission to do that")] + InsufficientPermission, + /// A failure due to not having an outstanding room invitation. #[error("You do not have a current invitation to this room")] NotInvited, diff --git a/src/commands.rs b/src/commands.rs index 152b41f..fc111c8 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2,9 +2,9 @@ //! //! The command-bar commands are set up here, and iamb-specific commands are defined here. See //! [modalkit::env::vim::command] for additional Vim commands we pull in. -use std::convert::TryFrom; +use std::{convert::TryFrom, str::FromStr as _}; -use matrix_sdk::ruma::{events::tag::TagName, OwnedUserId}; +use matrix_sdk::ruma::{events::tag::TagName, OwnedRoomId, OwnedUserId}; use modalkit::{ commands::{CommandError, CommandResult, CommandStep}, @@ -27,6 +27,7 @@ use crate::base::{ RoomAction, RoomField, SendAction, + SpaceAction, VerifyAction, }; @@ -535,6 +536,90 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { return Result::Err(CommandError::InvalidArgument) }, + // :room id show + ("id", "show", None) => RoomAction::Show(RoomField::Id).into(), + ("id", "show", Some(_)) => return Result::Err(CommandError::InvalidArgument), + + _ => return Result::Err(CommandError::InvalidArgument), + }; + + let step = CommandStep::Continue(act.into(), ctx.context.clone()); + + return Ok(step); +} + +fn iamb_space(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.options()?; + + if args.len() < 2 { + return Err(CommandError::InvalidArgument); + } + + let OptionType::Positional(field) = args.remove(0) else { + return Err(CommandError::InvalidArgument); + }; + let OptionType::Positional(action) = args.remove(0) else { + return Err(CommandError::InvalidArgument); + }; + + let act: IambAction = match (field.as_str(), action.as_str()) { + ("child", "remove") => { + if !(args.is_empty()) { + return Err(CommandError::InvalidArgument); + } + SpaceAction::RemoveChild.into() + }, + // :space child set + ("child", "set") => { + let mut order = None; + let mut suggested = false; + let mut raw_child = None; + + for arg in args { + match arg { + OptionType::Flag(name, Some(arg)) => { + match name.as_str() { + "order" => { + if order.is_some() { + let msg = "Multiple ++order arguments are not allowed"; + let err = CommandError::Error(msg.into()); + + return Err(err); + } else { + order = Some(arg); + } + }, + _ => return Err(CommandError::InvalidArgument), + } + }, + OptionType::Flag(name, None) => { + match name.as_str() { + "suggested" => suggested = true, + _ => return Err(CommandError::InvalidArgument), + } + }, + OptionType::Positional(arg) => { + if raw_child.is_some() { + let msg = "Multiple room arguments are not allowed"; + let err = CommandError::Error(msg.into()); + + return Err(err); + } + raw_child = Some(arg); + }, + } + } + + let child = if let Some(child) = raw_child { + OwnedRoomId::from_str(&child) + .map_err(|_| CommandError::Error("Invalid room id specified".into()))? + } else { + let msg = "Must specify a room to add"; + return Err(CommandError::Error(msg.into())); + }; + + SpaceAction::SetChild(child, order, suggested).into() + }, _ => return Result::Err(CommandError::InvalidArgument), }; @@ -671,6 +756,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) { f: iamb_rooms, }); cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: iamb_room }); + cmds.add_command(ProgramCommand { + name: "space".into(), + aliases: vec![], + f: iamb_space, + }); cmds.add_command(ProgramCommand { name: "spaces".into(), aliases: vec![], @@ -725,7 +815,7 @@ pub fn setup_commands() -> ProgramCommands { #[cfg(test)] mod tests { use super::*; - use matrix_sdk::ruma::user_id; + use matrix_sdk::ruma::{room_id, user_id}; use modalkit::actions::WindowAction; use modalkit::editing::context::EditContext; @@ -1067,6 +1157,103 @@ mod tests { assert_eq!(res, vec![(act.into(), ctx.clone())]); } + #[test] + fn test_cmd_room_id_show() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let res = cmds.input_cmd("room id show", ctx.clone()).unwrap(); + let act = RoomAction::Show(RoomField::Id); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let res = cmds.input_cmd("room id show foo", ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_space_child() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let cmd = "space"; + let res = cmds.input_cmd(cmd, ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let cmd = "space ++foo bar baz"; + let res = cmds.input_cmd(cmd, ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let cmd = "space child foo"; + let res = cmds.input_cmd(cmd, ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + + #[test] + fn test_cmd_space_child_set() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let cmd = "space child set !roomid:example.org"; + let res = cmds.input_cmd(cmd, ctx.clone()).unwrap(); + let act = SpaceAction::SetChild(room_id!("!roomid:example.org").to_owned(), None, false); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let cmd = "space child set ++order=abcd ++suggested !roomid:example.org"; + let res = cmds.input_cmd(cmd, ctx.clone()).unwrap(); + let act = SpaceAction::SetChild( + room_id!("!roomid:example.org").to_owned(), + Some("abcd".into()), + true, + ); + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let cmd = "space child set ++order=abcd ++order=1234 !roomid:example.org"; + let res = cmds.input_cmd(cmd, ctx.clone()); + assert_eq!( + res, + Err(CommandError::Error("Multiple ++order arguments are not allowed".into())) + ); + + let cmd = "space child set !roomid:example.org !otherroom:example.org"; + let res = cmds.input_cmd(cmd, ctx.clone()); + assert_eq!(res, Err(CommandError::Error("Multiple room arguments are not allowed".into()))); + + let cmd = "space child set ++foo=abcd !roomid:example.org"; + let res = cmds.input_cmd(cmd, ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let cmd = "space child set ++foo !roomid:example.org"; + let res = cmds.input_cmd(cmd, ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let cmd = "space child ++order=abcd ++suggested set !roomid:example.org"; + let res = cmds.input_cmd(cmd, ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + + let cmd = "space child set foo"; + let res = cmds.input_cmd(cmd, ctx.clone()); + assert_eq!(res, Err(CommandError::Error("Invalid room id specified".into()))); + + let cmd = "space child set"; + let res = cmds.input_cmd(cmd, ctx.clone()); + assert_eq!(res, Err(CommandError::Error("Must specify a room to add".into()))); + } + + #[test] + fn test_cmd_space_child_remove() { + let mut cmds = setup_commands(); + let ctx = EditContext::default(); + + let cmd = "space child remove"; + let res = cmds.input_cmd(cmd, ctx.clone()).unwrap(); + let act = SpaceAction::RemoveChild; + assert_eq!(res, vec![(act.into(), ctx.clone())]); + + let cmd = "space child remove foo"; + let res = cmds.input_cmd(cmd, ctx.clone()); + assert_eq!(res, Err(CommandError::InvalidArgument)); + } + #[test] fn test_cmd_invite() { let mut cmds = setup_commands(); diff --git a/src/main.rs b/src/main.rs index 7c0ef64..b300048 100644 --- a/src/main.rs +++ b/src/main.rs @@ -557,6 +557,9 @@ impl Application { IambAction::Message(act) => { self.screen.current_window_mut()?.message_command(act, ctx, store).await? }, + IambAction::Space(act) => { + self.screen.current_window_mut()?.space_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/windows/mod.rs b/src/windows/mod.rs index a642c95..0169e19 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -76,6 +76,7 @@ use crate::base::{ SortFieldRoom, SortFieldUser, SortOrder, + SpaceAction, UnreadInfo, }; @@ -360,6 +361,19 @@ impl IambWindow { } } + pub async fn space_command( + &mut self, + act: SpaceAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + if let IambWindow::Room(w) = self { + w.space_command(act, ctx, store).await + } else { + return Err(IambError::NoSelectedRoom.into()); + } + } + pub async fn room_command( &mut self, act: RoomAction, diff --git a/src/windows/room/mod.rs b/src/windows/room/mod.rs index b6d4d23..4898d86 100644 --- a/src/windows/room/mod.rs +++ b/src/windows/room/mod.rs @@ -66,6 +66,7 @@ use crate::base::{ RoomAction, RoomField, SendAction, + SpaceAction, }; use self::chat::ChatState; @@ -214,6 +215,18 @@ impl RoomState { } } + pub async fn space_command( + &mut self, + act: SpaceAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + match self { + RoomState::Space(space) => space.space_command(act, ctx, store).await, + RoomState::Chat(_) => Err(IambError::NoSelectedSpace.into()), + } + } + pub async fn send_command( &mut self, act: SendAction, @@ -464,6 +477,9 @@ impl RoomState { RoomField::Aliases => { // This never happens, aliases is only used for showing }, + RoomField::Id => { + // This never happens, id is only used for showing + }, } Ok(vec![]) @@ -559,6 +575,9 @@ impl RoomState { RoomField::Aliases => { // This will not happen, you cannot unset all aliases }, + RoomField::Id => { + // This never happens, id is only used for showing + }, } Ok(vec![]) @@ -574,6 +593,10 @@ impl RoomState { let visibility = room.history_visibility(); format!("Room history visibility: {visibility}") }, + RoomField::Id => { + let id = room.room_id(); + format!("Room identifier: {id}") + }, RoomField::Name => { match room.name() { None => "Room has no name".into(), diff --git a/src/windows/room/space.rs b/src/windows/room/space.rs index 7b6f967..6b3579e 100644 --- a/src/windows/room/space.rs +++ b/src/windows/room/space.rs @@ -2,11 +2,14 @@ use std::ops::{Deref, DerefMut}; use std::time::{Duration, Instant}; +use matrix_sdk::ruma::events::space::child::SpaceChildEventContent; +use matrix_sdk::ruma::events::StateEventType; use matrix_sdk::{ room::Room as MatrixRoom, ruma::{OwnedRoomId, RoomId}, }; +use modalkit::prelude::{EditInfo, InfoMessage}; use ratatui::{ buffer::Buffer, layout::Rect, @@ -22,9 +25,18 @@ use modalkit_ratatui::{ WindowOps, }; -use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus}; +use crate::base::{ + IambBufferId, + IambError, + IambInfo, + IambResult, + ProgramContext, + ProgramStore, + RoomFocus, + SpaceAction, +}; -use crate::windows::{room_fields_cmp, RoomItem}; +use crate::windows::{room_fields_cmp, RoomItem, RoomLikeItem}; const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5); @@ -68,6 +80,71 @@ impl SpaceState { last_fetch: self.last_fetch, } } + + pub async fn space_command( + &mut self, + act: SpaceAction, + _: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + match act { + SpaceAction::SetChild(child_id, order, suggested) => { + if !self + .room + .can_user_send_state( + &store.application.settings.profile.user_id, + StateEventType::SpaceChild, + ) + .await + .map_err(IambError::from)? + { + return Err(IambError::InsufficientPermission.into()); + } + + let via = self.room.route().await.map_err(IambError::from)?; + let mut ev = SpaceChildEventContent::new(via); + ev.order = order; + ev.suggested = suggested; + let _ = self + .room + .send_state_event_for_key(&child_id, ev) + .await + .map_err(IambError::from)?; + + Ok(InfoMessage::from("Space updated").into()) + }, + SpaceAction::RemoveChild => { + let space = self.list.get().ok_or(IambError::NoSelectedRoomOrSpaceItem)?; + if !self + .room + .can_user_send_state( + &store.application.settings.profile.user_id, + StateEventType::SpaceChild, + ) + .await + .map_err(IambError::from)? + { + return Err(IambError::InsufficientPermission.into()); + } + + let ev = SpaceChildEventContent::new(vec![]); + let event_id = self + .room + .send_state_event_for_key(&space.room_id().to_owned(), ev) + .await + .map_err(IambError::from)?; + + // Fix for element (see https://github.com/element-hq/element-web/issues/29606) + let _ = self + .room + .redact(&event_id.event_id, Some("workaround for element bug"), None) + .await + .map_err(IambError::from)?; + + Ok(InfoMessage::from("Room removed").into()) + }, + } + } } impl TerminalCursor for SpaceState {