Skip to content
6 changes: 5 additions & 1 deletion docs/iamb.1
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
.\"
.\" You can preview this file with:
.\" $ man ./docs/iamb.1
.Dd Mar 24, 2024
.Dd Sep 11, 2025
.Dt IAMB 1
.Os
.Sh NAME
Expand All @@ -16,6 +16,7 @@
.Op Fl hV
.Op Fl P Ar profile
.Op Fl C Ar dir
.Op Ar URI
.Sh DESCRIPTION
.Nm
is a client for the Matrix communication protocol.
Expand Down Expand Up @@ -46,6 +47,9 @@ Show the help text and quit.
Show the current
.Nm
version and quit.
.It Ar URI
A matrix uri or matrix.to link to open on startup. Specified at
.Lk https://spec.matrix.org/latest/appendices/#uris
.El

.Sh "GENERAL COMMANDS"
Expand Down
3 changes: 2 additions & 1 deletion iamb.desktop
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
[Desktop Entry]
Categories=Network;InstantMessaging;Chat;
Comment=A Matrix client for Vim addicts
Exec=iamb
Exec=iamb %u
MimeType=x-scheme-handler/matrix
GenericName=Matrix Client
Keywords=Matrix;matrix.org;chat;communications;talk;
Name=iamb
Expand Down
119 changes: 89 additions & 30 deletions src/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use std::time::{Duration, Instant};
use emojis::Emoji;
use matrix_sdk::ruma::events::receipt::ReceiptThread;
use matrix_sdk::ruma::room_version_rules::RedactionRules;
use matrix_sdk::ruma::OwnedRoomAliasId;
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
Expand Down Expand Up @@ -507,7 +508,7 @@ pub enum KeysAction {
Import(String, String),
}

