diff --git a/Cargo.lock b/Cargo.lock index 1a64bda..e8b84d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,15 +1019,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "hermit-abi" version = "0.2.6" @@ -1137,6 +1128,8 @@ dependencies = [ "gethostname", "lazy_static", "matrix-sdk", + "mime", + "mime_guess", "modalkit", "regex", "rpassword", @@ -1271,7 +1264,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "927609f78c2913a6f6ac3c27a4fe87f43e2a35367c0c4b0f8265e8f49a104330" dependencies = [ - "hermit-abi 0.2.6", + "hermit-abi", "io-lifetimes", "rustix", "windows-sys", @@ -1580,6 +1573,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1658,11 +1661,11 @@ dependencies = [ [[package]] name = "num_cpus" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6058e64324c71e02bc2b150e4f3bc8286db6c83092132ffa3f6b1eab0f9def5" +checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" dependencies = [ - "hermit-abi 0.1.19", + "hermit-abi", "libc", ] @@ -2589,9 +2592,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.23.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab6d665857cc6ca78d6e80303a02cea7a7851e85dfbd77cbdc09bd129f1ef46" +checksum = "1d9f76183f91ecfb55e1d7d5602bd1d979e38a3a522fe900241cf195624d67ae" dependencies = [ "autocfg", "bytes", @@ -2599,9 +2602,7 @@ dependencies = [ "memchr", "mio", "num_cpus", - "parking_lot 0.12.1", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys", @@ -2752,6 +2753,15 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.8" diff --git a/Cargo.toml b/Cargo.toml index f3200a0..4042b29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ dirs = "4.0.0" futures = "0.3.21" gethostname = "0.4.1" matrix-sdk = {version = "0.6", default-features = false, features = ["e2e-encryption", "sled", "rustls-tls"]} +mime = "^0.3.16" +mime_guess = "^2.0.4" modalkit = "0.0.9" regex = "^1.5" rpassword = "^7.2" @@ -26,7 +28,7 @@ serde = "^1.0" serde_json = "^1.0" sled = "0.34" thiserror = "^1.0.37" -tokio = {version = "1.17.0", features = ["full"]} +tokio = {version = "1.24.1", features = ["macros", "net", "rt-multi-thread", "sync", "time"]} tracing = "~0.1.36" tracing-appender = "~0.2.2" tracing-subscriber = "0.3.16" diff --git a/README.md b/README.md index 4f18f6f..dde272d 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ two other TUI clients and Element Web: | Typing Notification | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | E2E | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | Replies | :x: ([#3]) | :heavy_check_mark: | :x: | :heavy_check_mark: | -| Attachment uploading | :x: ([#13]) | :x: | :heavy_check_mark: | :heavy_check_mark: | -| Attachment downloading | :x: ([#13]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | +| Attachment uploading | :heavy_check_mark: | :x: | :heavy_check_mark: | :heavy_check_mark: | +| Attachment downloading | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | Send stickers | :x: | :x: | :x: | :heavy_check_mark: | | Send formatted messages (markdown) | :x: ([#10]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | Rich Text Editor for formatted messages | :x: | :x: | :x: | :heavy_check_mark: | diff --git a/src/base.rs b/src/base.rs index 42ecbe9..a8fbb2d 100644 --- a/src/base.rs +++ b/src/base.rs @@ -59,6 +59,11 @@ pub enum VerifyAction { Mismatch, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum MessageAction { + Download(Option, bool), +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum SetRoomField { Name(String), @@ -77,26 +82,46 @@ impl From for RoomAction { } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SendAction { + Submit, + Upload(String), +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum IambAction { + Message(MessageAction), Room(RoomAction), + Send(SendAction), Verify(VerifyAction, String), VerifyRequest(String), - SendMessage(OwnedRoomId, String), ToggleScrollbackFocus, } +impl From for IambAction { + fn from(act: MessageAction) -> Self { + IambAction::Message(act) + } +} + impl From for IambAction { fn from(act: RoomAction) -> Self { IambAction::Room(act) } } +impl From for IambAction { + fn from(act: SendAction) -> Self { + IambAction::Send(act) + } +} + impl ApplicationAction for IambAction { fn is_edit_sequence(&self, _: &C) -> SequenceStatus { match self { + IambAction::Message(..) => SequenceStatus::Break, IambAction::Room(..) => SequenceStatus::Break, - IambAction::SendMessage(..) => SequenceStatus::Break, + IambAction::Send(..) => SequenceStatus::Break, IambAction::ToggleScrollbackFocus => SequenceStatus::Break, IambAction::Verify(..) => SequenceStatus::Break, IambAction::VerifyRequest(..) => SequenceStatus::Break, @@ -105,8 +130,9 @@ impl ApplicationAction for IambAction { fn is_last_action(&self, _: &C) -> SequenceStatus { match self { + IambAction::Message(..) => SequenceStatus::Atom, IambAction::Room(..) => SequenceStatus::Atom, - IambAction::SendMessage(..) => SequenceStatus::Atom, + IambAction::Send(..) => SequenceStatus::Atom, IambAction::ToggleScrollbackFocus => SequenceStatus::Atom, IambAction::Verify(..) => SequenceStatus::Atom, IambAction::VerifyRequest(..) => SequenceStatus::Atom, @@ -115,8 +141,9 @@ impl ApplicationAction for IambAction { fn is_last_selection(&self, _: &C) -> SequenceStatus { match self { + IambAction::Message(..) => SequenceStatus::Ignore, IambAction::Room(..) => SequenceStatus::Ignore, - IambAction::SendMessage(..) => SequenceStatus::Ignore, + IambAction::Send(..) => SequenceStatus::Ignore, IambAction::ToggleScrollbackFocus => SequenceStatus::Ignore, IambAction::Verify(..) => SequenceStatus::Ignore, IambAction::VerifyRequest(..) => SequenceStatus::Ignore, @@ -125,8 +152,9 @@ impl ApplicationAction for IambAction { fn is_switchable(&self, _: &C) -> bool { match self { + IambAction::Message(..) => false, IambAction::Room(..) => false, - IambAction::SendMessage(..) => false, + IambAction::Send(..) => false, IambAction::ToggleScrollbackFocus => false, IambAction::Verify(..) => false, IambAction::VerifyRequest(..) => false, @@ -173,6 +201,21 @@ pub enum IambError { #[error("Serialization/deserialization error: {0}")] Serde(#[from] serde_json::Error), + #[error("Selected message does not have any attachments")] + NoAttachment, + + #[error("No message currently selected")] + NoSelectedMessage, + + #[error("Current window is not a room or space")] + NoSelectedRoomOrSpace, + + #[error("Current window is not a room")] + NoSelectedRoom, + + #[error("You need to join the room before you can do that")] + NotJoined, + #[error("Unknown room identifier: {0}")] UnknownRoom(OwnedRoomId), diff --git a/src/commands.rs b/src/commands.rs index 8ce02f1..00317c4 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -8,10 +8,12 @@ use modalkit::{ use crate::base::{ IambAction, IambId, + MessageAction, ProgramCommand, ProgramCommands, ProgramContext, RoomAction, + SendAction, SetRoomField, VerifyAction, }; @@ -149,13 +151,43 @@ fn iamb_set(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { return Ok(step); } +fn iamb_upload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() != 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let sact = SendAction::Upload(args.remove(0)); + let iact = IambAction::from(sact); + let step = CommandStep::Continue(iact.into(), ctx.context.take()); + + return Ok(step); +} + +fn iamb_download(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { + let mut args = desc.arg.strings()?; + + if args.len() > 1 { + return Result::Err(CommandError::InvalidArgument); + } + + let mact = MessageAction::Download(args.pop(), desc.bang); + let iact = IambAction::from(mact); + let step = CommandStep::Continue(iact.into(), ctx.context.take()); + + return Ok(step); +} + fn add_iamb_commands(cmds: &mut ProgramCommands) { cmds.add_command(ProgramCommand { names: vec!["dms".into()], f: iamb_dms }); + cmds.add_command(ProgramCommand { names: vec!["download".into()], f: iamb_download }); cmds.add_command(ProgramCommand { names: vec!["join".into()], f: iamb_join }); cmds.add_command(ProgramCommand { names: vec!["members".into()], f: iamb_members }); cmds.add_command(ProgramCommand { names: vec!["rooms".into()], f: iamb_rooms }); cmds.add_command(ProgramCommand { names: vec!["set".into()], f: iamb_set }); cmds.add_command(ProgramCommand { names: vec!["spaces".into()], f: iamb_spaces }); + cmds.add_command(ProgramCommand { names: vec!["upload".into()], f: iamb_upload }); cmds.add_command(ProgramCommand { names: vec!["verify".into()], f: iamb_verify }); cmds.add_command(ProgramCommand { names: vec!["welcome".into()], f: iamb_welcome }); } diff --git a/src/config.rs b/src/config.rs index 13ae6f8..82cdb9e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -237,7 +237,11 @@ impl Directories { fn values(self) -> DirectoryValues { let cache = self .cache - .or_else(dirs::cache_dir) + .or_else(|| { + let mut dir = dirs::cache_dir()?; + dir.push("iamb"); + dir.into() + }) .expect("no dirs.cache value configured!"); let logs = self.logs.unwrap_or_else(|| { diff --git a/src/main.rs b/src/main.rs index da27efb..0e485c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use std::fs::{create_dir_all, File}; use std::io::{stdout, BufReader, Stdout}; use std::ops::DerefMut; use std::process; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -63,7 +64,6 @@ use crate::{ ProgramStore, }, config::{ApplicationSettings, Iamb}, - message::{Message, MessageContent, MessageTimeStamp}, windows::IambWindow, worker::{ClientWorker, LoginStyle, Requester}, }; @@ -226,7 +226,7 @@ impl Application { } } - fn action_run( + async fn action_run( &mut self, action: ProgramAction, ctx: ProgramContext, @@ -257,7 +257,7 @@ impl Application { }, // Simple delegations. - Action::Application(act) => self.iamb_run(act, ctx, store)?, + Action::Application(act) => self.iamb_run(act, ctx, store).await?, Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?, Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?, Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?, @@ -314,7 +314,7 @@ impl Application { return Ok(info); } - fn iamb_run( + async fn iamb_run( &mut self, action: IambAction, ctx: ProgramContext, @@ -327,24 +327,19 @@ impl Application { None }, + IambAction::Message(act) => { + self.screen.current_window_mut()?.message_command(act, ctx, store).await? + }, IambAction::Room(act) => { - let acts = self.screen.current_window_mut()?.room_command(act, ctx, store)?; + let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?; self.action_prepend(acts); None }, - - IambAction::SendMessage(room_id, msg) => { - let (event_id, msg) = self.worker.send_message(room_id.clone(), msg)?; - let user = store.application.settings.profile.user_id.clone(); - let info = store.application.get_room_info(room_id); - let key = (MessageTimeStamp::LocalEcho, event_id); - let msg = MessageContent::Original(msg.into()); - let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho); - info.messages.insert(key, msg); - - None + IambAction::Send(act) => { + self.screen.current_window_mut()?.send_command(act, ctx, store).await? }, + IambAction::Verify(act, user_dev) => { if let Some(sas) = store.application.verifications.get(&user_dev) { self.worker.verify(act, sas.clone())? @@ -378,7 +373,7 @@ impl Application { let mut keyskip = false; while let Some((action, ctx)) = self.action_pop(keyskip) { - match self.action_run(action, ctx, locked.deref_mut()) { + match self.action_run(action, ctx, locked.deref_mut()).await { Ok(None) => { // Continue processing. continue; @@ -408,7 +403,7 @@ impl Application { } } -fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> { +async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> { println!("Logging in for {}...", settings.profile.user_id); if settings.session_json.is_file() { @@ -447,38 +442,15 @@ fn print_exit(v: T) -> N { process::exit(2); } -#[tokio::main] -async fn main() -> IambResult<()> { - // Parse command-line flags. - let iamb = Iamb::parse(); - - // Load configuration and set up the Matrix SDK. - let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit); - - // Set up the tracing subscriber so we can log client messages. - let log_prefix = format!("iamb-log-{}", settings.profile_name); - let log_dir = settings.dirs.logs.as_path(); - - create_dir_all(settings.matrix_dir.as_path())?; - create_dir_all(log_dir)?; - - let appender = tracing_appender::rolling::daily(log_dir, log_prefix); - let (appender, _) = tracing_appender::non_blocking(appender); - - let subscriber = FmtSubscriber::builder() - .with_writer(appender) - .with_max_level(Level::WARN) - .finish(); - tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); - +async fn run(settings: ApplicationSettings) -> IambResult<()> { // Set up the async worker thread and global store. - let worker = ClientWorker::spawn(settings.clone()); + let worker = ClientWorker::spawn(settings.clone()).await; let store = ChatStore::new(worker.clone(), settings.clone()); let store = Store::new(store); let store = Arc::new(AsyncMutex::new(store)); worker.init(store.clone()); - login(worker, &settings).unwrap_or_else(print_exit); + login(worker, &settings).await.unwrap_or_else(print_exit); // Make sure panics clean up the terminal properly. let orig_hook = std::panic::take_hook(); @@ -495,5 +467,44 @@ async fn main() -> IambResult<()> { // We can now run the application. application.run().await?; + Ok(()) +} + +fn main() -> IambResult<()> { + // Parse command-line flags. + let iamb = Iamb::parse(); + + // Load configuration and set up the Matrix SDK. + let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit); + + // Set up the tracing subscriber so we can log client messages. + let log_prefix = format!("iamb-log-{}", settings.profile_name); + let log_dir = settings.dirs.logs.as_path(); + + create_dir_all(settings.matrix_dir.as_path())?; + create_dir_all(log_dir)?; + + let appender = tracing_appender::rolling::daily(log_dir, log_prefix); + let (appender, guard) = tracing_appender::non_blocking(appender); + + let subscriber = FmtSubscriber::builder() + .with_writer(appender) + .with_max_level(Level::TRACE) + .finish(); + tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name_fn(|| { + static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0); + let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst); + format!("iamb-worker-{}", id) + }) + .build() + .unwrap(); + + rt.block_on(async move { run(settings).await })?; + + drop(guard); process::exit(0); } diff --git a/src/message.rs b/src/message.rs index 3050292..d0d5b02 100644 --- a/src/message.rs +++ b/src/message.rs @@ -309,46 +309,41 @@ pub enum MessageContent { Redacted, } -impl AsRef for MessageContent { - fn as_ref(&self) -> &str { +impl MessageContent { + pub fn show(&self) -> Cow<'_, str> { match self { MessageContent::Original(ev) => { - match &ev.msgtype { - MessageType::Text(content) => { - return content.body.as_ref(); - }, - MessageType::Emote(content) => { - return content.body.as_ref(); - }, - MessageType::Notice(content) => { - return content.body.as_str(); - }, - MessageType::ServerNotice(_) => { - // XXX: implement + let s = match &ev.msgtype { + MessageType::Text(content) => content.body.as_ref(), + MessageType::Emote(content) => content.body.as_ref(), + MessageType::Notice(content) => content.body.as_str(), + MessageType::ServerNotice(content) => content.body.as_str(), - return "[server notice]"; - }, MessageType::VerificationRequest(_) => { // XXX: implement - return "[verification request]"; + return Cow::Owned("[verification request]".into()); }, - MessageType::Audio(..) => { - return "[audio]"; + MessageType::Audio(content) => { + return Cow::Owned(format!("[Attached Audio: {}]", content.body)); }, - MessageType::File(..) => { - return "[file]"; + MessageType::File(content) => { + return Cow::Owned(format!("[Attached File: {}]", content.body)); }, - MessageType::Image(..) => { - return "[image]"; + MessageType::Image(content) => { + return Cow::Owned(format!("[Attached Image: {}]", content.body)); }, - MessageType::Video(..) => { - return "[video]"; + MessageType::Video(content) => { + return Cow::Owned(format!("[Attached Video: {}]", content.body)); }, - _ => return "[unknown message type]", - } + _ => { + return Cow::Owned(format!("[Unknown message type: {:?}]", ev.msgtype())); + }, + }; + + Cow::Borrowed(s) }, - MessageContent::Redacted => "[redacted]", + MessageContent::Redacted => Cow::Borrowed("[redacted]"), } } } @@ -358,11 +353,12 @@ pub struct Message { pub content: MessageContent, pub sender: OwnedUserId, pub timestamp: MessageTimeStamp, + pub downloaded: bool, } impl Message { pub fn new(content: MessageContent, sender: OwnedUserId, timestamp: MessageTimeStamp) -> Self { - Message { content, sender, timestamp } + Message { content, sender, timestamp, downloaded: false } } pub fn show( @@ -373,7 +369,13 @@ impl Message { settings: &ApplicationSettings, ) -> Text { let width = vwctx.get_width(); - let msg = self.as_ref(); + let mut msg = self.content.show(); + + if self.downloaded { + msg.to_mut().push_str(" \u{2705}"); + } + + let msg = msg.as_ref(); let mut lines = vec![]; @@ -391,7 +393,7 @@ impl Message { let lw = width - USER_GUTTER - TIME_GUTTER; for (i, (line, w)) in wrap(msg, lw).enumerate() { - let line = Span::styled(line, style); + let line = Span::styled(line.to_string(), style); let trailing = Span::styled(space(lw.saturating_sub(w)), style); if i == 0 { @@ -412,7 +414,7 @@ impl Message { let lw = width - USER_GUTTER; for (i, (line, w)) in wrap(msg, lw).enumerate() { - let line = Span::styled(line, style); + let line = Span::styled(line.to_string(), style); let trailing = Span::styled(space(lw.saturating_sub(w)), style); let prefix = if i == 0 { @@ -478,15 +480,9 @@ impl From for Message { } } -impl AsRef for Message { - fn as_ref(&self) -> &str { - self.content.as_ref() - } -} - impl ToString for Message { fn to_string(&self) -> String { - self.as_ref().to_string() + self.content.show().into_owned() } } diff --git a/src/tests.rs b/src/tests.rs index 342cf5e..3b79b20 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,6 +1,5 @@ use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; -use std::sync::mpsc::sync_channel; use matrix_sdk::ruma::{ event_id, @@ -16,6 +15,7 @@ use matrix_sdk::ruma::{ use lazy_static::lazy_static; use modalkit::tui::style::Color; +use tokio::sync::mpsc::unbounded_channel; use url::Url; use crate::{ @@ -154,9 +154,11 @@ pub fn mock_settings() -> ApplicationSettings { } } -pub fn mock_store() -> ProgramStore { - let (tx, _) = sync_channel(5); - let worker = Requester { tx }; +pub async fn mock_store() -> ProgramStore { + let (tx, _) = unbounded_channel(); + let homeserver = Url::parse("https://localhost").unwrap(); + let client = matrix_sdk::Client::new(homeserver).await.unwrap(); + let worker = Requester { tx, client }; let mut store = ChatStore::new(worker, mock_settings()); let room_id = TEST_ROOM1_ID.clone(); diff --git a/src/windows/mod.rs b/src/windows/mod.rs index 8b98557..5a356de 100644 --- a/src/windows/mod.rs +++ b/src/windows/mod.rs @@ -54,13 +54,16 @@ use modalkit::{ use crate::base::{ ChatStore, IambBufferId, + IambError, IambId, IambInfo, IambResult, + MessageAction, ProgramAction, ProgramContext, ProgramStore, RoomAction, + SendAction, }; use self::{room::RoomState, welcome::WelcomeState}; @@ -102,6 +105,20 @@ fn selected_text(s: &str, selected: bool) -> Text { Text::from(selected_span(s, selected)) } +fn room_cmp(a: &MatrixRoom, b: &MatrixRoom) -> Ordering { + let ca1 = a.canonical_alias(); + let ca2 = b.canonical_alias(); + + let ord = match (ca1, ca2) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(ca1), Some(ca2)) => ca1.cmp(&ca2), + }; + + ord.then_with(|| a.room_id().cmp(b.room_id())) +} + #[inline] fn room_prompt( room_id: &RoomId, @@ -165,19 +182,42 @@ impl IambWindow { } } - pub fn room_command( + pub async fn message_command( + &mut self, + act: MessageAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + if let IambWindow::Room(w) = self { + w.message_command(act, ctx, store).await + } else { + return Err(IambError::NoSelectedRoom.into()); + } + } + + pub async fn room_command( &mut self, act: RoomAction, ctx: ProgramContext, store: &mut ProgramStore, ) -> IambResult, ProgramContext)>> { if let IambWindow::Room(w) = self { - w.room_command(act, ctx, store) + w.room_command(act, ctx, store).await } else { - let msg = "No room currently focused!"; - let err = UIError::Failure(msg.into()); + return Err(IambError::NoSelectedRoomOrSpace.into()); + } + } - return Err(err); + pub async fn send_command( + &mut self, + act: SendAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + if let IambWindow::Room(w) = self { + w.send_command(act, ctx, store).await + } else { + return Err(IambError::NoSelectedRoom.into()); } } } @@ -304,8 +344,13 @@ impl WindowOps for IambWindow { }, IambWindow::RoomList(state) => { let joined = store.application.worker.joined_rooms(); - let items = joined.into_iter().map(|(id, name)| RoomItem::new(id, name, store)); - state.set(items.collect()); + let mut items = joined + .into_iter() + .map(|(id, name)| RoomItem::new(id, name, store)) + .collect::>(); + items.sort(); + + state.set(items); List::new(store) .empty_message("You haven't joined any rooms yet") @@ -515,6 +560,26 @@ impl RoomItem { } } +impl PartialEq for RoomItem { + fn eq(&self, other: &Self) -> bool { + self.room.room_id() == other.room.room_id() + } +} + +impl Eq for RoomItem {} + +impl Ord for RoomItem { + fn cmp(&self, other: &Self) -> Ordering { + room_cmp(&self.room, &other.room) + } +} + +impl PartialOrd for RoomItem { + fn partial_cmp(&self, other: &Self) -> Option { + self.cmp(other).into() + } +} + impl ToString for RoomItem { fn to_string(&self) -> String { return self.name.clone(); @@ -601,6 +666,26 @@ impl SpaceItem { } } +impl PartialEq for SpaceItem { + fn eq(&self, other: &Self) -> bool { + self.room.room_id() == other.room.room_id() + } +} + +impl Eq for SpaceItem {} + +impl Ord for SpaceItem { + fn cmp(&self, other: &Self) -> Ordering { + room_cmp(&self.room, &other.room) + } +} + +impl PartialOrd for SpaceItem { + fn partial_cmp(&self, other: &Self) -> Option { + self.cmp(other).into() + } +} + impl ToString for SpaceItem { fn to_string(&self) -> String { return self.room.room_id().to_string(); diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 9a74c27..e745b79 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -1,11 +1,21 @@ +use std::borrow::Cow; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; + use matrix_sdk::{ + attachment::AttachmentConfig, + media::{MediaFormat, MediaRequest}, room::Room as MatrixRoom, - ruma::{OwnedRoomId, RoomId}, + ruma::{ + events::room::message::{MessageType, RoomMessageEventContent, TextMessageEventContent}, + OwnedRoomId, + RoomId, + }, }; -use modalkit::tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget}; - use modalkit::{ + tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget}, widgets::textbox::{TextBox, TextBoxState}, widgets::TerminalCursor, widgets::{PromptActions, WindowOps}, @@ -18,10 +28,12 @@ use modalkit::editing::{ EditResult, Editable, EditorAction, + InfoMessage, Jumpable, PromptAction, Promptable, Scrollable, + UIError, }, base::{CloseFlags, Count, MoveDir1D, PositionList, ScrollStyle, WordStyle}, context::Resolve, @@ -32,14 +44,19 @@ use modalkit::editing::{ use crate::base::{ IambAction, IambBufferId, + IambError, IambInfo, IambResult, + MessageAction, ProgramAction, ProgramContext, ProgramStore, RoomFocus, + SendAction, }; +use crate::message::{Message, MessageContent, MessageTimeStamp}; + use super::scrollback::{Scrollback, ScrollbackState}; pub struct ChatState { @@ -75,6 +92,169 @@ impl ChatState { } } + pub async fn message_command( + &mut self, + act: MessageAction, + _: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + let client = &store.application.worker.client; + + let settings = &store.application.settings; + let info = store.application.rooms.entry(self.room_id.clone()).or_default(); + + let msg = self.scrollback.get_mut(info).ok_or(IambError::NoSelectedMessage)?; + + match act { + MessageAction::Download(filename, force) => { + if let MessageContent::Original(ev) = &msg.content { + let media = client.media(); + + let mut filename = match filename { + Some(f) => PathBuf::from(f), + None => settings.dirs.downloads.clone(), + }; + + let source = match &ev.msgtype { + MessageType::Audio(c) => { + if filename.is_dir() { + filename.push(c.body.as_str()); + } + + c.source.clone() + }, + MessageType::File(c) => { + if filename.is_dir() { + if let Some(name) = &c.filename { + filename.push(name); + } else { + filename.push(c.body.as_str()); + } + } + + c.source.clone() + }, + MessageType::Image(c) => { + if filename.is_dir() { + filename.push(c.body.as_str()); + } + + c.source.clone() + }, + MessageType::Video(c) => { + if filename.is_dir() { + filename.push(c.body.as_str()); + } + + c.source.clone() + }, + _ => { + return Err(IambError::NoAttachment.into()); + }, + }; + + if !force && filename.exists() { + let msg = format!( + "The file {} already exists; use :download! to overwrite it.", + filename.display() + ); + let err = UIError::Failure(msg); + + return Err(err); + } + + let req = MediaRequest { source, format: MediaFormat::File }; + + let bytes = + media.get_media_content(&req, true).await.map_err(IambError::from)?; + + fs::write(filename.as_path(), bytes.as_slice())?; + + msg.downloaded = true; + + let info = InfoMessage::from(format!( + "Attachment downloaded to {}", + filename.display() + )); + + return Ok(info.into()); + } + + Err(IambError::NoAttachment.into()) + }, + } + } + + pub async fn send_command( + &mut self, + act: SendAction, + _: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + let room = store + .application + .worker + .client + .get_joined_room(self.id()) + .ok_or(IambError::NotJoined)?; + + let (event_id, msg) = match act { + SendAction::Submit => { + let msg = self.tbox.get_text(); + + if msg.is_empty() { + return Ok(None); + } + + let msg = TextMessageEventContent::plain(msg); + let msg = MessageType::Text(msg); + let msg = RoomMessageEventContent::new(msg); + + // XXX: second parameter can be a locally unique transaction id. + // Useful for doing retries. + let resp = room.send(msg.clone(), None).await.map_err(IambError::from)?; + let event_id = resp.event_id; + + // Clear the TextBoxState contents now that the message is sent. + self.tbox.reset(); + + (event_id, msg) + }, + SendAction::Upload(file) => { + let path = Path::new(file.as_str()); + let mime = mime_guess::from_path(path).first_or(mime::APPLICATION_OCTET_STREAM); + + let bytes = fs::read(path)?; + let name = path + .file_name() + .map(OsStr::to_string_lossy) + .unwrap_or_else(|| Cow::from("Attachment")); + let config = AttachmentConfig::new(); + + let resp = room + .send_attachment(name.as_ref(), &mime, bytes.as_ref(), config) + .await + .map_err(IambError::from)?; + + // Mock up the local echo message for the scrollback. + let msg = TextMessageEventContent::plain(format!("[Attached File: {}]", name)); + let msg = MessageType::Text(msg); + let msg = RoomMessageEventContent::new(msg); + + (resp.event_id, msg) + }, + }; + + let user = store.application.settings.profile.user_id.clone(); + let info = store.application.get_room_info(self.id().to_owned()); + let key = (MessageTimeStamp::LocalEcho, event_id); + let msg = MessageContent::Original(msg.into()); + let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho); + info.messages.insert(key, msg); + + Ok(None) + } + pub fn focus_toggle(&mut self) { self.focus = match self.focus { RoomFocus::Scrollback => RoomFocus::MessageBar, @@ -229,17 +409,9 @@ impl PromptActions for ChatState { ctx: &ProgramContext, _: &mut ProgramStore, ) -> EditResult, IambInfo> { - let txt = self.tbox.reset_text(); + let act = SendAction::Submit; - let act = if txt.is_empty() { - vec![] - } else { - let act = IambAction::SendMessage(self.room_id.clone(), txt).into(); - - vec![(act, ctx.clone())] - }; - - Ok(act) + Ok(vec![(IambAction::from(act).into(), ctx.clone())]) } fn abort( diff --git a/src/windows/room/mod.rs b/src/windows/room/mod.rs index b8577fe..84fd687 100644 --- a/src/windows/room/mod.rs +++ b/src/windows/room/mod.rs @@ -1,6 +1,4 @@ -use matrix_sdk::room::Room as MatrixRoom; -use matrix_sdk::ruma::RoomId; -use matrix_sdk::DisplayName; +use matrix_sdk::{room::Room as MatrixRoom, ruma::RoomId, DisplayName}; use modalkit::tui::{ buffer::Buffer, @@ -37,13 +35,16 @@ use modalkit::{ }; use crate::base::{ + IambError, IambId, IambInfo, IambResult, + MessageAction, ProgramAction, ProgramContext, ProgramStore, RoomAction, + SendAction, }; use self::chat::ChatState; @@ -92,7 +93,31 @@ impl RoomState { } } - pub fn room_command( + pub async fn message_command( + &mut self, + act: MessageAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + match self { + RoomState::Chat(chat) => chat.message_command(act, ctx, store).await, + RoomState::Space(_) => Err(IambError::NoSelectedMessage.into()), + } + } + + pub async fn send_command( + &mut self, + act: SendAction, + ctx: ProgramContext, + store: &mut ProgramStore, + ) -> IambResult { + match self { + RoomState::Chat(chat) => chat.send_command(act, ctx, store).await, + RoomState::Space(_) => Err(IambError::NoSelectedRoom.into()), + } + } + + pub async fn room_command( &mut self, act: RoomAction, _: ProgramContext, diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index 6f4af26..53f7fb0 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -126,6 +126,14 @@ impl ScrollbackState { self.viewctx.dimensions = (area.width as usize, area.height as usize); } + pub fn get_mut<'a>(&mut self, info: &'a mut RoomInfo) -> Option<&'a mut Message> { + if let Some(k) = &self.cursor.timestamp { + info.messages.get_mut(k) + } else { + info.messages.last_entry().map(|o| o.into_mut()) + } + } + pub fn messages<'a>( &self, range: EditRange, @@ -389,7 +397,7 @@ impl ScrollbackState { continue; } - if needle.is_match(msg.as_ref()) { + if needle.is_match(msg.content.show().as_ref()) { mc = MessageCursor::from(key.clone()).into(); count -= 1; } @@ -413,7 +421,7 @@ impl ScrollbackState { break; } - if needle.is_match(msg.as_ref()) { + if needle.is_match(msg.content.show().as_ref()) { mc = MessageCursor::from(key.clone()).into(); count -= 1; } @@ -659,7 +667,7 @@ impl EditorActions for ScrollbackState { let mut yanked = EditRope::from(""); for (_, msg) in self.messages(range, info) { - yanked += EditRope::from(msg.as_ref()); + yanked += EditRope::from(msg.content.show().into_owned()); yanked += EditRope::from('\n'); } @@ -1159,12 +1167,14 @@ impl<'a> StatefulWidget for Scrollback<'a> { }; let corner = &state.viewctx.corner; - let corner_key = match (&corner.timestamp, &cursor.timestamp) { - (_, None) => nth_key_before(cursor_key.clone(), height, info), - (None, _) => nth_key_before(cursor_key.clone(), height, info), - (Some(k), _) => k.clone(), + let corner_key = if let Some(k) = &corner.timestamp { + k.clone() + } else { + nth_key_before(cursor_key.clone(), height, info) }; + let full = cursor.timestamp.is_none(); + let mut lines = vec![]; let mut sawit = false; let mut prev = None; @@ -1175,8 +1185,10 @@ impl<'a> StatefulWidget for Scrollback<'a> { prev = Some(item); + let incomplete_ok = !full || !sel; + for (row, line) in txt.lines.into_iter().enumerate() { - if sawit && lines.len() >= height { + if sawit && lines.len() >= height && incomplete_ok { // Check whether we've seen the first line of the // selected message and can fill the screen. break; @@ -1224,10 +1236,10 @@ mod tests { use super::*; use crate::tests::*; - #[test] - fn test_search_messages() { + #[tokio::test] + async fn test_search_messages() { let room_id = TEST_ROOM1_ID.clone(); - let mut store = mock_store(); + let mut store = mock_store().await; let mut scrollback = ScrollbackState::new(room_id.clone()); let ctx = ProgramContext::default(); @@ -1268,9 +1280,9 @@ mod tests { assert_eq!(scrollback.cursor, MSG1_KEY.clone().into()); } - #[test] - fn test_movement() { - let mut store = mock_store(); + #[tokio::test] + async fn test_movement() { + let mut store = mock_store().await; let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone()); let ctx = ProgramContext::default(); @@ -1302,9 +1314,9 @@ mod tests { assert_eq!(scrollback.cursor, MSG1_KEY.clone().into()); } - #[test] - fn test_dirscroll() { - let mut store = mock_store(); + #[tokio::test] + async fn test_dirscroll() { + let mut store = mock_store().await; let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone()); let ctx = ProgramContext::default(); @@ -1436,9 +1448,9 @@ mod tests { assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 4)); } - #[test] - fn test_cursorpos() { - let mut store = mock_store(); + #[tokio::test] + async fn test_cursorpos() { + let mut store = mock_store().await; let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone()); let ctx = ProgramContext::default(); diff --git a/src/worker.rs b/src/worker.rs index 2e00db5..dee3e01 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1,12 +1,14 @@ use std::convert::TryFrom; +use std::fmt::{Debug, Formatter}; use std::fs::File; use std::io::BufWriter; use std::str::FromStr; -use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender}; +use std::sync::mpsc::{sync_channel, Receiver, SyncSender}; use std::sync::Arc; use std::time::Duration; use gethostname::gethostname; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::task::JoinHandle; use tracing::error; @@ -31,7 +33,7 @@ use matrix_sdk::{ VerificationMethod, }, room::{ - message::{MessageType, RoomMessageEventContent, TextMessageEventContent}, + message::{MessageType, RoomMessageEventContent}, name::RoomNameEventContent, topic::RoomTopicEventContent, }, @@ -41,7 +43,6 @@ use matrix_sdk::{ SyncMessageLikeEvent, SyncStateEvent, }, - OwnedEventId, OwnedRoomId, OwnedRoomOrAliasId, OwnedUserId, @@ -67,6 +68,7 @@ fn initial_devname() -> String { format!("{} on {}", IAMB_DEVICE_NAME, gethostname().to_string_lossy()) } +#[derive(Debug)] pub enum LoginStyle { SessionRestore(Session), Password(String), @@ -95,8 +97,6 @@ fn oneshot() -> (ClientReply, ClientResponse) { return (reply, response); } -type EchoPair = (OwnedEventId, RoomMessageEventContent); - pub enum WorkerTask { DirectMessages(ClientReply>), Init(AsyncProgramStore, ClientReply<()>), @@ -108,16 +108,101 @@ pub enum WorkerTask { Members(OwnedRoomId, ClientReply>>), SpaceMembers(OwnedRoomId, ClientReply>>), Spaces(ClientReply>), - SendMessage(OwnedRoomId, String, ClientReply>), SetRoom(OwnedRoomId, SetRoomField, ClientReply>), TypingNotice(OwnedRoomId), Verify(VerifyAction, SasVerification, ClientReply>), VerifyRequest(OwnedUserId, ClientReply>), } +impl Debug for WorkerTask { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + WorkerTask::DirectMessages(_) => { + f.debug_tuple("WorkerTask::DirectMessages") + .field(&format_args!("_")) + .finish() + }, + WorkerTask::Init(_, _) => { + f.debug_tuple("WorkerTask::Init") + .field(&format_args!("_")) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::LoadOlder(room_id, from, n, _) => { + f.debug_tuple("WorkerTask::LoadOlder") + .field(room_id) + .field(from) + .field(n) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::Login(style, _) => { + f.debug_tuple("WorkerTask::Login") + .field(style) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::GetRoom(room_id, _) => { + f.debug_tuple("WorkerTask::GetRoom") + .field(room_id) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::JoinRoom(s, _) => { + f.debug_tuple("WorkerTask::JoinRoom") + .field(s) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::JoinedRooms(_) => { + f.debug_tuple("WorkerTask::JoinedRooms").field(&format_args!("_")).finish() + }, + WorkerTask::Members(room_id, _) => { + f.debug_tuple("WorkerTask::Members") + .field(room_id) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::SpaceMembers(room_id, _) => { + f.debug_tuple("WorkerTask::SpaceMembers") + .field(room_id) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::Spaces(_) => { + f.debug_tuple("WorkerTask::Spaces").field(&format_args!("_")).finish() + }, + WorkerTask::SetRoom(room_id, field, _) => { + f.debug_tuple("WorkerTask::SetRoom") + .field(room_id) + .field(field) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::TypingNotice(room_id) => { + f.debug_tuple("WorkerTask::TypingNotice").field(room_id).finish() + }, + WorkerTask::Verify(act, sasv1, _) => { + f.debug_tuple("WorkerTask::Verify") + .field(act) + .field(sasv1) + .field(&format_args!("_")) + .finish() + }, + WorkerTask::VerifyRequest(user_id, _) => { + f.debug_tuple("WorkerTask::VerifyRequest") + .field(user_id) + .field(&format_args!("_")) + .finish() + }, + } + } +} + #[derive(Clone)] pub struct Requester { - pub tx: SyncSender, + pub client: Client, + pub tx: UnboundedSender, } impl Requester { @@ -152,14 +237,6 @@ impl Requester { return response.recv(); } - pub fn send_message(&self, room_id: OwnedRoomId, msg: String) -> IambResult { - let (reply, response) = oneshot(); - - self.tx.send(WorkerTask::SendMessage(room_id, msg, reply)).unwrap(); - - return response.recv(); - } - pub fn direct_messages(&self) -> Vec<(MatrixRoom, DisplayName)> { let (reply, response) = oneshot(); @@ -253,60 +330,57 @@ pub struct ClientWorker { } impl ClientWorker { - pub fn spawn(settings: ApplicationSettings) -> Requester { - let (tx, rx) = sync_channel(5); + pub async fn spawn(settings: ApplicationSettings) -> Requester { + let (tx, rx) = unbounded_channel(); + let account = &settings.profile; + + // Set up a custom client that only uses HTTP/1. + // + // During my testing, I kept stumbling across something weird with sync and HTTP/2 that + // will need to be revisited in the future. + let http = reqwest::Client::builder() + .user_agent(IAMB_USER_AGENT) + .timeout(Duration::from_secs(30)) + .pool_idle_timeout(Duration::from_secs(60)) + .pool_max_idle_per_host(10) + .tcp_keepalive(Duration::from_secs(10)) + .http1_only() + .build() + .unwrap(); + + // Set up the Matrix client for the selected profile. + let client = Client::builder() + .http_client(Arc::new(http)) + .homeserver_url(account.url.clone()) + .store_config(StoreConfig::default()) + .sled_store(settings.matrix_dir.as_path(), None) + .expect("Failed to setup up sled store for Matrix SDK") + .request_config(RequestConfig::new().timeout(REQ_TIMEOUT).retry_timeout(REQ_TIMEOUT)) + .build() + .await + .expect("Failed to instantiate Matrix client"); + + let mut worker = ClientWorker { + initialized: false, + settings, + client: client.clone(), + sync_handle: None, + }; let _ = tokio::spawn(async move { - let account = &settings.profile; - - // Set up a custom client that only uses HTTP/1. - // - // During my testing, I kept stumbling across something weird with sync and HTTP/2 that - // will need to be revisited in the future. - let http = reqwest::Client::builder() - .user_agent(IAMB_USER_AGENT) - .timeout(Duration::from_secs(60)) - .pool_idle_timeout(Duration::from_secs(120)) - .pool_max_idle_per_host(5) - .http1_only() - .build() - .unwrap(); - - // Set up the Matrix client for the selected profile. - let client = Client::builder() - .http_client(Arc::new(http)) - .homeserver_url(account.url.clone()) - .store_config(StoreConfig::default()) - .sled_store(settings.matrix_dir.as_path(), None) - .expect("Failed to setup up sled store for Matrix SDK") - .request_config( - RequestConfig::new().timeout(REQ_TIMEOUT).retry_timeout(REQ_TIMEOUT), - ) - .build() - .await - .expect("Failed to instantiate Matrix client"); - - let mut worker = ClientWorker { - initialized: false, - settings, - client, - sync_handle: None, - }; - worker.work(rx).await; }); - return Requester { tx }; + return Requester { client, tx }; } - async fn work(&mut self, rx: Receiver) { + async fn work(&mut self, mut rx: UnboundedReceiver) { loop { - let t = rx.recv_timeout(Duration::from_secs(1)); + let t = rx.recv().await; match t { - Ok(task) => self.run(task).await, - Err(RecvTimeoutError::Timeout) => {}, - Err(RecvTimeoutError::Disconnected) => { + Some(task) => self.run(task).await, + None => { break; }, } @@ -364,10 +438,6 @@ impl ClientWorker { assert!(self.initialized); reply.send(self.spaces().await); }, - WorkerTask::SendMessage(room_id, msg, reply) => { - assert!(self.initialized); - reply.send(self.send_message(room_id, msg).await); - }, WorkerTask::TypingNotice(room_id) => { assert!(self.initialized); self.typing_notice(room_id).await; @@ -615,33 +685,6 @@ impl ClientWorker { Ok(Some(InfoMessage::from("Successfully logged in!"))) } - async fn send_message(&mut self, room_id: OwnedRoomId, msg: String) -> IambResult { - let room = if let r @ Some(_) = self.client.get_joined_room(&room_id) { - r - } else if self.client.join_room_by_id(&room_id).await.is_ok() { - self.client.get_joined_room(&room_id) - } else { - None - }; - - if let Some(room) = room { - let msg = TextMessageEventContent::plain(msg); - let msg = MessageType::Text(msg); - let msg = RoomMessageEventContent::new(msg); - - // XXX: second parameter can be a locally unique transaction id. - // Useful for doing retries. - let resp = room.send(msg.clone(), None).await.map_err(IambError::from)?; - let event_id = resp.event_id; - - // XXX: need to either give error messages and retry when needed! - - return Ok((event_id, msg)); - } else { - Err(IambError::UnknownRoom(room_id).into()) - } - } - async fn direct_message(&mut self, user: OwnedUserId) -> IambResult<(MatrixRoom, DisplayName)> { for (room, name) in self.direct_messages().await { if room.get_member(user.as_ref()).await.map_err(IambError::from)?.is_some() {