diff --git a/po/POTFILES.in b/po/POTFILES.in index 61acb991..f86b9ee4 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -71,7 +71,6 @@ src/session/view/content/room_details/member_page/member_menu.ui src/session/view/content/room_details/member_page/mod.rs src/session/view/content/room_details/member_page/mod.ui src/session/view/content/room_details/mod.ui -src/session/view/content/room_history/attachment_dialog.ui src/session/view/content/room_history/event_actions.ui src/session/view/content/room_history/item_row.rs src/session/view/content/room_history/message_row/audio.rs @@ -80,6 +79,9 @@ src/session/view/content/room_history/message_row/file.ui src/session/view/content/room_history/message_row/location.rs src/session/view/content/room_history/message_row/media.rs src/session/view/content/room_history/message_row/mod.ui +src/session/view/content/room_history/message_toolbar/attachment_dialog.ui +src/session/view/content/room_history/message_toolbar/mod.rs +src/session/view/content/room_history/message_toolbar/mod.ui src/session/view/content/room_history/mod.rs src/session/view/content/room_history/mod.ui src/session/view/content/room_history/state_row/creation.rs diff --git a/src/session/model/room/event/mod.rs b/src/session/model/room/event/mod.rs index 835ad29c..2c7e2f45 100644 --- a/src/session/model/room/event/mod.rs +++ b/src/session/model/room/event/mod.rs @@ -1,4 +1,4 @@ -use std::fmt; +use std::{borrow::Cow, fmt}; use gtk::{glib, prelude::*, subclass::prelude::*}; use indexmap::IndexMap; @@ -9,7 +9,7 @@ use matrix_sdk_ui::timeline::{ use ruma::{ events::{receipt::Receipt, room::message::MessageType, AnySyncTimelineEvent}, serde::Raw, - MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, OwnedUserId, + EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, OwnedUserId, }; mod reaction_group; @@ -41,6 +41,32 @@ impl fmt::Display for EventKey { } } +impl glib::StaticVariantType for EventKey { + fn static_variant_type() -> Cow<'static, glib::VariantTy> { + Cow::Borrowed(glib::VariantTy::STRING) + } +} + +impl glib::ToVariant for EventKey { + fn to_variant(&self) -> glib::Variant { + self.to_string().to_variant() + } +} + +impl glib::FromVariant for EventKey { + fn from_variant(variant: &glib::Variant) -> Option { + let s = variant.str()?; + + if let Some(s) = s.strip_prefix("transaction_id:") { + Some(EventKey::TransactionId(s.into())) + } else if let Some(s) = s.strip_prefix("event_id:") { + EventId::parse(s).ok().map(EventKey::EventId) + } else { + None + } + } +} + #[derive(Clone, Debug, glib::Boxed)] #[boxed_type(name = "BoxedEventTimelineItem")] pub struct BoxedEventTimelineItem(EventTimelineItem); diff --git a/src/session/view/content/room_history/item_row.rs b/src/session/view/content/room_history/item_row.rs index 9771dd3b..9c26e658 100644 --- a/src/session/view/content/room_history/item_row.rs +++ b/src/session/view/content/room_history/item_row.rs @@ -15,22 +15,21 @@ use crate::{ view::EventSourceDialog, }, spawn, spawn_tokio, toast, - utils::{media::save_to_file, BoundObjectWeakRef}, + utils::media::save_to_file, }; mod imp { use std::{cell::RefCell, collections::HashMap, rc::Rc}; - use glib::signal::SignalHandlerId; - use super::*; #[derive(Debug, Default)] pub struct ItemRow { - pub room_history: BoundObjectWeakRef, + pub room_history: glib::WeakRef, + pub message_toolbar_handler: RefCell>, pub item: RefCell>, pub action_group: RefCell>, - pub notify_handlers: RefCell>, + pub notify_handlers: RefCell>, pub binding: RefCell>, pub reaction_chooser: RefCell>, pub emoji_chooser: RefCell>, @@ -105,7 +104,11 @@ mod imp { expr_watch.unwatch(); } - self.room_history.disconnect_signals(); + if let Some(room_history) = self.room_history.upgrade() { + if let Some(handler) = self.message_toolbar_handler.take() { + room_history.message_toolbar().disconnect(handler); + } + } } } @@ -188,25 +191,27 @@ impl ItemRow { /// The ancestor room history of this row. pub fn room_history(&self) -> RoomHistory { - self.imp().room_history.obj().unwrap() + self.imp().room_history.upgrade().unwrap() } /// Set the ancestor room history of this row. fn set_room_history(&self, room_history: Option<&RoomHistory>) { let Some(room_history) = room_history else { + // Ignore missing `RoomHistory`. return; }; - let related_event_handler = room_history.connect_notify_local( + let imp = self.imp(); + imp.room_history.set(Some(room_history)); + + let related_event_handler = room_history.message_toolbar().connect_notify_local( Some("related-event"), - clone!(@weak self as obj => move |_, _| { - obj.update_for_related_event(); + clone!(@weak self as obj => move |message_toolbar, _| { + obj.update_for_related_event(message_toolbar.related_event()); }), ); - - self.imp() - .room_history - .set(room_history, vec![related_event_handler]); + imp.message_toolbar_handler + .replace(Some(related_event_handler)); } pub fn action_group(&self) -> Option { @@ -402,8 +407,7 @@ impl ItemRow { } /// Update this row for the currently related event. - fn update_for_related_event(&self) { - let related_event = self.room_history().related_event(); + fn update_for_related_event(&self, related_event: Option) { let event = self.item().and_downcast::(); if event.is_some() && event == related_event { diff --git a/src/session/view/content/room_history/message_row/mod.rs b/src/session/view/content/room_history/message_row/mod.rs index 6378b3e9..65eb9b83 100644 --- a/src/session/view/content/room_history/message_row/mod.rs +++ b/src/session/view/content/room_history/message_row/mod.rs @@ -1,5 +1,5 @@ mod audio; -pub mod content; +mod content; mod file; mod location; mod media; @@ -17,8 +17,8 @@ use gtk::{ use matrix_sdk::ruma::events::room::message::MessageType; use tracing::warn; -pub use self::content::ContentFormat; -use self::{content::MessageContent, media::MessageMedia, reaction_list::MessageReactionList}; +pub use self::content::{ContentFormat, MessageContent}; +use self::{media::MessageMedia, reaction_list::MessageReactionList}; use super::ReadReceiptsList; use crate::{components::Avatar, prelude::*, session::model::Event, Window}; diff --git a/src/session/view/content/room_history/attachment_dialog.rs b/src/session/view/content/room_history/message_toolbar/attachment_dialog.rs similarity index 100% rename from src/session/view/content/room_history/attachment_dialog.rs rename to src/session/view/content/room_history/message_toolbar/attachment_dialog.rs diff --git a/src/session/view/content/room_history/attachment_dialog.ui b/src/session/view/content/room_history/message_toolbar/attachment_dialog.ui similarity index 100% rename from src/session/view/content/room_history/attachment_dialog.ui rename to src/session/view/content/room_history/message_toolbar/attachment_dialog.ui diff --git a/src/session/view/content/room_history/completion/completion_popover.rs b/src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs similarity index 99% rename from src/session/view/content/room_history/completion/completion_popover.rs rename to src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs index 69778d56..3e96b132 100644 --- a/src/session/view/content/room_history/completion/completion_popover.rs +++ b/src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs @@ -27,7 +27,7 @@ mod imp { #[derive(Debug, Default, CompositeTemplate)] #[template( - resource = "/org/gnome/Fractal/ui/session/view/content/room_history/completion/completion_popover.ui" + resource = "/org/gnome/Fractal/ui/session/view/content/room_history/message_toolbar/completion/completion_popover.ui" )] pub struct CompletionPopover { #[template_child] diff --git a/src/session/view/content/room_history/completion/completion_popover.ui b/src/session/view/content/room_history/message_toolbar/completion/completion_popover.ui similarity index 100% rename from src/session/view/content/room_history/completion/completion_popover.ui rename to src/session/view/content/room_history/message_toolbar/completion/completion_popover.ui diff --git a/src/session/view/content/room_history/completion/completion_row.rs b/src/session/view/content/room_history/message_toolbar/completion/completion_row.rs similarity index 98% rename from src/session/view/content/room_history/completion/completion_row.rs rename to src/session/view/content/room_history/message_toolbar/completion/completion_row.rs index 96410ad6..9188ae08 100644 --- a/src/session/view/content/room_history/completion/completion_row.rs +++ b/src/session/view/content/room_history/message_toolbar/completion/completion_row.rs @@ -12,7 +12,7 @@ mod imp { #[derive(Debug, Default, CompositeTemplate)] #[template( - resource = "/org/gnome/Fractal/ui/session/view/content/room_history/completion/completion_row.ui" + resource = "/org/gnome/Fractal/ui/session/view/content/room_history/message_toolbar/completion/completion_row.ui" )] pub struct CompletionRow { #[template_child] diff --git a/src/session/view/content/room_history/completion/completion_row.ui b/src/session/view/content/room_history/message_toolbar/completion/completion_row.ui similarity index 100% rename from src/session/view/content/room_history/completion/completion_row.ui rename to src/session/view/content/room_history/message_toolbar/completion/completion_row.ui diff --git a/src/session/view/content/room_history/completion/mod.rs b/src/session/view/content/room_history/message_toolbar/completion/mod.rs similarity index 100% rename from src/session/view/content/room_history/completion/mod.rs rename to src/session/view/content/room_history/message_toolbar/completion/mod.rs diff --git a/src/session/view/content/room_history/message_toolbar/mod.rs b/src/session/view/content/room_history/message_toolbar/mod.rs new file mode 100644 index 00000000..1d830205 --- /dev/null +++ b/src/session/view/content/room_history/message_toolbar/mod.rs @@ -0,0 +1,929 @@ +use ashpd::{ + desktop::location::{Accuracy, LocationProxy}, + WindowIdentifier, +}; +use futures_util::{FutureExt, StreamExt, TryFutureExt}; +use geo_uri::GeoUri; +use gettextrs::{gettext, pgettext}; +use gtk::{ + gdk, gio, + glib::{self, clone}, + prelude::*, + subclass::prelude::*, + CompositeTemplate, +}; +use matrix_sdk::{ + attachment::{AttachmentInfo, BaseFileInfo, BaseImageInfo}, + ruma::events::{ + room::message::{EmoteMessageEventContent, FormattedBody, MessageType}, + AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent, + }, +}; +use ruma::events::{ + room::message::{ + AddMentions, ForwardThread, LocationMessageEventContent, MessageFormat, + OriginalSyncRoomMessageEvent, RoomMessageEventContent, + }, + AnyMessageLikeEventContent, +}; +use sourceview::prelude::*; +use tracing::{debug, error, warn}; + +mod attachment_dialog; +mod completion; + +use self::{attachment_dialog::AttachmentDialog, completion::CompletionPopover}; +use super::message_row::MessageContent; +use crate::{ + components::{CustomEntry, LabelWithWidgets, Pill}, + gettext_f, + prelude::*, + session::model::{Event, EventKey, Room}, + spawn, spawn_tokio, toast, + utils::{ + matrix::extract_mentions, + media::{filename_for_mime, get_audio_info, get_image_info, get_video_info, load_file}, + template_callbacks::TemplateCallbacks, + }, +}; + +#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)] +#[repr(i32)] +#[enum_type(name = "RelatedEventType")] +pub enum RelatedEventType { + #[default] + None = 0, + Reply = 1, + Edit = 2, +} + +mod imp { + use std::cell::{Cell, RefCell}; + + use glib::subclass::InitializingObject; + + use super::*; + use crate::Application; + + #[derive(Debug, Default, CompositeTemplate)] + #[template( + resource = "/org/gnome/Fractal/ui/session/view/content/room_history/message_toolbar/mod.ui" + )] + pub struct MessageToolbar { + pub room: glib::WeakRef, + pub md_enabled: Cell, + pub completion: CompletionPopover, + #[template_child] + pub message_entry: TemplateChild, + #[template_child] + pub related_event_header: TemplateChild, + #[template_child] + pub related_event_content: TemplateChild, + pub related_event_type: Cell, + pub related_event: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for MessageToolbar { + const NAME: &'static str = "MessageToolbar"; + type Type = super::MessageToolbar; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + CustomEntry::static_type(); + + Self::bind_template(klass); + Self::Type::bind_template_callbacks(klass); + TemplateCallbacks::bind_template_callbacks(klass); + + klass.install_action( + "message-toolbar.send-text-message", + None, + move |widget, _, _| { + widget.send_text_message(); + }, + ); + + klass.install_action("message-toolbar.select-file", None, move |widget, _, _| { + spawn!(clone!(@weak widget => async move { + widget.select_file().await; + })); + }); + + klass.install_action("message-toolbar.open-emoji", None, move |widget, _, _| { + widget.open_emoji(); + }); + + klass.install_action("message-toolbar.send-location", None, move |widget, _, _| { + spawn!(clone!(@weak widget => async move { + let toast_error = match widget.send_location().await { + // Do nothing if the request was cancelled by the user + Err(ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled)) => { + error!("Location request was cancelled by the user"); + Some(gettext("The location request has been cancelled.")) + }, + Err(error) => { + error!("Failed to send location {error}"); + Some(gettext("Failed to retrieve current location.")) + } + _ => None, + }; + + if let Some(message) = toast_error { + toast!(widget, message); + } + })); + }); + + klass.install_property_action("message-toolbar.markdown", "markdown-enabled"); + + klass.install_action( + "message-toolbar.clear-related-event", + None, + move |widget, _, _| widget.clear_related_event(), + ); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for MessageToolbar { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecObject::builder::("room") + .explicit_notify() + .build(), + glib::ParamSpecBoolean::builder("markdown-enabled") + .explicit_notify() + .build(), + glib::ParamSpecEnum::builder::("related-event-type") + .read_only() + .build(), + glib::ParamSpecObject::builder::("related-event") + .read_only() + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + let obj = self.obj(); + + match pspec.name() { + "room" => obj.set_room(value.get::>().unwrap().as_ref()), + "markdown-enabled" => obj.set_markdown_enabled(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let obj = self.obj(); + + match pspec.name() { + "room" => obj.room().to_value(), + "markdown-enabled" => obj.markdown_enabled().to_value(), + "related-event-type" => obj.related_event_type().to_value(), + "related-event" => obj.related_event().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self) { + self.parent_constructed(); + let obj = self.obj(); + + // Clipboard. + self.message_entry + .connect_paste_clipboard(clone!(@weak obj => move |entry| { + let formats = obj.clipboard().formats(); + + // We only handle files and supported images. + if formats.contains_type(gio::File::static_type()) || formats.contains_type(gdk::Texture::static_type()) { + entry.stop_signal_emission_by_name("paste-clipboard"); + spawn!( + clone!(@weak obj => async move { + obj.read_clipboard().await; + })); + } + })); + self.message_entry + .connect_copy_clipboard(clone!(@weak obj => move |entry| { + entry.stop_signal_emission_by_name("copy-clipboard"); + obj.copy_buffer_selection_to_clipboard(); + })); + self.message_entry + .connect_cut_clipboard(clone!(@weak obj => move |entry| { + entry.stop_signal_emission_by_name("cut-clipboard"); + obj.copy_buffer_selection_to_clipboard(); + entry.buffer().delete_selection(true, true); + })); + + // Key bindings. + let key_events = gtk::EventControllerKey::new(); + key_events + .connect_key_pressed(clone!(@weak obj => @default-return glib::Propagation::Proceed, move |_, key, _, modifier| { + if modifier.is_empty() && (key == gdk::Key::Return || key == gdk::Key::KP_Enter) { + obj.send_text_message(); + glib::Propagation::Stop + } else if modifier.is_empty() && key == gdk::Key::Escape && obj.related_event_type() != RelatedEventType::None { + obj.clear_related_event(); + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + })); + self.message_entry.add_controller(key_events); + + let buffer = self + .message_entry + .buffer() + .downcast::() + .unwrap(); + + crate::utils::sourceview::setup_style_scheme(&buffer); + + // Actions on changes in message entry. + buffer.connect_text_notify(clone!(@weak obj => move |buffer| { + let (start_iter, end_iter) = buffer.bounds(); + let is_empty = start_iter == end_iter; + obj.action_set_enabled("message-toolbar.send-text-message", !is_empty); + obj.send_typing_notification(!is_empty); + })); + + let (start_iter, end_iter) = buffer.bounds(); + obj.action_set_enabled("message-toolbar.send-text-message", start_iter != end_iter); + + // Markdown highlighting. + let md_lang = sourceview::LanguageManager::default().language("markdown"); + buffer.set_language(md_lang.as_ref()); + obj.bind_property("markdown-enabled", &buffer, "highlight-syntax") + .sync_create() + .build(); + + let settings = Application::default().settings(); + settings + .bind("markdown-enabled", &*obj, "markdown-enabled") + .build(); + + // Tab auto-completion. + self.completion.set_parent(&*self.message_entry); + } + + fn dispose(&self) { + self.completion.unparent(); + } + } + + impl WidgetImpl for MessageToolbar {} + impl BoxImpl for MessageToolbar {} +} + +glib::wrapper! { + /// A toolbar with different actions to send messages. + pub struct MessageToolbar(ObjectSubclass) + @extends gtk::Widget, gtk::Box, @implements gtk::Accessible; +} + +#[gtk::template_callbacks] +impl MessageToolbar { + pub fn new() -> Self { + glib::Object::new() + } + + /// The room to send messages in. + pub fn room(&self) -> Option { + self.imp().room.upgrade() + } + + /// Set the room currently displayed. + pub fn set_room(&self, room: Option<&Room>) { + if self.room().as_ref() == room { + return; + } + + let imp = self.imp(); + self.clear_related_event(); + + imp.room.set(room); + + self.update_completion(room); + imp.message_entry.grab_focus(); + + self.notify("room"); + } + + /// Whether outgoing messages should be interpreted as markdown. + pub fn markdown_enabled(&self) -> bool { + self.imp().md_enabled.get() + } + + /// Set whether outgoing messages should be interpreted as markdown. + pub fn set_markdown_enabled(&self, enabled: bool) { + let imp = self.imp(); + + imp.md_enabled.set(enabled); + + self.notify("markdown-enabled"); + } + + /// The type of related event of the composer. + pub fn related_event_type(&self) -> RelatedEventType { + self.imp().related_event_type.get() + } + + /// Set the type of related event of the composer. + fn set_related_event_type(&self, related_type: RelatedEventType) { + if self.related_event_type() == related_type { + return; + } + + self.imp().related_event_type.set(related_type); + self.notify("related-event-type"); + } + + /// The related event of the composer. + pub fn related_event(&self) -> Option { + self.imp().related_event.borrow().clone() + } + + /// Set the related event of the composer. + fn set_related_event(&self, event: Option) { + // We shouldn't reply to events that are not sent yet. + if let Some(event) = &event { + if event.event_id().is_none() { + return; + } + } + + let prev_event = self.related_event(); + + if prev_event == event { + return; + } + + self.imp().related_event.replace(event); + self.notify("related-event"); + } + + pub fn clear_related_event(&self) { + if self.related_event_type() == RelatedEventType::Edit { + // Clean up the entry. + self.imp().message_entry.buffer().set_text(""); + }; + + self.set_related_event(None); + self.set_related_event_type(RelatedEventType::default()); + } + + pub fn set_reply_to(&self, event: Event) { + let imp = self.imp(); + imp.related_event_header + .set_widgets(vec![Pill::for_user(event.sender().upcast_ref())]); + imp.related_event_header + // Translators: Do NOT translate the content between '{' and '}', + // this is a variable name. In this string, 'Reply' is a noun. + .set_label(Some(gettext_f("Reply to {user}", &[("user", "")]))); + + imp.related_event_content.update_for_event(&event); + imp.related_event_content.set_visible(true); + + self.set_related_event_type(RelatedEventType::Reply); + self.set_related_event(Some(event)); + imp.message_entry.grab_focus(); + } + + /// Set the event to edit. + pub fn set_edit(&self, event: Event) { + // We don't support editing non-text messages. + let Some((text, formatted)) = event.message().and_then(|msg| match msg { + MessageType::Emote(emote) => Some((format!("/me {}", emote.body), emote.formatted)), + MessageType::Text(text) => Some((text.body, text.formatted)), + _ => None, + }) else { + return; + }; + + let mentions = if let Some(html) = + formatted.and_then(|f| (f.format == MessageFormat::Html).then_some(f.body)) + { + let (_, mentions) = extract_mentions(&html, &event.room()); + let mut pos = 0; + // This is looking for the mention link's inner text in the Markdown + // so it is not super reliable: if there is other text that matches + // a user's display name in the string it might be replaced instead + // of the actual mention. + // Short of an HTML to Markdown converter, it won't be a simple task + // to locate mentions in Markdown. + mentions + .into_iter() + .filter_map(|(pill, s)| { + text[pos..].find(&s).map(|index| { + let start = pos + index; + let end = start + s.len(); + pos = end; + (pill, (start, end)) + }) + }) + .collect::>() + } else { + Vec::new() + }; + + let imp = self.imp(); + imp.related_event_header.set_widgets::(vec![]); + imp.related_event_header + // Translators: In this string, 'Edit' is a noun. + .set_label(Some(pgettext("room-history", "Edit"))); + + imp.related_event_content.set_visible(false); + + self.set_related_event_type(RelatedEventType::Edit); + self.set_related_event(Some(event)); + + let view = &*imp.message_entry; + let buffer = view.buffer(); + + if mentions.is_empty() { + buffer.set_text(&text); + } else { + // Place the pills instead of the text at the appropriate places in + // the TextView. + buffer.set_text(""); + + let mut pos = 0; + let mut iter = buffer.iter_at_offset(0); + + for (pill, (start, end)) in mentions { + if pos != start { + buffer.insert(&mut iter, &text[pos..start]); + } + + let anchor = buffer.create_child_anchor(&mut iter); + view.add_child_at_anchor(&pill, &anchor); + + pos = end; + } + + if pos != text.len() { + buffer.insert(&mut iter, &text[pos..]) + } + } + + imp.message_entry.grab_focus(); + } + + /// Get an iterator over chunks of the message entry's text between the + /// given start and end, split by mentions. + fn split_buffer_mentions(&self, start: gtk::TextIter, end: gtk::TextIter) -> SplitMentions { + SplitMentions { iter: start, end } + } + + fn send_text_message(&self) { + let Some(room) = self.room() else { + return; + }; + + let imp = self.imp(); + let buffer = imp.message_entry.buffer(); + let (start_iter, end_iter) = buffer.bounds(); + let body_len = buffer.text(&start_iter, &end_iter, true).len(); + + let is_markdown = imp.md_enabled.get(); + let mut has_mentions = false; + let mut plain_body = String::with_capacity(body_len); + // formatted_body is Markdown if is_markdown is true, and HTML if false. + let mut formatted_body = String::with_capacity(body_len); + + for chunk in self.split_buffer_mentions(start_iter, end_iter) { + match chunk { + MentionChunk::Text(text) => { + plain_body.push_str(&text); + formatted_body.push_str(&text); + } + MentionChunk::Mention { name, uri } => { + has_mentions = true; + plain_body.push_str(&name); + formatted_body.push_str(&if is_markdown { + format!("[{name}]({uri})") + } else { + format!("{name}") + }); + } + } + } + + let is_emote = plain_body.starts_with("/me "); + if is_emote { + plain_body.replace_range(.."/me ".len(), ""); + formatted_body.replace_range(.."/me ".len(), ""); + } + + let html_body = if is_markdown { + FormattedBody::markdown(formatted_body).map(|b| b.body) + } else if has_mentions { + // Already formatted with HTML + Some(formatted_body) + } else { + None + }; + + let mut content = if is_emote { + MessageType::Emote(if let Some(html_body) = html_body { + EmoteMessageEventContent::html(plain_body, html_body) + } else { + EmoteMessageEventContent::plain(plain_body) + }) + .into() + } else { + let mut content = if let Some(html_body) = html_body { + RoomMessageEventContent::text_html(plain_body, html_body) + } else { + RoomMessageEventContent::text_plain(plain_body) + }; + + if self.related_event_type() == RelatedEventType::Reply { + let related_event = self + .related_event() + .unwrap() + .raw() + .unwrap() + .deserialize() + .unwrap(); + if let AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage( + SyncMessageLikeEvent::Original(related_message_event), + )) = related_event + { + let full_related_message_event = related_message_event + .into_full_event(self.room().unwrap().room_id().to_owned()); + content = content.make_reply_to( + &full_related_message_event, + ForwardThread::Yes, + AddMentions::No, + ) + } + } + + content + }; + + // Handle edit. + if self.related_event_type() == RelatedEventType::Edit { + let related_event = self.related_event().unwrap(); + let related_message = related_event + .raw() + .unwrap() + .deserialize_as::() + .unwrap(); + + // Try to get the replied to message of the original event if it's available + // locally. + let replied_to_message = related_event + .reply_to_id() + .and_then(|id| room.timeline().event_by_key(&EventKey::EventId(id))) + .and_then(|e| e.raw()) + .and_then(|r| r.deserialize_as::().ok()) + .map(|e| e.into_full_event(room.room_id().to_owned())); + + content = content.make_replacement(&related_message, replied_to_message.as_ref()); + } + + room.send_room_message_event(content); + buffer.set_text(""); + self.clear_related_event(); + } + + fn open_emoji(&self) { + self.imp().message_entry.emit_insert_emoji(); + } + + async fn send_location(&self) -> ashpd::Result<()> { + let Some(room) = self.room() else { + return Ok(()); + }; + + let handle = spawn_tokio!(async move { + let proxy = LocationProxy::new().await?; + let identifier = WindowIdentifier::default(); + + let session = proxy + .create_session(Some(0), Some(0), Some(Accuracy::Exact)) + .await?; + + // We want to be listening for new locations whenever the session is up + // otherwise we might lose the first response and will have to wait for a future + // update by geoclue + // FIXME: We should update the location on the map according to updates received + // by the proxy. + let mut stream = proxy.receive_location_updated().await?; + let (_, location) = futures_util::try_join!( + proxy.start(&session, &identifier).into_future(), + stream.next().map(|l| l.ok_or(ashpd::Error::NoResponse)) + )?; + + ashpd::Result::Ok(location) + }); + + let location = handle.await.unwrap()?; + let geo_uri = GeoUri::builder() + .latitude(location.latitude()) + .longitude(location.longitude()) + .build() + .expect("Got invalid coordinates from ashpd"); + + let window = self.root().and_downcast::().unwrap(); + let dialog = AttachmentDialog::for_location(&window, &gettext("Your Location"), &geo_uri); + if dialog.run_future().await != gtk::ResponseType::Ok { + return Ok(()); + } + + let geo_uri_string = geo_uri.to_string(); + let iso8601_datetime = + glib::DateTime::from_unix_local(location.timestamp().as_secs() as i64) + .expect("Valid location timestamp"); + let location_body = gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a variable + // name. + "User Location {geo_uri} at {iso8601_datetime}", + &[ + ("geo_uri", &geo_uri_string), + ( + "iso8601_datetime", + iso8601_datetime.format_iso8601().unwrap().as_str(), + ), + ], + ); + room.send_room_message_event(AnyMessageLikeEventContent::RoomMessage( + RoomMessageEventContent::new(MessageType::Location(LocationMessageEventContent::new( + location_body, + geo_uri_string, + ))), + )); + + Ok(()) + } + + async fn send_image(&self, image: gdk::Texture) { + let window = self.root().and_downcast::().unwrap(); + let filename = filename_for_mime(Some(mime::IMAGE_PNG.as_ref()), None); + let dialog = AttachmentDialog::for_image(&window, &filename, &image); + + if dialog.run_future().await != gtk::ResponseType::Ok { + return; + } + + let Some(room) = self.room() else { + return; + }; + + let bytes = image.save_to_png_bytes(); + let info = AttachmentInfo::Image(BaseImageInfo { + width: Some((image.width() as u32).into()), + height: Some((image.height() as u32).into()), + size: Some((bytes.len() as u32).into()), + blurhash: None, + }); + + room.send_attachment(bytes.to_vec(), mime::IMAGE_PNG, &filename, info); + } + + pub async fn select_file(&self) { + let dialog = gtk::FileDialog::builder() + .title(gettext("Select File")) + .modal(true) + .accept_label(gettext("Select")) + .build(); + + match dialog + .open_future(self.root().and_downcast_ref::()) + .await + { + Ok(file) => { + self.send_file(file).await; + } + Err(error) => { + if error.matches(gtk::DialogError::Dismissed) { + debug!("File dialog dismissed by user"); + } else { + error!("Could not open file: {error:?}"); + toast!(self, gettext("Could not open file")); + } + } + }; + } + + pub async fn send_file(&self, file: gio::File) { + match load_file(&file).await { + Ok((bytes, file_info)) => { + let window = self.root().and_downcast::().unwrap(); + let dialog = AttachmentDialog::for_file(&window, &file_info.filename, &file); + + if dialog.run_future().await != gtk::ResponseType::Ok { + return; + } + + let Some(room) = self.room() else { + error!("Cannot send file without a room"); + return; + }; + + let size = file_info.size.map(Into::into); + let info = match file_info.mime.type_() { + mime::IMAGE => { + let mut info = get_image_info(&file).await; + info.size = size; + AttachmentInfo::Image(info) + } + mime::VIDEO => { + let mut info = get_video_info(&file).await; + info.size = size; + AttachmentInfo::Video(info) + } + mime::AUDIO => { + let mut info = get_audio_info(&file).await; + info.size = size; + AttachmentInfo::Audio(info) + } + _ => AttachmentInfo::File(BaseFileInfo { size }), + }; + + room.send_attachment(bytes, file_info.mime, &file_info.filename, info); + } + Err(error) => { + warn!("Could not read file: {error}"); + toast!(self, gettext("Error reading file")); + } + } + } + + async fn read_clipboard(&self) { + let clipboard = self.clipboard(); + let formats = clipboard.formats(); + + if formats.contains_type(gdk::Texture::static_type()) { + // There is an image in the clipboard. + match clipboard + .read_value_future(gdk::Texture::static_type(), glib::Priority::DEFAULT) + .await + { + Ok(value) => match value.get::() { + Ok(texture) => { + self.send_image(texture).await; + return; + } + Err(error) => warn!("Could not get GdkTexture from value: {error:?}"), + }, + Err(error) => warn!("Could not get GdkTexture from the clipboard: {error:?}"), + } + + toast!(self, gettext("Error getting image from clipboard")); + } else if formats.contains_type(gio::File::static_type()) { + // There is a file in the clipboard. + match clipboard + .read_value_future(gio::File::static_type(), glib::Priority::DEFAULT) + .await + { + Ok(value) => match value.get::() { + Ok(file) => { + self.send_file(file).await; + return; + } + Err(error) => warn!("Could not get file from value: {error:?}"), + }, + Err(error) => warn!("Could not get file from the clipboard: {error:?}"), + } + + toast!(self, gettext("Error getting file from clipboard")); + } + } + + #[template_callback] + fn handle_related_event_click(&self) { + if let Some(event) = &*self.imp().related_event.borrow() { + self.activate_action( + "room-history.scroll-to-event", + Some(&event.key().to_variant()), + ) + .unwrap(); + } + } + + pub fn handle_paste_action(&self) { + spawn!(glib::clone!(@weak self as obj => async move { + obj.read_clipboard().await; + })); + } + + // Update the completion for the current room. + fn update_completion(&self, room: Option<&Room>) { + let completion = &self.imp().completion; + + completion + .set_user_id(room.and_then(|r| r.session().user().map(|u| u.user_id().to_string()))); + // `RoomHistory` should have a strong reference to the list so we can use + // `get_or_create_members()`. + completion.set_members(room.map(|r| r.get_or_create_members())); + } + + // Copy the selection in the message entry to the clipboard while replacing + // mentions. + fn copy_buffer_selection_to_clipboard(&self) { + if let Some((start, end)) = self.imp().message_entry.buffer().selection_bounds() { + let content: String = self + .split_buffer_mentions(start, end) + .map(|chunk| match chunk { + MentionChunk::Text(str) => str, + MentionChunk::Mention { name, .. } => name, + }) + .collect(); + self.clipboard().set_text(&content); + } + } + + fn send_typing_notification(&self, typing: bool) { + if let Some(room) = self.room() { + room.send_typing_notification(typing); + } + } +} + +enum MentionChunk { + Text(String), + Mention { name: String, uri: String }, +} + +struct SplitMentions { + iter: gtk::TextIter, + end: gtk::TextIter, +} + +impl Iterator for SplitMentions { + type Item = MentionChunk; + + fn next(&mut self) -> Option { + if self.iter == self.end { + // We reached the end. + return None; + } + + if let Some(pill) = self + .iter + .child_anchor() + .map(|anchor| anchor.widgets()) + .as_ref() + .and_then(|widgets| widgets.first()) + .and_then(|widget| widget.downcast_ref::()) + { + // This chunk is a mention. + let (name, uri) = if let Some(user) = pill.user() { + ( + user.display_name(), + user.user_id().matrix_to_uri().to_string(), + ) + } else if let Some(room) = pill.room() { + ( + room.display_name(), + room.room_id().matrix_to_uri().to_string(), + ) + } else { + unreachable!() + }; + + self.iter.forward_cursor_position(); + + return Some(MentionChunk::Mention { name, uri }); + } + + // This chunk is not a mention. Go forward until the next mention or the + // end and return the text in between. + let start = self.iter; + while self.iter.forward_cursor_position() && self.iter != self.end { + if self + .iter + .child_anchor() + .map(|anchor| anchor.widgets()) + .as_ref() + .and_then(|widgets| widgets.first()) + .and_then(|widget| widget.downcast_ref::()) + .is_some() + { + break; + } + } + + let text = self.iter.buffer().text(&start, &self.iter, false); + // We might somehow have an empty string before the end, or at the end, + // because of hidden `char`s in the buffer, so we must only return + // `None` when we have an empty string at the end. + if self.iter == self.end && text.is_empty() { + None + } else { + Some(MentionChunk::Text(text.into())) + } + } +} diff --git a/src/session/view/content/room_history/message_toolbar/mod.ui b/src/session/view/content/room_history/message_toolbar/mod.ui new file mode 100644 index 00000000..88482858 --- /dev/null +++ b/src/session/view/content/room_history/message_toolbar/mod.ui @@ -0,0 +1,153 @@ + + + +
+ + _Location + message-toolbar.send-location + map-marker-symbolic + + + _Markdown + message-toolbar.markdown + +
+
+ +
diff --git a/src/session/view/content/room_history/mod.rs b/src/session/view/content/room_history/mod.rs index 2538ac8a..2f5cdd4e 100644 --- a/src/session/view/content/room_history/mod.rs +++ b/src/session/view/content/room_history/mod.rs @@ -1,8 +1,7 @@ -mod attachment_dialog; -mod completion; mod divider_row; mod item_row; mod message_row; +mod message_toolbar; mod read_receipts_list; mod state_row; mod typing_row; @@ -11,72 +10,31 @@ mod verification_info_bar; use std::time::Duration; use adw::{prelude::*, subclass::prelude::*}; -use ashpd::{ - desktop::location::{Accuracy, LocationProxy}, - WindowIdentifier, -}; -use futures_util::{FutureExt, StreamExt, TryFutureExt}; -use geo_uri::GeoUri; -use gettextrs::{gettext, pgettext}; +use gettextrs::gettext; use gtk::{ gdk, gio, glib::{self, clone, FromVariant}, CompositeTemplate, }; -use matrix_sdk::{ - attachment::{AttachmentInfo, BaseFileInfo, BaseImageInfo}, - ruma::{ - events::{ - room::message::{EmoteMessageEventContent, FormattedBody, MessageType}, - AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent, - }, - EventId, - }, -}; +use matrix_sdk::ruma::EventId; use ruma::{ api::client::receipt::create_receipt::v3::ReceiptType, - events::{ - receipt::ReceiptThread, - room::{ - message::{ - AddMentions, ForwardThread, LocationMessageEventContent, MessageFormat, - OriginalSyncRoomMessageEvent, RoomMessageEventContent, - }, - power_levels::PowerLevelAction, - }, - AnyMessageLikeEventContent, - }, + events::{receipt::ReceiptThread, room::power_levels::PowerLevelAction}, OwnedEventId, }; -use sourceview::prelude::*; -use tracing::{debug, error, warn}; +use tracing::{error, warn}; use self::{ - attachment_dialog::AttachmentDialog, - completion::CompletionPopover, - divider_row::DividerRow, - item_row::ItemRow, - message_row::{content::MessageContent, MessageRow}, - read_receipts_list::ReadReceiptsList, - state_row::StateRow, - typing_row::TypingRow, - verification_info_bar::VerificationInfoBar, + divider_row::DividerRow, item_row::ItemRow, message_row::MessageRow, + message_toolbar::MessageToolbar, read_receipts_list::ReadReceiptsList, state_row::StateRow, + typing_row::TypingRow, verification_info_bar::VerificationInfoBar, }; use super::{room_details, RoomDetails}; use crate::{ - components::{ - CustomEntry, DragOverlay, LabelWithWidgets, Pill, ReactionChooser, RoomTitle, Spinner, - }, - gettext_f, - prelude::*, + components::{DragOverlay, ReactionChooser, RoomTitle, Spinner}, session::model::{Event, EventKey, MemberList, Room, RoomType, Timeline, TimelineState}, spawn, spawn_tokio, toast, - utils::{ - matrix::extract_mentions, - media::{filename_for_mime, get_audio_info, get_image_info, get_video_info, load_file}, - message_dialog, - template_callbacks::TemplateCallbacks, - }, + utils::{message_dialog, template_callbacks::TemplateCallbacks}, Window, }; @@ -85,16 +43,6 @@ const SCROLL_TIMEOUT: Duration = Duration::from_millis(500); /// The time to wait before considering that messages on a screen where read. const READ_TIMEOUT: Duration = Duration::from_secs(5); -#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)] -#[repr(i32)] -#[enum_type(name = "RelatedEventType")] -pub enum RelatedEventType { - #[default] - None = 0, - Reply = 1, - Edit = 2, -} - mod imp { use std::{ cell::{Cell, RefCell}, @@ -105,7 +53,6 @@ mod imp { use once_cell::unsync::OnceCell; use super::*; - use crate::Application; #[derive(Debug, Default, CompositeTemplate)] #[template(resource = "/org/gnome/Fractal/ui/session/view/content/room_history/mod.ui")] @@ -114,12 +61,10 @@ mod imp { pub room_members: RefCell>, pub room_handlers: RefCell>, pub timeline_handlers: RefCell>, - pub md_enabled: Cell, pub is_auto_scrolling: Cell, pub sticky: Cell, pub item_context_menu: OnceCell, pub item_reaction_chooser: ReactionChooser, - pub completion: CompletionPopover, #[template_child] pub room_title: TemplateChild, #[template_child] @@ -135,7 +80,7 @@ mod imp { #[template_child] pub scroll_btn_revealer: TemplateChild, #[template_child] - pub message_entry: TemplateChild, + pub message_toolbar: TemplateChild, #[template_child] pub loading: TemplateChild, #[template_child] @@ -147,12 +92,6 @@ mod imp { pub is_loading: Cell, #[template_child] pub drag_overlay: TemplateChild, - #[template_child] - pub related_event_header: TemplateChild, - #[template_child] - pub related_event_content: TemplateChild, - pub related_event_type: Cell, - pub related_event: RefCell>, pub scroll_timeout: RefCell>, pub read_timeout: RefCell>, /// The GtkSelectionModel used in the listview. @@ -168,21 +107,16 @@ mod imp { type ParentType = adw::Bin; fn class_init(klass: &mut Self::Class) { - CustomEntry::static_type(); ItemRow::static_type(); VerificationInfoBar::static_type(); Timeline::static_type(); + Self::bind_template(klass); Self::Type::bind_template_callbacks(klass); TemplateCallbacks::bind_template_callbacks(klass); + klass.set_accessible_role(gtk::AccessibleRole::Group); - klass.install_action( - "room-history.send-text-message", - None, - move |widget, _, _| { - widget.send_text_message(); - }, - ); + klass.install_action("room-history.leave", None, move |obj, _, _| { spawn!(clone!(@weak obj => async move { obj.leave().await; @@ -209,44 +143,14 @@ mod imp { klass.install_action("room-history.scroll-down", None, move |widget, _, _| { widget.scroll_down(); }); - - klass.install_action("room-history.select-file", None, move |widget, _, _| { - spawn!(clone!(@weak widget => async move { - widget.select_file().await; - })); - }); - - klass.install_action("room-history.open-emoji", None, move |widget, _, _| { - widget.open_emoji(); - }); - - klass.install_action("room-history.send-location", None, move |widget, _, _| { - spawn!(clone!(@weak widget => async move { - let toast_error = match widget.send_location().await { - // Do nothing if the request was cancelled by the user - Err(ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled)) => { - error!("Location request was cancelled by the user"); - Some(gettext("The location request has been cancelled.")) - }, - Err(error) => { - error!("Failed to send location {error}"); - Some(gettext("Failed to retrieve current location.")) - } - _ => None, - }; - - if let Some(message) = toast_error { - toast!(widget, message); - } - })); - }); - - klass.install_property_action("room-history.markdown", "markdown-enabled"); - klass.install_action( - "room-history.clear-related-event", - None, - move |widget, _, _| widget.clear_related_event(), + "room-history.scroll-to-event", + Some(EventKey::static_variant_type().as_str()), + move |widget, _, v| { + if let Some(event_key) = v.and_then(EventKey::from_variant) { + widget.scroll_to_event(&event_key); + } + }, ); klass.install_action("room-history.reply", Some("s"), move |widget, _, v| { @@ -259,7 +163,7 @@ mod imp { .and_then(|room| room.timeline().event_by_key(&EventKey::EventId(event_id))) .and_downcast() { - widget.set_reply_to(event); + widget.message_toolbar().set_reply_to(event); } } }); @@ -274,7 +178,7 @@ mod imp { .and_then(|room| room.timeline().event_by_key(&EventKey::EventId(event_id))) .and_downcast() { - widget.set_edit(event); + widget.message_toolbar().set_edit(event); } } }); @@ -296,18 +200,9 @@ mod imp { glib::ParamSpecBoolean::builder("empty") .explicit_notify() .build(), - glib::ParamSpecBoolean::builder("markdown-enabled") - .explicit_notify() - .build(), glib::ParamSpecBoolean::builder("sticky") .explicit_notify() .build(), - glib::ParamSpecEnum::builder::("related-event-type") - .read_only() - .build(), - glib::ParamSpecObject::builder::("related-event") - .read_only() - .build(), ] }); @@ -319,7 +214,6 @@ mod imp { match pspec.name() { "room" => obj.set_room(value.get().unwrap()), - "markdown-enabled" => obj.set_markdown_enabled(value.get().unwrap()), "sticky" => obj.set_sticky(value.get().unwrap()), _ => unimplemented!(), } @@ -331,24 +225,28 @@ mod imp { match pspec.name() { "room" => obj.room().to_value(), "empty" => obj.is_empty().to_value(), - "markdown-enabled" => obj.markdown_enabled().to_value(), "sticky" => obj.sticky().to_value(), - "related-event-type" => obj.related_event_type().to_value(), - "related-event" => obj.related_event().to_value(), _ => unimplemented!(), } } fn constructed(&self) { self.setup_listview(); - self.setup_message_entry(); self.setup_drop_target(); self.parent_constructed(); } fn dispose(&self) { - self.completion.unparent(); + if let Some(room) = self.room.take() { + for handler in self.room_handlers.take() { + room.disconnect(handler); + } + + for handler in self.timeline_handlers.take() { + room.timeline().disconnect(handler); + } + } for (_, expr_watch) in self.room_expr_watches.take() { expr_watch.unwatch(); @@ -429,86 +327,6 @@ mod imp { })); } - fn setup_message_entry(&self) { - let obj = self.obj(); - - // Clipboard. - self.message_entry - .connect_paste_clipboard(clone!(@weak obj => move |entry| { - let formats = obj.clipboard().formats(); - - // We only handle files and supported images. - if formats.contains_type(gio::File::static_type()) || formats.contains_type(gdk::Texture::static_type()) { - entry.stop_signal_emission_by_name("paste-clipboard"); - spawn!( - clone!(@weak obj => async move { - obj.read_clipboard().await; - })); - } - })); - self.message_entry - .connect_copy_clipboard(clone!(@weak obj => move |entry| { - entry.stop_signal_emission_by_name("copy-clipboard"); - obj.copy_buffer_selection_to_clipboard(); - })); - self.message_entry - .connect_cut_clipboard(clone!(@weak obj => move |entry| { - entry.stop_signal_emission_by_name("cut-clipboard"); - obj.copy_buffer_selection_to_clipboard(); - entry.buffer().delete_selection(true, true); - })); - - // Key bindings. - let key_events = gtk::EventControllerKey::new(); - key_events - .connect_key_pressed(clone!(@weak obj => @default-return glib::Propagation::Proceed, move |_, key, _, modifier| { - if modifier.is_empty() && (key == gdk::Key::Return || key == gdk::Key::KP_Enter) { - obj.activate_action("room-history.send-text-message", None).unwrap(); - glib::Propagation::Stop - } else if modifier.is_empty() && key == gdk::Key::Escape && obj.related_event_type() != RelatedEventType::None { - obj.clear_related_event(); - glib::Propagation::Stop - } else { - glib::Propagation::Proceed - } - })); - self.message_entry.add_controller(key_events); - - let buffer = self - .message_entry - .buffer() - .downcast::() - .unwrap(); - - crate::utils::sourceview::setup_style_scheme(&buffer); - - // Actions on changes in message entry. - buffer.connect_text_notify(clone!(@weak obj => move |buffer| { - let (start_iter, end_iter) = buffer.bounds(); - let is_empty = start_iter == end_iter; - obj.action_set_enabled("room-history.send-text-message", !is_empty); - obj.send_typing_notification(!is_empty); - })); - - let (start_iter, end_iter) = buffer.bounds(); - obj.action_set_enabled("room-history.send-text-message", start_iter != end_iter); - - // Markdown highlighting. - let md_lang = sourceview::LanguageManager::default().language("markdown"); - buffer.set_language(md_lang.as_ref()); - obj.bind_property("markdown-enabled", &buffer, "highlight-syntax") - .sync_create() - .build(); - - let settings = Application::default().settings(); - settings - .bind("markdown-enabled", &*obj, "markdown-enabled") - .build(); - - // Tab auto-completion. - self.completion.set_parent(&*self.message_entry); - } - fn setup_drop_target(&self) { let obj = self.obj(); @@ -522,7 +340,7 @@ mod imp { match value.get::() { Ok(file) => { spawn!(clone!(@weak obj => async move { - obj.send_file(file).await; + obj.message_toolbar().send_file(file).await; })); true } @@ -555,6 +373,10 @@ impl RoomHistory { glib::Object::new() } + fn message_toolbar(&self) -> &MessageToolbar { + &self.imp().message_toolbar + } + /// Set the room currently displayed. pub fn set_room(&self, room: Option) { let imp = self.imp(); @@ -575,8 +397,6 @@ impl RoomHistory { for (_, expr_watch) in imp.room_expr_watches.take() { expr_watch.unwatch(); } - - self.clear_related_event(); } if let Some(source_id) = imp.scroll_timeout.take() { @@ -662,13 +482,12 @@ impl RoomHistory { self.selection_model().set_model(model); imp.is_loading.set(false); - imp.message_entry.grab_focus(); imp.room.replace(room); self.update_view(); self.start_loading(); self.update_room_state(); - self.update_completion(); self.update_tombstoned_banner(); + self.notify("room"); self.notify("empty"); } @@ -688,290 +507,12 @@ impl RoomHistory { self.imp().room.borrow().is_none() } - /// Whether outgoing messages should be interpreted as markdown. - pub fn markdown_enabled(&self) -> bool { - self.imp().md_enabled.get() - } - - /// Set whether outgoing messages should be interpreted as markdown. - pub fn set_markdown_enabled(&self, enabled: bool) { - let imp = self.imp(); - - imp.md_enabled.set(enabled); - - self.notify("markdown-enabled"); - } - - /// The type of related event of the composer. - pub fn related_event_type(&self) -> RelatedEventType { - self.imp().related_event_type.get() - } - - /// Set the type of related event of the composer. - fn set_related_event_type(&self, related_type: RelatedEventType) { - if self.related_event_type() == related_type { - return; - } - - self.imp().related_event_type.set(related_type); - self.notify("related-event-type"); - } - - /// The related event of the composer. - pub fn related_event(&self) -> Option { - self.imp().related_event.borrow().clone() - } - - /// Set the related event of the composer. - fn set_related_event(&self, event: Option) { - // We shouldn't reply to events that are not sent yet. - if let Some(event) = &event { - if event.event_id().is_none() { - return; - } - } - - let prev_event = self.related_event(); - - if prev_event == event { - return; - } - - self.imp().related_event.replace(event); - self.notify("related-event"); - } - - pub fn clear_related_event(&self) { - if self.related_event_type() == RelatedEventType::Edit { - // Clean up the entry. - self.imp().message_entry.buffer().set_text(""); - }; - - self.set_related_event(None); - self.set_related_event_type(RelatedEventType::default()); - } - fn selection_model(&self) -> >k::NoSelection { self.imp() .selection_model .get_or_init(|| gtk::NoSelection::new(gio::ListModel::NONE.cloned())) } - pub fn set_reply_to(&self, event: Event) { - let imp = self.imp(); - imp.related_event_header - .set_widgets(vec![Pill::for_user(event.sender().upcast_ref())]); - imp.related_event_header - // Translators: Do NOT translate the content between '{' and '}', - // this is a variable name. In this string, 'Reply' is a noun. - .set_label(Some(gettext_f("Reply to {user}", &[("user", "")]))); - - imp.related_event_content.update_for_event(&event); - imp.related_event_content.set_visible(true); - - self.set_related_event_type(RelatedEventType::Reply); - self.set_related_event(Some(event)); - imp.message_entry.grab_focus(); - } - - /// Set the event to edit. - pub fn set_edit(&self, event: Event) { - // We don't support editing non-text messages. - let Some((text, formatted)) = event.message().and_then(|msg| match msg { - MessageType::Emote(emote) => Some((format!("/me {}", emote.body), emote.formatted)), - MessageType::Text(text) => Some((text.body, text.formatted)), - _ => None, - }) else { - return; - }; - - let mentions = if let Some(html) = - formatted.and_then(|f| (f.format == MessageFormat::Html).then_some(f.body)) - { - let (_, mentions) = extract_mentions(&html, &event.room()); - let mut pos = 0; - // This is looking for the mention link's inner text in the Markdown - // so it is not super reliable: if there is other text that matches - // a user's display name in the string it might be replaced instead - // of the actual mention. - // Short of an HTML to Markdown converter, it won't be a simple task - // to locate mentions in Markdown. - mentions - .into_iter() - .filter_map(|(pill, s)| { - text[pos..].find(&s).map(|index| { - let start = pos + index; - let end = start + s.len(); - pos = end; - (pill, (start, end)) - }) - }) - .collect::>() - } else { - Vec::new() - }; - - let imp = self.imp(); - imp.related_event_header.set_widgets::(vec![]); - imp.related_event_header - // Translators: In this string, 'Edit' is a noun. - .set_label(Some(pgettext("room-history", "Edit"))); - - imp.related_event_content.set_visible(false); - - self.set_related_event_type(RelatedEventType::Edit); - self.set_related_event(Some(event)); - - let view = &*imp.message_entry; - let buffer = view.buffer(); - - if mentions.is_empty() { - buffer.set_text(&text); - } else { - // Place the pills instead of the text at the appropriate places in - // the TextView. - buffer.set_text(""); - - let mut pos = 0; - let mut iter = buffer.iter_at_offset(0); - - for (pill, (start, end)) in mentions { - if pos != start { - buffer.insert(&mut iter, &text[pos..start]); - } - - let anchor = buffer.create_child_anchor(&mut iter); - view.add_child_at_anchor(&pill, &anchor); - - pos = end; - } - - if pos != text.len() { - buffer.insert(&mut iter, &text[pos..]) - } - } - - imp.message_entry.grab_focus(); - } - - /// Get an iterator over chunks of the message entry's text between the - /// given start and end, split by mentions. - fn split_buffer_mentions(&self, start: gtk::TextIter, end: gtk::TextIter) -> SplitMentions { - SplitMentions { iter: start, end } - } - - pub fn send_text_message(&self) { - let imp = self.imp(); - let buffer = imp.message_entry.buffer(); - let (start_iter, end_iter) = buffer.bounds(); - let body_len = buffer.text(&start_iter, &end_iter, true).len(); - - let is_markdown = imp.md_enabled.get(); - let mut has_mentions = false; - let mut plain_body = String::with_capacity(body_len); - // formatted_body is Markdown if is_markdown is true, and HTML if false. - let mut formatted_body = String::with_capacity(body_len); - - for chunk in self.split_buffer_mentions(start_iter, end_iter) { - match chunk { - MentionChunk::Text(text) => { - plain_body.push_str(&text); - formatted_body.push_str(&text); - } - MentionChunk::Mention { name, uri } => { - has_mentions = true; - plain_body.push_str(&name); - formatted_body.push_str(&if is_markdown { - format!("[{name}]({uri})") - } else { - format!("{name}") - }); - } - } - } - - let is_emote = plain_body.starts_with("/me "); - if is_emote { - plain_body.replace_range(.."/me ".len(), ""); - formatted_body.replace_range(.."/me ".len(), ""); - } - - let html_body = if is_markdown { - FormattedBody::markdown(formatted_body).map(|b| b.body) - } else if has_mentions { - // Already formatted with HTML - Some(formatted_body) - } else { - None - }; - - let mut content = if is_emote { - MessageType::Emote(if let Some(html_body) = html_body { - EmoteMessageEventContent::html(plain_body, html_body) - } else { - EmoteMessageEventContent::plain(plain_body) - }) - .into() - } else { - let mut content = if let Some(html_body) = html_body { - RoomMessageEventContent::text_html(plain_body, html_body) - } else { - RoomMessageEventContent::text_plain(plain_body) - }; - - if self.related_event_type() == RelatedEventType::Reply { - let related_event = self - .related_event() - .unwrap() - .raw() - .unwrap() - .deserialize() - .unwrap(); - if let AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage( - SyncMessageLikeEvent::Original(related_message_event), - )) = related_event - { - let full_related_message_event = related_message_event - .into_full_event(self.room().unwrap().room_id().to_owned()); - content = content.make_reply_to( - &full_related_message_event, - ForwardThread::Yes, - AddMentions::No, - ) - } - } - - content - }; - - let room = self.room().unwrap(); - - // Handle edit. - if self.related_event_type() == RelatedEventType::Edit { - let related_event = self.related_event().unwrap(); - let related_message = related_event - .raw() - .unwrap() - .deserialize_as::() - .unwrap(); - - // Try to get the replied to message of the original event if it's available - // locally. - let replied_to_message = related_event - .reply_to_id() - .and_then(|id| room.timeline().event_by_key(&EventKey::EventId(id))) - .and_then(|e| e.raw()) - .and_then(|r| r.deserialize_as::().ok()) - .map(|e| e.into_full_event(room.room_id().to_owned())); - - content = content.make_replacement(&related_message, replied_to_message.as_ref()); - } - - room.send_room_message_event(content); - buffer.set_text(""); - self.clear_related_event(); - } - /// Leave the room. pub async fn leave(&self) { let Some(window) = self.root().and_downcast::() else { @@ -1185,214 +726,8 @@ impl RoomHistory { self.start_loading(); } - fn open_emoji(&self) { - self.imp().message_entry.emit_insert_emoji(); - } - - async fn send_location(&self) -> ashpd::Result<()> { - let Some(room) = self.room() else { - return Ok(()); - }; - - let handle = spawn_tokio!(async move { - let proxy = LocationProxy::new().await?; - let identifier = WindowIdentifier::default(); - - let session = proxy - .create_session(Some(0), Some(0), Some(Accuracy::Exact)) - .await?; - - // We want to be listening for new locations whenever the session is up - // otherwise we might lose the first response and will have to wait for a future - // update by geoclue - // FIXME: We should update the location on the map according to updates received - // by the proxy. - let mut stream = proxy.receive_location_updated().await?; - let (_, location) = futures_util::try_join!( - proxy.start(&session, &identifier).into_future(), - stream.next().map(|l| l.ok_or(ashpd::Error::NoResponse)) - )?; - - ashpd::Result::Ok(location) - }); - - let location = handle.await.unwrap()?; - let geo_uri = GeoUri::builder() - .latitude(location.latitude()) - .longitude(location.longitude()) - .build() - .expect("Got invalid coordinates from ashpd"); - - let window = self.root().and_downcast::().unwrap(); - let dialog = AttachmentDialog::for_location(&window, &gettext("Your Location"), &geo_uri); - if dialog.run_future().await != gtk::ResponseType::Ok { - return Ok(()); - } - - let geo_uri_string = geo_uri.to_string(); - let iso8601_datetime = - glib::DateTime::from_unix_local(location.timestamp().as_secs() as i64) - .expect("Valid location timestamp"); - let location_body = gettext_f( - // Translators: Do NOT translate the content between '{' and '}', this is a variable - // name. - "User Location {geo_uri} at {iso8601_datetime}", - &[ - ("geo_uri", &geo_uri_string), - ( - "iso8601_datetime", - iso8601_datetime.format_iso8601().unwrap().as_str(), - ), - ], - ); - room.send_room_message_event(AnyMessageLikeEventContent::RoomMessage( - RoomMessageEventContent::new(MessageType::Location(LocationMessageEventContent::new( - location_body, - geo_uri_string, - ))), - )); - - Ok(()) - } - - async fn send_image(&self, image: gdk::Texture) { - let window = self.root().and_downcast::().unwrap(); - let filename = filename_for_mime(Some(mime::IMAGE_PNG.as_ref()), None); - let dialog = AttachmentDialog::for_image(&window, &filename, &image); - - if dialog.run_future().await != gtk::ResponseType::Ok { - return; - } - - let Some(room) = self.room() else { - return; - }; - - let bytes = image.save_to_png_bytes(); - let info = AttachmentInfo::Image(BaseImageInfo { - width: Some((image.width() as u32).into()), - height: Some((image.height() as u32).into()), - size: Some((bytes.len() as u32).into()), - blurhash: None, - }); - - room.send_attachment(bytes.to_vec(), mime::IMAGE_PNG, &filename, info); - } - - pub async fn select_file(&self) { - let dialog = gtk::FileDialog::builder() - .title(gettext("Select File")) - .modal(true) - .accept_label(gettext("Select")) - .build(); - - match dialog - .open_future(self.root().and_downcast_ref::()) - .await - { - Ok(file) => { - self.send_file(file).await; - } - Err(error) => { - if error.matches(gtk::DialogError::Dismissed) { - debug!("File dialog dismissed by user"); - } else { - error!("Could not open file: {error:?}"); - toast!(self, gettext("Could not open file")); - } - } - }; - } - - async fn send_file(&self, file: gio::File) { - match load_file(&file).await { - Ok((bytes, file_info)) => { - let window = self.root().and_downcast::().unwrap(); - let dialog = AttachmentDialog::for_file(&window, &file_info.filename, &file); - - if dialog.run_future().await != gtk::ResponseType::Ok { - return; - } - - let Some(room) = self.room() else { - error!("Cannot send file without a room"); - return; - }; - - let size = file_info.size.map(Into::into); - let info = match file_info.mime.type_() { - mime::IMAGE => { - let mut info = get_image_info(&file).await; - info.size = size; - AttachmentInfo::Image(info) - } - mime::VIDEO => { - let mut info = get_video_info(&file).await; - info.size = size; - AttachmentInfo::Video(info) - } - mime::AUDIO => { - let mut info = get_audio_info(&file).await; - info.size = size; - AttachmentInfo::Audio(info) - } - _ => AttachmentInfo::File(BaseFileInfo { size }), - }; - - room.send_attachment(bytes, file_info.mime, &file_info.filename, info); - } - Err(error) => { - warn!("Could not read file: {error}"); - toast!(self, gettext("Error reading file")); - } - } - } - - async fn read_clipboard(&self) { - let clipboard = self.clipboard(); - let formats = clipboard.formats(); - - if formats.contains_type(gdk::Texture::static_type()) { - // There is an image in the clipboard. - match clipboard - .read_value_future(gdk::Texture::static_type(), glib::Priority::DEFAULT) - .await - { - Ok(value) => match value.get::() { - Ok(texture) => { - self.send_image(texture).await; - return; - } - Err(error) => warn!("Could not get GdkTexture from value: {error:?}"), - }, - Err(error) => warn!("Could not get GdkTexture from the clipboard: {error:?}"), - } - - toast!(self, gettext("Error getting image from clipboard")); - } else if formats.contains_type(gio::File::static_type()) { - // There is a file in the clipboard. - match clipboard - .read_value_future(gio::File::static_type(), glib::Priority::DEFAULT) - .await - { - Ok(value) => match value.get::() { - Ok(file) => { - self.send_file(file).await; - return; - } - Err(error) => warn!("Could not get file from value: {error:?}"), - }, - Err(error) => warn!("Could not get file from the clipboard: {error:?}"), - } - - toast!(self, gettext("Error getting file from clipboard")); - } - } - pub fn handle_paste_action(&self) { - spawn!(glib::clone!(@weak self as obj => async move { - obj.read_clipboard().await; - })); + self.message_toolbar().handle_paste_action(); } pub fn item_context_menu(&self) -> >k::PopoverMenu { @@ -1405,46 +740,6 @@ impl RoomHistory { &self.imp().item_reaction_chooser } - // Update the completion for the current room. - fn update_completion(&self) { - let Some(room) = self.room() else { - return; - }; - let Some(room_members) = self.room_members() else { - return; - }; - - let completion = &self.imp().completion; - completion.set_user_id(Some(room.session().user().unwrap().user_id().to_string())); - // We should have a strong reference to the list so we can use - // `get_or_create_members()`. - completion.set_members(Some(room_members)) - } - - // Copy the selection in the message entry to the clipboard while replacing - // mentions. - fn copy_buffer_selection_to_clipboard(&self) { - if let Some((start, end)) = self.imp().message_entry.buffer().selection_bounds() { - let content: String = self - .split_buffer_mentions(start, end) - .map(|chunk| match chunk { - MentionChunk::Text(str) => str, - MentionChunk::Mention { name, .. } => name, - }) - .collect(); - self.clipboard().set_text(&content); - } - } - - #[template_callback] - fn handle_related_event_click(&self, n_pressed: i32) { - if n_pressed == 1 { - if let Some(related_event) = &*self.imp().related_event.borrow() { - self.scroll_to_event(&related_event.key()); - } - } - } - fn scroll_to_event(&self, key: &EventKey) { let room = match self.room() { Some(room) => room, @@ -1460,12 +755,6 @@ impl RoomHistory { } } - fn send_typing_notification(&self, typing: bool) { - if let Some(room) = self.room() { - room.send_typing_notification(typing); - } - } - /// Trigger the process to update read receipts. fn trigger_read_receipts_update(&self) { let Some(room) = self.room() else { @@ -1647,79 +936,3 @@ impl RoomHistory { } } } - -enum MentionChunk { - Text(String), - Mention { name: String, uri: String }, -} - -struct SplitMentions { - iter: gtk::TextIter, - end: gtk::TextIter, -} - -impl Iterator for SplitMentions { - type Item = MentionChunk; - - fn next(&mut self) -> Option { - if self.iter == self.end { - // We reached the end. - return None; - } - - if let Some(pill) = self - .iter - .child_anchor() - .map(|anchor| anchor.widgets()) - .as_ref() - .and_then(|widgets| widgets.first()) - .and_then(|widget| widget.downcast_ref::()) - { - // This chunk is a mention. - let (name, uri) = if let Some(user) = pill.user() { - ( - user.display_name(), - user.user_id().matrix_to_uri().to_string(), - ) - } else if let Some(room) = pill.room() { - ( - room.display_name(), - room.room_id().matrix_to_uri().to_string(), - ) - } else { - unreachable!() - }; - - self.iter.forward_cursor_position(); - - return Some(MentionChunk::Mention { name, uri }); - } - - // This chunk is not a mention. Go forward until the next mention or the - // end and return the text in between. - let start = self.iter; - while self.iter.forward_cursor_position() && self.iter != self.end { - if self - .iter - .child_anchor() - .map(|anchor| anchor.widgets()) - .as_ref() - .and_then(|widgets| widgets.first()) - .and_then(|widget| widget.downcast_ref::()) - .is_some() - { - break; - } - } - - let text = self.iter.buffer().text(&start, &self.iter, false); - // We might somehow have an empty string before the end, or at the end, - // because of hidden `char`s in the buffer, so we must only return - // `None` when we have an empty string at the end. - if self.iter == self.end && text.is_empty() { - None - } else { - Some(MentionChunk::Text(text.into())) - } - } -} diff --git a/src/session/view/content/room_history/mod.ui b/src/session/view/content/room_history/mod.ui index 9d3f7736..d512448b 100644 --- a/src/session/view/content/room_history/mod.ui +++ b/src/session/view/content/room_history/mod.ui @@ -25,19 +25,6 @@ - -
- - _Location - room-history.send-location - map-marker-symbolic - - - _Markdown - room-history.markdown - -
-