/// An action that the main program loop should.
/// An action that the main program loop should execute.
///
/// See [the commands module][super::commands] for where these are usually created.
#[derive(Clone, Debug, Eq, PartialEq)]
Expand All @@ -524,8 +525,8 @@ pub enum IambAction {
/// Perform an action on the current space.
Space(SpaceAction),

/// Open a URL.
OpenLink(String),
/// Open a URL (and specify whether to join linked matrix rooms).
OpenLink(String, bool),

/// Perform an action on the currently focused room.
Room(RoomAction),
Expand Down Expand Up @@ -920,7 +921,10 @@ pub struct RoomInfo {
pub users_typing: Option<(Instant, Vec<OwnedUserId>)>,

/// The display names for users in this room.
pub display_names: HashMap<OwnedUserId, String>,
pub display_names: CompletionMap<OwnedUserId, String>,

/// Tab completion for the display names in this room.
pub display_name_completion: CompletionMap<String, OwnedUserId>,

/// The last time the room was rendered, used to detect if it is currently open.
pub draw_last: Option<Instant>,
Expand All @@ -943,6 +947,7 @@ impl Default for RoomInfo {
fetch_last: Default::default(),
users_typing: Default::default(),
display_names: Default::default(),
display_name_completion: Default::default(),
draw_last: Default::default(),
}
}
Expand Down Expand Up @@ -1024,12 +1029,34 @@ impl RoomInfo {

/// Get an event for an identifier.
pub fn get_event(&self, event_id: &EventId) -> Option<&Message> {
self.messages.get(self.get_message_key(event_id)?)
let (thread_root, key) = match self.keys.get(event_id)? {
EventLocation::Message(thread_root, key) => (thread_root, key),
EventLocation::State(key) => (&None, key),
_ => return None,
};

let messages = if let Some(root) = thread_root {
self.threads.get(root)?
} else {
&self.messages
};
messages.get(key)
}

/// Get an event for an identifier as mutable.
pub fn get_event_mut(&mut self, event_id: &EventId) -> Option<&mut Message> {
self.messages.get_mut(self.keys.get(event_id)?.to_message_key()?)
let (thread_root, key) = match self.keys.get(event_id)? {
EventLocation::Message(thread_root, key) => (thread_root, key),
EventLocation::State(key) => (&None, key),
_ => return None,
};

let messages = if let Some(root) = thread_root {
self.threads.get_mut(root)?
} else {
&mut self.messages
};
messages.get_mut(key)
}

pub fn redact(&mut self, ev: OriginalSyncRoomRedactionEvent, rules: &RedactionRules) {
Expand Down Expand Up @@ -1581,7 +1608,7 @@ pub struct ChatStore {
pub rooms: CompletionMap<OwnedRoomId, RoomInfo>,

/// Map of room names.
pub names: CompletionMap<String, OwnedRoomId>,
pub names: CompletionMap<OwnedRoomAliasId, OwnedRoomId>,

/// Presence information for other users.
pub presences: CompletionMap<OwnedUserId, PresenceState>,
Expand Down Expand Up @@ -1993,7 +2020,9 @@ impl Completer<IambInfo> for IambCompleter {
match content {
IambBufferId::Command(CommandType::Command) => complete_cmdbar(text, cursor, store),
IambBufferId::Command(CommandType::Search) => vec![],
IambBufferId::Room(_, _, RoomFocus::MessageBar) => complete_msgbar(text, cursor, store),
IambBufferId::Room(room_id, _, RoomFocus::MessageBar) => {
complete_msgbar(text, cursor, store, room_id)
},
IambBufferId::Room(_, _, RoomFocus::Scrollback) => vec![],

IambBufferId::DirectList => vec![],
Expand Down Expand Up @@ -2024,61 +2053,90 @@ fn complete_users(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Ve
}

/// Tab completion within the message bar.
fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
fn complete_msgbar(
text: &EditRope,
cursor: &mut Cursor,
store: &mut ChatStore,
room_id: &RoomId,
) -> Vec<String> {
let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
let id = Cow::from(&id);

let info = store.rooms.get_or_default(room_id.to_owned());

match id.chars().next() {
// Complete room aliases.
Some('#') => {
return store.names.complete(id.as_ref());
store
.names
.complete(id.as_ref())
.into_iter()
.map(|i| format!("[{}]({})", i, i.matrix_to_uri()))
.collect()
},

// Complete room identifiers.
Some('!') => {
return store
store
.rooms
.complete(id.as_ref())
.into_iter()
.map(|i| i.to_string())
.collect();
.map(|i| format!("[{}]({})", i, i.matrix_to_uri()))
.collect()
},

// Complete Emoji shortcodes.
Some(':') => {
let list = store.emojis.complete(&id[1..]);
let iter = list.into_iter().take(200).map(|s| format!(":{s}:"));

return iter.collect();
iter.collect()
},

// Complete usernames for @ and empty strings.
Some('@') | None => {
return store
.presences
.complete(id.as_ref())
// spec says to mention with display name in anchor text
let mut users: HashSet<_> = info
.display_name_completion
.complete(id.strip_prefix('@').unwrap_or(&id))
.into_iter()
.map(|i| i.to_string())
.map(|n| {
format!(
"[{}]({})",
n,
info.display_name_completion.get(&n).unwrap().matrix_to_uri()
)
})
.collect();

users.extend(info.display_names.complete(id.as_ref()).into_iter().map(|i| {
format!(
"[{}]({})",
info.display_names.get(&i).unwrap_or(&i.to_string()),
i.matrix_to_uri()
)
}));

users.into_iter().collect()
},

// Unknown sigil.
Some(_) => return vec![],
Some(_) => vec![],
}
}

/// Tab completion for Matrix identifiers (usernames, room aliases, etc.)
fn complete_matrix_names(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
/// Tab completion for Matrix room aliases
fn complete_matrix_aliases(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec<String> {
let id = text
.get_prefix_word_mut(cursor, &MATRIX_ID_WORD)
.unwrap_or_else(EditRope::empty);
let id = Cow::from(&id);

let list = store.names.complete(id.as_ref());
if !list.is_empty() {
return list;
return list.into_iter().map(|i| i.to_string()).collect();
}

let list = store.presences.complete(id.as_ref());
Expand Down Expand Up @@ -2134,7 +2192,7 @@ fn complete_cmdarg(
"react" | "unreact" => complete_emoji(text, cursor, store),

"invite" => complete_users(text, cursor, store),
"join" | "split" | "vsplit" | "tabedit" => complete_matrix_names(text, cursor, store),
"join" | "split" | "vsplit" | "tabedit" => complete_matrix_aliases(text, cursor, store),
"room" => vec![],
"verify" => vec![],
"vertical" | "horizontal" | "aboveleft" | "belowright" | "tab" => {
Expand Down Expand Up @@ -2342,24 +2400,25 @@ pub mod tests {
#[tokio::test]
async fn test_complete_msgbar() {
let store = mock_store().await;
let store = store.application;
let mut store = store.application;
let room_id = TEST_ROOM1_ID.clone();

let text = EditRope::from("going for a walk :walk ");
let mut cursor = Cursor::new(0, 22);
let res = complete_msgbar(&text, &mut cursor, &store);
let res = complete_msgbar(&text, &mut cursor, &mut store, &room_id);
assert_eq!(res, vec![":walking:", ":walking_man:", ":walking_woman:"]);
assert_eq!(cursor, Cursor::new(0, 17));

let text = EditRope::from("hello @user1 ");
let text = EditRope::from("hello @user2 ");
let mut cursor = Cursor::new(0, 12);
let res = complete_msgbar(&text, &mut cursor, &store);
assert_eq!(res, vec!["@user1:example.com"]);
let res = complete_msgbar(&text, &mut cursor, &mut store, &room_id);
assert_eq!(res, vec!["[User 2](https://matrix.to/#/@user2:example.com)"]);
assert_eq!(cursor, Cursor::new(0, 6));

let text = EditRope::from("see #room ");
let mut cursor = Cursor::new(0, 9);
let res = complete_msgbar(&text, &mut cursor, &store);
assert_eq!(res, vec!["#room1:example.com"]);
let res = complete_msgbar(&text, &mut cursor, &mut store, &room_id);
assert_eq!(res, vec!["[#room1:example.com](https://matrix.to/#/%23room1:example.com)"]);
assert_eq!(cursor, Cursor::new(0, 4));
}

Expand Down
14 changes: 14 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ pub struct Iamb {

#[clap(short = 'C', long, value_parser)]
pub config_directory: Option<PathBuf>,

/// `matrix:` uri or `https://matrix.to` link to open
pub uri: Option<String>,
}

#[derive(thiserror::Error, Debug)]
Expand Down Expand Up @@ -1100,9 +1103,20 @@ impl ApplicationSettings {
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::*;
use matrix_sdk::ruma::user_id;
use std::convert::TryFrom;

#[test]
fn test_get_user_span_borrowed() {
// fix `StyleTreeNode::print` for `StyleTreeNode::UserId` if this breaks
let info = mock_room();
let settings = mock_settings();
let span = settings.get_user_span(&TEST_USER1, &info);

assert!(matches!(span.content, Cow::Borrowed(_)));
}

#[test]
fn test_profile_name_invalid() {
assert_eq!(validate_profile_name(""), false);
Expand Down
Loading
Loading