diff --git a/src/base.rs b/src/base.rs index 4a02e2c8..1a424e4b 100644 --- a/src/base.rs +++ b/src/base.rs @@ -491,10 +491,15 @@ pub enum SendAction { SubmitFromEditor, /// Upload a file. - Upload(String), + /// + /// The second argument indicates whether to use the messagebar as a caption, don't use it or + /// ask the user. + Upload(String, Option), /// Upload the image data. - UploadImage(usize, usize, Cow<'static, [u8]>), + /// + /// The [`bool`] arguments indicates whether to use the messagebar as a caption. + UploadImage(usize, usize, Cow<'static, [u8]>, bool), } /// An action performed against the user's homeserver. diff --git a/src/commands.rs b/src/commands.rs index 4139a8ff..e9e578c7 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -675,7 +675,7 @@ fn iamb_upload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult { return Result::Err(CommandError::InvalidArgument); } - let sact = SendAction::Upload(args.remove(0)); + let sact = SendAction::Upload(args.remove(0), None); let iact = IambAction::from(sact); let step = CommandStep::Continue(iact.into(), ctx.context.clone()); diff --git a/src/message/compose.rs b/src/message/compose.rs index f09a167f..517a5aee 100644 --- a/src/message/compose.rs +++ b/src/message/compose.rs @@ -180,6 +180,34 @@ pub fn text_to_message(input: String) -> RoomMessageEventContent { RoomMessageEventContent::new(msg) } +/// Returns `None` if `input` contains a non-text slash command. +pub fn text_to_text_message_event_content(input: String) -> Option { + let cmd = parse_slash_command(&input); + + let content = match cmd { + Ok((body, SlashCommand::Html)) => TextMessageEventContent::html(body, body), + Ok((body, SlashCommand::Plaintext)) => TextMessageEventContent::plain(body), + Ok((body, SlashCommand::Markdown)) => { + if let Some(html) = text_to_html(body) { + TextMessageEventContent::html(body, html) + } else { + TextMessageEventContent::plain(body) + } + }, + Ok(_) => return None, + + _ => { + if let Some(html) = text_to_html(&input) { + TextMessageEventContent::html(input, html) + } else { + TextMessageEventContent::plain(input) + } + }, + }; + + Some(content) +} + #[cfg(test)] pub mod tests { use super::*; diff --git a/src/message/mod.rs b/src/message/mod.rs index 4c3a8d81..4a0abb44 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -73,7 +73,7 @@ mod html; mod printer; mod state; -pub use self::compose::text_to_message; +pub use self::compose::{text_to_message, text_to_text_message_event_content}; use self::state::{body_cow_state, html_state}; pub use html::TreeGenState; @@ -462,17 +462,33 @@ impl MessageEvent { MessageEvent::Local(_, _, content) => content, }; - if let MessageType::Text(content) = &content.msgtype { - if let Some(FormattedBody { format: MessageFormat::Html, body }) = &content.formatted { - Some(parse_matrix_html(body.as_str())) - } else { - None - } + let formatted = match &content.msgtype { + MessageType::Text(content) => content.formatted.as_ref(), + MessageType::Emote(content) => content.formatted.as_ref(), + MessageType::Notice(content) => content.formatted.as_ref(), + + MessageType::Audio(content) => content.formatted.as_ref(), + MessageType::File(content) => content.formatted.as_ref(), + MessageType::Image(content) => content.formatted.as_ref(), + MessageType::Video(content) => content.formatted.as_ref(), + _ => None, + }; + + if let Some(FormattedBody { format: MessageFormat::Html, body }) = formatted { + Some(parse_matrix_html(body.as_str())) } else { None } } + pub fn filename(&self) -> Option { + match self { + MessageEvent::Original(ev) => content_filename(&ev.content), + MessageEvent::Local(_, _, content) => content_filename(content), + _ => None, + } + } + fn redact(&mut self, redaction: SyncRoomRedactionEvent) { match self { MessageEvent::EncryptedOriginal(_) => return, @@ -514,12 +530,12 @@ impl MessageEvent { /// Macro rule converting a File / Image / Audio / Video to its text content with the shape: /// `[Attached : [ ()]]` -macro_rules! display_file_to_text { - ( $msgtype:ident, $content:expr ) => { - return Cow::Owned(format!( +macro_rules! display_file_name { + ( $msgtype:ident, $content:expr ) => {{ + Some(format!( "[Attached {}: {}{}]", stringify!($msgtype), - $content.body, + $content.filename(), $content .info .as_ref() @@ -530,7 +546,39 @@ macro_rules! display_file_to_text { }) .unwrap_or_else(String::new) )) - }; + }}; +} + +/// Macro rule extraction the text caption of a File / Image / Audio / Video +macro_rules! display_file_to_text { + ( $msgtype:ident, $content:expr ) => {{ + if $content + .filename + .as_ref() + .is_none_or(|filename| *filename == $content.body) + { + return Cow::Borrowed(""); + } + $content.body.as_str() + }}; +} + +fn content_filename(content: &RoomMessageEventContent) -> Option { + match &content.msgtype { + MessageType::Audio(content) => { + display_file_name!(Audio, content) + }, + MessageType::File(content) => { + display_file_name!(File, content) + }, + MessageType::Image(content) => { + display_file_name!(Image, content) + }, + MessageType::Video(content) => { + display_file_name!(Video, content) + }, + _ => None, + } } fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> { @@ -542,16 +590,16 @@ fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> { MessageType::ServerNotice(content) => content.body.as_str(), MessageType::Audio(content) => { - display_file_to_text!(Audio, content); + display_file_to_text!(Audio, content) }, MessageType::File(content) => { - display_file_to_text!(File, content); + display_file_to_text!(File, content) }, MessageType::Image(content) => { - display_file_to_text!(Image, content); + display_file_to_text!(Image, content) }, MessageType::Video(content) => { - display_file_to_text!(Video, content); + display_file_to_text!(Video, content) }, _ => content.body(), }; @@ -1100,41 +1148,48 @@ impl Message { settings: &'a ApplicationSettings, previews: &'a PreviewManager, ) -> (Text<'a>, Option<&'a Protocol>) { - if let Some(html) = &self.html { - (html.to_text(width, style, settings), None) + let mut proto = None; + let placeholder = match self.image_preview.as_ref().and_then(|source| previews.get(source)) + { + None => None, + Some(ImageStatus::Queued(image_preview_size)) => { + placeholder_frame(Some("Queued..."), width, image_preview_size) + }, + Some(ImageStatus::Downloading(image_preview_size)) => { + placeholder_frame(Some("Downloading..."), width, image_preview_size) + }, + Some(ImageStatus::Loaded(backend)) => { + proto = Some(backend); + placeholder_frame(Some("No Space..."), width, &backend.area().into()) + }, + Some(ImageStatus::Error(err)) => Some(format!("[Image error: {err}]\n")), + }; + + let mut text = if let Some(placeholder) = placeholder { + wrapped_text(placeholder, width, style) } else { - let mut msg = self.event.body(); - if settings.tunables.message_shortcode_display { - msg = Cow::Owned(replace_emojis_in_str(msg.as_ref())); - } + Default::default() + }; + if let Some(mut filename) = self.event.filename() { if self.downloaded { - msg.to_mut().push_str(" \u{2705}"); + filename.push_str(" \u{2705}"); } - let mut proto = None; - let placeholder = - match self.image_preview.as_ref().and_then(|source| previews.get(source)) { - None => None, - Some(ImageStatus::Queued(image_preview_size)) => { - placeholder_frame(Some("Queued..."), width, image_preview_size) - }, - Some(ImageStatus::Downloading(image_preview_size)) => { - placeholder_frame(Some("Downloading..."), width, image_preview_size) - }, - Some(ImageStatus::Loaded(backend)) => { - proto = Some(backend); - placeholder_frame(Some("No Space..."), width, &backend.area().into()) - }, - Some(ImageStatus::Error(err)) => Some(format!("[Image error: {err}]\n")), - }; - - if let Some(placeholder) = placeholder { - msg.to_mut().insert_str(0, &placeholder); + text = text + wrapped_text(filename, width, style); + } + + if let Some(html) = &self.html { + text = text + html.to_text(width, style, settings); + } else { + let mut msg = self.event.body(); + if settings.tunables.message_shortcode_display { + msg = Cow::Owned(replace_emojis_in_str(msg.as_ref())); } + text = text + wrapped_text(msg, width, style); + }; - (wrapped_text(msg, width, style), proto) - } + (text, proto) } fn sender_span<'a>( @@ -1490,79 +1545,79 @@ pub mod tests { #[test] fn test_display_attachment_size() { assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::Image( + content_filename(&RoomMessageEventContent::new(MessageType::Image( ImageMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::default())) ))), - "[Attached Image: Alt text]".to_string() + "[Attached Image: Alt text]".to_string().into() ); let mut info = ImageInfo::default(); info.size = Some(442630_u32.into()); assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::Image( + content_filename(&RoomMessageEventContent::new(MessageType::Image( ImageMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::new(info))) ))), - "[Attached Image: Alt text (442.63 kB)]".to_string() + "[Attached Image: Alt text (442.63 kB)]".to_string().into() ); let mut info = ImageInfo::default(); info.size = Some(12_u32.into()); assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::Image( + content_filename(&RoomMessageEventContent::new(MessageType::Image( ImageMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::new(info))) ))), - "[Attached Image: Alt text (12 B)]".to_string() + "[Attached Image: Alt text (12 B)]".to_string().into() ); let mut info = AudioInfo::default(); info.size = Some(4294967295_u32.into()); assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::Audio( + content_filename(&RoomMessageEventContent::new(MessageType::Audio( AudioMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::new(info))) ))), - "[Attached Audio: Alt text (4.29 GB)]".to_string() + "[Attached Audio: Alt text (4.29 GB)]".to_string().into() ); let mut info = FileInfo::default(); info.size = Some(4426300_u32.into()); assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::File( + content_filename(&RoomMessageEventContent::new(MessageType::File( FileMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::new(info))) ))), - "[Attached File: Alt text (4.43 MB)]".to_string() + "[Attached File: Alt text (4.43 MB)]".to_string().into() ); let mut info = VideoInfo::default(); info.size = Some(44000_u32.into()); assert_eq!( - body_cow_content(&RoomMessageEventContent::new(MessageType::Video( + content_filename(&RoomMessageEventContent::new(MessageType::Video( VideoMessageEventContent::plain( "Alt text".to_string(), "mxc://matrix.org/jDErsDugkNlfavzLTjJNUKAH".into() ) .info(Some(Box::new(info))) ))), - "[Attached Video: Alt text (44 kB)]".to_string() + "[Attached Video: Alt text (44 kB)]".to_string().into() ); } } diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 190183d9..24f18a14 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -1,5 +1,6 @@ //! Window for Matrix rooms use std::borrow::Cow; +use std::convert::TryInto; use std::ffi::{OsStr, OsString}; use std::fs; use std::ops::Deref; @@ -7,6 +8,8 @@ use std::path::{Path, PathBuf}; use edit::Builder; use edit::edit_with_builder as external_edit; +use matrix_sdk::attachment::{AttachmentInfo, BaseImageInfo}; +use matrix_sdk::room::reply::{EnforceThread, Reply}; use modalkit::editing::store::RegisterError; use std::process::Command; use tokio; @@ -42,7 +45,7 @@ use ratatui::{ widgets::{Paragraph, StatefulWidget, Widget}, }; -use modalkit::keybindings::dialog::{MultiChoice, MultiChoiceItem, PromptYesNo}; +use modalkit::keybindings::dialog::{Dialog, MultiChoice, MultiChoiceItem, PromptYesNo}; use modalkit_ratatui::{ PromptActions, @@ -87,7 +90,14 @@ use crate::base::{ }; use crate::config::EncryptionIndicatorLocation; -use crate::message::{MessageEvent, MessageId, MessageKey, TreeGenState, text_to_message}; +use crate::message::{ + MessageEvent, + MessageId, + MessageKey, + TreeGenState, + text_to_message, + text_to_text_message_event_content, +}; use crate::worker::Requester; use super::scrollback::{Scrollback, ScrollbackState}; @@ -562,6 +572,49 @@ impl ChatState { } } + /// Generate a [`Reply`] setting thread info and reply_to (if `set_reply` is true) + fn generate_reply_info(&self, info: &RoomInfo, set_reply: bool) -> Option { + let thread_last = self.scrollback.thread().and_then(|id| info.get_thread_last(id)); + + let (event_id, enforce_thread) = if let Some(last) = thread_last { + if let Some(m) = self.get_reply_to(info) && + set_reply + { + // thread reply + (m.event_id.to_owned(), EnforceThread::Threaded(ReplyWithinThread::Yes)) + } else { + // thread message + (last.event_id.to_owned(), EnforceThread::Threaded(ReplyWithinThread::No)) + } + } else if let Some(m) = self.get_reply_to(info) && + set_reply + { + // normal reply in main timeline: + (m.event_id.to_owned(), EnforceThread::Unthreaded) + } else { + // not any kind of reply: + return None; + }; + + Some(Reply { + add_mentions: AddMentions::No, + event_id, + enforce_thread, + }) + } + + /// Generate an attachment for this room based on the current message bar state. + fn generate_attachment_config(&self, info: &RoomInfo, add_caption: bool) -> AttachmentConfig { + let mut config = AttachmentConfig::new(); + config.caption = add_caption + .then(|| self.tbox.get()) + .filter(|c| !c.is_blank()) + .map(|c| c.trim_end().to_string()) + .and_then(text_to_text_message_event_content); + config.reply = self.generate_reply_info(info, add_caption); + config + } + pub async fn send_command( &mut self, act: SendAction, @@ -655,7 +708,28 @@ impl ChatState { // Reset message bar state now that it's been sent. self.reset(); }, - SendAction::Upload(file) => { + SendAction::Upload(file, add_caption) => { + let caption = self.tbox.get(); + + if add_caption.is_none() && + (!caption.is_blank() || self.get_reply_to(info).is_some()) + { + let msg = "Would you like to use the message bar as a caption?"; + + let yes_act = SendAction::Upload(file.clone(), Some(true)); + let no_act = SendAction::Upload(file, Some(false)); + + let yes_choice = + MultiChoiceItem::new('y', msg, vec![IambAction::from(yes_act).into()]); + let no_choice = + MultiChoiceItem::new('n', "", vec![IambAction::from(no_act).into()]); + + let prompt = MultiChoice::new(vec![yes_choice, no_choice]); + let prompt = Box::new(prompt); + + return Err(UIError::NeedConfirm(prompt)); + } + let path = Path::new(file.as_str()); let mime = mime_guess::from_path(path).first_or(mime::APPLICATION_OCTET_STREAM); @@ -664,14 +738,20 @@ impl ChatState { .file_name() .map(OsStr::to_string_lossy) .unwrap_or_else(|| Cow::from("Attachment")); - let config = AttachmentConfig::new(); + + let add_caption = add_caption.unwrap_or(false); + let config = self.generate_attachment_config(info, add_caption); room.send_queue() .send_attachment(name.as_ref(), mime, bytes, config) .await .map_err(IambError::from)?; + + if add_caption { + self.reset(); + } }, - SendAction::UploadImage(width, height, bytes) => { + SendAction::UploadImage(width, height, bytes, add_caption) => { // Convert to png because arboard does not give us the mime type. let bytes = image::ImageBuffer::from_raw(width as _, height as _, bytes.into_owned()) @@ -684,14 +764,23 @@ impl ChatState { Ok(buff.into_inner()) })?; let mime = mime::IMAGE_PNG; - let name = "Clipboard.png"; - let config = AttachmentConfig::new(); + + let mut config = self.generate_attachment_config(info, add_caption); + config.info = Some(AttachmentInfo::Image(BaseImageInfo { + height: height.try_into().ok(), + width: width.try_into().ok(), + ..Default::default() + })); room.send_queue() .send_attachment(name, mime, bytes, config) .await .map_err(IambError::from)?; + + if add_caption { + self.reset(); + } }, } @@ -852,11 +941,36 @@ impl Editable for ChatState { delegate!(self, w => w.editor_command(act, ctx, store)) }, Err(EditError::Register(RegisterError::ClipboardImage(data))) => { - let msg = "Do you really want to upload the image from your system clipboard?"; - let send = - IambAction::Send(SendAction::UploadImage(data.width, data.height, data.bytes)); - let prompt = PromptYesNo::new(msg, vec![Action::from(send)]); - let prompt = Box::new(prompt); + let info = store.application.rooms.get_or_default(self.id().to_owned()); + let prompt = if self.tbox.get().is_blank() && self.get_reply_to(info).is_none() { + let msg = "Do you really want to upload the image from your system clipboard?"; + let send = IambAction::Send(SendAction::UploadImage( + data.width, + data.height, + data.bytes, + false, + )); + let prompt = PromptYesNo::new(msg, vec![Action::from(send)]); + Box::new(prompt) as Box> + } else { + let msg_c = "Upload clipboard image with message bar as caption"; + let act_c = + SendAction::UploadImage(data.width, data.height, data.bytes.clone(), true); + let choice_c = + MultiChoiceItem::new('c', msg_c, vec![IambAction::from(act_c).into()]); + + let msg_y = "Upload clipboard image without caption"; + let act_y = + SendAction::UploadImage(data.width, data.height, data.bytes.clone(), false); + let choice_y = + MultiChoiceItem::new('y', msg_y, vec![IambAction::from(act_y).into()]); + + let msg_n = "Do not upload clipboard image"; + let choice_n = MultiChoiceItem::new('n', msg_n, vec![Action::NoOp]); + + let prompt = MultiChoice::new(vec![choice_c, choice_y, choice_n]); + Box::new(prompt) as Box> + }; Err(EditError::NeedConfirm(prompt)) },