use std::sync::LazyLock;
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{gio, glib, glib::clone};
use matrix_sdk_ui::timeline::TimelineItemContent;
use ruma::events::room::message::MessageType;
use tracing::error;
use super::{DividerRow, MessageRow, RoomHistory, StateRow, TypingRow};
use crate::{
components::{ContextMenuBin, ContextMenuBinExt, ContextMenuBinImpl, ReactionChooser},
prelude::*,
session::{
model::{Event, EventKey, MessageState, Room, TimelineItem, VirtualItem, VirtualItemKind},
view::{content::room_history::message_toolbar::ComposerState, EventDetailsDialog},
},
spawn, spawn_tokio, toast,
utils::{matrix::MediaMessage, BoundObjectWeakRef},
};
mod imp {
use std::{cell::RefCell, rc::Rc};
use super::*;
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::ItemRow)]
pub struct ItemRow {
#[property(get, set = Self::set_room_history, construct_only)]
pub room_history: glib::WeakRef<RoomHistory>,
pub message_toolbar_handler: RefCell<Option<glib::SignalHandlerId>>,
pub composer_state: BoundObjectWeakRef<ComposerState>,
#[property(get, set = Self::set_item, explicit_notify, nullable)]
pub item: RefCell<Option<TimelineItem>>,
pub action_group: RefCell<Option<gio::SimpleActionGroup>>,
pub event_handlers: RefCell<Vec<glib::SignalHandlerId>>,
pub permissions_handler: RefCell<Option<glib::SignalHandlerId>>,
pub binding: RefCell<Option<glib::Binding>>,
pub reaction_chooser: RefCell<Option<ReactionChooser>>,
pub emoji_chooser: RefCell<Option<gtk::EmojiChooser>>,
}
#[glib::object_subclass]
impl ObjectSubclass for ItemRow {
const NAME: &'static str = "RoomHistoryItemRow";
type Type = super::ItemRow;
type ParentType = ContextMenuBin;
fn class_init(klass: &mut Self::Class) {
klass.set_css_name("room-history-row");
klass.set_accessible_role(gtk::AccessibleRole::ListItem);
}
}
#[glib::derived_properties]
impl ObjectImpl for ItemRow {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
obj.connect_parent_notify(|obj| {
obj.update_highlight();
});
}
fn dispose(&self) {
if let Some(event) = self.item.borrow().and_downcast_ref::<Event>() {
for handler in self.event_handlers.take() {
event.disconnect(handler);
}
if let Some(handler) = self.permissions_handler.take() {
event.room().permissions().disconnect(handler);
}
}
if let Some(binding) = self.binding.take() {
binding.unbind();
}
if let Some(handler) = self.message_toolbar_handler.take() {
if let Some(room_history) = self.room_history.upgrade() {
room_history.message_toolbar().disconnect(handler);
}
}
}
}
impl WidgetImpl for ItemRow {}
impl ContextMenuBinImpl for ItemRow {
fn menu_opened(&self) {
let obj = self.obj();
let Some(event) = self.item.borrow().clone().and_downcast::<Event>() else {
obj.set_popover(None);
return;
};
let Some(action_group) = self.action_group.borrow().clone() else {
obj.set_popover(None);
return;
};
let Some(room_history) = obj.room_history() else {
return;
};
let popover = room_history.item_context_menu().to_owned();
room_history.enable_sticky_mode(false);
obj.add_css_class("has-open-popup");
let cell: Rc<RefCell<Option<glib::signal::SignalHandlerId>>> =
Rc::new(RefCell::new(None));
let signal_id = popover.connect_closed(clone!(
#[weak]
obj,
#[strong]
cell,
#[weak]
room_history,
move |popover| {
room_history.enable_sticky_mode(true);
obj.remove_css_class("has-open-popup");
if let Some(signal_id) = cell.take() {
popover.disconnect(signal_id);
}
}
));
cell.replace(Some(signal_id));
if let Some(event) = event
.downcast_ref::<Event>()
.filter(|event| event.is_message())
{
let has_event_id = event.event_id().is_some();
let can_send_reaction = event.room().permissions().can_send_reaction();
let menu_model = if has_event_id && can_send_reaction {
event_message_menu_model_with_reactions()
} else {
event_message_menu_model_no_reactions()
};
if popover.menu_model().as_ref() != Some(menu_model) {
popover.set_menu_model(Some(menu_model));
}
if can_send_reaction {
let reaction_chooser = room_history.item_reaction_chooser();
reaction_chooser.set_reactions(Some(event.reactions()));
popover.add_child(reaction_chooser, "reaction-chooser");
action_group.add_action_entries([gio::ActionEntry::builder("more-reactions")
.activate(clone!(
#[weak]
obj,
#[weak]
popover,
move |_, _, _| {
obj.show_emoji_chooser(&popover);
}
))
.build()]);
}
} else {
let menu_model = event_state_menu_model();
if popover.menu_model().as_ref() != Some(menu_model) {
popover.set_menu_model(Some(menu_model));
}
}
obj.set_popover(Some(popover));
}
}
impl ItemRow {
fn set_room_history(&self, room_history: &RoomHistory) {
self.room_history.set(Some(room_history));
let message_toolbar = room_history.message_toolbar();
let message_toolbar_handler =
message_toolbar.connect_current_composer_state_notify(clone!(
#[weak(rename_to = imp)]
self,
move |message_toolbar| {
imp.watch_related_event(&message_toolbar.current_composer_state());
}
));
self.message_toolbar_handler
.replace(Some(message_toolbar_handler));
self.watch_related_event(&message_toolbar.current_composer_state());
}
fn watch_related_event(&self, composer_state: &ComposerState) {
let obj = self.obj();
self.composer_state.disconnect_signals();
let composer_state_handler = composer_state.connect_related_to_changed(clone!(
#[weak]
obj,
move |composer_state| {
obj.update_for_related_event(
composer_state.related_to().map(|i| i.key()).as_ref(),
);
}
));
self.composer_state
.set(composer_state, vec![composer_state_handler]);
obj.update_for_related_event(composer_state.related_to().map(|i| i.key()).as_ref());
}
#[allow(clippy::too_many_lines)]
fn set_item(&self, item: Option<TimelineItem>) {
let obj = self.obj();
obj.remove_css_class("has-header");
if let Some(event) = self.item.borrow().and_downcast_ref::<Event>() {
for handler in self.event_handlers.take() {
event.disconnect(handler);
}
if let Some(handler) = self.permissions_handler.take() {
event.room().permissions().disconnect(handler);
}
}
if let Some(binding) = self.binding.take() {
binding.unbind();
}
if let Some(item) = &item {
if let Some(event) = item.downcast_ref::<Event>() {
let state_notify_handler = event.connect_state_notify(clone!(
#[weak]
obj,
move |event| {
obj.update_event_actions(Some(event.upcast_ref()));
}
));
let source_notify_handler = event.connect_source_notify(clone!(
#[weak]
obj,
move |event| {
obj.set_event_widget(event.clone());
obj.update_event_actions(Some(event.upcast_ref()));
}
));
let edit_source_notify_handler =
event.connect_latest_edit_source_notify(clone!(
#[weak]
obj,
move |event| {
obj.set_event_widget(event.clone());
obj.update_event_actions(Some(event.upcast_ref()));
}
));
let is_highlighted_notify_handler =
event.connect_is_highlighted_notify(clone!(
#[weak]
obj,
move |_| {
obj.update_highlight();
}
));
self.event_handlers.replace(vec![
state_notify_handler,
source_notify_handler,
edit_source_notify_handler,
is_highlighted_notify_handler,
]);
let permissions_handler = event.room().permissions().connect_changed(clone!(
#[weak]
obj,
#[weak]
event,
move |_| {
obj.update_event_actions(Some(event.upcast_ref()));
}
));
self.permissions_handler.replace(Some(permissions_handler));
obj.set_event_widget(event.clone());
obj.update_event_actions(Some(event.upcast_ref()));
} else if let Some(item) = item.downcast_ref::<VirtualItem>() {
obj.set_popover(None);
obj.update_event_actions(None);
let kind = &*item.kind();
match kind {
VirtualItemKind::Spinner => {
if !obj
.child()
.is_some_and(|widget| widget.is::<adw::Spinner>())
{
obj.set_child(Some(&spinner()));
}
}
VirtualItemKind::Typing => {
let child = if let Some(child) = obj.child().and_downcast::<TypingRow>()
{
child
} else {
let child = TypingRow::new();
obj.set_child(Some(&child));
child
};
child.set_list(
obj.room_history()
.and_then(|h| h.room())
.map(|room| room.typing_list()),
);
}
VirtualItemKind::TimelineStart => {
if let Some(timeline) = self
.room_history
.upgrade()
.and_then(|h| h.room())
.map(|r| r.timeline())
{
let binding = timeline
.bind_property("has-room-create", &*obj, "visible")
.sync_create()
.invert_boolean()
.build();
self.binding.replace(Some(binding));
}
let divider =
if let Some(divider) = obj.child().and_downcast::<DividerRow>() {
divider
} else {
let divider = DividerRow::new();
obj.set_child(Some(÷r));
divider
};
divider.set_kind(kind);
}
VirtualItemKind::DayDivider(_) | VirtualItemKind::NewMessages => {
let divider =
if let Some(divider) = obj.child().and_downcast::<DividerRow>() {
divider
} else {
let divider = DividerRow::new();
obj.set_child(Some(÷r));
divider
};
divider.set_kind(kind);
}
}
}
}
self.item.replace(item);
obj.update_highlight();
}
}
}
glib::wrapper! {
pub struct ItemRow(ObjectSubclass<imp::ItemRow>)
@extends gtk::Widget, ContextMenuBin, @implements gtk::Accessible;
}
impl ItemRow {
pub fn new(room_history: &RoomHistory) -> Self {
glib::Object::builder()
.property("room-history", room_history)
.build()
}
pub fn action_group(&self) -> Option<gio::SimpleActionGroup> {
self.imp().action_group.borrow().clone()
}
fn set_action_group(&self, action_group: Option<gio::SimpleActionGroup>) {
if self.action_group() == action_group {
return;
}
self.imp().action_group.replace(action_group);
}
fn set_event_widget(&self, event: Event) {
match event.content() {
TimelineItemContent::MembershipChange(_)
| TimelineItemContent::ProfileChange(_)
| TimelineItemContent::OtherState(_) => {
let child = if let Some(child) = self.child().and_downcast::<StateRow>() {
child
} else {
let child = StateRow::new();
self.set_child(Some(&child));
child
};
child.set_event(event);
}
_ => {
let child = if let Some(child) = self.child().and_downcast::<MessageRow>() {
child
} else {
let child = MessageRow::new();
self.set_child(Some(&child));
child
};
child.set_event(event);
}
}
}
fn update_highlight(&self) {
let item_ref = self.imp().item.borrow();
if let Some(event) = item_ref.and_downcast_ref::<Event>() {
if event.is_highlighted() {
self.add_css_class("highlight");
return;
}
}
self.remove_css_class("highlight");
}
fn show_emoji_chooser(&self, popover: >k::PopoverMenu) {
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(rename_to = obj)]
self,
move |_, emoji| {
obj.activate_action("event.toggle-reaction", Some(&emoji.to_variant()))
.unwrap();
}
));
emoji_chooser.connect_closed(|emoji_chooser| {
emoji_chooser.unparent();
});
emoji_chooser.set_parent(self);
popover.popdown();
emoji_chooser.popup();
}
fn update_for_related_event(&self, related_event_id: Option<&EventKey>) {
let event = self.item().and_downcast::<Event>();
if event.is_some_and(|event| related_event_id.is_some_and(|key| event.matches_key(key))) {
self.add_css_class("selected");
} else {
self.remove_css_class("selected");
}
}
fn update_event_actions(&self, event: Option<&Event>) {
let Some(event) = event else {
self.insert_action_group("event", None::<&gio::ActionGroup>);
self.set_action_group(None);
self.set_has_context_menu(false);
return;
};
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([
gio::ActionEntry::builder("permalink")
.activate(clone!(
#[weak(rename_to = obj)]
self,
#[weak]
event,
move |_, _, _| {
spawn!(async move {
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(),
gio::ActionEntry::builder("view-details")
.activate(clone!(
#[weak(rename_to = obj)]
self,
#[weak]
event,
move |_, _, _| {
let dialog = EventDetailsDialog::new(&event);
dialog.present(Some(&obj));
}
))
.build(),
]);
if room.is_joined() {
action_group.add_action_entries([
gio::ActionEntry::builder("report")
.activate(clone!(
#[weak(rename_to = obj)]
self,
move |_, _, _| {
spawn!(async move {
obj.report_event().await;
});
}
))
.build(),
]);
}
} else {
let state = event.state();
if matches!(
state,
MessageState::Sending
| MessageState::RecoverableError
| MessageState::PermanentError
) {
action_group.add_action_entries([gio::ActionEntry::builder("cancel-send")
.activate(clone!(
#[weak(rename_to = obj)]
self,
move |_, _, _| {
spawn!(async move {
obj.cancel_send().await;
});
}
))
.build()]);
}
}
self.add_message_actions(&action_group, &room, event);
self.insert_action_group("event", Some(&action_group));
self.set_action_group(Some(action_group));
self.set_has_context_menu(true);
}
#[allow(clippy::too_many_lines)]
fn add_message_actions(
&self,
action_group: &gio::SimpleActionGroup,
room: &Room,
event: &Event,
) {
let TimelineItemContent::Message(message) = event.content() 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();
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 = obj)]
self,
move |_, _, _| {
spawn!(async move {
obj.redact_message().await;
});
}
))
.build()]);
};
if has_event_id && permissions.can_send_reaction() {
action_group.add_action_entries([gio::ActionEntry::builder("toggle-reaction")
.parameter_type(Some(&String::static_variant_type()))
.activate(clone!(
#[weak(rename_to = obj)]
self,
move |_, _, variant| {
let Some(key) = variant.unwrap().get::<String>() else {
return;
};
spawn!(async move {
obj.toggle_reaction(key).await;
});
}
))
.build()]);
}
if has_event_id && permissions.can_send_message() {
action_group.add_action_entries([
gio::ActionEntry::builder("reply")
.activate(clone!(
#[weak]
event,
#[weak(rename_to = obj)]
self,
move |_, _, _| {
if let Some(event_id) = event.event_id() {
let _ = obj.activate_action(
"room-history.reply",
Some(&event_id.as_str().to_variant()),
);
}
}
))
.build(),
]);
}
match message.msgtype() {
MessageType::Text(text_message) => {
let body = text_message.body.clone();
action_group.add_action_entries([gio::ActionEntry::builder("copy-text")
.activate(clone!(
#[weak(rename_to = obj)]
self,
move |_, _, _| {
obj.clipboard().set_text(&body);
toast!(obj, gettext("Text copied to clipboard"));
}
))
.build()]);
if has_event_id && is_from_own_user && permissions.can_send_message() {
action_group.add_action_entries([gio::ActionEntry::builder("edit")
.activate(clone!(
#[weak]
event,
#[weak(rename_to = obj)]
self,
move |_, _, _| {
if let Some(event_id) = event.event_id() {
let _ = obj.activate_action(
"room-history.edit",
Some(&event_id.as_str().to_variant()),
);
}
}
))
.build()]);
}
}
MessageType::File(_) => {
action_group.add_action_entries([gio::ActionEntry::builder("file-save")
.activate(clone!(
#[weak(rename_to = obj)]
self,
#[weak]
event,
move |_, _, _| {
obj.save_event_file(event);
}
))
.build()]);
}
MessageType::Emote(message) => {
let message = message.clone();
action_group.add_action_entries([gio::ActionEntry::builder("copy-text")
.activate(clone!(
#[weak(rename_to = obj)]
self,
#[weak]
event,
move |_, _, _| {
let display_name = event.sender().display_name();
let message = format!("{display_name} {}", message.body);
obj.clipboard().set_text(&message);
toast!(obj, gettext("Text copied to clipboard"));
}
))
.build()]);
if has_event_id && is_from_own_user && permissions.can_send_message() {
action_group.add_action_entries([gio::ActionEntry::builder("edit")
.activate(clone!(
#[weak]
event,
#[weak(rename_to = obj)]
self,
move |_, _, _| {
if let Some(event_id) = event.event_id() {
let _ = obj.activate_action(
"room-history.edit",
Some(&event_id.as_str().to_variant()),
);
}
}
))
.build()]);
}
}
MessageType::Notice(message) => {
let body = message.body.clone();
action_group.add_action_entries([gio::ActionEntry::builder("copy-text")
.activate(clone!(
#[weak(rename_to = obj)]
self,
move |_, _, _| {
obj.clipboard().set_text(&body);
toast!(obj, gettext("Text copied to clipboard"));
}
))
.build()]);
}
MessageType::Image(_) => {
action_group.add_action_entries([
gio::ActionEntry::builder("copy-image")
.activate(clone!(
#[weak(rename_to = obj)]
self,
move |_, _, _| {
let texture = obj
.child()
.and_downcast::<MessageRow>()
.and_then(|r| r.texture())
.expect("An ItemRow with an image should have a texture");
obj.clipboard().set_texture(&texture);
toast!(obj, gettext("Thumbnail copied to clipboard"));
}
))
.build(),
gio::ActionEntry::builder("save-image")
.activate(clone!(
#[weak(rename_to = obj)]
self,
#[weak]
event,
move |_, _, _| {
obj.save_event_file(event);
}
))
.build(),
]);
}
MessageType::Video(_) => {
action_group.add_action_entries([gio::ActionEntry::builder("save-video")
.activate(clone!(
#[weak(rename_to = obj)]
self,
#[weak]
event,
move |_, _, _| {
obj.save_event_file(event);
}
))
.build()]);
}
MessageType::Audio(_) => {
action_group.add_action_entries([gio::ActionEntry::builder("save-audio")
.activate(clone!(
#[weak(rename_to = obj)]
self,
#[weak]
event,
move |_, _, _| {
obj.save_event_file(event);
}
))
.build()]);
}
_ => {}
}
if let Some(media_message) = MediaMessage::from_message(message.msgtype()) {
if let Some((caption, _)) = media_message.caption() {
let caption = caption.to_owned();
action_group.add_action_entries([gio::ActionEntry::builder("copy-text")
.activate(clone!(
#[weak(rename_to = obj)]
self,
move |_, _, _| {
obj.clipboard().set_text(&caption);
toast!(obj, gettext("Text copied to clipboard"));
}
))
.build()]);
}
}
}
fn save_event_file(&self, event: Event) {
spawn!(clone!(
#[weak(rename_to = obj)]
self,
async move {
let Some(session) = event.room().session() else {
return;
};
let Some(media_message) = event.media_message() else {
return;
};
let client = session.client();
media_message.save_to_file(&client, &obj).await;
}
));
}
async fn redact_message(&self) {
let Some(event) = self.item().and_downcast::<Event>() else {
return;
};
let Some(event_id) = event.event_id() else {
return;
};
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(self).await != "remove" {
return;
}
if event.room().redact(&[event_id], None).await.is_err() {
toast!(self, gettext("Could not remove message"));
}
}
async fn toggle_reaction(&self, key: String) {
let Some(event) = self.item().and_downcast::<Event>() else {
return;
};
if event.room().toggle_reaction(key, &event).await.is_err() {
toast!(self, gettext("Could not toggle reaction"));
}
}
async fn report_event(&self) {
let Some(event) = self.item().and_downcast::<Event>() else {
return;
};
let Some(event_id) = event.event_id() else {
return;
};
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")),
("report", &gettext("Report")),
]);
confirm_dialog.set_response_appearance("report", adw::ResponseAppearance::Destructive);
if confirm_dialog.choose_future(self).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!(self, gettext("Could not report event"));
}
}
async fn cancel_send(&self) {
let Some(event) = self.item().and_downcast::<Event>() else {
return;
};
let matrix_timeline = event.room().timeline().matrix_timeline();
let event_item = event.item();
let handle = spawn_tokio!(async move { matrix_timeline.redact(&event_item, None).await });
if let Err(error) = handle.await.unwrap() {
error!("Could not discard local event: {error}");
toast!(self, gettext("Could not discard message"));
}
}
}
struct MenuModelSendSync(gio::MenuModel);
#[allow(clippy::non_send_fields_in_send_ty)]
unsafe impl Send for MenuModelSendSync {}
unsafe impl Sync for MenuModelSendSync {}
fn event_message_menu_model_with_reactions() -> &'static gio::MenuModel {
static MODEL: LazyLock<MenuModelSendSync> = LazyLock::new(|| {
MenuModelSendSync(
gtk::Builder::from_resource(
"/org/gnome/Fractal/ui/session/view/content/room_history/event_actions.ui",
)
.object::<gio::MenuModel>("message_menu_model_with_reactions")
.unwrap(),
)
});
&MODEL.0
}
fn event_message_menu_model_no_reactions() -> &'static gio::MenuModel {
static MODEL: LazyLock<MenuModelSendSync> = LazyLock::new(|| {
MenuModelSendSync(
gtk::Builder::from_resource(
"/org/gnome/Fractal/ui/session/view/content/room_history/event_actions.ui",
)
.object::<gio::MenuModel>("message_menu_model_no_reactions")
.unwrap(),
)
});
&MODEL.0
}
fn event_state_menu_model() -> &'static gio::MenuModel {
static MODEL: LazyLock<MenuModelSendSync> = LazyLock::new(|| {
MenuModelSendSync(
gtk::Builder::from_resource(
"/org/gnome/Fractal/ui/session/view/content/room_history/event_actions.ui",
)
.object::<gio::MenuModel>("state_menu_model")
.unwrap(),
)
});
&MODEL.0
}
fn spinner() -> adw::Spinner {
adw::Spinner::builder()
.margin_top(12)
.margin_bottom(12)
.height_request(24)
.width_request(24)
.build()
}