diff --git a/docs/iamb.1 b/docs/iamb.1 index e7036e37..899407d4 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -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 @@ -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. @@ -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" diff --git a/iamb.desktop b/iamb.desktop index c49d23e4..869a4c02 100644 --- a/iamb.desktop +++ b/iamb.desktop @@ -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 diff --git a/src/base.rs b/src/base.rs index d191b809..cfc71340 100644 --- a/src/base.rs +++ b/src/base.rs @@ -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}, @@ -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)] @@ -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), @@ -920,7 +921,10 @@ pub struct RoomInfo { pub users_typing: Option<(Instant, Vec)>, /// The display names for users in this room. - pub display_names: HashMap, + pub display_names: CompletionMap, + + /// Tab completion for the display names in this room. + pub display_name_completion: CompletionMap, /// The last time the room was rendered, used to detect if it is currently open. pub draw_last: Option, @@ -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(), } } @@ -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) { @@ -1581,7 +1608,7 @@ pub struct ChatStore { pub rooms: CompletionMap, /// Map of room names. - pub names: CompletionMap, + pub names: CompletionMap, /// Presence information for other users. pub presences: CompletionMap, @@ -1993,7 +2020,9 @@ impl Completer 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![], @@ -2024,26 +2053,38 @@ 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 { +fn complete_msgbar( + text: &EditRope, + cursor: &mut Cursor, + store: &mut ChatStore, + room_id: &RoomId, +) -> Vec { 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. @@ -2051,26 +2092,43 @@ fn complete_msgbar(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> V 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 { +/// Tab completion for Matrix room aliases +fn complete_matrix_aliases(text: &EditRope, cursor: &mut Cursor, store: &ChatStore) -> Vec { let id = text .get_prefix_word_mut(cursor, &MATRIX_ID_WORD) .unwrap_or_else(EditRope::empty); @@ -2078,7 +2136,7 @@ fn complete_matrix_names(text: &EditRope, cursor: &mut Cursor, store: &ChatStore 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()); @@ -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" => { @@ -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)); } diff --git a/src/config.rs b/src/config.rs index e7a4a47b..bd094057 100644 --- a/src/config.rs +++ b/src/config.rs @@ -135,6 +135,9 @@ pub struct Iamb { #[clap(short = 'C', long, value_parser)] pub config_directory: Option, + + /// `matrix:` uri or `https://matrix.to` link to open + pub uri: Option, } #[derive(thiserror::Error, Debug)] @@ -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); diff --git a/src/main.rs b/src/main.rs index 0a316c76..c3cb8a76 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,9 @@ use std::time::{Duration, Instant}; use clap::Parser; use matrix_sdk::crypto::encrypt_room_key_export; use matrix_sdk::ruma::api::client::error::ErrorKind; -use matrix_sdk::ruma::OwnedUserId; +use matrix_sdk::ruma::matrix_uri::MatrixId; +use matrix_sdk::ruma::{MatrixToUri, MatrixUri, OwnedUserId}; +use matrix_sdk::OwnedServerName; use modalkit::keybindings::InputBindings; use rand::{distributions::Alphanumeric, Rng}; use temp_dir::TempDir; @@ -150,16 +152,13 @@ fn config_tab_to_desc( let window = match window { config::WindowPath::UserId(user_id) => { - let name = user_id.to_string(); - let room_id = worker.join_room(name.clone())?; - names.insert(name, room_id.clone()); + let room_id = worker.join_room(user_id.to_string(), vec![])?; IambId::Room(room_id, None) }, config::WindowPath::RoomId(room_id) => IambId::Room(room_id, None), config::WindowPath::AliasId(alias) => { - let name = alias.to_string(); - let room_id = worker.join_room(name.clone())?; - names.insert(name, room_id.clone()); + let room_id = worker.join_room(alias.to_string(), vec![])?; + names.insert(alias, room_id.clone()); IambId::Room(room_id, None) }, config::WindowPath::Window(id) => id, @@ -191,14 +190,101 @@ fn restore_layout( tabs.to_layout(area.into(), store) } +/// Returns the `IambId` for the new window or a string to query the user. If they answer `y` this +/// function should be rerun with `join_or_create` set to `true`. +fn resolve_mxid( + store: &mut ProgramStore, + id: MatrixId, + via: &[OwnedServerName], + join_or_create: bool, +) -> IambResult> { + let mut room_name = String::new(); + let room_id = match id { + MatrixId::Room(id) => { + room_name = id.to_string(); + id + }, + MatrixId::RoomAlias(alias_id) => { + room_name = alias_id.to_string(); + store.application.worker.resolve_alias(alias_id)? + }, + MatrixId::User(user_id) => { + match store.application.worker.client.get_dm_room(&user_id) { + Some(room) => room.room_id().to_owned(), + None if join_or_create => { + store.application.worker.join_room(user_id.to_string(), via.to_owned())? + }, + None => return Ok(Err(format!("No dm with {user_id} found. Create new DM?"))), + } + }, + MatrixId::Event(owned_room_or_alias_id, _event_id) => { + // ignore event id for now + room_name = owned_room_or_alias_id.to_string(); + let room_or_alias_id: &matrix_sdk::ruma::RoomOrAliasId = &owned_room_or_alias_id; + if let Ok(alias_id) = <&matrix_sdk::ruma::RoomAliasId>::try_from(room_or_alias_id) { + store.application.worker.resolve_alias(alias_id.to_owned())? + } else { + matrix_sdk::ruma::OwnedRoomId::try_from(owned_room_or_alias_id).unwrap() + } + }, + _ => { + tracing::error!("encountered unrecoginsed matrix id: {id:?}"); + return Ok(Err("Matrix link cannot be opened. Press 'n' to continue.".to_owned())); + }, + }; + + if !store + .application + .worker + .client + .joined_rooms() + .iter() + .any(|room| room.room_id() == room_id) + { + if join_or_create { + store.application.worker.join_room(room_id.to_string(), via.to_owned())?; + } else { + return Ok(Err(format!("Join room {room_name:?}?"))); + } + } + + Ok(Ok(IambId::Room(room_id, None))) +} + fn setup_screen( settings: ApplicationSettings, store: &mut ProgramStore, + initial_room: Option<(MatrixId, Vec)>, ) -> IambResult> { let cmd = CommandBarState::new(store); let dims = crossterm::terminal::size()?; let area = Rect::new(0, 0, dims.0, dims.1); + if let Some((id, via)) = initial_room { + match resolve_mxid(store, id.clone(), &via, false)? { + Ok(id) => { + return Ok(ScreenState::new(IambWindow::open(id, store)?, cmd)); + }, + Err(question) => { + restore_tty(false, settings.tunables.mouse.enabled); + let join_or_create = loop { + match read_yesno(&format!("{question} [y]es/[n]o")) { + Some('y') => break true, + Some('n') => break false, + Some(_) | None => continue, + } + }; + setup_tty(&settings, false)?; + + if join_or_create { + if let Ok(id) = resolve_mxid(store, id, &via, true)? { + return Ok(ScreenState::new(IambWindow::open(id, store)?, cmd)); + } + } + }, + } + } + match settings.layout { config::Layout::Restore => { match restore_layout(area, &settings, store) { @@ -269,6 +355,7 @@ impl Application { pub async fn new( settings: ApplicationSettings, store: AsyncProgramStore, + initial_room: Option<(MatrixId, Vec)>, ) -> IambResult { let backend = CrosstermBackend::new(stdout()); let terminal = Terminal::new(backend)?; @@ -278,7 +365,7 @@ impl Application { let bindings = KeyManager::new(bindings); let mut locked = store.lock().await; - let screen = setup_screen(settings, locked.deref_mut())?; + let screen = setup_screen(settings, locked.deref_mut(), initial_room)?; let worker = locked.application.worker.clone(); @@ -605,12 +692,36 @@ impl Application { self.screen.current_window_mut()?.send_command(act, ctx, store).await? }, - IambAction::OpenLink(url) => { - tokio::task::spawn_blocking(move || { - return open::that(url); - }); + IambAction::OpenLink(url, join_or_create) => { + let matrix_uri = MatrixUri::parse(&url).ok(); + let matrix_to_uri = MatrixToUri::parse(&url).ok(); - None + let matrix_id = matrix_uri + .as_ref() + .map(|uri| (uri.id(), uri.via())) + .or(matrix_to_uri.as_ref().map(|uri| (uri.id(), uri.via()))); + + if let Some((id, via)) = matrix_id { + match resolve_mxid(store, id.clone(), via, join_or_create)? { + Ok(room) => { + let target = OpenTarget::Application(room); + let action = WindowAction::Switch(target); + + self.action_prepend(vec![(action.into(), ctx)]); + None + }, + Err(prompt) => { + let act = IambAction::OpenLink(url, true).into(); + let dialog = PromptYesNo::new(prompt, vec![act]); + let err = UIError::NeedConfirm(Box::new(dialog)); + return Err(err); + }, + } + } else { + tokio::task::spawn_blocking(move || open::that(url)); + + None + } }, IambAction::Verify(act, user_dev) => { @@ -1017,7 +1128,10 @@ fn restore_tty(enable_enhanced_keys: bool, enable_mouse: bool) { let _ = crossterm::terminal::disable_raw_mode(); } -async fn run(settings: ApplicationSettings) -> IambResult<()> { +async fn run( + settings: ApplicationSettings, + initial_room: Option<(MatrixId, Vec)>, +) -> IambResult<()> { // Get old keys the first time we run w/ the upgraded SDK. let import_keys = check_import_keys(&settings).await?; @@ -1072,8 +1186,13 @@ async fn run(settings: ApplicationSettings) -> IambResult<()> { })); // And finally, start running the terminal UI. - let mut application = Application::new(settings, store).await?; - application.run().await?; + let mut application = Application::new(settings, store, initial_room) + .await + .inspect_err(|_| restore_tty(enable_enhanced_keys, enable_mouse))?; + application + .run() + .await + .inspect_err(|_| restore_tty(enable_enhanced_keys, enable_mouse))?; // Clean up the terminal on exit. restore_tty(enable_enhanced_keys, enable_mouse); @@ -1085,6 +1204,17 @@ fn main() -> IambResult<()> { // Parse command-line flags. let iamb = Iamb::parse(); + let initial_room = if let Some(uri) = &iamb.uri { + MatrixUri::parse(uri) + .map(|uri| (uri.id().clone(), uri.via().to_owned())) + .or_else(|_| { + MatrixToUri::parse(uri).map(|uri| (uri.id().clone(), uri.via().to_owned())) + }) + .ok() + } else { + None + }; + // Load configuration and set up the Matrix SDK. let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit); @@ -1118,7 +1248,7 @@ fn main() -> IambResult<()> { .build() .unwrap(); - rt.block_on(async move { run(settings).await })?; + rt.block_on(async move { run(settings, initial_room).await })?; drop(guard); process::exit(0); diff --git a/src/message/html.rs b/src/message/html.rs index 1aa1fd6c..9718d0b4 100644 --- a/src/message/html.rs +++ b/src/message/html.rs @@ -11,11 +11,22 @@ //! This isn't as important for iamb, since it isn't a browser environment, but we do still map //! input onto an enum of the safe list of tags to keep it easy to understand and process. use std::borrow::Cow; +use std::convert::TryFrom; use std::ops::Deref; use css_color_parser::Color as CssColor; use markup5ever_rcdom::{Handle, NodeData, RcDom}; -use matrix_sdk::ruma::{OwnedRoomAliasId, OwnedRoomId, OwnedUserId}; +use matrix_sdk::{ + ruma::{ + matrix_uri::MatrixId, + MatrixToUri, + MatrixUri, + OwnedRoomAliasId, + OwnedRoomId, + OwnedUserId, + }, + OwnedServerName, +}; use unicode_segmentation::UnicodeSegmentation; use url::Url; @@ -36,6 +47,7 @@ use ratatui::{ }; use crate::{ + base::RoomInfo, config::ApplicationSettings, message::printer::TextPrinter, util::{join_cell_text, space_text}, @@ -97,12 +109,14 @@ impl ListStyle { pub type StyleTreeChildren = Vec; /// Type of contents in a table cell. +#[derive(Debug, Clone, Copy)] pub enum CellType { Data, Header, } /// A collection of cells for a single row in a table. +#[derive(Debug, Clone)] pub struct TableRow { cells: Vec<(CellType, StyleTreeNode)>, } @@ -120,6 +134,7 @@ impl TableRow { } /// A collection of rows in a table. +#[derive(Debug, Clone)] pub struct TableSection { rows: Vec, } @@ -137,6 +152,7 @@ impl TableSection { } /// A table. +#[derive(Debug, Clone)] pub struct Table { caption: Option>, sections: Vec, @@ -158,6 +174,7 @@ impl Table { width: usize, style: Style, settings: &'a ApplicationSettings, + info: &'a RoomInfo, ) -> Text<'a> { let mut text = Text::default(); let columns = self.columns(); @@ -177,7 +194,7 @@ impl Table { if let Some(caption) = &self.caption { let subw = width.saturating_sub(6); let mut printer = - TextPrinter::new(subw, style, true, settings).align(Alignment::Center); + TextPrinter::new(subw, style, true, settings, info).align(Alignment::Center); caption.print(&mut printer, style); for mut line in printer.finish().lines { @@ -224,7 +241,7 @@ impl Table { CellType::Data => style, }; - cell.to_text(*w, style, settings) + cell.to_text(*w, style, settings, info) } else { space_text(*w, style) }; @@ -266,6 +283,7 @@ impl Table { } /// A processed HTML element that we can render to the terminal. +#[derive(Debug, Clone)] pub enum StyleTreeNode { Anchor(Box, char, Url), Blockquote(Box), @@ -283,10 +301,10 @@ pub enum StyleTreeNode { Table(Table), Text(Cow<'static, str>), Sequence(StyleTreeChildren), - RoomAlias(OwnedRoomAliasId), - RoomId(OwnedRoomId), - UserId(OwnedUserId), - DisplayName(String, OwnedUserId), + RoomAlias(OwnedRoomAliasId, Option), + RoomId(OwnedRoomId, Vec, Option), + UserId(OwnedUserId, Option), + DisplayName(String, OwnedUserId, Option), } impl StyleTreeNode { @@ -295,8 +313,9 @@ impl StyleTreeNode { width: usize, style: Style, settings: &'a ApplicationSettings, + info: &'a RoomInfo, ) -> Text<'a> { - let mut printer = TextPrinter::new(width, style, true, settings); + let mut printer = TextPrinter::new(width, style, true, settings, info); self.print(&mut printer, style); printer.finish() } @@ -327,16 +346,31 @@ impl StyleTreeNode { table.gather_links(urls); }, + StyleTreeNode::DisplayName(_, user_id, c) | StyleTreeNode::UserId(user_id, c) => { + if let Some(c) = c { + let to_url = Url::parse(&user_id.matrix_uri(false).to_string()).unwrap(); + urls.push((*c, to_url)); + } + }, + StyleTreeNode::RoomId(room_id, via, c) => { + if let Some(c) = c { + let to_url = + Url::parse(&room_id.matrix_uri_via(via.iter().cloned(), false).to_string()) + .unwrap(); + urls.push((*c, to_url)); + } + }, + StyleTreeNode::RoomAlias(alias, c) => { + if let Some(c) = c { + let to_url = Url::parse(&alias.matrix_uri(false).to_string()).unwrap(); + urls.push((*c, to_url)); + } + }, + StyleTreeNode::Image(_) => {}, StyleTreeNode::Ruler => {}, StyleTreeNode::Text(_) => {}, StyleTreeNode::Break => {}, - - // TODO: eventually these should turn into internal links: - StyleTreeNode::UserId(_) => {}, - StyleTreeNode::RoomId(_) => {}, - StyleTreeNode::RoomAlias(_) => {}, - StyleTreeNode::DisplayName(_, _) => {}, } } @@ -458,7 +492,7 @@ impl StyleTreeNode { } }, StyleTreeNode::Table(table) => { - let text = table.to_text(width, style, printer.settings); + let text = table.to_text(width, style, printer.settings, printer.info); printer.push_text(text); }, StyleTreeNode::Break => { @@ -475,19 +509,27 @@ impl StyleTreeNode { } }, - StyleTreeNode::UserId(user_id) => { - let style = printer.settings().get_user_style(user_id); - printer.push_str(user_id.as_str(), style); + StyleTreeNode::UserId(user_id, _) => { + let span: Span<'a> = printer.settings().get_user_span(user_id, printer.info); + let style = span.style; + + let Cow::Borrowed(name) = span.content else { + unreachable!() + }; + if !name.starts_with('@') { + printer.push_str("@", style); + } + printer.push_str(name, style); }, - StyleTreeNode::DisplayName(display_name, user_id) => { + StyleTreeNode::DisplayName(display_name, user_id, _) => { let style = printer.settings().get_user_style(user_id); printer.push_str(display_name.as_str(), style); }, - StyleTreeNode::RoomId(room_id) => { + StyleTreeNode::RoomId(room_id, _, _) => { let bold = style.add_modifier(StyleModifier::BOLD); printer.push_str(room_id.as_str(), bold); }, - StyleTreeNode::RoomAlias(alias) => { + StyleTreeNode::RoomAlias(alias, _) => { let bold = style.add_modifier(StyleModifier::BOLD); printer.push_str(alias.as_str(), bold); }, @@ -517,8 +559,9 @@ impl StyleTree { style: Style, hide_reply: bool, settings: &'a ApplicationSettings, + info: &'a RoomInfo, ) -> Text<'a> { - let mut printer = TextPrinter::new(width, style, hide_reply, settings); + let mut printer = TextPrinter::new(width, style, hide_reply, settings, info); for child in self.children.iter() { child.print(&mut printer, style); @@ -701,6 +744,37 @@ fn attrs_to_style(attrs: &[Attribute]) -> Style { return style; } +fn mxid2t( + id: &MatrixId, + via: &[OwnedServerName], + n: Option, + c: &StyleTreeNode, + h: &Url, +) -> StyleTreeNode { + match id { + MatrixId::Room(room_id) => StyleTreeNode::RoomId(room_id.to_owned(), via.to_owned(), n), + MatrixId::RoomAlias(alias) => StyleTreeNode::RoomAlias(alias.to_owned(), n), + MatrixId::User(user_id) => StyleTreeNode::UserId(user_id.to_owned(), n), + MatrixId::Event(room_or_alias_id, _) => { + let room_or_alias_id: &matrix_sdk::ruma::RoomOrAliasId = room_or_alias_id; + // ignore event id for now + if let Ok(alias_id) = <&matrix_sdk::ruma::RoomAliasId>::try_from(room_or_alias_id) { + StyleTreeNode::RoomAlias(alias_id.to_owned(), n) + } else { + let room_id = <&matrix_sdk::ruma::RoomId>::try_from(room_or_alias_id).unwrap(); + StyleTreeNode::RoomId(room_id.to_owned(), via.to_owned(), n) + } + }, + _ => { + if let Some(n) = n { + StyleTreeNode::Anchor(Box::new(c.to_owned()), n, h.to_owned()) + } else { + c.clone() + } + }, + } +} + fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren { let node = hdl.deref(); @@ -718,7 +792,13 @@ fn h2t(hdl: &Handle, state: &mut TreeGenState) -> StyleTreeChildren { let h = attrs_to_href(&attrs.borrow()).and_then(|u| Url::parse(&u).ok()); if let Some(h) = h { - if let Some(n) = state.next_link_char() { + let n = state.next_link_char(); + + if let Ok(uri) = MatrixToUri::parse(h.as_str()) { + mxid2t(uri.id(), uri.via(), n, &c, &h) + } else if let Ok(uri) = MatrixUri::parse(h.as_str()) { + mxid2t(uri.id(), uri.via(), n, &c, &h) + } else if let Some(n) = n { StyleTreeNode::Anchor(c, n, h) } else { *c @@ -857,19 +937,20 @@ pub fn parse_matrix_html(s: &str) -> StyleTree { #[cfg(test)] pub mod tests { use super::*; - use crate::tests::mock_settings; + use crate::tests::{mock_room, mock_settings}; use crate::util::space_span; use pretty_assertions::assert_eq; use unicode_width::UnicodeWidthStr; #[test] fn test_header() { + let info = mock_room(); let settings = mock_settings(); let bold = Style::default().add_modifier(StyleModifier::BOLD); let s = "

Header 1

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled(" ", bold), @@ -881,7 +962,7 @@ pub mod tests { let s = "

Header 2

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -894,7 +975,7 @@ pub mod tests { let s = "

Header 3

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -908,7 +989,7 @@ pub mod tests { let s = "

Header 4

"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -923,7 +1004,7 @@ pub mod tests { let s = "
Header 5
"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -939,7 +1020,7 @@ pub mod tests { let s = "
Header 6
"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("#", bold), Span::styled("#", bold), @@ -957,6 +1038,7 @@ pub mod tests { #[test] fn test_style() { + let info = mock_room(); let settings = mock_settings(); let def = Style::default(); let bold = def.add_modifier(StyleModifier::BOLD); @@ -967,7 +1049,7 @@ pub mod tests { let s = "Bold!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Bold", bold), Span::styled("!", bold), @@ -976,7 +1058,7 @@ pub mod tests { let s = "Bold!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Bold", bold), Span::styled("!", bold), @@ -985,7 +1067,7 @@ pub mod tests { let s = "Italic!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Italic", italic), Span::styled("!", italic), @@ -994,7 +1076,7 @@ pub mod tests { let s = "Italic!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Italic", italic), Span::styled("!", italic), @@ -1003,7 +1085,7 @@ pub mod tests { let s = "Strikethrough!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Strikethrough", strike), Span::styled("!", strike), @@ -1012,7 +1094,7 @@ pub mod tests { let s = "Strikethrough!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Strikethrough", strike), Span::styled("!", strike), @@ -1021,7 +1103,7 @@ pub mod tests { let s = "Underline!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Underline", underl), Span::styled("!", underl), @@ -1030,7 +1112,7 @@ pub mod tests { let s = "Red!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Red", red), Span::styled("!", red), @@ -1039,7 +1121,7 @@ pub mod tests { let s = "Red!"; let tree = parse_matrix_html(s); - let text = tree.to_text(20, Style::default(), false, &settings); + let text = tree.to_text(20, Style::default(), false, &settings, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::styled("Red", red), Span::styled("!", red), @@ -1049,10 +1131,11 @@ pub mod tests { #[test] fn test_paragraph() { + let info = mock_room(); let settings = mock_settings(); let s = "

Hello world!

Content

Goodbye world!

"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false, &settings); + let text = tree.to_text(10, Style::default(), false, &settings, &info); assert_eq!(text.lines.len(), 7); assert_eq!( text.lines[0], @@ -1077,10 +1160,11 @@ pub mod tests { #[test] fn test_blockquote() { + let info = mock_room(); let settings = mock_settings(); let s = "
Hello world!
"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false, &settings); + let text = tree.to_text(10, Style::default(), false, &settings, &info); let style = Style::new().fg(QUOTE_COLOR); assert_eq!(text.lines.len(), 2); assert_eq!( @@ -1109,10 +1193,11 @@ pub mod tests { #[test] fn test_list_unordered() { + let info = mock_room(); let settings = mock_settings(); let s = "
  • List Item 1
  • List Item 2
  • List Item 3
"; let tree = parse_matrix_html(s); - let text = tree.to_text(8, Style::default(), false, &settings); + let text = tree.to_text(8, Style::default(), false, &settings, &info); assert_eq!(text.lines.len(), 6); assert_eq!( text.lines[0], @@ -1172,10 +1257,11 @@ pub mod tests { #[test] fn test_list_ordered() { + let info = mock_room(); let settings = mock_settings(); let s = "
  1. List Item 1
  2. List Item 2
  3. List Item 3
"; let tree = parse_matrix_html(s); - let text = tree.to_text(9, Style::default(), false, &settings); + let text = tree.to_text(9, Style::default(), false, &settings, &info); assert_eq!(text.lines.len(), 6); assert_eq!( text.lines[0], @@ -1235,6 +1321,7 @@ pub mod tests { #[test] fn test_table() { + let info = mock_room(); let settings = mock_settings(); let s = "\ \ @@ -1246,7 +1333,7 @@ pub mod tests { \
abc
"; let tree = parse_matrix_html(s); - let text = tree.to_text(15, Style::default(), false, &settings); + let text = tree.to_text(15, Style::default(), false, &settings, &info); let bold = Style::default().add_modifier(StyleModifier::BOLD); assert_eq!(text.lines.len(), 11); @@ -1336,11 +1423,12 @@ pub mod tests { #[test] fn test_matrix_reply() { + let info = mock_room(); let settings = mock_settings(); let s = "This was replied toThis is the reply"; let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), false, &settings); + let text = tree.to_text(10, Style::default(), false, &settings, &info); assert_eq!(text.lines.len(), 4); assert_eq!( text.lines[0], @@ -1377,7 +1465,7 @@ pub mod tests { ); let tree = parse_matrix_html(s); - let text = tree.to_text(10, Style::default(), true, &settings); + let text = tree.to_text(10, Style::default(), true, &settings, &info); assert_eq!(text.lines.len(), 2); assert_eq!( text.lines[0], @@ -1402,10 +1490,11 @@ pub mod tests { #[test] fn test_self_closing() { + let info = mock_room(); let settings = mock_settings(); let s = "Hello
World
Goodbye"; let tree = parse_matrix_html(s); - let text = tree.to_text(7, Style::default(), true, &settings); + let text = tree.to_text(7, Style::default(), true, &settings, &info); assert_eq!(text.lines.len(), 3); assert_eq!(text.lines[0], Line::from(vec![Span::raw("Hello"), Span::raw(" "),])); assert_eq!(text.lines[1], Line::from(vec![Span::raw("World"), Span::raw(" "),])); @@ -1414,10 +1503,11 @@ pub mod tests { #[test] fn test_embedded_newline() { + let info = mock_room(); let settings = mock_settings(); let s = "

Hello\nWorld

"; let tree = parse_matrix_html(s); - let text = tree.to_text(15, Style::default(), true, &settings); + let text = tree.to_text(15, Style::default(), true, &settings, &info); assert_eq!(text.lines.len(), 1); assert_eq!( text.lines[0], @@ -1432,6 +1522,7 @@ pub mod tests { #[test] fn test_pre_tag() { + let info = mock_room(); let settings = mock_settings(); let s = concat!( "
",
@@ -1442,7 +1533,7 @@ pub mod tests {
             "
\n" ); let tree = parse_matrix_html(s); - let text = tree.to_text(25, Style::default(), true, &settings); + let text = tree.to_text(25, Style::default(), true, &settings, &info); assert_eq!(text.lines.len(), 6); assert_eq!( text.lines[0], @@ -1520,6 +1611,7 @@ pub mod tests { #[test] fn test_emoji_shortcodes() { + let info = mock_room(); let mut enabled = mock_settings(); enabled.tunables.message_shortcode_display = true; let mut disabled = mock_settings(); @@ -1533,13 +1625,13 @@ pub mod tests { let s = format!("

{emoji}

"); let tree = parse_matrix_html(s.as_str()); // Test with emojis_shortcodes set to false - let text = tree.to_text(20, Style::default(), false, &disabled); + let text = tree.to_text(20, Style::default(), false, &disabled, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::raw(emoji), space_span(20 - emoji_width, Style::default()), ]),]); // Test with emojis_shortcodes set to true - let text = tree.to_text(20, Style::default(), false, &enabled); + let text = tree.to_text(20, Style::default(), false, &enabled, &info); assert_eq!(text.lines, vec![Line::from(vec![ Span::raw(replacement.as_str()), space_span(20 - replacement_width, Style::default()), diff --git a/src/message/mod.rs b/src/message/mod.rs index 718c7a68..28dfecc3 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -620,6 +620,7 @@ impl MessageColumns { struct MessageFormatter<'a> { settings: &'a ApplicationSettings, + info: &'a RoomInfo, /// How many columns to print. cols: MessageColumns, @@ -740,7 +741,7 @@ impl<'a> MessageFormatter<'a> { let width = self.width(); let w = width.saturating_sub(2); - let (mut replied, proto) = msg.show_msg(w, reply_style, true, settings); + let (mut replied, proto) = msg.show_msg(w, reply_style, true, settings, info); let mut sender = msg.sender_span(info, self.settings); let sender_width = UnicodeWidthStr::width(sender.content.as_ref()); let trailing = w.saturating_sub(sender_width + 1); @@ -778,7 +779,8 @@ impl<'a> MessageFormatter<'a> { } fn push_reactions(&mut self, counts: Vec<(&'a str, usize)>, style: Style, text: &mut Text<'a>) { - let mut emojis = printer::TextPrinter::new(self.width(), style, false, self.settings); + let mut emojis = + printer::TextPrinter::new(self.width(), style, false, self.settings, self.info); let mut reactions = 0; for (key, count) in counts { @@ -827,7 +829,8 @@ impl<'a> MessageFormatter<'a> { let plural = len != 1; let style = Style::default(); let mut threaded = - printer::TextPrinter::new(self.width(), style, false, self.settings).literal(true); + printer::TextPrinter::new(self.width(), style, false, self.settings, self.info) + .literal(true); let len = Span::styled(len.to_string(), style.add_modifier(StyleModifier::BOLD)); threaded.push_str(" \u{2937} ", style); threaded.push_span_nobreak(len); @@ -962,7 +965,17 @@ impl Message { .map(|user_id| user_id.to_owned()) .collect(); - MessageFormatter { settings, cols, orig, fill, user, date, time, read } + MessageFormatter { + settings, + cols, + orig, + fill, + user, + date, + time, + read, + info, + } } else if user_gutter + TIME_GUTTER + MIN_MSG_LEN <= width { let cols = MessageColumns::Three; let fill = width - user_gutter - TIME_GUTTER; @@ -970,7 +983,17 @@ impl Message { let time = self.timestamp.show_time(); let read = Vec::new(); - MessageFormatter { settings, cols, orig, fill, user, date, time, read } + MessageFormatter { + settings, + cols, + orig, + fill, + user, + date, + time, + read, + info, + } } else if user_gutter + MIN_MSG_LEN <= width { let cols = MessageColumns::Two; let fill = width - user_gutter; @@ -978,7 +1001,17 @@ impl Message { let time = None; let read = Vec::new(); - MessageFormatter { settings, cols, orig, fill, user, date, time, read } + MessageFormatter { + settings, + cols, + orig, + fill, + user, + date, + time, + read, + info, + } } else { let cols = MessageColumns::One; let fill = width.saturating_sub(2); @@ -986,7 +1019,17 @@ impl Message { let time = None; let read = Vec::new(); - MessageFormatter { settings, cols, orig, fill, user, date, time, read } + MessageFormatter { + settings, + cols, + orig, + fill, + user, + date, + time, + read, + info, + } } } @@ -1019,7 +1062,7 @@ impl Message { }); // Now show the message contents, and the inlined reply if we couldn't find it above. - let (msg, proto) = self.show_msg(width, style, reply.is_some(), settings); + let (msg, proto) = self.show_msg(width, style, reply.is_some(), settings, info); // Given our text so far, determine the image offset. let proto_main = proto.map(|p| { @@ -1067,9 +1110,10 @@ impl Message { style: Style, hide_reply: bool, settings: &'a ApplicationSettings, + info: &'a RoomInfo, ) -> (Text<'a>, Option<&'a Protocol>) { if let Some(html) = &self.html { - (html.to_text(width, style, hide_reply, settings), None) + (html.to_text(width, style, hide_reply, settings, info), None) } else { let mut msg = self.event.body(); if settings.tunables.message_shortcode_display { diff --git a/src/message/printer.rs b/src/message/printer.rs index 34187521..db4514c2 100644 --- a/src/message/printer.rs +++ b/src/message/printer.rs @@ -11,6 +11,7 @@ use ratatui::text::{Line, Span, Text}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +use crate::base::RoomInfo; use crate::config::{ApplicationSettings, TunableValues}; use crate::util::{ replace_emojis_in_line, @@ -33,6 +34,7 @@ pub struct TextPrinter<'a> { literal: bool, pub(super) settings: &'a ApplicationSettings, + pub(super) info: &'a RoomInfo, } impl<'a> TextPrinter<'a> { @@ -42,6 +44,7 @@ impl<'a> TextPrinter<'a> { base_style: Style, hide_reply: bool, settings: &'a ApplicationSettings, + info: &'a RoomInfo, ) -> Self { TextPrinter { text: Text::default(), @@ -54,6 +57,7 @@ impl<'a> TextPrinter<'a> { curr_width: 0, literal: false, settings, + info, } } @@ -105,6 +109,7 @@ impl<'a> TextPrinter<'a> { curr_width: 0, literal: self.literal, settings: self.settings, + info: self.info, } } @@ -303,12 +308,13 @@ impl<'a> TextPrinter<'a> { #[cfg(test)] pub mod tests { use super::*; - use crate::tests::mock_settings; + use crate::tests::{mock_room, mock_settings}; #[test] fn test_push_nobreak() { let settings = mock_settings(); - let mut printer = TextPrinter::new(5, Style::default(), false, &settings); + let info = mock_room(); + let mut printer = TextPrinter::new(5, Style::default(), false, &settings, &info); printer.push_span_nobreak("hello world".into()); let text = printer.finish(); assert_eq!(text.lines.len(), 1); diff --git a/src/message/state.rs b/src/message/state.rs index 6756140f..fd6baf7b 100644 --- a/src/message/state.rs +++ b/src/message/state.rs @@ -13,6 +13,8 @@ use matrix_sdk::ruma::{ UserId, }; +use crate::message::TreeGenState; + use super::html::{StyleTree, StyleTreeNode}; use ratatui::style::{Modifier as StyleModifier, Style}; @@ -497,12 +499,16 @@ pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree { let prefix = StyleTreeNode::Text("* set the room aliases to: ".into()); let mut cs = vec![prefix]; + let mut state = TreeGenState { link_num: 0 }; + for (i, alias) in content.aliases.iter().enumerate() { if i != 0 { cs.push(StyleTreeNode::Text(", ".into())); } - cs.push(StyleTreeNode::RoomAlias(alias.clone())); + let c = state.next_link_char(); + + cs.push(StyleTreeNode::RoomAlias(alias.clone(), c)); } cs @@ -597,7 +603,7 @@ pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree { let prev_details = prev_content.as_ref().map(|p| p.details()); let change = content.membership_change(prev_details, ev.sender(), &state_key); - let user_id = StyleTreeNode::UserId(state_key.clone()); + let user_id = StyleTreeNode::UserId(state_key.clone(), Some('0')); match change { MembershipChange::None => { @@ -676,7 +682,11 @@ pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree { (None, Some(new)) => { vec![ StyleTreeNode::Text("* set their display name to ".into()), - StyleTreeNode::DisplayName(new.into(), state_key), + StyleTreeNode::DisplayName( + new.into(), + state_key, + Some('0'), + ), ] }, (Some(old), Some(new)) => { @@ -684,9 +694,13 @@ pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree { StyleTreeNode::Text( "* changed their display name from ".into(), ), - StyleTreeNode::DisplayName(old.into(), state_key.clone()), + StyleTreeNode::DisplayName( + old.into(), + state_key.clone(), + Some('0'), + ), StyleTreeNode::Text(" to ".into()), - StyleTreeNode::DisplayName(new.into(), state_key), + StyleTreeNode::DisplayName(new.into(), state_key, None), ] }, (Some(_), None) => { @@ -765,7 +779,7 @@ pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree { .. }) => { let prefix = StyleTreeNode::Text("* upgraded the room; replacement room is ".into()); - let room = StyleTreeNode::RoomId(content.replacement_room.clone()); + let room = StyleTreeNode::RoomId(content.replacement_room.clone(), vec![], Some('0')); vec![prefix, room] }, AnyFullStateEventContent::RoomTopic(FullStateEventContent::Original { @@ -779,7 +793,7 @@ pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree { let prefix = StyleTreeNode::Text("* added a space child: ".into()); let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) { - StyleTreeNode::RoomId(room_id) + StyleTreeNode::RoomId(room_id, vec![], Some('0')) } else { bold(ev.state_key().to_string()) }; @@ -796,7 +810,7 @@ pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree { }; let room_id = if let Ok(room_id) = OwnedRoomId::from_str(ev.state_key()) { - StyleTreeNode::RoomId(room_id) + StyleTreeNode::RoomId(room_id, vec![], Some('0')) } else { bold(ev.state_key().to_string()) }; @@ -819,12 +833,16 @@ pub fn html_state(ev: &AnySyncStateEvent) -> StyleTree { ); let mut cs = vec![prefix]; + let mut state = TreeGenState { link_num: 0 }; + for (i, member) in content.service_members.iter().enumerate() { if i != 0 { cs.push(StyleTreeNode::Text(", ".into())); } - cs.push(StyleTreeNode::UserId(member.clone())); + let c = state.next_link_char(); + + cs.push(StyleTreeNode::UserId(member.clone(), c)); } cs diff --git a/src/tests.rs b/src/tests.rs index e9b05021..127d3181 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -4,10 +4,12 @@ use std::path::PathBuf; use matrix_sdk::ruma::{ event_id, events::room::message::{OriginalRoomMessageEvent, RoomMessageEventContent}, + owned_room_alias_id, server_name, user_id, EventId, OwnedEventId, + OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomId, @@ -46,9 +48,8 @@ use crate::{ worker::Requester, }; -const TEST_ROOM1_ALIAS: &str = "#room1:example.com"; - lazy_static! { + pub static ref TEST_ROOM1_ALIAS: OwnedRoomAliasId = owned_room_alias_id!("#room1:example.com"); pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new_v1(server_name!("example.com")).to_owned(); pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned(); @@ -154,6 +155,12 @@ pub fn mock_room() -> RoomInfo { room.name = Some("Watercooler Discussion".into()); room.keys = mock_keys(); *room.get_thread_mut(None) = mock_messages(); + + let user_id = TEST_USER2.clone(); + let name = "User 2"; + room.display_names.insert(user_id.clone(), name.to_string()); + room.display_name_completion.insert(name.to_string(), user_id.clone()); + room } @@ -248,7 +255,7 @@ pub async fn mock_store() -> ProgramStore { let info = mock_room(); store.rooms.insert(room_id.clone(), info); - store.names.insert(TEST_ROOM1_ALIAS.to_string(), room_id); + store.names.insert(TEST_ROOM1_ALIAS.clone(), room_id); ProgramStore::new(store) } diff --git a/src/windows/mod.rs b/src/windows/mod.rs index bb10415b..6cd57c5d 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -9,6 +9,7 @@ use std::cmp::{Ord, Ordering, PartialOrd}; use std::fmt::{self, Display}; use std::ops::Deref; +use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -856,8 +857,11 @@ impl Window for IambWindow { IambWindow::open(id, store) } else { - let room_id = worker.join_room(name.clone())?; - names.insert(name, room_id.clone()); + let room_id = worker.join_room(name.clone(), vec![])?; + + if let Ok(alias) = OwnedRoomAliasId::from_str(&name) { + names.insert(alias, room_id.clone()); + } let (room, name, tags) = store.application.worker.get_room(room_id)?; let room = RoomState::new(room, None, name, tags, store); @@ -900,7 +904,7 @@ impl GenericChatItem { info.tags.clone_from(&room_info.deref().1); if let Some(alias) = &alias { - store.application.names.insert(alias.to_string(), room_id.to_owned()); + store.application.names.insert(alias.to_owned(), room_id.to_owned()); } GenericChatItem { room_info, name, alias, is_dm, unread } @@ -1019,7 +1023,7 @@ impl RoomItem { info.tags.clone_from(&room_info.deref().1); if let Some(alias) = &alias { - store.application.names.insert(alias.to_string(), room_id.to_owned()); + store.application.names.insert(alias.to_owned(), room_id.to_owned()); } RoomItem { room_info, name, alias, unread } @@ -1241,7 +1245,7 @@ impl SpaceItem { let alias = room_info.0.canonical_alias(); if let Some(alias) = &alias { - store.application.names.insert(alias.to_string(), room_id.to_owned()); + store.application.names.insert(alias.to_owned(), room_id.to_owned()); } SpaceItem { room_info, name, alias } diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 3a518aa0..92c077bf 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -7,9 +7,14 @@ use std::path::{Path, PathBuf}; use edit::edit_with_builder as external_edit; use edit::Builder; +use matrix_sdk::ruma::events::room::message::{MessageFormat, ReplacementMetadata}; +use matrix_sdk::ruma::events::Mentions; +use matrix_sdk::ruma::matrix_uri::MatrixId; +use matrix_sdk::ruma::MatrixToUri; use matrix_sdk::EncryptionState; use modalkit::editing::store::RegisterError; use ratatui::style::{Color, Style}; +use regex::Regex; use std::process::Command; use tokio; use url::Url; @@ -20,13 +25,12 @@ use matrix_sdk::{ room::Room as MatrixRoom, ruma::{ events::reaction::ReactionEventContent, - events::relation::{Annotation, Replacement}, + events::relation::Annotation, events::room::message::{ AddMentions, ForwardThread, MessageType, OriginalRoomMessageEvent, - Relation, ReplyWithinThread, RoomMessageEventContent, TextMessageEventContent, @@ -212,123 +216,98 @@ impl ChatState { Err(UIError::NeedConfirm(prompt)) }, MessageAction::Download(filename, flags) => { - if let MessageEvent::Original(ev) = &msg.event { - let media = client.media(); - - let mut filename = match (filename, &settings.dirs.downloads) { - (Some(f), _) => PathBuf::from(f), - (None, Some(downloads)) => downloads.clone(), - (None, None) => return Err(IambError::NoDownloadDir.into()), - }; - - let (source, msg_filename) = match &ev.content.msgtype { - MessageType::Audio(c) => (c.source.clone(), c.filename()), - MessageType::File(c) => (c.source.clone(), c.filename()), - MessageType::Image(c) => (c.source.clone(), c.filename()), - MessageType::Video(c) => (c.source.clone(), c.filename()), - _ => { - if !flags.contains(DownloadFlags::OPEN) { - return Err(IambError::NoAttachment.into()); - } - - let links = if let Some(html) = &msg.html { - html.get_links() - } else { - linkify::LinkFinder::new() - .links(&msg.event.body()) - .filter_map(|u| Url::parse(u.as_str()).ok()) - .scan(TreeGenState { link_num: 0 }, |state, u| { - state.next_link_char().map(|c| (c, u)) - }) - .collect() - }; - - if links.is_empty() { - return Err(IambError::NoAttachment.into()); - } - - let choices = links - .into_iter() - .map(|l| { - let url = l.1.to_string(); - let act = IambAction::OpenLink(url.clone()).into(); - MultiChoiceItem::new(l.0, url, vec![act]) - }) - .collect(); - let dialog = MultiChoice::new(choices); - let err = UIError::NeedConfirm(Box::new(dialog)); - - return Err(err); - }, - }; - - if filename.is_dir() { - filename.push(msg_filename.replace(std::path::MAIN_SEPARATOR_STR, "_")); - } - - if filename.exists() && !flags.contains(DownloadFlags::FORCE) { - // Find an incrementally suffixed filename, e.g. image-2.jpg -> image-3.jpg - if let Some(stem) = filename.file_stem().and_then(OsStr::to_str) { - let ext = filename.extension(); - let mut filename_incr = filename.clone(); - for n in 1..=1000 { - if let Some(ext) = ext.and_then(OsStr::to_str) { - filename_incr.set_file_name(format!("{stem}-{n}.{ext}")); - } else { - filename_incr.set_file_name(format!("{stem}-{n}")); + match &msg.event { + MessageEvent::Original(ev) => { + let media = client.media(); + let mut filename = match (filename, &settings.dirs.downloads) { + (Some(f), _) => PathBuf::from(f), + (None, Some(downloads)) => downloads.clone(), + (None, None) => return Err(IambError::NoDownloadDir.into()), + }; + let (source, msg_filename) = match &ev.content.msgtype { + MessageType::Audio(c) => (c.source.clone(), c.filename()), + MessageType::File(c) => (c.source.clone(), c.filename()), + MessageType::Image(c) => (c.source.clone(), c.filename()), + MessageType::Video(c) => (c.source.clone(), c.filename()), + _ => { + if !flags.contains(DownloadFlags::OPEN) { + return Err(IambError::NoAttachment.into()); } - - if !filename_incr.exists() { - filename = filename_incr; - break; + let err = open_links(msg); + return Err(err); + }, + }; + if filename.is_dir() { + filename.push(msg_filename.replace(std::path::MAIN_SEPARATOR_STR, "_")); + } + if filename.exists() && !flags.contains(DownloadFlags::FORCE) { + // Find an incrementally suffixed filename, e.g. image-2.jpg -> image-3.jpg + if let Some(stem) = filename.file_stem().and_then(OsStr::to_str) { + let ext = filename.extension(); + let mut filename_incr = filename.clone(); + for n in 1..=1000 { + if let Some(ext) = ext.and_then(OsStr::to_str) { + filename_incr.set_file_name(format!("{stem}-{n}.{ext}")); + } else { + filename_incr.set_file_name(format!("{stem}-{n}")); + } + + if !filename_incr.exists() { + filename = filename_incr; + break; + } } } } - } + if !filename.exists() || flags.contains(DownloadFlags::FORCE) { + let req = MediaRequestParameters { source, format: MediaFormat::File }; - if !filename.exists() || flags.contains(DownloadFlags::FORCE) { - let req = MediaRequestParameters { source, format: MediaFormat::File }; + let bytes = media + .get_media_content(&req, true) + .await + .map_err(IambError::from)?; - let bytes = - media.get_media_content(&req, true).await.map_err(IambError::from)?; + fs::write(filename.as_path(), bytes.as_slice())?; - fs::write(filename.as_path(), bytes.as_slice())?; + msg.downloaded = true; + } else if !flags.contains(DownloadFlags::OPEN) { + let msg = format!( + "The file {} already exists; add ! to end of command to overwrite it.", + filename.display() + ); + let err = UIError::Failure(msg); - msg.downloaded = true; - } else if !flags.contains(DownloadFlags::OPEN) { - let msg = format!( - "The file {} already exists; add ! to end of command to overwrite it.", - filename.display() - ); - let err = UIError::Failure(msg); - - return Err(err); - } - - let info = if flags.contains(DownloadFlags::OPEN) { - let target = filename.clone().into_os_string(); - match open_command( - store.application.settings.tunables.open_command.as_ref(), - target, - ) { - Ok(_) => { - InfoMessage::from(format!( - "Attachment downloaded to {} and opened", - filename.display() - )) - }, - Err(err) => { - return Err(err); - }, + return Err(err); } - } else { - InfoMessage::from(format!( - "Attachment downloaded to {}", - filename.display() - )) - }; - - return Ok(info.into()); + let info = if flags.contains(DownloadFlags::OPEN) { + let target = filename.clone().into_os_string(); + match open_command( + store.application.settings.tunables.open_command.as_ref(), + target, + ) { + Ok(_) => { + InfoMessage::from(format!( + "Attachment downloaded to {} and opened", + filename.display() + )) + }, + Err(err) => { + return Err(err); + }, + } + } else { + InfoMessage::from(format!( + "Attachment downloaded to {}", + filename.display() + )) + }; + return Ok(info.into()); + }, + MessageEvent::State(_) => { + let err = open_links(msg); + return Err(err); + }, + _ => (), } Err(IambError::NoAttachment.into()) @@ -564,23 +543,52 @@ impl ChatState { let mut msg = text_to_message(msg); + // extract mentions from matrix links + let mut mentions = Mentions::new(); + if let MessageType::Text(content) = &msg.msgtype { + if let Some(formatted) = &content.formatted { + if matches!(&formatted.format, MessageFormat::Html) { + let html = formatted.body.as_str(); + + let re = Regex::new(r#""#) + .unwrap(); + + let user_ids = re.captures_iter(html).map(|capture| { + let link = capture.get(1).unwrap().as_str(); + let uri = MatrixToUri::parse(link).unwrap(); + let MatrixId::User(user_id) = uri.id() else { + unreachable!() + }; + user_id.to_owned() + }); + + mentions = Mentions::with_user_ids(user_ids); + } + } + } + msg = msg.add_mentions(mentions); + if let Some((_, event_id)) = &self.editing { - msg.relates_to = Some(Relation::Replacement(Replacement::new( - event_id.clone(), - msg.msgtype.clone().into(), - ))); + let mut mentions = None; + if let Some(message) = info.get_event(event_id) { + if let MessageEvent::Original(ev) = &message.event { + mentions = ev.content.mentions.clone(); + } + } + let metadata = ReplacementMetadata::new(event_id.to_owned(), mentions); + msg = msg.make_replacement(metadata); show_echo = false; } else if let Some(thread_root) = self.scrollback.thread() { if let Some(m) = self.get_reply_to(info) { - msg = msg.make_for_thread(m, ReplyWithinThread::Yes, AddMentions::No); + msg = msg.make_for_thread(m, ReplyWithinThread::Yes, AddMentions::Yes); } else if let Some(m) = info.get_thread_last(thread_root) { - msg = msg.make_for_thread(m, ReplyWithinThread::No, AddMentions::No); + msg = msg.make_for_thread(m, ReplyWithinThread::No, AddMentions::Yes); } else { // Internal state is wonky? } } else if let Some(m) = self.get_reply_to(info) { - msg = msg.make_reply_to(m, ForwardThread::Yes, AddMentions::No); + msg = msg.make_reply_to(m, ForwardThread::Yes, AddMentions::Yes); } // XXX: second parameter can be a locally unique transaction id. @@ -700,6 +708,33 @@ impl ChatState { } } +fn open_links(msg: &Message) -> UIError { + let links = if let Some(html) = &msg.html { + html.get_links() + } else { + linkify::LinkFinder::new() + .links(&msg.event.body()) + .filter_map(|u| Url::parse(u.as_str()).ok()) + .scan(TreeGenState { link_num: 0 }, |state, u| state.next_link_char().map(|c| (c, u))) + .collect() + }; + + if links.is_empty() { + return IambError::NoAttachment.into(); + } + + let choices = links + .into_iter() + .map(|l| { + let url = l.1.to_string(); + let act = IambAction::OpenLink(url.clone(), false).into(); + MultiChoiceItem::new(l.0, url, vec![act]) + }) + .collect(); + let dialog = MultiChoice::new(choices); + UIError::NeedConfirm(Box::new(dialog)) +} + macro_rules! delegate { ($s: expr, $id: ident => $e: expr) => { match $s.focus { diff --git a/src/worker.rs b/src/worker.rs index f3c9791a..60c36e95 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -13,6 +13,8 @@ use std::time::{Duration, Instant}; use futures::{stream::FuturesUnordered, StreamExt}; use gethostname::gethostname; +use matrix_sdk::ruma::OwnedRoomAliasId; +use matrix_sdk::OwnedServerName; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::Semaphore; use tokio::task::JoinHandle; @@ -435,7 +437,11 @@ fn members_insert( let user_id = member.user_id(); let display_name = member.display_name().map_or(user_id.to_string(), |str| str.to_string()); - info.display_names.insert(user_id.to_owned(), display_name); + let old_name = info.display_names.insert(user_id.to_owned(), display_name.clone()); + if let Some(old_name) = old_name { + info.display_name_completion.remove(&old_name); + } + info.display_name_completion.insert(display_name, user_id.to_owned()); } } // else ??? @@ -631,7 +637,8 @@ pub enum WorkerTask { Logout(String, ClientReply>), GetInviter(MatrixRoom, ClientReply>>), GetRoom(OwnedRoomId, ClientReply>), - JoinRoom(String, ClientReply>), + ResolveAlias(OwnedRoomAliasId, ClientReply>), + JoinRoom(String, Vec, ClientReply>), Members(OwnedRoomId, ClientReply>>), SpaceMembers(OwnedRoomId, ClientReply>>), TypingNotice(OwnedRoomId), @@ -666,9 +673,16 @@ impl Debug for WorkerTask { .field(&format_args!("_")) .finish() }, - WorkerTask::JoinRoom(s, _) => { + WorkerTask::ResolveAlias(s, _) => { + f.debug_tuple("WorkerTask::ResolveAlias") + .field(s) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::JoinRoom(s, via, _) => { f.debug_tuple("WorkerTask::JoinRoom") .field(s) + .field(via) .field(&format_args!("_")) .finish() }, @@ -802,10 +816,18 @@ impl Requester { return response.recv(); } - pub fn join_room(&self, name: String) -> IambResult { + pub fn resolve_alias(&self, alias_id: OwnedRoomAliasId) -> IambResult { + let (reply, response) = oneshot(); + + self.tx.send(WorkerTask::ResolveAlias(alias_id, reply)).unwrap(); + + return response.recv(); + } + + pub fn join_room(&self, name: String, via: Vec) -> IambResult { let (reply, response) = oneshot(); - self.tx.send(WorkerTask::JoinRoom(name, reply)).unwrap(); + self.tx.send(WorkerTask::JoinRoom(name, via, reply)).unwrap(); return response.recv(); } @@ -898,9 +920,13 @@ impl ClientWorker { self.init(store).await; reply.send(()); }, - WorkerTask::JoinRoom(room_id, reply) => { + WorkerTask::ResolveAlias(alias_id, reply) => { assert!(self.initialized); - reply.send(self.join_room(room_id).await); + reply.send(self.resolve_alias(alias_id).await); + }, + WorkerTask::JoinRoom(name, via, reply) => { + assert!(self.initialized); + reply.send(self.join_room(name, via).await); }, WorkerTask::GetInviter(invited, reply) => { assert!(self.initialized); @@ -1132,11 +1158,21 @@ impl ClientWorker { let info = locked.application.get_room_info(room_id.to_owned()); if ambiguous { - info.display_names.remove(&user_id); + let old_name = info.display_names.remove(&user_id); + if let Some(old_name) = old_name { + info.display_name_completion.remove(&old_name); + } } else if let Some(display) = ev.content.displayname { - info.display_names.insert(user_id, display); + let old_name = info.display_names.insert(user_id.clone(), display.clone()); + if let Some(old_name) = old_name { + info.display_name_completion.remove(&old_name); + } + info.display_name_completion.insert(display, user_id); } else { - info.display_names.remove(&user_id); + let old_name = info.display_names.remove(&user_id); + if let Some(old_name) = old_name { + info.display_name_completion.remove(&old_name); + } } } }, @@ -1356,8 +1392,8 @@ impl ClientWorker { } async fn direct_message(&mut self, user: OwnedUserId) -> IambResult { - for room in self.client.rooms() { - if !is_direct(&room).await { + for room in self.client.joined_rooms().iter().chain(self.client.invited_rooms().iter()) { + if !is_direct(room).await { continue; } @@ -1389,7 +1425,11 @@ impl ClientWorker { async fn get_room(&mut self, room_id: OwnedRoomId) -> IambResult { if let Some(room) = self.client.get_room(&room_id) { - let name = room.cached_display_name().ok_or_else(|| IambError::UnknownRoom(room_id))?; + let name = if let Some(name) = room.cached_display_name() { + name + } else { + room.display_name().await.map_err(IambError::from)? + }; let tags = room.tags().await.map_err(IambError::from)?; Ok((room, name, tags)) @@ -1398,9 +1438,25 @@ impl ClientWorker { } } - async fn join_room(&mut self, name: String) -> IambResult { + async fn resolve_alias(&mut self, alias_id: OwnedRoomAliasId) -> IambResult { + match self.client.resolve_room_alias(&alias_id).await { + Ok(resp) => Ok(resp.room_id), + Err(e) => { + let msg = e.to_string(); + let err = UIError::Failure(msg); + + return Err(err); + }, + } + } + + async fn join_room( + &mut self, + name: String, + via: Vec, + ) -> IambResult { if let Ok(alias_id) = OwnedRoomOrAliasId::from_str(name.as_str()) { - match self.client.join_room_by_id_or_alias(&alias_id, &[]).await { + match self.client.join_room_by_id_or_alias(&alias_id, &via).await { Ok(resp) => Ok(resp.room_id().to_owned()), Err(e) => { let msg = e.to_string();