Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
145 changes: 86 additions & 59 deletions src/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//!
//! The types defined here get used throughout iamb.
use std::borrow::Cow;
use std::collections::hash_map::{Entry, IntoIter};
use std::collections::hash_map::IntoIter;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::convert::TryFrom;
use std::fmt::{self, Display};
Expand All @@ -14,6 +14,7 @@ use std::time::{Duration, Instant};
use emojis::Emoji;

use matrix_sdk::ruma::OwnedMxcUri;
use matrix_sdk::ruma::OwnedRoomAliasId;
use matrix_sdk::ruma::OwnedTransactionId;
use matrix_sdk::ruma::events::receipt::ReceiptThread;
use matrix_sdk::ruma::events::room::MediaSource;
Expand Down Expand Up @@ -524,7 +525,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 @@ -541,8 +542,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 @@ -939,8 +940,8 @@ impl UnreadInfo {
/// those with overlapping names.
#[derive(Default)]
pub struct DisplayNameStore {
by_ids: HashMap<OwnedUserId, String>,
by_names: HashMap<String, HashSet<OwnedUserId>>,
by_ids: CompletionMap<OwnedUserId, String>,
by_names: CompletionMap<String, HashSet<OwnedUserId>>,
}

impl DisplayNameStore {
Expand All @@ -963,27 +964,23 @@ impl DisplayNameStore {
self.set_by_name(user_id.clone(), name);
}

let previous = match (self.by_ids.entry(user_id), name) {
// Nothing to do!
(Entry::Vacant(_), None) => None,

// Setting initial display name for user:
(Entry::Vacant(v), Some(name)) => {
v.insert(name);
None
},

// Unsetting display name:
(Entry::Occupied(o), None) => Some(o.remove_entry()),

let previous = if let Some(name) = name {
// Replacing existing name:
(Entry::Occupied(mut o), Some(name)) => {
if o.get() == &name {
if let Some(entry) = self.by_ids.get_mut(&user_id) {
if entry == &name {
None
} else {
Some((o.key().clone(), o.insert(name)))
Some((user_id, std::mem::replace(entry, name)))
}
},
}
// Setting initial display name for user:
else {
self.by_ids.insert(user_id, name);
None
}
} else {
// Unsetting display name if it exists:
self.by_ids.remove(&user_id).map(|name| (user_id, name))
};

let Some((user_id, previous)) = previous else {
Expand Down Expand Up @@ -1018,6 +1015,28 @@ impl DisplayNameStore {
// Ambiguous username, so include unique user ID:
Some(Cow::Owned(format!("{displayname} ({user_id})")))
}

fn complete_mention(&self, prefix: &str) -> Vec<String> {
// spec says to mention with display name in anchor text
let mut users: BTreeSet<_> = self
.by_names
.complete(prefix.strip_prefix('@').unwrap_or(prefix))
.into_iter()
.flat_map(|name| {
self.by_names
.get(&name)
.unwrap()
.iter()
.map(move |id| format!("[{name}]({})", id.matrix_to_uri()))
})
.collect();

users.extend(self.by_ids.complete(prefix).into_iter().map(|id| {
format!("[{}]({})", self.by_ids.get(&id).unwrap_or(&id.to_string()), id.matrix_to_uri())
}));

users.into_iter().collect()
}
}

/// Information about room's the user's joined.
Expand Down Expand Up @@ -1168,14 +1187,6 @@ impl RoomInfo {
}
}

pub fn get_reaction_images(&self, event_id: &EventId) -> impl Iterator<Item = &MediaSource> {
self.reactions
.get(event_id)
.map(HashMap::iter)
.unwrap_or_default()
.filter_map(|(_, (_, _, source))| source.as_ref())
}

/// Map an event identifier to its [MessageKey].
pub fn get_message_key(&self, event_id: &EventId) -> Option<&MessageKey> {
self.keys.get(event_id)?.to_message_key()
Expand Down Expand Up @@ -1326,6 +1337,14 @@ impl RoomInfo {
}
}

pub fn get_reaction_images(&self, event_id: &EventId) -> impl Iterator<Item = &MediaSource> {
self.reactions
.get(event_id)
.map(HashMap::iter)
.unwrap_or_default()
.filter_map(|(_, (_, _, source))| source.as_ref())
}

/// Insert a reaction to a message.
pub fn insert_reaction_with_preview(
&mut self,
Expand Down Expand Up @@ -1847,7 +1866,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 @@ -2274,7 +2293,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 @@ -2306,61 +2327,66 @@ 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())
.into_iter()
.map(|i| i.to_string())
.collect();
},
Some('@') | None => info.display_names.complete_mention(&id),

// 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 @@ -2416,7 +2442,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 @@ -2622,24 +2648,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 @@ -166,6 +166,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 @@ -1422,9 +1425,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