diff --git a/po/POTFILES.in b/po/POTFILES.in index 7d1ea0f2..2d126bd8 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -71,7 +71,6 @@ 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.rs 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 diff --git a/src/session/view/content/room_history/event_actions.rs b/src/session/view/content/room_history/event_actions.rs deleted file mode 100644 index 47d8e395..00000000 --- a/src/session/view/content/room_history/event_actions.rs +++ /dev/null @@ -1,352 +0,0 @@ -use gettextrs::gettext; -use gtk::{gdk, gio, glib, glib::clone, prelude::*}; -use matrix_sdk_ui::timeline::TimelineItemContent; -use once_cell::sync::Lazy; -use ruma::events::room::{message::MessageType, power_levels::PowerLevelAction}; -use tracing::error; - -use crate::{ - prelude::*, - session::{ - model::{Event, EventKey}, - view::EventSourceDialog, - }, - spawn, spawn_tokio, toast, - utils::media::save_to_file, -}; - -// This is only safe because the trait `EventActions` can -// only be implemented on `gtk::Widgets` that run only on the main thread -struct MenuModelSendSync(gio::MenuModel); -#[allow(clippy::non_send_fields_in_send_ty)] -unsafe impl Send for MenuModelSendSync {} -unsafe impl Sync for MenuModelSendSync {} - -pub trait EventActions -where - Self: IsA, - Self: glib::clone::Downgrade, - ::Weak: glib::clone::Upgrade, -{ - /// The `MenuModel` for common message event actions. - fn event_message_menu_model() -> &'static gio::MenuModel { - static MODEL: Lazy = Lazy::new(|| { - MenuModelSendSync( - gtk::Builder::from_resource( - "/org/gnome/Fractal/ui/session/view/content/room_history/event_actions.ui", - ) - .object::("message_menu_model") - .unwrap(), - ) - }); - &MODEL.0 - } - - /// The default `MenuModel` for common state event actions. - fn event_state_menu_model() -> &'static gio::MenuModel { - static MODEL: Lazy = Lazy::new(|| { - MenuModelSendSync( - gtk::Builder::from_resource( - "/org/gnome/Fractal/ui/session/view/content/room_history/event_actions.ui", - ) - .object::("state_menu_model") - .unwrap(), - ) - }); - &MODEL.0 - } - - /// Store a `GtkExpressionWatch` for re-use later. - fn set_expression_watch(&self, key: &'static str, expr_watch: gtk::ExpressionWatch); - - /// Get a `GtkExpressionWatch` by key. - fn expression_watch(&self, key: &&str) -> Option; - - /// Unwatch and drop all the expression watches on this widget. - fn clear_expression_watches(&self); - - /// Set the actions available on `self` for `event`. - /// - /// Unsets the actions if `event` is `None`. - /// - /// Should be paired with the `EventActions` menu models. - fn set_event_actions(&self, event: Option<&Event>) -> Option { - self.clear_expression_watches(); - let event = match event { - Some(event) => event, - None => { - self.insert_action_group("event", gio::ActionGroup::NONE); - return None; - } - }; - let action_group = gio::SimpleActionGroup::new(); - - if event.raw().is_some() { - action_group.add_action_entries([ - // View Event Source - gio::ActionEntry::builder("view-source") - .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { - let window = widget.root().and_downcast().unwrap(); - let dialog = EventSourceDialog::new(&window, &event); - dialog.present(); - })) - .build(), - ]); - } - - if event.event_id().is_some() { - action_group.add_action_entries([ - // Create a permalink - gio::ActionEntry::builder("permalink") - .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { - let matrix_room = event.room().matrix_room(); - let event_id = event.event_id().unwrap(); - spawn!(clone!(@weak widget => async move { - let handle = spawn_tokio!(async move { - matrix_room.matrix_to_event_permalink(event_id).await - }); - match handle.await.unwrap() { - Ok(permalink) => { - widget.clipboard().set_text(&permalink.to_string()); - toast!(widget, gettext("Permalink copied to clipboard")); - }, - Err(error) => { - error!("Could not get permalink: {error}"); - toast!(widget, gettext("Failed to copy the permalink")); - } - } - }) - ); - })) - .build() - ]); - - if let TimelineItemContent::Message(message) = event.content() { - let own_user_id = event - .room() - .session() - .user() - .map(|user| user.user_id()) - .unwrap(); - let is_from_own_user = event.sender_id() == own_user_id; - - // Remove message - fn update_remove_action( - action_group: &gio::SimpleActionGroup, - event: &Event, - allowed: bool, - ) { - if allowed { - action_group.add_action_entries([gio::ActionEntry::builder("remove") - .activate(clone!(@weak event, => move |_, _, _| { - if let Some(event_id) = event.event_id() { - event.room().redact(event_id, None); - } - })) - .build()]); - } else { - action_group.remove_action("remove"); - } - } - - if is_from_own_user { - update_remove_action(&action_group, event, true); - } else { - let remove_watch = event - .room() - .own_user_is_allowed_to_expr(PowerLevelAction::Redact) - .watch( - glib::Object::NONE, - clone!(@weak self as widget, @weak action_group, @weak event => move || { - let Some(allowed) = widget.expression_watch(&"remove").and_then(|e| e.evaluate_as::()) else { - return; - }; - - update_remove_action(&action_group, &event, allowed); - }), - ); - - let allowed = remove_watch.evaluate_as::().unwrap(); - update_remove_action(&action_group, event, allowed); - - self.set_expression_watch("remove", remove_watch); - } - - action_group.add_action_entries([ - // Send/redact a reaction - gio::ActionEntry::builder("toggle-reaction") - .parameter_type(Some(&String::static_variant_type())) - .activate(clone!(@weak event => move |_, _, variant| { - let key: String = variant.unwrap().get().unwrap(); - let room = event.room(); - - let reaction_group = event.reactions().reaction_group_by_key(&key); - - if let Some(reaction_key) = reaction_group.and_then(|group| group.user_reaction_event_key()) { - // The user already sent that reaction, redact it if it has been sent. - if let EventKey::EventId(reaction_id) = reaction_key { - room.redact(reaction_id, None); - } - } else if let Some(event_id) = event.event_id() { - // The user didn't send that reaction, send it. - room.send_reaction(key, event_id); - } - })) - .build(), - // Reply - gio::ActionEntry::builder("reply") - .activate(clone!(@weak event, @weak self as widget => move |_, _, _| { - if let Some(event_id) = event.event_id() { - let _ = widget.activate_action( - "room-history.reply", - Some(&event_id.as_str().to_variant()) - ); - } - })) - .build() - ]); - - match message.msgtype() { - MessageType::Text(text_message) => { - // Copy text message. - let body = text_message.body.clone(); - - action_group.add_action_entries([gio::ActionEntry::builder("copy-text") - .activate(clone!(@weak self as widget => move |_, _, _| { - widget.clipboard().set_text(&body); - toast!(widget, gettext("Message copied to clipboard")); - })) - .build()]); - - // Edit - if is_from_own_user { - action_group.add_action_entries([gio::ActionEntry::builder("edit") - .activate( - clone!(@weak event, @weak self as widget => move |_, _, _| { - if let Some(event_id) = event.event_id() { - let _ = widget.activate_action( - "room-history.edit", - Some(&event_id.as_str().to_variant()) - ); - } - }), - ) - .build()]); - } - } - MessageType::File(_) => { - // Save message's file. - action_group.add_action_entries([gio::ActionEntry::builder("file-save") - .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { - widget.save_event_file(event); - })) - .build()]); - } - MessageType::Emote(message) => { - // Copy text message. - let message = message.clone(); - - action_group.add_action_entries([gio::ActionEntry::builder("copy-text") - .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { - let display_name = event.sender().display_name(); - let message = format!("{display_name} {}", message.body); - widget.clipboard().set_text(&message); - toast!(widget, gettext("Message copied to clipboard")); - })) - .build()]); - - // Edit - if is_from_own_user { - action_group.add_action_entries([gio::ActionEntry::builder("edit") - .activate( - clone!(@weak event, @weak self as widget => move |_, _, _| { - if let Some(event_id) = event.event_id() { - let _ = widget.activate_action( - "room-history.edit", - Some(&event_id.as_str().to_variant()) - ); - } - }), - ) - .build()]); - } - } - MessageType::Notice(message) => { - // Copy text message. - let body = message.body.clone(); - - action_group.add_action_entries([gio::ActionEntry::builder("copy-text") - .activate(clone!(@weak self as widget => move |_, _, _| { - widget.clipboard().set_text(&body); - toast!(widget, gettext("Message copied to clipboard")); - })) - .build()]); - } - MessageType::Image(_) => { - action_group.add_action_entries([ - // Copy the texture to the clipboard. - gio::ActionEntry::builder("copy-image") - .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { - let texture = widget.texture().expect("A widget with an image should have a texture"); - - widget.clipboard().set_texture(&texture); - toast!(widget, gettext("Thumbnail copied to clipboard")); - }) - ).build(), - // Save the image to a file. - gio::ActionEntry::builder("save-image") - .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { - widget.save_event_file(event); - }) - ).build() - ]); - } - MessageType::Video(_) => { - // Save the video to a file. - action_group.add_action_entries([gio::ActionEntry::builder("save-video") - .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { - widget.save_event_file(event); - })) - .build()]); - } - MessageType::Audio(_) => { - // Save the audio to a file. - action_group.add_action_entries([gio::ActionEntry::builder("save-audio") - .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { - widget.save_event_file(event); - })) - .build()]); - } - _ => {} - } - } - } - - self.insert_action_group("event", Some(&action_group)); - - Some(action_group) - } - - /// Save the file in `event`. - /// - /// See [`Event::get_media_content()`] for compatible events. - /// Panics on an incompatible event. - fn save_event_file(&self, event: Event) { - spawn!(clone!(@weak self as obj => async move { - let (filename, data) = match event.get_media_content().await { - Ok(res) => res, - Err(error) => { - error!("Could not get event file: {error}"); - toast!(obj, error.to_user_facing()); - - return; - } - }; - - save_to_file(&obj, data, filename).await; - })); - } - - /// Get the texture displayed by this widget, if any. - fn texture(&self) -> Option; -} diff --git a/src/session/view/content/room_history/item_row.rs b/src/session/view/content/room_history/item_row.rs index 6fb2634e..f8108b6d 100644 --- a/src/session/view/content/room_history/item_row.rs +++ b/src/session/view/content/room_history/item_row.rs @@ -1,13 +1,21 @@ use adw::{prelude::*, subclass::prelude::*}; use gettextrs::gettext; -use gtk::{gdk, gio, glib, glib::clone}; +use gtk::{gio, glib, glib::clone}; use matrix_sdk_ui::timeline::TimelineItemContent; +use once_cell::sync::Lazy; +use ruma::events::room::{message::MessageType, power_levels::PowerLevelAction}; +use tracing::error; -use super::{DividerRow, EventActions, MessageRow, RoomHistory, StateRow, TypingRow}; +use super::{DividerRow, MessageRow, RoomHistory, StateRow, TypingRow}; use crate::{ components::{ContextMenuBin, ContextMenuBinExt, ContextMenuBinImpl, ReactionChooser, Spinner}, - session::model::{Event, TimelineItem, VirtualItem, VirtualItemKind}, - utils::BoundObjectWeakRef, + prelude::*, + session::{ + model::{Event, EventKey, TimelineItem, VirtualItem, VirtualItemKind}, + view::EventSourceDialog, + }, + spawn, spawn_tokio, toast, + utils::{media::save_to_file, BoundObjectWeakRef}, }; mod imp { @@ -42,7 +50,6 @@ mod imp { impl ObjectImpl for ItemRow { fn properties() -> &'static [glib::ParamSpec] { - use once_cell::sync::Lazy; static PROPERTIES: Lazy> = Lazy::new(|| { vec![ glib::ParamSpecObject::builder::("item").build(), @@ -139,7 +146,7 @@ mod imp { .downcast_ref::() .filter(|event| event.is_message()) { - let menu_model = Self::Type::event_message_menu_model(); + let menu_model = event_message_menu_model(); let reaction_chooser = room_history.item_reaction_chooser(); if popover.menu_model().as_ref() != Some(menu_model) { popover.set_menu_model(Some(menu_model)); @@ -155,7 +162,7 @@ mod imp { })); obj.action_group().unwrap().add_action(&more_reactions); } else { - let menu_model = Self::Type::event_state_menu_model(); + let menu_model = event_state_menu_model(); if popover.menu_model().as_ref() != Some(menu_model) { popover.set_menu_model(Some(menu_model)); } @@ -405,13 +412,288 @@ impl ItemRow { self.remove_css_class("selected"); } } -} -impl EventActions for ItemRow { - fn texture(&self) -> Option { - self.child() - .and_downcast::() - .and_then(|r| r.texture()) + /// Set the actions available on `self` for `event`. + /// + /// Unsets the actions if `event` is `None`. + fn set_event_actions(&self, event: Option<&Event>) -> Option { + self.clear_expression_watches(); + let event = match event { + Some(event) => event, + None => { + self.insert_action_group("event", gio::ActionGroup::NONE); + return None; + } + }; + let action_group = gio::SimpleActionGroup::new(); + + if event.raw().is_some() { + action_group.add_action_entries([ + // View Event Source + gio::ActionEntry::builder("view-source") + .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { + let window = widget.root().and_downcast().unwrap(); + let dialog = EventSourceDialog::new(&window, &event); + dialog.present(); + })) + .build(), + ]); + } + + if event.event_id().is_some() { + action_group.add_action_entries([ + // Create a permalink + gio::ActionEntry::builder("permalink") + .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { + let matrix_room = event.room().matrix_room(); + let event_id = event.event_id().unwrap(); + spawn!(clone!(@weak widget => async move { + let handle = spawn_tokio!(async move { + matrix_room.matrix_to_event_permalink(event_id).await + }); + match handle.await.unwrap() { + Ok(permalink) => { + widget.clipboard().set_text(&permalink.to_string()); + toast!(widget, gettext("Permalink copied to clipboard")); + }, + Err(error) => { + error!("Could not get permalink: {error}"); + toast!(widget, gettext("Failed to copy the permalink")); + } + } + }) + ); + })) + .build() + ]); + + if let TimelineItemContent::Message(message) = event.content() { + let own_user_id = event + .room() + .session() + .user() + .map(|user| user.user_id()) + .unwrap(); + let is_from_own_user = event.sender_id() == own_user_id; + + // Remove message + fn update_remove_action( + action_group: &gio::SimpleActionGroup, + event: &Event, + allowed: bool, + ) { + if allowed { + action_group.add_action_entries([gio::ActionEntry::builder("remove") + .activate(clone!(@weak event, => move |_, _, _| { + if let Some(event_id) = event.event_id() { + event.room().redact(event_id, None); + } + })) + .build()]); + } else { + action_group.remove_action("remove"); + } + } + + if is_from_own_user { + update_remove_action(&action_group, event, true); + } else { + let remove_watch = event + .room() + .own_user_is_allowed_to_expr(PowerLevelAction::Redact) + .watch( + glib::Object::NONE, + clone!(@weak self as widget, @weak action_group, @weak event => move || { + let Some(allowed) = widget.expression_watch(&"remove").and_then(|e| e.evaluate_as::()) else { + return; + }; + + update_remove_action(&action_group, &event, allowed); + }), + ); + + let allowed = remove_watch.evaluate_as::().unwrap(); + update_remove_action(&action_group, event, allowed); + + self.set_expression_watch("remove", remove_watch); + } + + action_group.add_action_entries([ + // Send/redact a reaction + gio::ActionEntry::builder("toggle-reaction") + .parameter_type(Some(&String::static_variant_type())) + .activate(clone!(@weak event => move |_, _, variant| { + let key: String = variant.unwrap().get().unwrap(); + let room = event.room(); + + let reaction_group = event.reactions().reaction_group_by_key(&key); + + if let Some(reaction_key) = reaction_group.and_then(|group| group.user_reaction_event_key()) { + // The user already sent that reaction, redact it if it has been sent. + if let EventKey::EventId(reaction_id) = reaction_key { + room.redact(reaction_id, None); + } + } else if let Some(event_id) = event.event_id() { + // The user didn't send that reaction, send it. + room.send_reaction(key, event_id); + } + })) + .build(), + // Reply + gio::ActionEntry::builder("reply") + .activate(clone!(@weak event, @weak self as widget => move |_, _, _| { + if let Some(event_id) = event.event_id() { + let _ = widget.activate_action( + "room-history.reply", + Some(&event_id.as_str().to_variant()) + ); + } + })) + .build() + ]); + + match message.msgtype() { + MessageType::Text(text_message) => { + // Copy text message. + let body = text_message.body.clone(); + + action_group.add_action_entries([gio::ActionEntry::builder("copy-text") + .activate(clone!(@weak self as widget => move |_, _, _| { + widget.clipboard().set_text(&body); + toast!(widget, gettext("Message copied to clipboard")); + })) + .build()]); + + // Edit + if is_from_own_user { + action_group.add_action_entries([gio::ActionEntry::builder("edit") + .activate( + clone!(@weak event, @weak self as widget => move |_, _, _| { + if let Some(event_id) = event.event_id() { + let _ = widget.activate_action( + "room-history.edit", + Some(&event_id.as_str().to_variant()) + ); + } + }), + ) + .build()]); + } + } + MessageType::File(_) => { + // Save message's file. + action_group.add_action_entries([gio::ActionEntry::builder("file-save") + .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { + widget.save_event_file(event); + })) + .build()]); + } + MessageType::Emote(message) => { + // Copy text message. + let message = message.clone(); + + action_group.add_action_entries([gio::ActionEntry::builder("copy-text") + .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { + let display_name = event.sender().display_name(); + let message = format!("{display_name} {}", message.body); + widget.clipboard().set_text(&message); + toast!(widget, gettext("Message copied to clipboard")); + })) + .build()]); + + // Edit + if is_from_own_user { + action_group.add_action_entries([gio::ActionEntry::builder("edit") + .activate( + clone!(@weak event, @weak self as widget => move |_, _, _| { + if let Some(event_id) = event.event_id() { + let _ = widget.activate_action( + "room-history.edit", + Some(&event_id.as_str().to_variant()) + ); + } + }), + ) + .build()]); + } + } + MessageType::Notice(message) => { + // Copy text message. + let body = message.body.clone(); + + action_group.add_action_entries([gio::ActionEntry::builder("copy-text") + .activate(clone!(@weak self as widget => move |_, _, _| { + widget.clipboard().set_text(&body); + toast!(widget, gettext("Message copied to clipboard")); + })) + .build()]); + } + MessageType::Image(_) => { + action_group.add_action_entries([ + // Copy the texture to the clipboard. + gio::ActionEntry::builder("copy-image") + .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { + let texture = widget.child() + .and_downcast::() + .and_then(|r| r.texture()) + .expect("An ItemRow with an image should have a texture"); + + widget.clipboard().set_texture(&texture); + toast!(widget, gettext("Thumbnail copied to clipboard")); + }) + ).build(), + // Save the image to a file. + gio::ActionEntry::builder("save-image") + .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { + widget.save_event_file(event); + }) + ).build() + ]); + } + MessageType::Video(_) => { + // Save the video to a file. + action_group.add_action_entries([gio::ActionEntry::builder("save-video") + .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { + widget.save_event_file(event); + })) + .build()]); + } + MessageType::Audio(_) => { + // Save the audio to a file. + action_group.add_action_entries([gio::ActionEntry::builder("save-audio") + .activate(clone!(@weak self as widget, @weak event => move |_, _, _| { + widget.save_event_file(event); + })) + .build()]); + } + _ => {} + } + } + } + + self.insert_action_group("event", Some(&action_group)); + + Some(action_group) + } + + /// Save the file in `event`. + /// + /// See [`Event::get_media_content()`] for compatible events. + /// Panics on an incompatible event. + fn save_event_file(&self, event: Event) { + spawn!(clone!(@weak self as obj => async move { + let (filename, data) = match event.get_media_content().await { + Ok(res) => res, + Err(error) => { + error!("Could not get event file: {error}"); + toast!(obj, error.to_user_facing()); + + return; + } + }; + + save_to_file(&obj, data, filename).await; + })); } fn set_expression_watch(&self, key: &'static str, expr_watch: gtk::ExpressionWatch) { @@ -435,3 +717,38 @@ impl EventActions for ItemRow { } } } + +// This is only safe because the trait `EventActions` can +// only be implemented on `gtk::Widgets` that run only on the main thread +struct MenuModelSendSync(gio::MenuModel); +#[allow(clippy::non_send_fields_in_send_ty)] +unsafe impl Send for MenuModelSendSync {} +unsafe impl Sync for MenuModelSendSync {} + +/// The `MenuModel` for common message event actions. +fn event_message_menu_model() -> &'static gio::MenuModel { + static MODEL: Lazy = Lazy::new(|| { + MenuModelSendSync( + gtk::Builder::from_resource( + "/org/gnome/Fractal/ui/session/view/content/room_history/event_actions.ui", + ) + .object::("message_menu_model") + .unwrap(), + ) + }); + &MODEL.0 +} + +/// The `MenuModel` for common state event actions. +fn event_state_menu_model() -> &'static gio::MenuModel { + static MODEL: Lazy = Lazy::new(|| { + MenuModelSendSync( + gtk::Builder::from_resource( + "/org/gnome/Fractal/ui/session/view/content/room_history/event_actions.ui", + ) + .object::("state_menu_model") + .unwrap(), + ) + }); + &MODEL.0 +} diff --git a/src/session/view/content/room_history/mod.rs b/src/session/view/content/room_history/mod.rs index c7cb434c..fe589d62 100644 --- a/src/session/view/content/room_history/mod.rs +++ b/src/session/view/content/room_history/mod.rs @@ -1,7 +1,6 @@ mod attachment_dialog; mod completion; mod divider_row; -mod event_actions; mod item_row; mod message_row; mod read_receipts_list; @@ -57,7 +56,6 @@ use self::{ attachment_dialog::AttachmentDialog, completion::CompletionPopover, divider_row::DividerRow, - event_actions::EventActions, item_row::ItemRow, message_row::{content::MessageContent, MessageRow}, read_receipts_list::ReadReceiptsList,