diff --git a/data/resources/stylesheet/_room_history.scss b/data/resources/stylesheet/_room_history.scss index 37662f30..d4a3faae 100644 --- a/data/resources/stylesheet/_room_history.scss +++ b/data/resources/stylesheet/_room_history.scss @@ -3,6 +3,40 @@ @use 'config'; @use 'vendor'; +%nested-effect { + border-left: 2px solid var(--accent-bg-color); + padding-left: 6px; + opacity: if(config.$contrast == 'high', 90%, 70%); +} + +room-title { + margin-top: -6px; + margin-bottom: -6px; + min-height: 12px; + padding: 3px 0; + + .title { + padding: 0; + font-weight: bold; + } + + .subtitle { + padding: 0; + font-weight: normal; + } + + &.with-subtitle { + button { + padding-top: 0; + padding-bottom: 0; + } + + .title, .subtitle { + margin-top: -0.2rem; + } + } +} + .room-history .room-history-list { padding-bottom: 0; @@ -27,7 +61,7 @@ } &:not(.has-header) { - .event-content, message-reactions { + .event-content { &:dir(ltr) { margin-left: 54px; } @@ -65,14 +99,167 @@ } } } +} - .event-content { - .emoji { - font-size: 3em; +sender-avatar { + padding: 3px; + border-radius: 100%; + + @include vendor.focus-ring(); + + &:hover { + background-color: vendor.$hover_color; + + image { + filter: brightness(1.07) ; + } + } + + &:active { + background-color: vendor.$active_color; + + image { + filter: brightness(1.16) ; + } + } + + &:checked { + background-color: vendor.$selected_color; + + image { + filter: brightness(1.1) ; + } + } + + popover button.text-button { + padding-left: 10px; + padding-right: 10px; + font-weight: 400; + } +} + +.event-content { + .h1 { + font-weight: 800; + font-size: 15pt; + } + + .h2 { + font-weight: 800; + font-size: 14pt; + } + + .h3 { + font-weight: 700; + font-size: 14pt; + } + + .h4 { + font-weight: 700; + font-size: 13pt; + } + + .h5 { + font-weight: 700; + font-size: 12pt; + } + + .h6 { + font-weight: 700; + font-size: 11pt; + } + + .emoji-message { + font-size: 3em; + } + + .emote { + color: var(--accent-color); + } + + .quote { + @extend %nested-effect; + } + + expander-widget > box > { + title { + border-spacing: 6px; + } + + :not(title) { + padding: 12px; + } + } + + .codeview { + border-radius: vendor.$menu_radius; + padding: 6px; + font-family: monospace; + background-color: var(--text-view-bg); + color: var(--view-fg-color); + } + + .timestamp { + min-width: 36px; + font-weight: normal; + } +} + +state-group-row.room-history-row { + &:not(.has-header) { + .event-content { + &:dir(ltr) { + margin-left: 42px; + } + + &:dir(rtl) { + margin-right: 42px; + } + } + } + + .expander-title { + padding: 6px 12px; + border-radius: vendor.$menu_radius; + + &:hover { + background-color: vendor.$button_hover_color; + } + + &:active { + background-color: vendor.$button_active_color; + } + } + + image.arrow { + transition: 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94); + } + + &:not(:checked) image.arrow { + &:dir(ltr) { + transform: rotate(-0.5turn); } - .emote { - color: var(--accent-color); + &:dir(rtl) { + transform: rotate(0.5turn); + } + } + + .expander-content { + padding: 3px 6px; + background-color: color-mix(in srgb, var(--view-fg-color) 4%, transparent); + border-radius: vendor.$menu_radius; + } + + state-group-item-row { + padding: 6px 12px; + margin: 2px 0; + border-radius: vendor.$menu_radius; + + @include vendor.focus-ring(); + + &.has-open-popup { + background-color: vendor.$hover_color; } } } @@ -114,21 +301,14 @@ } } -.room-history-row .event-content .quote, -.related-event-content { - border-left: 2px solid var(--accent-bg-color); - padding-left: 6px; - opacity: if(config.$contrast == 'high', 90%, 70%);; -} - -message-reactions flowboxchild { - &:hover, &:active { - // Cancel effect under .navigation-sidebar from libadwaita - background-color: transparent; +message-reactions { + flowboxchild { + &:hover, &:active { + // Cancel effect under .navigation-sidebar from libadwaita + background-color: transparent; + } } -} -message-reactions { &:dir(ltr) .toggle { padding: 1px 0 1px 6px; } @@ -137,11 +317,11 @@ message-reactions { padding: 1px 6px 1px 0; } - .reaction-key { + .reaction-key-text { font-size: 0.8em; } - .reaction-key.emoji { + .reaction-key-emoji { font-size: 1.1em; padding-right: 2px; padding-left: 2px; @@ -205,17 +385,9 @@ divider-row { } } -.timestamp { - min-width: 36px; - font-weight: normal; -} - -.codeview { - border-radius: vendor.$menu_radius; - padding: 6px; - font-family: monospace; - background-color: var(--text-view-bg); - color: var(--view-fg-color); +typing-row { + padding: 0 6px; + min-height: 30px; } .related-event-toolbar { @@ -226,122 +398,11 @@ divider-row { min-height: 24px; min-width: 24px; } -} - -.related-event-content { - padding-top: 2px; - padding-bottom: 2px; -} - -typing-row { - padding: 0 6px; - min-height: 30px; -} - -.room-history-row, .related-event-content { - .h1 { - font-weight: 800; - font-size: 15pt; - } - - .h2 { - font-weight: 800; - font-size: 14pt; - } - - .h3 { - font-weight: 700; - font-size: 14pt; - } - - .h4 { - font-weight: 700; - font-size: 13pt; - } - - .h5 { - font-weight: 700; - font-size: 12pt; - } - - .h6 { - font-weight: 700; - font-size: 11pt; - } -} - -.room-history-row expander-widget > box > { - title { - border-spacing: 6px; - } - - :not(title) { - padding: 12px; - } -} - -room-title { - margin-top: -6px; - margin-bottom: -6px; - min-height: 12px; - padding: 3px 0; - - .title { - padding: 0; - font-weight: bold; - } - - .subtitle { - padding: 0; - font-weight: normal; - } - - &.with-subtitle { - button { - padding-top: 0; - padding-bottom: 0; - } - - .title, .subtitle { - margin-top: -0.2rem; - } - } -} - -sender-avatar { - padding: 3px; - border-radius: 100%; - - @include vendor.focus-ring(); - - &:hover { - background-color: vendor.$hover_color; - - image { - filter: brightness(1.07) ; - } - } - - &:active { - background-color: vendor.$active_color; - - image { - filter: brightness(1.16) ; - } - } - &:checked { - background-color: vendor.$selected_color; - - image { - filter: brightness(1.1) ; - } - } - - popover button.text-button { - padding-left: 10px; - padding-right: 10px; - font-weight: 400; + .event-content { + @extend %nested-effect; + padding-top: 2px; + padding-bottom: 2px; } } diff --git a/po/POTFILES.in b/po/POTFILES.in index b479596a..77bf269e 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -143,9 +143,10 @@ src/session/view/content/room_details/permissions/permissions_subpage.rs src/session/view/content/room_details/permissions/permissions_subpage.ui src/session/view/content/room_details/room_upgrade_dialog.rs src/session/view/content/room_history/divider_row.rs -src/session/view/content/room_history/event_row.rs -src/session/view/content/room_history/event_row_context_menu.rs -src/session/view/content/room_history/event_row_context_menu.ui +src/session/view/content/room_history/event_actions/context_menu.rs +src/session/view/content/room_history/event_actions/context_menu.ui +src/session/view/content/room_history/event_actions/group.rs +src/session/view/content/room_history/event_actions/quick_reaction_chooser.ui src/session/view/content/room_history/message_row/audio.rs src/session/view/content/room_history/message_row/content.rs src/session/view/content/room_history/message_row/file.rs @@ -167,15 +168,15 @@ src/session/view/content/room_history/message_toolbar/mod.ui src/session/view/content/room_history/member_timestamp/row.rs src/session/view/content/room_history/mod.rs src/session/view/content/room_history/mod.ui -src/session/view/content/room_history/quick_reaction_chooser.ui src/session/view/content/room_history/read_receipts_list/mod.rs src/session/view/content/room_history/sender_avatar/mod.rs src/session/view/content/room_history/sender_avatar/mod.ui -src/session/view/content/room_history/state_row/creation.rs -src/session/view/content/room_history/state_row/creation.ui -src/session/view/content/room_history/state_row/mod.rs -src/session/view/content/room_history/state_row/tombstone.rs -src/session/view/content/room_history/state_row/tombstone.ui +src/session/view/content/room_history/state/content.rs +src/session/view/content/room_history/state/creation.rs +src/session/view/content/room_history/state/creation.ui +src/session/view/content/room_history/state/group_row.rs +src/session/view/content/room_history/state/tombstone.rs +src/session/view/content/room_history/state/tombstone.ui src/session/view/content/room_history/title.ui src/session/view/content/room_history/typing_row.rs src/session/view/content/room_history/verification_info_bar.rs diff --git a/src/session/model/room/timeline/event/mod.rs b/src/session/model/room/timeline/event/mod.rs index 9c830d5d..a67317cc 100644 --- a/src/session/model/room/timeline/event/mod.rs +++ b/src/session/model/room/timeline/event/mod.rs @@ -594,6 +594,35 @@ impl Event { } } + /// Whether this is a state event. + pub(crate) fn is_state_event(&self) -> bool { + matches!( + self.item().content(), + TimelineItemContent::MembershipChange(_) + | TimelineItemContent::ProfileChange(_) + | TimelineItemContent::OtherState(_) + ) + } + + /// Whether this is a state event that can be grouped with others. + pub(crate) fn is_state_group_event(&self) -> bool { + match self.item().content() { + TimelineItemContent::MembershipChange(_) | TimelineItemContent::ProfileChange(_) => { + true + } + TimelineItemContent::OtherState(other_state) => { + // `m.room.create` and `m.room.tombstone` should only occur once per room and + // they have special rendering so we do not group them. + !matches!( + other_state.content(), + AnyOtherFullStateEventContent::RoomCreate(_) + | AnyOtherFullStateEventContent::RoomTombstone(_) + ) + } + _ => false, + } + } + /// Whether this is the `m.room.create` event of the room. pub(crate) fn is_room_create(&self) -> bool { match self.item().content() { diff --git a/src/session/view/content/room_history/event_actions/context_menu.rs b/src/session/view/content/room_history/event_actions/context_menu.rs new file mode 100644 index 00000000..1832f9ae --- /dev/null +++ b/src/session/view/content/room_history/event_actions/context_menu.rs @@ -0,0 +1,91 @@ +use gettextrs::gettext; +use gtk::{gio, prelude::*}; + +use super::QuickReactionChooser; +use crate::session::model::ReactionList; + +/// Helper struct for the context menu of a row presenting an [`Event`]. +/// +/// [`Event`]: crate::session::model::Event +#[derive(Debug)] +pub(crate) struct EventActionsContextMenu { + /// The popover of the context menu. + pub(crate) popover: gtk::PopoverMenu, + /// The menu model of the popover. + menu_model: gio::Menu, + /// The quick reaction chooser in the context menu. + quick_reaction_chooser: QuickReactionChooser, +} + +impl EventActionsContextMenu { + /// The identifier in the context menu for the quick reaction chooser. + const QUICK_REACTION_CHOOSER_ID: &str = "quick-reaction-chooser"; + + /// Whether the menu includes an item for the quick reaction chooser. + fn has_quick_reaction_chooser(&self) -> bool { + let first_section = self + .menu_model + .item_link(0, gio::MENU_LINK_SECTION) + .and_downcast::() + .expect("event context menu should have at least one section"); + first_section + .item_attribute_value(0, "custom", Some(&String::static_variant_type())) + .and_then(|variant| variant.get::()) + .is_some_and(|value| value == Self::QUICK_REACTION_CHOOSER_ID) + } + + /// Add the quick reaction chooser to this menu, if it is not already + /// present, and set the reaction list. + pub(crate) fn add_quick_reaction_chooser(&self, reactions: ReactionList) { + if !self.has_quick_reaction_chooser() { + let section_menu = gio::Menu::new(); + let item = gio::MenuItem::new(None, None); + item.set_attribute_value( + "custom", + Some(&Self::QUICK_REACTION_CHOOSER_ID.to_variant()), + ); + section_menu.append_item(&item); + self.menu_model.insert_section(0, None, §ion_menu); + + self.popover.add_child( + &self.quick_reaction_chooser, + Self::QUICK_REACTION_CHOOSER_ID, + ); + } + + self.quick_reaction_chooser.set_reactions(Some(reactions)); + } + + /// Remove the quick reaction chooser from this menu, if it is present. + pub(crate) fn remove_quick_reaction_chooser(&self) { + if !self.has_quick_reaction_chooser() { + return; + } + + self.popover.remove_child(&self.quick_reaction_chooser); + self.menu_model.remove(0); + } +} + +impl Default for EventActionsContextMenu { + fn default() -> Self { + let menu_model = gtk::Builder::from_resource( + "/org/gnome/Fractal/ui/session/view/content/room_history/event_actions/context_menu.ui", + ) + .object::("event-actions-menu") + .expect("GResource and menu should exist"); + + let popover = gtk::PopoverMenu::builder() + .has_arrow(false) + .halign(gtk::Align::Start) + .menu_model(&menu_model) + .build(); + popover.update_property(&[gtk::accessible::Property::Label(&gettext("Context Menu"))]); + + Self { + popover, + menu_model, + quick_reaction_chooser: Default::default(), + } + } +} diff --git a/src/session/view/content/room_history/event_row_context_menu.ui b/src/session/view/content/room_history/event_actions/context_menu.ui similarity index 99% rename from src/session/view/content/room_history/event_row_context_menu.ui rename to src/session/view/content/room_history/event_actions/context_menu.ui index 1ebee1f2..02bddd94 100644 --- a/src/session/view/content/room_history/event_row_context_menu.ui +++ b/src/session/view/content/room_history/event_actions/context_menu.ui @@ -1,6 +1,6 @@ - +
diff --git a/src/session/view/content/room_history/event_actions/group.rs b/src/session/view/content/room_history/event_actions/group.rs new file mode 100644 index 00000000..2dfc325e --- /dev/null +++ b/src/session/view/content/room_history/event_actions/group.rs @@ -0,0 +1,648 @@ +use std::ops::Deref; + +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::{gdk, gio, glib, glib::clone}; +use ruma::events::room::message::MessageType; +use tracing::error; + +use crate::{ + prelude::*, + session::{ + model::{Event, MessageState, Room}, + view::EventDetailsDialog, + }, + spawn, spawn_tokio, toast, +}; + +/// Trait to help a row that presents an `Event` to provide the proper actions. +pub(crate) trait EventActionsGroup: ObjectSubclass { + /// The current event of the row, if any. + fn event(&self) -> Option; + + /// The current `GdkTexture` of the row, if any. + fn texture(&self) -> Option; + + /// The current `GtkPopoverMenu` of the row, if any. + fn popover(&self) -> Option; + + /// Get the `GActionGroup` with the proper actions for the current event. + fn event_actions_group(&self) -> Option + where + Self: glib::clone::Downgrade, + Self::Type: IsA, + Self::Weak: 'static, + ::Strong: Deref, + <::Strong as Deref>::Target: EventActionsGroup, + <<::Strong as Deref>::Target as ObjectSubclass>::Type: + IsA, + { + let event = self.event()?; + let action_group = gio::SimpleActionGroup::new(); + let room = event.room(); + let has_event_id = event.event_id().is_some(); + + if has_event_id { + action_group.add_action_entries([ + // Create a permalink. + gio::ActionEntry::builder("permalink") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + spawn!(async move { + let Some(event) = imp.event() else { + return; + }; + let Some(permalink) = event.matrix_to_uri().await else { + return; + }; + + let obj = imp.obj(); + obj.clipboard().set_text(&permalink.to_string()); + toast!(obj, gettext("Message link copied to clipboard")); + }); + } + )) + .build(), + // View event details. + gio::ActionEntry::builder("view-details") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + let Some(event) = imp.event() else { + return; + }; + + let dialog = EventDetailsDialog::new(&event); + dialog.present(Some(&*imp.obj())); + } + )) + .build(), + ]); + + if room.is_joined() { + action_group.add_action_entries([ + // Report the event. + gio::ActionEntry::builder("report") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + spawn!(async move { + imp.report_event().await; + }); + } + )) + .build(), + ]); + } + } else { + let state = event.state(); + + if matches!( + state, + MessageState::Sending + | MessageState::RecoverableError + | MessageState::PermanentError + ) { + // Cancel the event. + action_group.add_action_entries([gio::ActionEntry::builder("cancel-send") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + spawn!(async move { + imp.cancel_send().await; + }); + } + )) + .build()]); + } + } + + self.add_message_like_actions(&action_group, &room, &event); + + Some(action_group) + } + + /// Add actions to the given action group for the given event, if it is + /// message-like. + /// + /// See [`Event::is_message_like()`] for the definition of a message + /// event. + fn add_message_like_actions( + &self, + action_group: &gio::SimpleActionGroup, + room: &Room, + event: &Event, + ) where + Self: glib::clone::Downgrade, + Self::Type: IsA, + Self::Weak: 'static, + ::Strong: Deref, + <::Strong as Deref>::Target: EventActionsGroup, + <<::Strong as Deref>::Target as ObjectSubclass>::Type: + IsA, + { + if !event.is_message_like() { + return; + } + + let own_member = room.own_member(); + let own_user_id = own_member.user_id(); + let is_from_own_user = event.sender_id() == *own_user_id; + let permissions = room.permissions(); + let has_event_id = event.event_id().is_some(); + + // Redact/remove the event. + if has_event_id + && ((is_from_own_user && permissions.can_redact_own()) + || permissions.can_redact_other()) + { + action_group.add_action_entries([gio::ActionEntry::builder("remove") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + spawn!(async move { + imp.redact_message().await; + }); + } + )) + .build()]); + } + + // Send/redact a reaction. + if event.can_be_reacted_to() { + action_group.add_action_entries([ + gio::ActionEntry::builder("toggle-reaction") + .parameter_type(Some(&String::static_variant_type())) + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, variant| { + let Some(key) = variant + .expect("toggle-reaction action should have a parameter") + .get::() + else { + error!("Could not parse reaction to toggle"); + return; + }; + + spawn!(async move { + imp.toggle_reaction(key).await; + }); + } + )) + .build(), + gio::ActionEntry::builder("show-reactions-chooser") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + imp.show_reactions_chooser(); + } + )) + .build(), + ]); + } + + // Reply. + if event.can_be_replied_to() { + action_group.add_action_entries([gio::ActionEntry::builder("reply") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + let Some(event) = imp.event() else { + error!("Could not reply to timeline item that is not an event"); + return; + }; + let Some(event_id) = event.event_id() else { + error!("Event to reply to does not have an event ID"); + return; + }; + + if imp + .obj() + .activate_action( + "room-history.reply", + Some(&event_id.as_str().to_variant()), + ) + .is_err() + { + error!("Could not activate `room-history.reply` action"); + } + } + )) + .build()]); + } + + self.add_message_actions(action_group, room, event); + } + + /// Add actions to the given action group for the given event, if it + /// is a message. + #[allow(clippy::too_many_lines)] + fn add_message_actions(&self, action_group: &gio::SimpleActionGroup, room: &Room, event: &Event) + where + Self: glib::clone::Downgrade, + Self::Type: IsA, + Self::Weak: 'static, + ::Strong: Deref, + <::Strong as Deref>::Target: EventActionsGroup, + <<::Strong as Deref>::Target as ObjectSubclass>::Type: + IsA, + { + let Some(message) = event.message() else { + return; + }; + + let own_member = room.own_member(); + let own_user_id = own_member.user_id(); + let is_from_own_user = event.sender_id() == *own_user_id; + let permissions = room.permissions(); + let has_event_id = event.event_id().is_some(); + + match message.msgtype() { + MessageType::Text(_) | MessageType::Emote(_) => { + // Copy text. + action_group.add_action_entries([gio::ActionEntry::builder("copy-text") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + imp.copy_text(); + } + )) + .build()]); + + // Edit message. + if has_event_id && is_from_own_user && permissions.can_send_message() { + action_group.add_action_entries([gio::ActionEntry::builder("edit") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + imp.edit_message(); + } + )) + .build()]); + } + } + MessageType::File(_) => { + // Save message's file. + action_group.add_action_entries([gio::ActionEntry::builder("file-save") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + spawn!(async move { + imp.save_file().await; + }); + } + )) + .build()]); + } + MessageType::Notice(_) => { + // Copy text. + action_group.add_action_entries([gio::ActionEntry::builder("copy-text") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + imp.copy_text(); + } + )) + .build()]); + } + MessageType::Image(_) => { + action_group.add_action_entries([ + // Copy the texture to the clipboard. + gio::ActionEntry::builder("copy-image") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + let Some(texture) = imp.texture() else { + error!("Could not find texture to copy"); + return; + }; + + let obj = imp.obj(); + obj.clipboard().set_texture(&texture); + toast!(obj, gettext("Thumbnail copied to clipboard")); + } + )) + .build(), + // Save the image to a file. + gio::ActionEntry::builder("save-image") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + spawn!(async move { + imp.save_file().await; + }); + } + )) + .build(), + ]); + } + MessageType::Video(_) => { + // Save the video to a file. + action_group.add_action_entries([gio::ActionEntry::builder("save-video") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + spawn!(async move { + imp.save_file().await; + }); + } + )) + .build()]); + } + MessageType::Audio(_) => { + // Save the audio to a file. + action_group.add_action_entries([gio::ActionEntry::builder("save-audio") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + spawn!(async move { + imp.save_file().await; + }); + } + )) + .build()]); + } + _ => {} + } + + if let Some(media_message) = event.media_message() { + if media_message.caption().is_some() { + // Copy caption. + action_group.add_action_entries([gio::ActionEntry::builder("copy-text") + .activate(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _| { + imp.copy_text(); + } + )) + .build()]); + } + } + } + + /// Replace the context menu with an emoji chooser for reactions. + fn show_reactions_chooser(&self) + where + Self::Type: IsA, + { + let Some(popover) = self.popover() else { + return; + }; + + let obj = self.obj(); + let (_, rectangle) = popover.pointing_to(); + + let emoji_chooser = gtk::EmojiChooser::builder() + .has_arrow(false) + .pointing_to(&rectangle) + .build(); + + emoji_chooser.connect_emoji_picked(clone!( + #[strong] + obj, + move |_, emoji| { + let _ = obj.activate_action("event.toggle-reaction", Some(&emoji.to_variant())); + } + )); + emoji_chooser.connect_closed(|emoji_chooser| { + emoji_chooser.unparent(); + }); + emoji_chooser.set_parent(&*obj); + + popover.popdown(); + emoji_chooser.popup(); + } + + /// Copy the text of this row. + fn copy_text(&self) + where + Self::Type: IsA, + { + let Some(event) = self.event() else { + error!("Could not copy text of timeline item that is not an event"); + return; + }; + let Some(message) = event.message() else { + error!("Could not copy text of event that is not a message"); + return; + }; + + let text = match message.msgtype() { + MessageType::Text(text_message) => text_message.body.clone(), + MessageType::Emote(emote_message) => { + let display_name = event.sender().display_name(); + format!("{display_name} {}", emote_message.body) + } + MessageType::Notice(notice_message) => notice_message.body.clone(), + _ => { + if let Some(caption) = event + .media_message() + .and_then(|m| m.caption().map(|(caption, _)| caption.to_owned())) + { + caption + } else { + error!("Could not copy text of event that is not a textual message"); + return; + } + } + }; + + let obj = self.obj(); + obj.clipboard().set_text(&text); + toast!(obj, gettext("Text copied to clipboard")); + } + + /// Edit the message of this row. + fn edit_message(&self) + where + Self::Type: IsA, + { + let Some(event) = self.event() else { + error!("Could not edit timeline item that is not an event"); + return; + }; + let Some(event_id) = event.event_id() else { + error!("Could not edit event without an event ID"); + return; + }; + + if self + .obj() + .activate_action("room-history.edit", Some(&event_id.as_str().to_variant())) + .is_err() + { + error!("Could not activate `room-history.edit` action"); + } + } + + /// Save the media file of this row. + async fn save_file(&self) + where + Self::Type: IsA, + { + let Some(event) = self.event() else { + error!("Could not save file of timeline item that is not an event"); + return; + }; + let Some(session) = event.room().session() else { + // Should only happen if the process is being closed. + return; + }; + let Some(media_message) = event.media_message() else { + error!("Could not save file for non-media event"); + return; + }; + + let client = session.client(); + media_message.save_to_file(&client, &*self.obj()).await; + } + + /// Redact the event of this row. + async fn redact_message(&self) + where + Self::Type: IsA, + { + let Some(event) = self.event() else { + error!("Could not redact timeline item that is not an event"); + return; + }; + let Some(event_id) = event.event_id() else { + error!("Event to redact does not have an event ID"); + return; + }; + let obj = self.obj(); + + let confirm_dialog = adw::AlertDialog::builder() + .default_response("cancel") + .heading(gettext("Remove Message?")) + .body(gettext( + "Do you really want to remove this message? This cannot be undone.", + )) + .build(); + confirm_dialog.add_responses(&[ + ("cancel", &gettext("Cancel")), + ("remove", &gettext("Remove")), + ]); + confirm_dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); + + if confirm_dialog.choose_future(&*obj).await != "remove" { + return; + } + + if event.room().redact(&[event_id], None).await.is_err() { + toast!(obj, gettext("Could not remove message")); + } + } + + /// Toggle the reaction with the given key for the event of this row. + async fn toggle_reaction(&self, key: String) + where + Self::Type: IsA, + { + let Some(event) = self.event() else { + error!("Could not toggle reaction on timeline item that is not an event"); + return; + }; + + if event.room().toggle_reaction(key, &event).await.is_err() { + toast!(self.obj(), gettext("Could not toggle reaction")); + } + } + + /// Report the current event. + async fn report_event(&self) + where + Self::Type: IsA, + { + let Some(event) = self.event() else { + error!("Could not report timeline item that is not an event"); + return; + }; + let Some(event_id) = event.event_id() else { + error!("Event to report does not have an event ID"); + return; + }; + let obj = self.obj(); + + // Ask the user to confirm, and provide optional reason. + let reason_entry = adw::EntryRow::builder() + .title(gettext("Reason (optional)")) + .build(); + let list_box = gtk::ListBox::builder() + .css_classes(["boxed-list"]) + .margin_top(6) + .accessible_role(gtk::AccessibleRole::Group) + .build(); + list_box.append(&reason_entry); + + let confirm_dialog = adw::AlertDialog::builder() + .default_response("cancel") + .heading(gettext("Report Event?")) + .body(gettext( + "Reporting an event will send its unique ID to the administrator of your homeserver. The administrator will not be able to see the content of the event if it is encrypted or redacted.", + )) + .extra_child(&list_box) + .build(); + confirm_dialog.add_responses(&[ + ("cancel", &gettext("Cancel")), + // Translators: This is a verb, as in 'Report Event'. + ("report", &gettext("Report")), + ]); + confirm_dialog.set_response_appearance("report", adw::ResponseAppearance::Destructive); + + if confirm_dialog.choose_future(&*obj).await != "report" { + return; + } + + let reason = Some(reason_entry.text()) + .filter(|s| !s.is_empty()) + .map(Into::into); + + if event + .room() + .report_events(&[(event_id, reason)]) + .await + .is_err() + { + toast!(obj, gettext("Could not report event")); + } + } + + /// Cancel sending the event of this row. + async fn cancel_send(&self) + where + Self::Type: IsA, + { + let Some(event) = self.event() else { + error!("Could not discard timeline item that is not an event"); + return; + }; + + let matrix_timeline = event.timeline().matrix_timeline(); + let identifier = event.identifier(); + let handle = spawn_tokio!(async move { matrix_timeline.redact(&identifier, None).await }); + + if let Err(error) = handle.await.unwrap() { + error!("Could not discard local event: {error}"); + toast!(self.obj(), gettext("Could not discard message")); + } + } +} diff --git a/src/session/view/content/room_history/event_actions/mod.rs b/src/session/view/content/room_history/event_actions/mod.rs new file mode 100644 index 00000000..5f4befdf --- /dev/null +++ b/src/session/view/content/room_history/event_actions/mod.rs @@ -0,0 +1,6 @@ +mod context_menu; +mod group; +mod quick_reaction_chooser; + +use self::quick_reaction_chooser::*; +pub(super) use self::{context_menu::*, group::*}; diff --git a/src/session/view/content/room_history/event_row_context_menu.rs b/src/session/view/content/room_history/event_actions/quick_reaction_chooser.rs similarity index 71% rename from src/session/view/content/room_history/event_row_context_menu.rs rename to src/session/view/content/room_history/event_actions/quick_reaction_chooser.rs index aa0eefe3..d3758ec4 100644 --- a/src/session/view/content/room_history/event_row_context_menu.rs +++ b/src/session/view/content/room_history/event_actions/quick_reaction_chooser.rs @@ -1,99 +1,12 @@ use adw::{prelude::*, subclass::prelude::*}; -use gettextrs::gettext; use gtk::{ - gio, glib, + glib, glib::{clone, closure_local}, CompositeTemplate, }; use crate::{session::model::ReactionList, utils::BoundObject}; -/// Helper struct for the context menu of an [`EventRow`]. -/// -/// [`EventRow`]: super::EventRow -#[derive(Debug)] -pub(super) struct EventRowContextMenu { - /// The popover of the context menu. - pub(super) popover: gtk::PopoverMenu, - /// The menu model of the popover. - menu_model: gio::Menu, - /// The quick reaction chooser in the context menu. - quick_reaction_chooser: QuickReactionChooser, -} - -impl EventRowContextMenu { - /// The identifier in the context menu for the quick reaction chooser. - const QUICK_REACTION_CHOOSER_ID: &str = "quick-reaction-chooser"; - - /// Whether the menu includes an item for the quick reaction chooser. - fn has_quick_reaction_chooser(&self) -> bool { - let first_section = self - .menu_model - .item_link(0, gio::MENU_LINK_SECTION) - .and_downcast::() - .expect("event row context menu should have at least one section"); - first_section - .item_attribute_value(0, "custom", Some(&String::static_variant_type())) - .and_then(|variant| variant.get::()) - .is_some_and(|value| value == Self::QUICK_REACTION_CHOOSER_ID) - } - - /// Add the quick reaction chooser to this menu, if it is not already - /// present, and set the reaction list. - pub(super) fn add_quick_reaction_chooser(&self, reactions: ReactionList) { - if !self.has_quick_reaction_chooser() { - let section_menu = gio::Menu::new(); - let item = gio::MenuItem::new(None, None); - item.set_attribute_value( - "custom", - Some(&Self::QUICK_REACTION_CHOOSER_ID.to_variant()), - ); - section_menu.append_item(&item); - self.menu_model.insert_section(0, None, §ion_menu); - - self.popover.add_child( - &self.quick_reaction_chooser, - Self::QUICK_REACTION_CHOOSER_ID, - ); - } - - self.quick_reaction_chooser.set_reactions(Some(reactions)); - } - - /// Remove the quick reaction chooser from this menu, if it is present. - pub(super) fn remove_quick_reaction_chooser(&self) { - if !self.has_quick_reaction_chooser() { - return; - } - - self.popover.remove_child(&self.quick_reaction_chooser); - self.menu_model.remove(0); - } -} - -impl Default for EventRowContextMenu { - fn default() -> Self { - let menu_model = gtk::Builder::from_resource( - "/org/gnome/Fractal/ui/session/view/content/room_history/event_row_context_menu.ui", - ) - .object::("event-menu") - .expect("GResource and menu should exist"); - - let popover = gtk::PopoverMenu::builder() - .has_arrow(false) - .halign(gtk::Align::Start) - .menu_model(&menu_model) - .build(); - popover.update_property(&[gtk::accessible::Property::Label(&gettext("Context Menu"))]); - - Self { - popover, - menu_model, - quick_reaction_chooser: Default::default(), - } - } -} - /// A quick reaction. #[derive(Debug, Clone, Copy)] struct QuickReaction { @@ -158,7 +71,7 @@ mod imp { #[derive(Debug, Default, CompositeTemplate, glib::Properties)] #[template( - resource = "/org/gnome/Fractal/ui/session/view/content/room_history/quick_reaction_chooser.ui" + resource = "/org/gnome/Fractal/ui/session/view/content/room_history/event_actions/quick_reaction_chooser.ui" )] #[properties(wrapper_type = super::QuickReactionChooser)] pub struct QuickReactionChooser { diff --git a/src/session/view/content/room_history/quick_reaction_chooser.ui b/src/session/view/content/room_history/event_actions/quick_reaction_chooser.ui similarity index 100% rename from src/session/view/content/room_history/quick_reaction_chooser.ui rename to src/session/view/content/room_history/event_actions/quick_reaction_chooser.ui diff --git a/src/session/view/content/room_history/event_row.rs b/src/session/view/content/room_history/event_row.rs index dda21fd6..41c8d1f8 100644 --- a/src/session/view/content/room_history/event_row.rs +++ b/src/session/view/content/room_history/event_row.rs @@ -1,19 +1,13 @@ use adw::{prelude::*, subclass::prelude::*}; -use gettextrs::gettext; -use gtk::{gio, glib, glib::clone}; -use matrix_sdk_ui::timeline::{TimelineEventItemId, TimelineItemContent}; -use ruma::events::room::message::MessageType; +use gtk::{gdk, gio, glib, glib::clone}; +use matrix_sdk_ui::timeline::TimelineEventItemId; use tracing::error; -use super::{MessageRow, RoomHistory, StateRow}; +use super::{EventActionsGroup, MessageRow, RoomHistory, StateRow}; use crate::{ components::ContextMenuBin, prelude::*, - session::{ - model::{Event, MessageState, Room}, - view::{content::room_history::message_toolbar::ComposerState, EventDetailsDialog}, - }, - spawn, spawn_tokio, toast, + session::{model::Event, view::content::room_history::message_toolbar::ComposerState}, utils::{BoundObject, BoundObjectWeakRef}, }; @@ -34,7 +28,6 @@ mod imp { #[property(get, set = Self::set_event, explicit_notify, nullable)] event: BoundObject, /// The event action group of this row. - #[property(get, set = Self::set_action_group)] action_group: RefCell>, permissions_handler: RefCell>, } @@ -121,8 +114,7 @@ mod imp { let menu = room_history.event_context_menu(); // Reset the state when the popover is closed. - let closed_handler_cell: Rc>> = - Rc::default(); + let closed_handler_cell: Rc>> = Rc::default(); let closed_handler = menu.popover.connect_closed(clone!( #[weak] obj, @@ -154,6 +146,23 @@ mod imp { } } + impl EventActionsGroup for EventRow { + fn event(&self) -> Option { + self.event.obj() + } + + fn texture(&self) -> Option { + self.obj() + .child() + .and_downcast::() + .and_then(|r| r.texture()) + } + + fn popover(&self) -> Option { + self.obj().popover() + } + } + impl EventRow { /// Set the ancestor room history of this row. fn set_room_history(&self, room_history: &RoomHistory) { @@ -224,10 +233,8 @@ mod imp { let permissions_handler = event.room().permissions().connect_changed(clone!( #[weak(rename_to = imp)] self, - #[weak] - event, move |_| { - imp.update_actions(&event); + imp.update_actions(); } )); self.permissions_handler.replace(Some(permissions_handler)); @@ -235,8 +242,8 @@ mod imp { let state_notify_handler = event.connect_state_notify(clone!( #[weak(rename_to = imp)] self, - move |event| { - imp.update_actions(event); + move |_| { + imp.update_actions(); } )); let source_notify_handler = event.connect_source_notify(clone!( @@ -244,7 +251,7 @@ mod imp { self, move |event| { imp.build_event_widget(event.clone()); - imp.update_actions(event); + imp.update_actions(); } )); let edit_source_notify_handler = event.connect_latest_edit_source_notify(clone!( @@ -252,7 +259,7 @@ mod imp { self, move |event| { imp.build_event_widget(event.clone()); - imp.update_actions(event); + imp.update_actions(); } )); let is_highlighted_notify_handler = event.connect_is_highlighted_notify(clone!( @@ -273,49 +280,35 @@ mod imp { ], ); - self.update_actions(&event); self.build_event_widget(event); } + self.update_actions(); self.update_highlight(); } - /// Set the event action group of this row. - fn set_action_group(&self, action_group: Option) { - if *self.action_group.borrow() == action_group { - return; - } - - self.action_group.replace(action_group); - } - /// Construct the widget for the given event fn build_event_widget(&self, event: Event) { let obj = self.obj(); - match event.content() { - TimelineItemContent::MembershipChange(_) - | TimelineItemContent::ProfileChange(_) - | TimelineItemContent::OtherState(_) => { - let child = if let Some(child) = obj.child().and_downcast::() { - child - } else { - let child = StateRow::new(); - obj.set_child(Some(&child)); - child - }; - child.set_event(event); - } - _ => { - let child = if let Some(child) = obj.child().and_downcast::() { - child - } else { - let child = MessageRow::new(); - obj.set_child(Some(&child)); - child - }; - child.set_event(event); - } + if event.is_state_event() { + let child = if let Some(child) = obj.child().and_downcast::() { + child + } else { + let child = StateRow::new(); + obj.set_child(Some(&child)); + child + }; + child.set_event(event); + } else { + let child = if let Some(child) = obj.child().and_downcast::() { + child + } else { + let child = MessageRow::new(); + obj.set_child(Some(&child)); + child + }; + child.set_event(event); } } @@ -331,37 +324,6 @@ mod imp { } } - /// Replace the context menu with an emoji chooser for reactions. - fn show_reactions_chooser(&self) { - let obj = self.obj(); - - let Some(popover) = obj.popover() else { - return; - }; - - let (_, rectangle) = popover.pointing_to(); - - let emoji_chooser = gtk::EmojiChooser::builder() - .has_arrow(false) - .pointing_to(&rectangle) - .build(); - - emoji_chooser.connect_emoji_picked(clone!( - #[weak] - obj, - move |_, emoji| { - let _ = obj.activate_action("event.toggle-reaction", Some(&emoji.to_variant())); - } - )); - emoji_chooser.connect_closed(|emoji_chooser| { - emoji_chooser.unparent(); - }); - emoji_chooser.set_parent(&*obj); - - popover.popdown(); - emoji_chooser.popup(); - } - /// Update this row for the related event with the given identifier. fn update_for_related_event(&self, related_event_id: Option<&TimelineEventItemId>) { let obj = self.obj(); @@ -378,547 +340,14 @@ mod imp { } /// Update the actions available for the given event. - fn update_actions(&self, event: &Event) { + fn update_actions(&self) { let obj = self.obj(); + let action_group = self.event_actions_group(); + let has_context_menu = action_group.is_some(); - let action_group = gio::SimpleActionGroup::new(); - let room = event.room(); - let has_event_id = event.event_id().is_some(); - - if has_event_id { - action_group.add_action_entries([ - // Create a permalink. - gio::ActionEntry::builder("permalink") - .activate(clone!( - #[weak] - obj, - move |_, _, _| { - spawn!(async move { - let Some(event) = obj.imp().event.obj() else { - return; - }; - let Some(permalink) = event.matrix_to_uri().await else { - return; - }; - - obj.clipboard().set_text(&permalink.to_string()); - toast!(obj, gettext("Message link copied to clipboard")); - }); - } - )) - .build(), - // View event details. - gio::ActionEntry::builder("view-details") - .activate(clone!( - #[weak] - obj, - move |_, _, _| { - let Some(event) = obj.imp().event.obj() else { - return; - }; - - let dialog = EventDetailsDialog::new(&event); - dialog.present(Some(&obj)); - } - )) - .build(), - ]); - - if room.is_joined() { - action_group.add_action_entries([ - // Report the event. - gio::ActionEntry::builder("report") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - spawn!(async move { - imp.report_event().await; - }); - } - )) - .build(), - ]); - } - } else { - let state = event.state(); - - if matches!( - state, - MessageState::Sending - | MessageState::RecoverableError - | MessageState::PermanentError - ) { - // Cancel the event. - action_group.add_action_entries([gio::ActionEntry::builder("cancel-send") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - spawn!(async move { - imp.cancel_send().await; - }); - } - )) - .build()]); - } - } - - self.add_message_like_actions(&action_group, &room, event); - - obj.insert_action_group("event", Some(&action_group)); - self.set_action_group(Some(action_group)); - obj.set_has_context_menu(true); - } - - /// Add actions to the given action group for the given event, if it is - /// message-like. - /// - /// See [`Event::is_message_like()`] for the definition of a message - /// event. - fn add_message_like_actions( - &self, - action_group: &gio::SimpleActionGroup, - room: &Room, - event: &Event, - ) { - if !event.is_message_like() { - return; - } - - let own_member = room.own_member(); - let own_user_id = own_member.user_id(); - let is_from_own_user = event.sender_id() == *own_user_id; - let permissions = room.permissions(); - let has_event_id = event.event_id().is_some(); - - // Redact/remove the event. - if has_event_id - && ((is_from_own_user && permissions.can_redact_own()) - || permissions.can_redact_other()) - { - action_group.add_action_entries([gio::ActionEntry::builder("remove") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - spawn!(async move { - imp.redact_message().await; - }); - } - )) - .build()]); - } - - // Send/redact a reaction. - if event.can_be_reacted_to() { - action_group.add_action_entries([ - gio::ActionEntry::builder("toggle-reaction") - .parameter_type(Some(&String::static_variant_type())) - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, variant| { - let Some(key) = variant.unwrap().get::() else { - error!("Could not parse reaction to toggle"); - return; - }; - - spawn!(async move { - imp.toggle_reaction(key).await; - }); - } - )) - .build(), - gio::ActionEntry::builder("show-reactions-chooser") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - imp.show_reactions_chooser(); - } - )) - .build(), - ]); - } - - // Reply. - if event.can_be_replied_to() { - action_group.add_action_entries([gio::ActionEntry::builder("reply") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - let Some(event) = imp.event.obj() else { - error!("Could not reply to timeline item that is not an event"); - return; - }; - let Some(event_id) = event.event_id() else { - error!("Event to reply to does not have an event ID"); - return; - }; - - if imp - .obj() - .activate_action( - "room-history.reply", - Some(&event_id.as_str().to_variant()), - ) - .is_err() - { - error!("Could not activate `room-history.reply` action"); - } - } - )) - .build()]); - } - - self.add_message_actions(action_group, room, event); - } - - /// Add actions to the given action group for the given event, if it - /// is a message. - #[allow(clippy::too_many_lines)] - fn add_message_actions( - &self, - action_group: &gio::SimpleActionGroup, - room: &Room, - event: &Event, - ) { - let Some(message) = event.message() else { - return; - }; - - let obj = self.obj(); - let own_member = room.own_member(); - let own_user_id = own_member.user_id(); - let is_from_own_user = event.sender_id() == *own_user_id; - let permissions = room.permissions(); - let has_event_id = event.event_id().is_some(); - - match message.msgtype() { - MessageType::Text(_) | MessageType::Emote(_) => { - // Copy text. - action_group.add_action_entries([gio::ActionEntry::builder("copy-text") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - imp.copy_text(); - } - )) - .build()]); - - // Edit message. - if has_event_id && is_from_own_user && permissions.can_send_message() { - action_group.add_action_entries([gio::ActionEntry::builder("edit") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - imp.edit_message(); - } - )) - .build()]); - } - } - MessageType::File(_) => { - // Save message's file. - action_group.add_action_entries([gio::ActionEntry::builder("file-save") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - imp.save_file(); - } - )) - .build()]); - } - MessageType::Notice(_) => { - // Copy text. - action_group.add_action_entries([gio::ActionEntry::builder("copy-text") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - imp.copy_text(); - } - )) - .build()]); - } - MessageType::Image(_) => { - action_group.add_action_entries([ - // Copy the texture to the clipboard. - gio::ActionEntry::builder("copy-image") - .activate(clone!( - #[weak] - obj, - move |_, _, _| { - let texture = obj - .child() - .and_downcast::() - .and_then(|r| r.texture()) - .expect("An EventRow with an image should have a texture"); - - obj.clipboard().set_texture(&texture); - toast!(obj, gettext("Thumbnail copied to clipboard")); - } - )) - .build(), - // Save the image to a file. - gio::ActionEntry::builder("save-image") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - imp.save_file(); - } - )) - .build(), - ]); - } - MessageType::Video(_) => { - // Save the video to a file. - action_group.add_action_entries([gio::ActionEntry::builder("save-video") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - imp.save_file(); - } - )) - .build()]); - } - MessageType::Audio(_) => { - // Save the audio to a file. - action_group.add_action_entries([gio::ActionEntry::builder("save-audio") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - imp.save_file(); - } - )) - .build()]); - } - _ => {} - } - - if let Some(media_message) = event.media_message() { - if media_message.caption().is_some() { - // Copy caption. - action_group.add_action_entries([gio::ActionEntry::builder("copy-text") - .activate(clone!( - #[weak(rename_to = imp)] - self, - move |_, _, _| { - imp.copy_text(); - } - )) - .build()]); - } - } - } - - /// Copy the text of this row. - fn copy_text(&self) { - let Some(event) = self.event.obj() else { - error!("Could not copy text of timeline item that is not an event"); - return; - }; - let Some(message) = event.message() else { - error!("Could not copy text of event that is not a message"); - return; - }; - - let text = match message.msgtype() { - MessageType::Text(text_message) => text_message.body.clone(), - MessageType::Emote(emote_message) => { - let display_name = event.sender().display_name(); - format!("{display_name} {}", emote_message.body) - } - MessageType::Notice(notice_message) => notice_message.body.clone(), - _ => { - if let Some(caption) = event - .media_message() - .and_then(|m| m.caption().map(|(caption, _)| caption.to_owned())) - { - caption - } else { - error!("Could not copy text of event that is not a textual message"); - return; - } - } - }; - - let obj = self.obj(); - obj.clipboard().set_text(&text); - toast!(obj, gettext("Text copied to clipboard")); - } - - /// Edit the message of this row. - fn edit_message(&self) { - let Some(event) = self.event.obj() else { - error!("Could not edit timeline item that is not an event"); - return; - }; - let Some(event_id) = event.event_id() else { - error!("Could not edit event without an event ID"); - return; - }; - - if self - .obj() - .activate_action("room-history.edit", Some(&event_id.as_str().to_variant())) - .is_err() - { - error!("Could not activate `room-history.edit` action"); - } - } - - /// Save the media file of this row. - fn save_file(&self) { - spawn!(clone!( - #[weak(rename_to = imp)] - self, - async move { - let Some(event) = imp.event.obj() else { - error!("Could not save file of timeline item that is not an event"); - return; - }; - let Some(session) = event.room().session() else { - // Should only happen if the process is being closed. - return; - }; - let Some(media_message) = event.media_message() else { - error!("Could not save file for non-media event"); - return; - }; - - let client = session.client(); - media_message.save_to_file(&client, &*imp.obj()).await; - } - )); - } - - /// Redact the event of this row. - async fn redact_message(&self) { - let Some(event) = self.event.obj() else { - error!("Could not redact timeline item that is not an event"); - return; - }; - let Some(event_id) = event.event_id() else { - error!("Event to redact does not have an event ID"); - return; - }; - let obj = self.obj(); - - let confirm_dialog = adw::AlertDialog::builder() - .default_response("cancel") - .heading(gettext("Remove Message?")) - .body(gettext( - "Do you really want to remove this message? This cannot be undone.", - )) - .build(); - confirm_dialog.add_responses(&[ - ("cancel", &gettext("Cancel")), - ("remove", &gettext("Remove")), - ]); - confirm_dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); - - if confirm_dialog.choose_future(&*obj).await != "remove" { - return; - } - - if event.room().redact(&[event_id], None).await.is_err() { - toast!(obj, gettext("Could not remove message")); - } - } - - /// Toggle the reaction with the given key for the event of this row. - async fn toggle_reaction(&self, key: String) { - let Some(event) = self.event.obj() else { - error!("Could not toggle reaction on timeline item that is not an event"); - return; - }; - - if event.room().toggle_reaction(key, &event).await.is_err() { - toast!(self.obj(), gettext("Could not toggle reaction")); - } - } - - /// Report the current event. - async fn report_event(&self) { - let Some(event) = self.event.obj() else { - error!("Could not report timeline item that is not an event"); - return; - }; - let Some(event_id) = event.event_id() else { - error!("Event to report does not have an event ID"); - return; - }; - let obj = self.obj(); - - // Ask the user to confirm, and provide optional reason. - let reason_entry = adw::EntryRow::builder() - .title(gettext("Reason (optional)")) - .build(); - let list_box = gtk::ListBox::builder() - .css_classes(["boxed-list"]) - .margin_top(6) - .accessible_role(gtk::AccessibleRole::Group) - .build(); - list_box.append(&reason_entry); - - let confirm_dialog = adw::AlertDialog::builder() - .default_response("cancel") - .heading(gettext("Report Event?")) - .body(gettext( - "Reporting an event will send its unique ID to the administrator of your homeserver. The administrator will not be able to see the content of the event if it is encrypted or redacted.", - )) - .extra_child(&list_box) - .build(); - confirm_dialog.add_responses(&[ - ("cancel", &gettext("Cancel")), - // Translators: This is a verb, as in 'Report Event'. - ("report", &gettext("Report")), - ]); - confirm_dialog.set_response_appearance("report", adw::ResponseAppearance::Destructive); - - if confirm_dialog.choose_future(&*obj).await != "report" { - return; - } - - let reason = Some(reason_entry.text()) - .filter(|s| !s.is_empty()) - .map(Into::into); - - if event - .room() - .report_events(&[(event_id, reason)]) - .await - .is_err() - { - toast!(obj, gettext("Could not report event")); - } - } - - /// Cancel sending the event of this row. - async fn cancel_send(&self) { - let Some(event) = self.event.obj() else { - error!("Could not discard timeline item that is not an event"); - return; - }; - - let matrix_timeline = event.timeline().matrix_timeline(); - let identifier = event.identifier(); - let handle = - spawn_tokio!(async move { matrix_timeline.redact(&identifier, None).await }); - - if let Err(error) = handle.await.unwrap() { - error!("Could not discard local event: {error}"); - toast!(self.obj(), gettext("Could not discard message")); - } + obj.insert_action_group("event", action_group.as_ref()); + self.action_group.replace(action_group); + obj.set_has_context_menu(has_context_menu); } } } diff --git a/src/session/view/content/room_history/message_row/mod.ui b/src/session/view/content/room_history/message_row/mod.ui index c5e6b006..f46050b6 100644 --- a/src/session/view/content/room_history/message_row/mod.ui +++ b/src/session/view/content/room_history/message_row/mod.ui @@ -3,6 +3,9 @@