Support adding rooms to spaces (#407)

This commit is contained in:
VAWVAW 2025-05-15 03:26:35 +00:00 committed by GitHub
parent 7dd09e32a8
commit 3e45ca3d2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 366 additions and 5 deletions

View file

@ -159,6 +159,8 @@ Create and point the given alias to the room.
Delete the provided alias from the room's alternative alias list. Delete the provided alias from the room's alternative alias list.
.It Sy ":room alias show" .It Sy ":room alias show"
Show alternative aliases to the room, if any are set. 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]" .It Sy ":room canon set [alias]"
Set the room's canonical alias to the one provided, and make the previous one an alternative 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]" .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. Kick a user from this room with an optional reason.
.El .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" .Sh "WINDOW COMMANDS"
.Bl -tag -width Ds .Bl -tag -width Ds
.It Sy ":horizontal [cmd]" .It Sy ":horizontal [cmd]"

View file

@ -177,6 +177,19 @@ pub enum MessageAction {
Unreact(Option<String>, bool), Unreact(Option<String>, 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<String>`] argument is the order parameter.
/// The [`bool`] argument indicates whether the room is suggested.
SetChild(OwnedRoomId, Option<String>, bool),
/// Remove the selected room.
RemoveChild,
}
/// The type of room being created. /// The type of room being created.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum CreateRoomType { pub enum CreateRoomType {
@ -379,6 +392,9 @@ pub enum RoomField {
/// The room name. /// The room name.
Name, Name,
/// The room id.
Id,
/// A room tag. /// A room tag.
Tag(TagName), Tag(TagName),
@ -497,6 +513,9 @@ pub enum IambAction {
/// Perform an action on the currently selected message. /// Perform an action on the currently selected message.
Message(MessageAction), Message(MessageAction),
/// Perform an action on the current space.
Space(SpaceAction),
/// Open a URL. /// Open a URL.
OpenLink(String), OpenLink(String),
@ -538,6 +557,12 @@ impl From<MessageAction> for IambAction {
} }
} }
impl From<SpaceAction> for IambAction {
fn from(act: SpaceAction) -> Self {
IambAction::Space(act)
}
}
impl From<RoomAction> for IambAction { impl From<RoomAction> for IambAction {
fn from(act: RoomAction) -> Self { fn from(act: RoomAction) -> Self {
IambAction::Room(act) IambAction::Room(act)
@ -557,6 +582,7 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Break, IambAction::Homeserver(..) => SequenceStatus::Break,
IambAction::Keys(..) => SequenceStatus::Break, IambAction::Keys(..) => SequenceStatus::Break,
IambAction::Message(..) => SequenceStatus::Break, IambAction::Message(..) => SequenceStatus::Break,
IambAction::Space(..) => SequenceStatus::Break,
IambAction::Room(..) => SequenceStatus::Break, IambAction::Room(..) => SequenceStatus::Break,
IambAction::OpenLink(..) => SequenceStatus::Break, IambAction::OpenLink(..) => SequenceStatus::Break,
IambAction::Send(..) => SequenceStatus::Break, IambAction::Send(..) => SequenceStatus::Break,
@ -572,6 +598,7 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Atom, IambAction::Homeserver(..) => SequenceStatus::Atom,
IambAction::Keys(..) => SequenceStatus::Atom, IambAction::Keys(..) => SequenceStatus::Atom,
IambAction::Message(..) => SequenceStatus::Atom, IambAction::Message(..) => SequenceStatus::Atom,
IambAction::Space(..) => SequenceStatus::Atom,
IambAction::OpenLink(..) => SequenceStatus::Atom, IambAction::OpenLink(..) => SequenceStatus::Atom,
IambAction::Room(..) => SequenceStatus::Atom, IambAction::Room(..) => SequenceStatus::Atom,
IambAction::Send(..) => SequenceStatus::Atom, IambAction::Send(..) => SequenceStatus::Atom,
@ -587,6 +614,7 @@ impl ApplicationAction for IambAction {
IambAction::Homeserver(..) => SequenceStatus::Ignore, IambAction::Homeserver(..) => SequenceStatus::Ignore,
IambAction::Keys(..) => SequenceStatus::Ignore, IambAction::Keys(..) => SequenceStatus::Ignore,
IambAction::Message(..) => SequenceStatus::Ignore, IambAction::Message(..) => SequenceStatus::Ignore,
IambAction::Space(..) => SequenceStatus::Ignore,
IambAction::Room(..) => SequenceStatus::Ignore, IambAction::Room(..) => SequenceStatus::Ignore,
IambAction::OpenLink(..) => SequenceStatus::Ignore, IambAction::OpenLink(..) => SequenceStatus::Ignore,
IambAction::Send(..) => SequenceStatus::Ignore, IambAction::Send(..) => SequenceStatus::Ignore,
@ -601,6 +629,7 @@ impl ApplicationAction for IambAction {
IambAction::ClearUnreads => false, IambAction::ClearUnreads => false,
IambAction::Homeserver(..) => false, IambAction::Homeserver(..) => false,
IambAction::Message(..) => false, IambAction::Message(..) => false,
IambAction::Space(..) => false,
IambAction::Room(..) => false, IambAction::Room(..) => false,
IambAction::Keys(..) => false, IambAction::Keys(..) => false,
IambAction::Send(..) => false, IambAction::Send(..) => false,
@ -618,6 +647,12 @@ impl From<RoomAction> for ProgramAction {
} }
} }
impl From<SpaceAction> for ProgramAction {
fn from(act: SpaceAction) -> Self {
IambAction::from(act).into()
}
}
impl From<IambAction> for ProgramAction { impl From<IambAction> for ProgramAction {
fn from(act: IambAction) -> Self { fn from(act: IambAction) -> Self {
Action::Application(act) Action::Application(act)
@ -713,10 +748,22 @@ pub enum IambError {
#[error("Current window is not a room or space")] #[error("Current window is not a room or space")]
NoSelectedRoomOrSpace, 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. /// A failure due to not having a room selected.
#[error("Current window is not a room")] #[error("Current window is not a room")]
NoSelectedRoom, 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. /// A failure due to not having an outstanding room invitation.
#[error("You do not have a current invitation to this room")] #[error("You do not have a current invitation to this room")]
NotInvited, NotInvited,

View file

@ -2,9 +2,9 @@
//! //!
//! The command-bar commands are set up here, and iamb-specific commands are defined here. See //! 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. //! [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::{ use modalkit::{
commands::{CommandError, CommandResult, CommandStep}, commands::{CommandError, CommandResult, CommandStep},
@ -27,6 +27,7 @@ use crate::base::{
RoomAction, RoomAction,
RoomField, RoomField,
SendAction, SendAction,
SpaceAction,
VerifyAction, VerifyAction,
}; };
@ -535,6 +536,90 @@ fn iamb_room(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Result::Err(CommandError::InvalidArgument) 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), _ => return Result::Err(CommandError::InvalidArgument),
}; };
@ -671,6 +756,11 @@ fn add_iamb_commands(cmds: &mut ProgramCommands) {
f: iamb_rooms, f: iamb_rooms,
}); });
cmds.add_command(ProgramCommand { name: "room".into(), aliases: vec![], f: iamb_room }); 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 { cmds.add_command(ProgramCommand {
name: "spaces".into(), name: "spaces".into(),
aliases: vec![], aliases: vec![],
@ -725,7 +815,7 @@ pub fn setup_commands() -> ProgramCommands {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use matrix_sdk::ruma::user_id; use matrix_sdk::ruma::{room_id, user_id};
use modalkit::actions::WindowAction; use modalkit::actions::WindowAction;
use modalkit::editing::context::EditContext; use modalkit::editing::context::EditContext;
@ -1067,6 +1157,103 @@ mod tests {
assert_eq!(res, vec![(act.into(), ctx.clone())]); 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] #[test]
fn test_cmd_invite() { fn test_cmd_invite() {
let mut cmds = setup_commands(); let mut cmds = setup_commands();

View file

@ -557,6 +557,9 @@ impl Application {
IambAction::Message(act) => { IambAction::Message(act) => {
self.screen.current_window_mut()?.message_command(act, ctx, store).await? 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) => { IambAction::Room(act) => {
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?; let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?;
self.action_prepend(acts); self.action_prepend(acts);

View file

@ -76,6 +76,7 @@ use crate::base::{
SortFieldRoom, SortFieldRoom,
SortFieldUser, SortFieldUser,
SortOrder, SortOrder,
SpaceAction,
UnreadInfo, UnreadInfo,
}; };
@ -360,6 +361,19 @@ impl IambWindow {
} }
} }
pub async fn space_command(
&mut self,
act: SpaceAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
if let IambWindow::Room(w) = self {
w.space_command(act, ctx, store).await
} else {
return Err(IambError::NoSelectedRoom.into());
}
}
pub async fn room_command( pub async fn room_command(
&mut self, &mut self,
act: RoomAction, act: RoomAction,

View file

@ -66,6 +66,7 @@ use crate::base::{
RoomAction, RoomAction,
RoomField, RoomField,
SendAction, SendAction,
SpaceAction,
}; };
use self::chat::ChatState; 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<EditInfo> {
match self {
RoomState::Space(space) => space.space_command(act, ctx, store).await,
RoomState::Chat(_) => Err(IambError::NoSelectedSpace.into()),
}
}
pub async fn send_command( pub async fn send_command(
&mut self, &mut self,
act: SendAction, act: SendAction,
@ -464,6 +477,9 @@ impl RoomState {
RoomField::Aliases => { RoomField::Aliases => {
// This never happens, aliases is only used for showing // This never happens, aliases is only used for showing
}, },
RoomField::Id => {
// This never happens, id is only used for showing
},
} }
Ok(vec![]) Ok(vec![])
@ -559,6 +575,9 @@ impl RoomState {
RoomField::Aliases => { RoomField::Aliases => {
// This will not happen, you cannot unset all aliases // This will not happen, you cannot unset all aliases
}, },
RoomField::Id => {
// This never happens, id is only used for showing
},
} }
Ok(vec![]) Ok(vec![])
@ -574,6 +593,10 @@ impl RoomState {
let visibility = room.history_visibility(); let visibility = room.history_visibility();
format!("Room history visibility: {visibility}") format!("Room history visibility: {visibility}")
}, },
RoomField::Id => {
let id = room.room_id();
format!("Room identifier: {id}")
},
RoomField::Name => { RoomField::Name => {
match room.name() { match room.name() {
None => "Room has no name".into(), None => "Room has no name".into(),

View file

@ -2,11 +2,14 @@
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use matrix_sdk::ruma::events::space::child::SpaceChildEventContent;
use matrix_sdk::ruma::events::StateEventType;
use matrix_sdk::{ use matrix_sdk::{
room::Room as MatrixRoom, room::Room as MatrixRoom,
ruma::{OwnedRoomId, RoomId}, ruma::{OwnedRoomId, RoomId},
}; };
use modalkit::prelude::{EditInfo, InfoMessage};
use ratatui::{ use ratatui::{
buffer::Buffer, buffer::Buffer,
layout::Rect, layout::Rect,
@ -22,9 +25,18 @@ use modalkit_ratatui::{
WindowOps, 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); const SPACE_HIERARCHY_DEBOUNCE: Duration = Duration::from_secs(5);
@ -68,6 +80,71 @@ impl SpaceState {
last_fetch: self.last_fetch, last_fetch: self.last_fetch,
} }
} }
pub async fn space_command(
&mut self,
act: SpaceAction,
_: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
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 { impl TerminalCursor for SpaceState {