Browse Source

room-history: Group contiguous state events

They are replaced by an item that shows the count of state rows that
are hidden. This item can be expanded to show all the hidden state rows.
merge-requests/1958/merge
Kévin Commaille 11 months ago
parent
commit
e0a7eb4cd1
No known key found for this signature in database
GPG Key ID: C971D9DBC9D678D
  1. 355
      data/resources/stylesheet/_room_history.scss
  2. 19
      po/POTFILES.in
  3. 29
      src/session/model/room/timeline/event/mod.rs
  4. 91
      src/session/view/content/room_history/event_actions/context_menu.rs
  5. 2
      src/session/view/content/room_history/event_actions/context_menu.ui
  6. 648
      src/session/view/content/room_history/event_actions/group.rs
  7. 6
      src/session/view/content/room_history/event_actions/mod.rs
  8. 91
      src/session/view/content/room_history/event_actions/quick_reaction_chooser.rs
  9. 0
      src/session/view/content/room_history/event_actions/quick_reaction_chooser.ui
  10. 675
      src/session/view/content/room_history/event_row.rs
  11. 6
      src/session/view/content/room_history/message_row/mod.ui
  12. 6
      src/session/view/content/room_history/message_row/reaction/mod.rs
  13. 3
      src/session/view/content/room_history/message_row/reaction/mod.ui
  14. 4
      src/session/view/content/room_history/message_row/text/mod.rs
  15. 29
      src/session/view/content/room_history/message_row/visual_media.rs
  16. 5
      src/session/view/content/room_history/message_toolbar/mod.ui
  17. 123
      src/session/view/content/room_history/mod.rs
  18. 6
      src/session/view/content/room_history/read_receipts_list/mod.rs
  19. 96
      src/session/view/content/room_history/state/content.rs
  20. 2
      src/session/view/content/room_history/state/creation.rs
  21. 0
      src/session/view/content/room_history/state/creation.ui
  22. 309
      src/session/view/content/room_history/state/group_item_row.rs
  23. 248
      src/session/view/content/room_history/state/group_row.rs
  24. 83
      src/session/view/content/room_history/state/group_row.ui
  25. 9
      src/session/view/content/room_history/state/mod.rs
  26. 58
      src/session/view/content/room_history/state/row.rs
  27. 15
      src/session/view/content/room_history/state/row.ui
  28. 2
      src/session/view/content/room_history/state/tombstone.rs
  29. 0
      src/session/view/content/room_history/state/tombstone.ui
  30. 11
      src/ui-resources.gresource.xml
  31. 409
      src/utils/grouping_list_model/group.rs
  32. 603
      src/utils/grouping_list_model/mod.rs
  33. 3772
      src/utils/grouping_list_model/tests.rs
  34. 1
      src/utils/mod.rs

355
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;
}
}

19
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

29
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() {

91
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::<gio::Menu>()
.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::<String>())
.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, &section_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::<gio::Menu>("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(),
}
}
}

2
src/session/view/content/room_history/event_row_context_menu.ui → src/session/view/content/room_history/event_actions/context_menu.ui

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<menu id="event-menu">
<menu id="event-actions-menu">
<section>
<item>
<!-- Translators: In this string, 'Reply' is a verb. -->

648
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<Event>;
/// The current `GdkTexture` of the row, if any.
fn texture(&self) -> Option<gdk::Texture>;
/// The current `GtkPopoverMenu` of the row, if any.
fn popover(&self) -> Option<gtk::PopoverMenu>;
/// Get the `GActionGroup` with the proper actions for the current event.
fn event_actions_group(&self) -> Option<gio::SimpleActionGroup>
where
Self: glib::clone::Downgrade,
Self::Type: IsA<gtk::Widget>,
Self::Weak: 'static,
<Self::Weak as glib::clone::Upgrade>::Strong: Deref,
<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target: EventActionsGroup,
<<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target as ObjectSubclass>::Type:
IsA<gtk::Widget>,
{
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<gtk::Widget>,
Self::Weak: 'static,
<Self::Weak as glib::clone::Upgrade>::Strong: Deref,
<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target: EventActionsGroup,
<<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target as ObjectSubclass>::Type:
IsA<gtk::Widget>,
{
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::<String>()
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<gtk::Widget>,
Self::Weak: 'static,
<Self::Weak as glib::clone::Upgrade>::Strong: Deref,
<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target: EventActionsGroup,
<<<Self::Weak as glib::clone::Upgrade>::Strong as Deref>::Target as ObjectSubclass>::Type:
IsA<gtk::Widget>,
{
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<gtk::Widget>,
{
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<gtk::Widget>,
{
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<gtk::Widget>,
{
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<gtk::Widget>,
{
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<gtk::Widget>,
{
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<gtk::Widget>,
{
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<gtk::Widget>,
{
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<gtk::Widget>,
{
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"));
}
}
}

6
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::*};

91
src/session/view/content/room_history/event_row_context_menu.rs → 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::<gio::Menu>()
.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::<String>())
.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, &section_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::<gio::Menu>("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 {

0
src/session/view/content/room_history/quick_reaction_chooser.ui → src/session/view/content/room_history/event_actions/quick_reaction_chooser.ui

675
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<Event>,
/// The event action group of this row.
#[property(get, set = Self::set_action_group)]
action_group: RefCell<Option<gio::SimpleActionGroup>>,
permissions_handler: RefCell<Option<glib::SignalHandlerId>>,
}
@ -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<RefCell<Option<glib::signal::SignalHandlerId>>> =
Rc::default();
let closed_handler_cell: Rc<RefCell<Option<glib::SignalHandlerId>>> = 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<Event> {
self.event.obj()
}
fn texture(&self) -> Option<gdk::Texture> {
self.obj()
.child()
.and_downcast::<MessageRow>()
.and_then(|r| r.texture())
}
fn popover(&self) -> Option<gtk::PopoverMenu> {
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<gio::SimpleActionGroup>) {
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::<StateRow>() {
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::<MessageRow>() {
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::<StateRow>() {
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::<MessageRow>() {
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::<String>() 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::<MessageRow>()
.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);
}
}
}

6
src/session/view/content/room_history/message_row/mod.ui

@ -3,6 +3,9 @@
<template class="ContentMessageRow" parent="AdwBin">
<child>
<object class="GtkGrid">
<style>
<class name="event-content"/>
</style>
<property name="column-spacing">12</property>
<child>
<object class="ContentSenderAvatar" id="avatar">
@ -53,9 +56,6 @@
<property name="column">1</property>
<property name="row">1</property>
</layout>
<style>
<class name="event-content"/>
</style>
</object>
</child>
<child>

6
src/session/view/content/room_history/message_row/reaction/mod.rs

@ -95,9 +95,11 @@ mod imp {
self.reaction_key.set_label(&key);
if EMOJI_REGEX.is_match(&key) {
self.reaction_key.add_css_class("emoji");
self.reaction_key.add_css_class("reaction-key-emoji");
self.reaction_key.remove_css_class("reaction-key-text");
} else {
self.reaction_key.remove_css_class("emoji");
self.reaction_key.remove_css_class("reaction-key-emoji");
self.reaction_key.add_css_class("reaction-key-text");
}
self.button.set_action_target_value(Some(&key.to_variant()));

3
src/session/view/content/room_history/message_row/reaction/mod.ui

@ -14,9 +14,6 @@
<object class="GtkLabel" id="reaction_key">
<property name="max-width-chars">23</property>
<property name="ellipsize">end</property>
<style>
<class name="reaction-key"/>
</style>
</object>
</child>
<child>

4
src/session/view/content/room_history/message_row/text/mod.rs

@ -223,9 +223,9 @@ mod imp {
};
if EMOJI_REGEX.is_match(&text) {
child.add_css_class("emoji");
child.add_css_class("emoji-message");
} else {
child.remove_css_class("emoji");
child.remove_css_class("emoji-message");
}
let ellipsize = self.format.get() == ContentFormat::Ellipsized;

29
src/session/view/content/room_history/message_row/visual_media.rs

@ -11,6 +11,7 @@ use crate::{
session::model::Room,
spawn,
utils::{
key_bindings,
matrix::{VisualMediaMessage, VisualMediaType},
media::{
image::{ImageRequestPriority, ThumbnailSettings, THUMBNAIL_MAX_DIMENSIONS},
@ -104,6 +105,11 @@ mod imp {
klass.set_css_name("message-visual-media");
klass.set_accessible_role(gtk::AccessibleRole::Group);
klass.install_action("message-visual-media.activate", None, |obj, _, _| {
obj.imp().activate();
});
key_bindings::add_activate_bindings(klass, "message-visual-media.activate");
}
fn instance_init(obj: &InitializingObject<Self>) {
@ -344,15 +350,7 @@ mod imp {
#[weak(rename_to = imp)]
self,
move |_, _, _, _| {
if imp.state.get() == LoadingState::Initial {
imp.show_media();
} else if imp
.obj()
.activate_action("message-row.show-media", None)
.is_err()
{
error!("Could not activate `message-row.show-media` action");
}
imp.activate();
}
));
@ -718,6 +716,19 @@ mod imp {
}
}
}
/// Handle when the widget is activated.
fn activate(&self) {
if self.state.get() == LoadingState::Initial {
self.show_media();
} else if self
.obj()
.activate_action("message-row.show-media", None)
.is_err()
{
error!("Could not activate `message-row.show-media` action");
}
}
}
}

5
src/session/view/content/room_history/message_toolbar/mod.ui

@ -53,8 +53,7 @@
<child>
<object class="ContentMessageContent" id="related_event_content">
<style>
<class name="related-event-content"/>
<class name="dimmed"/>
<class name="event-content"/>
</style>
<property name="format">ellipsized</property>
</object>
@ -167,7 +166,7 @@
</object>
</property>
</object>
</child>
</child>
<child>
<object class="GtkStackPage">
<property name="name">disabled</property>

123
src/session/view/content/room_history/mod.rs

@ -9,23 +9,30 @@ use ruma::{api::client::receipt::create_receipt::v3::ReceiptType, OwnedEventId};
use tracing::{error, warn};
mod divider_row;
mod event_actions;
mod event_row;
mod event_row_context_menu;
mod member_timestamp;
mod message_row;
mod message_toolbar;
mod read_receipts_list;
mod sender_avatar;
mod state_row;
mod state;
mod title;
mod typing_row;
mod verification_info_bar;
use self::{
divider_row::DividerRow, event_row::EventRow, event_row_context_menu::EventRowContextMenu,
message_row::MessageRow, message_toolbar::MessageToolbar, read_receipts_list::ReadReceiptsList,
sender_avatar::SenderAvatar, state_row::StateRow, title::RoomHistoryTitle,
typing_row::TypingRow, verification_info_bar::VerificationInfoBar,
divider_row::DividerRow,
event_actions::*,
event_row::EventRow,
message_row::MessageRow,
message_toolbar::MessageToolbar,
read_receipts_list::ReadReceiptsList,
sender_avatar::SenderAvatar,
state::{StateGroupRow, StateRow},
title::RoomHistoryTitle,
typing_row::TypingRow,
verification_info_bar::VerificationInfoBar,
};
use super::{room_details, RoomDetails};
use crate::{
@ -36,7 +43,7 @@ use crate::{
VirtualItem, VirtualItemKind,
},
spawn, toast,
utils::{BoundObject, LoadingState, TemplateCallbacks},
utils::{BoundObject, GroupingListGroup, GroupingListModel, LoadingState, TemplateCallbacks},
Window,
};
@ -89,7 +96,8 @@ mod imp {
tombstoned_banner: TemplateChild<adw::Banner>,
#[template_child]
drag_overlay: TemplateChild<DragOverlay>,
event_context_menu: OnceCell<EventRowContextMenu>,
/// The context menu for rows presenting an [`Event`].
event_context_menu: OnceCell<EventActionsContextMenu>,
sender_context_menu: OnceCell<gtk::PopoverMenu>,
/// The timeline currently displayed.
#[property(get, set = Self::set_timeline, explicit_notify, nullable)]
@ -108,8 +116,8 @@ mod imp {
/// timeline.
#[property(get)]
is_sticky: Cell<bool>,
/// The `GtkSelectionModel` used in the list view.
selection_model: OnceCell<gtk::NoSelection>,
/// The `GroupingListModel` used in the list view.
grouping_model: OnceCell<GroupingListModel>,
scroll_timeout: RefCell<Option<glib::SourceId>>,
read_timeout: RefCell<Option<glib::SourceId>>,
room_handlers: RefCell<Vec<glib::SignalHandlerId>>,
@ -254,8 +262,6 @@ mod imp {
impl RoomHistory {
/// Initialize the list view.
fn init_listview(&self) {
let obj = self.obj();
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(move |_, list_item| {
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else {
@ -267,35 +273,15 @@ mod imp {
list_item.set_selectable(false);
});
factory.connect_bind(clone!(
#[weak]
obj,
#[weak(rename_to = imp)]
self,
move |_, list_item| {
let Some(list_item) = list_item.downcast_ref::<gtk::ListItem>() else {
error!("List item factory did not receive a list item: {list_item:?}");
return;
};
let Some(item) = list_item.item() else {
list_item.set_child(None::<&gtk::Widget>);
return;
};
if let Some(event) = item.downcast_ref::<Event>() {
let child =
if let Some(child) = list_item.child().and_downcast::<EventRow>() {
child
} else {
let child = EventRow::new(&obj);
list_item.set_child(Some(&child));
child
};
child.set_event(Some(event.clone()));
} else if let Some(virtual_item) = item.downcast_ref::<VirtualItem>() {
set_virtual_item_child(list_item, virtual_item);
} else {
error!(
"Could not build widget for unsupported room history item: {item:?}"
);
}
imp.bind_list_item_to_item(list_item);
}
));
self.listview.set_factory(Some(&factory));
@ -304,7 +290,8 @@ mod imp {
self.listview
.set_vscroll_policy(gtk::ScrollablePolicy::Natural);
self.listview.set_model(Some(self.selection_model()));
let selection_model = gtk::NoSelection::new(Some(self.grouping_model().clone()));
self.listview.set_model(Some(&selection_model));
self.set_sticky(true);
let adj = self.listview.vadjustment().unwrap();
@ -499,12 +486,12 @@ mod imp {
.set(timeline.clone(), vec![empty_handler, state_handler]);
timeline.remove_empty_typing_row();
self.selection_model().set_model(Some(&timeline.items()));
self.grouping_model().set_model(Some(timeline.items()));
self.trigger_read_receipts_update();
self.scroll_down();
} else {
self.selection_model().set_model(None::<&gio::ListModel>);
self.grouping_model().set_model(None::<gio::ListModel>);
}
self.update_view();
@ -521,10 +508,50 @@ mod imp {
self.timeline.obj().map(|timeline| timeline.room())
}
/// The `GtkSelectionModel` used in the list view.
fn selection_model(&self) -> &gtk::NoSelection {
self.selection_model
.get_or_init(|| gtk::NoSelection::new(gio::ListModel::NONE.cloned()))
/// The `GroupingListModel` used in the list view.
fn grouping_model(&self) -> &GroupingListModel {
self.grouping_model.get_or_init(|| {
GroupingListModel::new(|lhs, rhs| {
lhs.downcast_ref::<Event>()
.is_some_and(Event::is_state_group_event)
&& rhs
.downcast_ref::<Event>()
.is_some_and(Event::is_state_group_event)
})
})
}
/// Bind the given `GtkListItem` to its item.
fn bind_list_item_to_item(&self, list_item: &gtk::ListItem) {
let Some(item) = list_item.item() else {
error!("List item does not have an item",);
list_item.set_child(None::<&gtk::Widget>);
return;
};
if let Some(event) = item.downcast_ref::<Event>() {
let child = if let Some(child) = list_item.child().and_downcast::<EventRow>() {
child
} else {
let child = EventRow::new(&self.obj());
list_item.set_child(Some(&child));
child
};
child.set_event(Some(event.clone()));
} else if let Some(virtual_item) = item.downcast_ref::<VirtualItem>() {
set_virtual_item_child(list_item, virtual_item);
} else if let Some(group) = item.downcast_ref::<GroupingListGroup>() {
let child = if let Some(child) = list_item.child().and_downcast::<StateGroupRow>() {
child
} else {
let child = StateGroupRow::new();
list_item.set_child(Some(&child));
child
};
child.set_group(Some(group.clone()));
} else {
error!("Could not build widget for unsupported room history item: {item:?}");
}
}
/// Handle when the scroll value changed.
@ -598,7 +625,7 @@ mod imp {
self.set_is_auto_scrolling(true);
let n_items = self.selection_model().n_items();
let n_items = self.grouping_model().n_items();
if n_items > 0 {
// Wait until the next tick, to make sure that the GtkListView has created the
@ -677,7 +704,7 @@ mod imp {
/// Whether we need to load more events at the start of the timeline.
fn needs_more_events_at_the_start(&self) -> bool {
if self.selection_model().n_items() == 0 {
if self.grouping_model().n_items() == 0 {
// We definitely want events if the history is empty.
return true;
}
@ -1029,8 +1056,8 @@ mod imp {
}
}
/// The context menu for the [`EventRow`]s.
pub(super) fn event_context_menu(&self) -> &EventRowContextMenu {
/// The context menu for rows presenting an [`Event`].
pub(super) fn event_context_menu(&self) -> &EventActionsContextMenu {
self.event_context_menu.get_or_init(Default::default)
}
@ -1104,8 +1131,8 @@ impl RoomHistory {
self.imp().message_toolbar.handle_paste_action();
}
/// The context menu for the [`EventRow`]s.
fn event_context_menu(&self) -> &EventRowContextMenu {
/// The context menu for rows presenting an [`Event`].
fn event_context_menu(&self) -> &EventActionsContextMenu {
self.imp().event_context_menu()
}

6
src/session/view/content/room_history/read_receipts_list/mod.rs

@ -49,7 +49,7 @@ mod imp {
list: gio::ListStore,
/// The read receipts used as a source.
#[property(get, set = Self::set_source, explicit_notify)]
source: BoundObjectWeakRef<gio::ListStore>,
source: BoundObjectWeakRef<gio::ListModel>,
/// The displayed member if there is only one receipt.
receipt_member: BoundObjectWeakRef<Member>,
}
@ -164,7 +164,7 @@ mod imp {
}
/// Set the read receipts that are used as a source of data.
fn set_source(&self, source: &gio::ListStore) {
fn set_source(&self, source: &gio::ListModel) {
if self.source.obj().as_ref() == Some(source) {
return;
}
@ -201,7 +201,7 @@ mod imp {
}
/// Handle when items changed in the source.
fn items_changed(&self, source: &gio::ListStore, pos: u32, removed: u32, added: u32) {
fn items_changed(&self, source: &gio::ListModel, pos: u32, removed: u32, added: u32) {
let Some(members) = &*self.members.borrow() else {
return;
};

96
src/session/view/content/room_history/state_row/mod.rs → src/session/view/content/room_history/state/content.rs

@ -1,9 +1,6 @@
mod creation;
mod tombstone;
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{glib, CompositeTemplate};
use gtk::glib;
use matrix_sdk_ui::timeline::{
AnyOtherFullStateEventContent, MemberProfileChange, MembershipChange, OtherState,
RoomMembershipChange, TimelineItemContent,
@ -14,58 +11,41 @@ use ruma::{
};
use tracing::warn;
use self::{creation::StateCreation, tombstone::StateTombstone};
use super::ReadReceiptsList;
use super::{StateCreation, StateTombstone};
use crate::{gettext_f, prelude::*, session::model::Event};
mod imp {
use std::cell::RefCell;
use glib::subclass::InitializingObject;
use super::*;
use crate::utils::TemplateCallbacks;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(
resource = "/org/gnome/Fractal/ui/session/view/content/room_history/state_row/mod.ui"
)]
#[properties(wrapper_type = super::StateRow)]
pub struct StateRow {
#[template_child]
content: TemplateChild<adw::Bin>,
#[template_child]
read_receipts: TemplateChild<ReadReceiptsList>,
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::StateContent)]
pub struct StateContent {
/// The state event displayed by this widget.
#[property(get, set = Self::set_event)]
event: RefCell<Option<Event>>,
#[property(get, set = Self::set_event, nullable)]
event: glib::WeakRef<Event>,
}
#[glib::object_subclass]
impl ObjectSubclass for StateRow {
const NAME: &'static str = "ContentStateRow";
type Type = super::StateRow;
impl ObjectSubclass for StateContent {
const NAME: &'static str = "ContentStateContent";
type Type = super::StateContent;
type ParentType = adw::Bin;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
TemplateCallbacks::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for StateRow {}
impl ObjectImpl for StateContent {}
impl WidgetImpl for StateRow {}
impl BinImpl for StateRow {}
impl WidgetImpl for StateContent {}
impl BinImpl for StateContent {}
impl StateRow {
impl StateContent {
/// Set the event presented by this row.
fn set_event(&self, event: Event) {
fn set_event(&self, event: Option<&Event>) {
let Some(event) = event else {
// Only handle when an event is set.
return;
};
match event.content() {
TimelineItemContent::MembershipChange(membership_change) => {
self.update_with_membership_change(&membership_change, &event.sender_id());
@ -76,13 +56,12 @@ mod imp {
&event.sender().disambiguated_name(),
),
TimelineItemContent::OtherState(other_state) => {
self.update_with_other_state(&event, &other_state);
self.update_with_other_state(event, &other_state);
}
_ => unreachable!(),
}
self.read_receipts.set_source(event.read_receipts());
self.event.replace(Some(event));
self.event.set(Some(event));
}
/// Update this row with the given [`OtherState`].
@ -123,16 +102,17 @@ mod imp {
}
};
let obj = self.obj();
match widget {
WidgetType::Text(message) => {
if let Some(child) = self.content.child().and_downcast::<gtk::Label>() {
if let Some(child) = obj.child().and_downcast::<gtk::Label>() {
child.set_text(&message);
} else {
self.content.set_child(Some(&text(&message)));
obj.set_child(Some(&text(&message)));
}
}
WidgetType::Creation(widget) => self.content.set_child(Some(&widget)),
WidgetType::Tombstone(widget) => self.content.set_child(Some(&widget)),
WidgetType::Creation(widget) => obj.set_child(Some(&widget)),
WidgetType::Tombstone(widget) => obj.set_child(Some(&widget)),
}
}
@ -243,10 +223,11 @@ mod imp {
}
};
if let Some(child) = self.content.child().and_downcast::<gtk::Label>() {
let obj = self.obj();
if let Some(child) = obj.child().and_downcast::<gtk::Label>() {
child.set_text(&message);
} else {
self.content.set_child(Some(&text(&message)));
obj.set_child(Some(&text(&message)));
}
}
@ -368,10 +349,11 @@ mod imp {
gettext_f("{user} joined this room.", &[("user", display_name)])
};
if let Some(child) = self.content.child().and_downcast::<gtk::Label>() {
let obj = self.obj();
if let Some(child) = obj.child().and_downcast::<gtk::Label>() {
child.set_text(&message);
} else {
self.content.set_child(Some(&text(&message)));
obj.set_child(Some(&text(&message)));
}
}
}
@ -379,16 +361,22 @@ mod imp {
glib::wrapper! {
/// A row presenting a state event.
pub struct StateRow(ObjectSubclass<imp::StateRow>)
pub struct StateContent(ObjectSubclass<imp::StateContent>)
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
}
impl StateRow {
impl StateContent {
pub fn new() -> Self {
glib::Object::new()
}
}
impl Default for StateContent {
fn default() -> Self {
Self::new()
}
}
enum WidgetType {
Text(String),
Creation(StateCreation),
@ -398,7 +386,7 @@ enum WidgetType {
/// Construct a `GtkLabel` for the given text.
fn text(label: &str) -> gtk::Label {
let child = gtk::Label::new(Some(label));
child.set_css_classes(&["event-content", "dimmed"]);
child.set_css_classes(&["dimmed"]);
child.set_wrap(true);
child.set_wrap_mode(gtk::pango::WrapMode::WordChar);
child.set_xalign(0.0);

2
src/session/view/content/room_history/state_row/creation.rs → src/session/view/content/room_history/state/creation.rs

@ -11,7 +11,7 @@ mod imp {
#[derive(Debug, Default, CompositeTemplate)]
#[template(
resource = "/org/gnome/Fractal/ui/session/view/content/room_history/state_row/creation.ui"
resource = "/org/gnome/Fractal/ui/session/view/content/room_history/state/creation.ui"
)]
pub struct StateCreation {
#[template_child]

0
src/session/view/content/room_history/state_row/creation.ui → src/session/view/content/room_history/state/creation.ui

309
src/session/view/content/room_history/state/group_item_row.rs

@ -0,0 +1,309 @@
use gtk::{gdk, gio, glib, glib::clone, prelude::*, subclass::prelude::*};
use tracing::error;
use super::StateContent;
use crate::{
prelude::*,
session::{
model::Event,
view::content::room_history::{EventActionsGroup, RoomHistory},
},
utils::{key_bindings, BoundObject, BoundObjectWeakRef},
};
mod imp {
use std::{cell::RefCell, rc::Rc};
use super::*;
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::StateGroupItemRow)]
pub struct StateGroupItemRow {
content: StateContent,
/// The state event presented by this row.
#[property(get, set = Self::set_event, explicit_notify)]
event: BoundObjectWeakRef<Event>,
/// The event action group of this row.
action_group: RefCell<Option<gio::SimpleActionGroup>>,
/// The popover of this row.
popover: BoundObject<gtk::PopoverMenu>,
permissions_handler: RefCell<Option<glib::SignalHandlerId>>,
}
#[glib::object_subclass]
impl ObjectSubclass for StateGroupItemRow {
const NAME: &'static str = "ContentStateGroupItemRow";
type Type = super::StateGroupItemRow;
type ParentType = gtk::ListBoxRow;
fn class_init(klass: &mut Self::Class) {
klass.set_css_name("state-group-item-row");
klass.install_action("context-menu.activate", None, |obj, _, _| {
obj.imp().open_context_menu(0, 0);
});
key_bindings::add_context_menu_bindings(klass, "context-menu.activate");
klass.install_action("context-menu.close", None, |obj, _, _| {
if let Some(popover) = obj.imp().popover.obj() {
popover.popdown();
}
});
}
}
#[glib::derived_properties]
impl ObjectImpl for StateGroupItemRow {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
obj.set_child(Some(&self.content));
// Open context menu on long tap.
let long_press_gesture = gtk::GestureLongPress::builder()
.touch_only(true)
.exclusive(true)
.build();
long_press_gesture.connect_pressed(clone!(
#[weak(rename_to = imp)]
self,
move |gesture, x, y| {
if imp.action_group.borrow().is_some() {
gesture.set_state(gtk::EventSequenceState::Claimed);
gesture.reset();
imp.open_context_menu(x as i32, y as i32);
}
}
));
obj.add_controller(long_press_gesture);
// Open context menu on right click.
let right_click_gesture = gtk::GestureClick::builder()
.button(3)
.exclusive(true)
.build();
right_click_gesture.connect_released(clone!(
#[weak(rename_to = imp)]
self,
move |gesture, n_press, x, y| {
if n_press > 1 {
return;
}
if imp.action_group.borrow().is_some() {
gesture.set_state(gtk::EventSequenceState::Claimed);
imp.open_context_menu(x as i32, y as i32);
}
}
));
obj.add_controller(right_click_gesture);
}
fn dispose(&self) {
self.disconnect_event_signals();
if let Some(popover) = self.popover.obj() {
popover.unparent();
}
}
}
impl WidgetImpl for StateGroupItemRow {}
impl ListBoxRowImpl for StateGroupItemRow {}
impl EventActionsGroup for StateGroupItemRow {
fn event(&self) -> Option<Event> {
self.event.obj()
}
fn texture(&self) -> Option<gdk::Texture> {
None
}
fn popover(&self) -> Option<gtk::PopoverMenu> {
self.popover.obj()
}
}
impl StateGroupItemRow {
/// Set the state event presented by this row.
fn set_event(&self, event: Option<&Event>) {
if self.event.obj().as_ref() == event {
return;
}
self.disconnect_event_signals();
if let Some(event) = event {
let permissions_handler = event.room().permissions().connect_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_actions();
}
));
self.permissions_handler.replace(Some(permissions_handler));
let state_notify_handler = event.connect_state_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_actions();
}
));
let source_notify_handler = event.connect_source_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_actions();
}
));
let edit_source_notify_handler = event.connect_latest_edit_source_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_actions();
}
));
self.event.set(
event,
vec![
state_notify_handler,
source_notify_handler,
edit_source_notify_handler,
],
);
}
self.content.set_event(event);
self.update_actions();
self.obj().notify_event();
}
/// Update the actions available for the given event.
fn update_actions(&self) {
let obj = self.obj();
let action_group = self.event_actions_group();
let has_context_menu = action_group.is_some();
obj.insert_action_group("event", action_group.as_ref());
self.action_group.replace(action_group);
obj.update_property(&[gtk::accessible::Property::HasPopup(has_context_menu)]);
obj.action_set_enabled("context-menu.activate", has_context_menu);
obj.action_set_enabled("context-menu.close", has_context_menu);
}
/// Set the popover of this row.
fn set_popover(&self, popover: Option<gtk::PopoverMenu>) {
let prev_popover = self.popover.obj();
if prev_popover == popover {
return;
}
let obj = self.obj();
if let Some(popover) = prev_popover {
if popover.parent().is_some_and(|w| w == *obj) {
popover.unparent();
}
}
self.popover.disconnect_signals();
if let Some(popover) = popover {
popover.unparent();
popover.set_parent(&*obj);
let parent_handler = popover.connect_parent_notify(clone!(
#[weak]
obj,
move |popover| {
if popover.parent().is_none_or(|w| w != obj) {
obj.imp().popover.disconnect_signals();
}
}
));
self.popover.set(popover, vec![parent_handler]);
}
}
/// Open the context menu of the row at the given coordinates.
fn open_context_menu(&self, x: i32, y: i32) {
let obj = self.obj();
if self.action_group.borrow().is_none() {
// There are no possible actions.
self.set_popover(None);
return;
}
let Some(room_history) = obj
.ancestor(RoomHistory::static_type())
.and_downcast::<RoomHistory>()
else {
error!("Could not find RoomHistory ancestor");
self.set_popover(None);
return;
};
let menu = room_history.event_context_menu();
menu.remove_quick_reaction_chooser();
room_history.enable_sticky_mode(false);
obj.add_css_class("has-open-popup");
// Reset the state when the popover is closed.
let closed_handler_cell: Rc<RefCell<Option<glib::SignalHandlerId>>> = Rc::default();
let closed_handler = menu.popover.connect_closed(clone!(
#[weak]
obj,
#[weak]
room_history,
#[strong]
closed_handler_cell,
move |popover| {
room_history.enable_sticky_mode(true);
obj.remove_css_class("has-open-popup");
if let Some(handler) = closed_handler_cell.take() {
popover.disconnect(handler);
}
}
));
closed_handler_cell.replace(Some(closed_handler));
self.set_popover(Some(menu.popover.clone()));
menu.popover
.set_pointing_to(Some(&gdk::Rectangle::new(x, y, 0, 0)));
menu.popover.popup();
}
/// Disconnect the signal handlers.
fn disconnect_event_signals(&self) {
if let Some(event) = self.event.obj() {
self.event.disconnect_signals();
if let Some(handler) = self.permissions_handler.take() {
event.room().permissions().disconnect(handler);
}
}
}
}
}
glib::wrapper! {
/// A row presenting a state event that is part of a group.
pub struct StateGroupItemRow(ObjectSubclass<imp::StateGroupItemRow>)
@extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible;
}
impl StateGroupItemRow {
pub fn new(event: &Event) -> Self {
glib::Object::builder().property("event", event).build()
}
}

248
src/session/view/content/room_history/state/group_row.rs

@ -0,0 +1,248 @@
use adw::{prelude::*, subclass::prelude::*};
use gtk::{gio, glib, glib::clone, CompositeTemplate};
use super::StateGroupItemRow;
use crate::{
ngettext_f,
prelude::*,
session::{
model::{Event, Room},
view::content::room_history::ReadReceiptsList,
},
utils::{key_bindings, BoundObject, GroupingListGroup},
};
mod imp {
use std::{
cell::{Cell, OnceCell},
marker::PhantomData,
};
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(
resource = "/org/gnome/Fractal/ui/session/view/content/room_history/state/group_row.ui"
)]
#[properties(wrapper_type = super::StateGroupRow)]
pub struct StateGroupRow {
#[template_child]
label: TemplateChild<gtk::Label>,
#[template_child]
list_box: TemplateChild<gtk::ListBox>,
/// The group displayed by this widget.
#[property(get, set = Self::set_group, explicit_notify, nullable)]
group: BoundObject<GroupingListGroup>,
/// The room containing the events of the current group.
#[property(get = Self::room)]
room: PhantomData<Option<Room>>,
/// The list model containing the read receipts lists of the children.
read_receipts_lists: OnceCell<gio::ListStore>,
/// The list model containing all the read receiptsof the children.
#[property(get = Self::read_receipts_list_model_owned)]
read_receipts_list_model: OnceCell<gtk::FlattenListModel>,
/// Whether this group contains read receipts.
#[property(get = Self::has_read_receipts)]
has_read_receipts: PhantomData<bool>,
/// Whether this group is expanded.
#[property(get, set = Self::set_is_expanded, construct)]
is_expanded: Cell<bool>,
}
#[glib::object_subclass]
impl ObjectSubclass for StateGroupRow {
const NAME: &'static str = "ContentStateGroupRow";
type Type = super::StateGroupRow;
type ParentType = adw::Bin;
fn class_init(klass: &mut Self::Class) {
ReadReceiptsList::ensure_type();
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
klass.set_css_name("state-group-row");
klass.set_accessible_role(gtk::AccessibleRole::ListItem);
klass.install_action("state-group-row.toggle-expanded", None, |obj, _, _| {
obj.imp().toggle_expanded();
});
key_bindings::add_activate_bindings(klass, "state-group-row.toggle-expanded");
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for StateGroupRow {}
impl WidgetImpl for StateGroupRow {}
impl BinImpl for StateGroupRow {}
#[gtk::template_callbacks]
impl StateGroupRow {
/// Set the group presented by this row.
fn set_group(&self, group: Option<GroupingListGroup>) {
let prev_group = self.group.obj();
if prev_group == group {
return;
}
self.group.disconnect_signals();
let removed = prev_group.map(|group| group.n_items()).unwrap_or_default();
let added = group
.as_ref()
.map(GroupingListGroup::n_items)
.unwrap_or_default();
if let Some(group) = group {
let items_changed_handler = group.connect_items_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_, position, removed, added| {
imp.items_changed(position, removed, added);
}
));
self.list_box.bind_model(Some(&group), |item| {
let event = item
.downcast_ref::<Event>()
.expect("group item should be an event");
StateGroupItemRow::new(event).upcast()
});
self.group.set(group, vec![items_changed_handler]);
}
self.items_changed(0, removed, added);
let obj = self.obj();
obj.notify_group();
obj.notify_room();
}
/// The room containing the events of this group.
fn room(&self) -> Option<Room> {
// Get the room of the first event, since they are all in the same room.
self.group
.obj()
.and_then(|group| group.item(0))
.and_downcast::<Event>()
.map(|event| event.room())
}
/// The list model containing the read receipts lists of the children.
fn read_receipts_lists(&self) -> &gio::ListStore {
self.read_receipts_lists
.get_or_init(gio::ListStore::new::<gio::ListStore>)
}
/// The list model containing all the read receipts of the children.
fn read_receipts_list_model(&self) -> &gtk::FlattenListModel {
self.read_receipts_list_model.get_or_init(|| {
gtk::FlattenListModel::new(Some(self.read_receipts_lists().clone()))
})
}
/// The owned list model containing all the read receipts of the
/// children.
fn read_receipts_list_model_owned(&self) -> gtk::FlattenListModel {
self.read_receipts_list_model().clone()
}
/// Whether this group contains read receipts.
fn has_read_receipts(&self) -> bool {
self.read_receipts_list_model().n_items() > 0
}
/// Set whether this row is expanded.
fn set_is_expanded(&self, is_expanded: bool) {
let obj = self.obj();
if is_expanded {
obj.set_state_flags(gtk::StateFlags::CHECKED, false);
} else {
obj.unset_state_flags(gtk::StateFlags::CHECKED);
}
self.is_expanded.set(is_expanded);
obj.notify_is_expanded();
obj.update_state(&[gtk::accessible::State::Expanded(Some(is_expanded))]);
}
/// Toggle whether this group is expanded.
#[template_callback]
fn toggle_expanded(&self) {
self.set_is_expanded(!self.is_expanded.get());
}
/// Handle when items changed in the underlying group.
fn items_changed(&self, position: u32, removed: u32, added: u32) {
let Some(group) = self.group.obj() else {
self.read_receipts_lists().remove_all();
self.update_label();
self.obj().notify_has_read_receipts();
return;
};
let had_read_receipts = self.has_read_receipts();
let added_read_receipts = (position..position + added)
.map(|pos| {
group
.item(pos)
.and_downcast::<Event>()
.expect("state group item should be an event")
.read_receipts()
})
.collect::<Vec<_>>();
self.read_receipts_lists()
.splice(position, removed, &added_read_receipts);
self.update_label();
if had_read_receipts != self.has_read_receipts() {
self.obj().notify_has_read_receipts();
}
}
/// Update the label of this row for the current state.
fn update_label(&self) {
let n = self
.group
.obj()
.map(|group| group.n_items())
.unwrap_or_default();
let label = ngettext_f(
// Translators: This is a change in the room, not a change between
// rooms. Do NOT translate the content between '{' and '}', this
// is a variable name.
"1 room change",
"{n} room changes",
n,
&[("n", &n.to_string())],
);
self.label.set_label(&label);
}
}
}
glib::wrapper! {
/// A row presenting a group of state events.
pub struct StateGroupRow(ObjectSubclass<imp::StateGroupRow>)
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
}
impl StateGroupRow {
pub fn new() -> Self {
glib::Object::new()
}
}

83
src/session/view/content/room_history/state/group_row.ui

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="ContentStateGroupRow" parent="AdwBin">
<property name="focusable">True</property>
<accessibility>
<relation name="labelled-by">label</relation>
</accessibility>
<style>
<class name="room-history-row"/>
</style>
<property name="child">
<object class="GtkBox">
<style>
<class name="event-content"/>
</style>
<property name="spacing">2</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox">
<style>
<class name="expander-title"/>
</style>
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="label">
<style>
<class name="dimmed"/>
</style>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<property name="hexpand">True</property>
</object>
</child>
<child>
<object class="GtkImage" id="arrow">
<property name="icon-name">expander-arrow-symbolic</property>
<property name="accessible-role">presentation</property>
<style>
<class name="arrow"/>
<class name="dimmed"/>
</style>
</object>
</child>
<child>
<object class="GtkGestureClick">
<signal name="released" handler="toggle_expanded" swapped="yes"/>
</object>
</child>
</object>
</child>
<child>
<object class="GtkRevealer">
<property name="reveal-child" bind-source="ContentStateGroupRow" bind-property="is-expanded" bind-flags="sync-create"/>
<property name="child">
<object class="GtkListBox" id="list_box">
<style>
<class name="expander-content"/>
</style>
<property name="selection-mode">none</property>
</object>
</property>
</object>
</child>
<child>
<object class="ContentReadReceiptsList" id="read_receipts">
<binding name="visible">
<lookup name="has-read-receipts">ContentStateGroupRow</lookup>
</binding>
<binding name="members">
<lookup name="members">
<lookup name="room">ContentStateGroupRow</lookup>
</lookup>
</binding>
<binding name="source">
<lookup name="read-receipts-list-model">ContentStateGroupRow</lookup>
</binding>
</object>
</child>
</object>
</property>
</template>
</interface>

9
src/session/view/content/room_history/state/mod.rs

@ -0,0 +1,9 @@
mod content;
mod creation;
mod group_item_row;
mod group_row;
mod row;
mod tombstone;
use self::{content::*, creation::*, group_item_row::*, tombstone::*};
pub(super) use self::{group_row::*, row::*};

58
src/session/view/content/room_history/state/row.rs

@ -0,0 +1,58 @@
use adw::{prelude::*, subclass::prelude::*};
use gtk::{glib, CompositeTemplate};
use super::StateContent;
use crate::session::{model::Event, view::content::room_history::ReadReceiptsList};
mod imp {
use std::cell::RefCell;
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(resource = "/org/gnome/Fractal/ui/session/view/content/room_history/state/row.ui")]
#[properties(wrapper_type = super::StateRow)]
pub struct StateRow {
/// The state event displayed by this widget.
#[property(get, set)]
event: RefCell<Option<Event>>,
}
#[glib::object_subclass]
impl ObjectSubclass for StateRow {
const NAME: &'static str = "ContentStateRow";
type Type = super::StateRow;
type ParentType = adw::Bin;
fn class_init(klass: &mut Self::Class) {
ReadReceiptsList::ensure_type();
StateContent::ensure_type();
Self::bind_template(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for StateRow {}
impl WidgetImpl for StateRow {}
impl BinImpl for StateRow {}
}
glib::wrapper! {
/// A row presenting a state event.
pub struct StateRow(ObjectSubclass<imp::StateRow>)
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
}
impl StateRow {
pub fn new() -> Self {
glib::Object::new()
}
}

15
src/session/view/content/room_history/state_row/mod.ui → src/session/view/content/room_history/state/row.ui

@ -1,15 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="ContentStateRow" parent="AdwBin">
<accessibility>
<relation name="labelled-by">content</relation>
</accessibility>
<property name="child">
<object class="GtkBox">
<style>
<class name="event-content"/>
</style>
<property name="spacing">2</property>
<property name="orientation">vertical</property>
<child>
<object class="AdwBin" id="content" />
<object class="ContentStateContent">
<property name="event" bind-source="ContentStateRow" bind-property="event" bind-flags="sync-create"/>
</object>
</child>
<child>
<object class="ContentReadReceiptsList" id="read_receipts">
@ -27,6 +29,11 @@
</lookup>
</lookup>
</binding>
<binding name="source">
<lookup name="read-receipts">
<lookup name="event">ContentStateRow</lookup>
</lookup>
</binding>
</object>
</child>
</object>

2
src/session/view/content/room_history/state_row/tombstone.rs → src/session/view/content/room_history/state/tombstone.rs

@ -11,7 +11,7 @@ mod imp {
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(
resource = "/org/gnome/Fractal/ui/session/view/content/room_history/state_row/tombstone.ui"
resource = "/org/gnome/Fractal/ui/session/view/content/room_history/state/tombstone.ui"
)]
#[properties(wrapper_type = super::StateTombstone)]
pub struct StateTombstone {

0
src/session/view/content/room_history/state_row/tombstone.ui → src/session/view/content/room_history/state/tombstone.ui

11
src/ui-resources.gresource.xml

@ -107,7 +107,8 @@
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_details/permissions/permissions_subpage.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_details/permissions/select_member_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/divider_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/event_row_context_menu.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/event_actions/context_menu.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/event_actions/quick_reaction_chooser.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/member_timestamp/row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/message_row/audio.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/message_row/file.ui</file>
@ -123,13 +124,13 @@
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/message_toolbar/completion/completion_popover.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/message_toolbar/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/quick_reaction_chooser.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/read_receipts_list/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/read_receipts_list/read_receipts_popover.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/sender_avatar/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state_row/creation.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state_row/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state_row/tombstone.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state/creation.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state/group_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state/row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/state/tombstone.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/title.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/typing_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_history/verification_info_bar.ui</file>

409
src/utils/grouping_list_model/group.rs

@ -0,0 +1,409 @@
use std::{cmp::Ordering, ops::RangeInclusive};
use gtk::{gio, glib, prelude::*, subclass::prelude::*};
mod imp {
use std::{cell::RefCell, marker::PhantomData};
use super::*;
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::GroupingListGroup)]
pub struct GroupingListGroup {
/// The underlying model.
#[property(get, set = Self::set_model, construct_only)]
model: glib::WeakRef<gio::ListModel>,
/// The range of items in this group.
pub(super) range: RefCell<Option<RangeInclusive<u32>>>,
/// The position of the first item in this group.
#[property(get = Self::start)]
start: PhantomData<u32>,
/// The position of the last item in this group.
#[property(get = Self::end)]
end: PhantomData<u32>,
/// The batch of changes that have not been signalled yet.
pub(super) batch: RefCell<Vec<ChangesBatch>>,
}
#[glib::object_subclass]
impl ObjectSubclass for GroupingListGroup {
const NAME: &'static str = "GroupingListGroup";
type Type = super::GroupingListGroup;
type Interfaces = (gio::ListModel,);
}
#[glib::derived_properties]
impl ObjectImpl for GroupingListGroup {}
impl ListModelImpl for GroupingListGroup {
fn item_type(&self) -> glib::Type {
self.model
.upgrade()
.map_or_else(glib::Object::static_type, |model| model.item_type())
}
fn n_items(&self) -> u32 {
self.range
.borrow()
.as_ref()
.map(|range| range.end() - range.start() + 1)
.unwrap_or_default()
}
fn item(&self, position: u32) -> Option<glib::Object> {
if position >= self.n_items() {
return None;
}
self.model
.upgrade()
.and_then(|m| m.item(self.start() + position))
}
}
impl GroupingListGroup {
/// Set the underlying model.
fn set_model(&self, model: &gio::ListModel) {
if self.model.upgrade().is_some_and(|prev| prev == *model) {
return;
}
self.model.set(Some(model));
self.obj().notify_model();
}
/// Set the range of items in this group.
pub(super) fn set_range(&self, range: RangeInclusive<u32>) {
// TODO: optimize when the range has changed but not the items.
if self
.range
.borrow()
.as_ref()
.is_some_and(|prev| *prev == range)
{
return;
}
let items_changes = if let Some(prev) = self.range.take() {
let prev_start = *prev.start();
let new_start = *range.start();
let start_change = match new_start.cmp(&prev_start) {
Ordering::Less => Some((0, 0, prev_start - new_start)),
Ordering::Equal => None,
Ordering::Greater => Some((0, new_start - prev_start, 0)),
};
let prev_end = *prev.end();
let new_end = *range.end();
let end_change = match new_end.cmp(&prev_end) {
Ordering::Less => Some((new_end, prev_end - new_end, 0)),
Ordering::Equal => None,
Ordering::Greater => Some((prev_end, 0, new_end - prev_end)),
};
[start_change, end_change]
} else {
[Some((0, 0, range.end() - range.start())), None]
};
self.range.replace(Some(range));
let obj = self.obj();
for change in items_changes {
let Some((pos, removed, added)) = change else {
continue;
};
obj.items_changed(pos, removed, added);
}
}
/// The position of the first item in this group.
fn start(&self) -> u32 {
*self
.range
.borrow()
.as_ref()
.expect("range should be initialized")
.start()
}
/// The position of the last item in this group.
fn end(&self) -> u32 {
*self
.range
.borrow()
.as_ref()
.expect("range should be initialized")
.end()
}
/// The bounds of this group.
pub(super) fn bounds(&self) -> (u32, u32) {
self.range
.borrow()
.as_ref()
.map(|range| (*range.start(), *range.end()))
.expect("range should be initialized")
}
/// Add a change to the batch.
pub(super) fn push_change(&self, change: ChangesBatch) {
let mut batch = self.batch.borrow_mut();
match &change {
ChangesBatch::Remove(_) => batch.push(change),
ChangesBatch::Add(add) => {
// Merge the change with the previous one if they are contiguous.
if let Some(prev_change) =
batch.last_mut().and_then(|previous| match previous {
ChangesBatch::Remove(_) => None,
ChangesBatch::Add(prev_change) => {
((prev_change.position + prev_change.added) == add.position)
.then_some(prev_change)
}
})
{
prev_change.added += add.added;
} else {
batch.push(change);
}
}
}
}
}
}
glib::wrapper! {
/// A group of items in a [`GroupingListModel`].
///
/// [`GroupingListModel`]: super::GroupingListModel
pub struct GroupingListGroup(ObjectSubclass<imp::GroupingListGroup>)
@implements gio::ListModel;
}
impl GroupingListGroup {
/// Construct a new `GroupingListGroup` for the given model and range.
pub fn new(model: &gio::ListModel, range: RangeInclusive<u32>) -> Self {
let obj = glib::Object::builder::<Self>()
.property("model", model)
.build();
obj.imp().set_range(range);
obj
}
/// The bounds of this group.
pub(super) fn bounds(&self) -> (u32, u32) {
self.imp().bounds()
}
/// Whether this group contains the given position.
pub(super) fn contains(&self, position: u32) -> bool {
self.imp()
.range
.borrow()
.as_ref()
.is_some_and(|range| range.contains(&position))
}
/// Handle the given removal of items that might affect this group.
///
/// This function panics if there would be no items left in this group.
pub(super) fn handle_removal(&self, position: u32, removed: u32) {
if removed == 0 {
// Nothing to do.
return;
}
let imp = self.imp();
let (start, end) = imp.bounds();
if position > end {
// This group is not affected.
return;
}
let removal_end = position + removed - 1;
assert!(
position > start || removal_end < end,
"should not remove whole group",
);
let (remove, new_range) = if removal_end < start {
let new_range = (start - removed)..=(end - removed);
(None, new_range)
} else if position <= start {
let remove = RemoveBatch {
position: 0,
removed: removal_end - start + 1,
};
let new_range = position..=(end - removed);
(Some(remove), new_range)
} else if position > start && removal_end <= end {
let remove = RemoveBatch {
position: position - start,
removed,
};
let new_range = start..=(end - removed);
(Some(remove), new_range)
} else {
// position > start && removal_end > end
let remove = RemoveBatch {
position: position - start,
removed: end - position + 1,
};
#[allow(clippy::range_minus_one)] // We need an inclusive range.
let new_range = start..=(position - 1);
(Some(remove), new_range)
};
if let Some(remove) = remove {
imp.push_change(remove.into());
}
*imp.range.borrow_mut() = Some(new_range);
}
/// Handle the given addition of items that might affect this group.
pub(super) fn handle_addition(&self, position: u32, added: u32) {
if added == 0 {
// Nothing to do.
return;
}
let imp = self.imp();
let (start, end) = imp.bounds();
if position > end {
// This group is not affected.
return;
}
let (add, new_range) = if position <= start {
let new_range = (start + added)..=(end + added);
(None, new_range)
} else {
// start < position <= end
let add = AddBatch {
position: position - start,
added,
};
let new_range = start..=(end + added);
(Some(add), new_range)
};
if let Some(add) = add {
imp.push_change(add.into());
}
*imp.range.borrow_mut() = Some(new_range);
}
/// Add items to this list item.
///
/// This function assumes that the new items are contiguous to the current
/// ones.
pub(super) fn add(&self, position: u32, added: u32) {
if added == 0 {
// Nothing to do.
return;
}
let imp = self.imp();
let (start, end) = imp.bounds();
let (add, new_range) = if position <= start {
let new_range = position..=end;
let add = AddBatch { position: 0, added };
(add, new_range)
} else {
// start < position <= end
let add = AddBatch {
position: position - start,
added,
};
let new_range = start..=(end + added);
(add, new_range)
};
imp.push_change(add.into());
*imp.range.borrow_mut() = Some(new_range);
}
/// Process the accumulated batch of changes.
pub(super) fn process_batch(&self) {
let batch = self.imp().batch.take();
// Do not process removals right away, to batch a removal with the corresponding
// addition, if any.
let mut previous_remove = None;
for changes in batch {
match changes {
ChangesBatch::Remove(remove) => {
if let Some(remove) = previous_remove.replace(remove) {
self.items_changed(remove.position, remove.removed, 0);
}
}
ChangesBatch::Add(add) => {
let removed = if let Some(remove) = previous_remove.take() {
if remove.position == add.position {
remove.removed
} else {
self.items_changed(remove.position, remove.removed, 0);
0
}
} else {
0
};
self.items_changed(add.position, removed, add.added);
}
}
}
if let Some(remove) = previous_remove.take() {
self.items_changed(remove.position, remove.removed, 0);
}
}
}
// A batch of changes.
#[derive(Debug, Clone, Copy)]
enum ChangesBatch {
// Remove items.
Remove(RemoveBatch),
// Add items.
Add(AddBatch),
}
impl From<RemoveBatch> for ChangesBatch {
fn from(value: RemoveBatch) -> Self {
Self::Remove(value)
}
}
impl From<AddBatch> for ChangesBatch {
fn from(value: AddBatch) -> Self {
Self::Add(value)
}
}
// A batch of removals.
#[derive(Debug, Clone, Copy)]
struct RemoveBatch {
// The position of the first item that was removed.
position: u32,
// The number of items that were removed.
removed: u32,
}
// A batch of additions.
#[derive(Debug, Clone, Copy)]
struct AddBatch {
// The position of the first item that was added.
position: u32,
// The number of items that were added.
added: u32,
}

603
src/utils/grouping_list_model/mod.rs

@ -0,0 +1,603 @@
use std::{cmp::Ordering, fmt, ops::RangeInclusive};
use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
mod group;
#[cfg(test)]
mod tests;
pub(crate) use self::group::GroupingListGroup;
use crate::utils::BoundObject;
/// A function to determine if an item should be grouped with another contiguous
/// item.
///
/// This function MUST always return `true` when used with any two items in the
/// same group.
pub(crate) type GroupFn = dyn Fn(&glib::Object, &glib::Object) -> bool;
mod imp {
use std::{
cell::{OnceCell, RefCell},
collections::{HashSet, VecDeque},
};
use super::*;
#[derive(Default, glib::Properties)]
#[properties(wrapper_type = super::GroupingListModel)]
pub struct GroupingListModel {
/// The underlying model.
#[property(get, set = Self::set_model, explicit_notify, nullable)]
model: BoundObject<gio::ListModel>,
/// The function to determine if adjacent items should be grouped.
pub(super) group_fn: OnceCell<Box<GroupFn>>,
/// The groups created by this list model.
items: RefCell<VecDeque<GroupingListItem>>,
}
impl fmt::Debug for GroupingListModel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GroupingListModel")
.field("model", &self.model)
.field("items", &self.items)
.finish_non_exhaustive()
}
}
#[glib::object_subclass]
impl ObjectSubclass for GroupingListModel {
const NAME: &'static str = "GroupingListModel";
type Type = super::GroupingListModel;
type Interfaces = (gio::ListModel,);
}
#[glib::derived_properties]
impl ObjectImpl for GroupingListModel {}
impl ListModelImpl for GroupingListModel {
fn item_type(&self) -> glib::Type {
glib::Object::static_type()
}
fn n_items(&self) -> u32 {
self.items.borrow().len() as u32
}
fn item(&self, position: u32) -> Option<glib::Object> {
let model = self.model.obj()?;
self.items
.borrow()
.get(position as usize)
.and_then(|item| match item {
GroupingListItem::Singleton(position) => model.item(*position),
GroupingListItem::Group(obj) => Some(obj.clone().upcast()),
})
}
}
impl GroupingListModel {
/// The function to determine if adjacent items should be grouped.
fn group_fn(&self) -> &GroupFn {
self.group_fn.get().expect("group Fn should be initialized")
}
/// Set the underlying model.
fn set_model(&self, model: Option<gio::ListModel>) {
let prev_model = self.model.obj();
if prev_model == model {
return;
}
self.model.disconnect_signals();
if let Some(model) = model {
let items_changed_handler = model.connect_items_changed(clone!(
#[weak(rename_to = imp)]
self,
move |model, position, removed, added| {
imp.items_changed(model, position, removed, added);
}
));
self.model.set(model.clone(), vec![items_changed_handler]);
let removed = prev_model.map(|model| model.n_items()).unwrap_or_default();
self.items_changed(&model, 0, removed, model.n_items());
} else {
let removed = self.n_items();
self.items.borrow_mut().clear();
self.obj().items_changed(0, removed, 0);
}
self.obj().notify_model();
}
/// Find the index of the list item containing the given position in the
/// underlying model.
fn model_position_to_index(&self, position: u32) -> Option<usize> {
for (index, item) in self.items.borrow().iter().enumerate() {
if item.contains(position) {
return Some(index);
}
// Because items are sorted, we can return early when the position is in a gap
// between items. This should only happen during `items_changed`.
if item.end() > position {
return None;
}
}
None
}
/// Handle when items have changed in the underlying model.
#[allow(clippy::too_many_lines)]
fn items_changed(&self, model: &gio::ListModel, position: u32, removed: u32, added: u32) {
if removed == 0 && added == 0 {
// Nothing to do.
return;
}
// Index of the list item that contains the item right before the changes in the
// model.
let index_before_changes = position
.checked_sub(1)
.and_then(|position| self.model_position_to_index(position));
let mut replaced_list_items = HashSet::new();
let mut list_items_removed =
self.items_removed(position, removed, index_before_changes);
let mut list_items_added = self.items_added(
model,
position,
added,
&mut replaced_list_items,
index_before_changes,
);
let position_after_changes = position + added;
let mut index_after_changes = self.model_position_to_index(position_after_changes);
// Check if the list item after the changes can be merged with the previous list
// item.
if let Some(index_after_changes) =
index_after_changes.as_mut().filter(|index| **index > 0)
{
let mut items = self.items.borrow_mut();
let previous_item_in_other_list_item = !items
.get(*index_after_changes)
.expect("list item index should be valid")
.contains(position_after_changes - 1);
if previous_item_in_other_list_item {
let item_after_changes = model
.item(position_after_changes)
.expect("item position should be valid");
let previous_item = model
.item(position_after_changes - 1)
.expect("item position should be valid");
if self.group_fn()(&item_after_changes, &previous_item) {
// We can merge the items.
*index_after_changes -= 1;
let (removed_list_item, list_item_to_merge_into) = if index_before_changes
.is_some_and(|index| index == *index_after_changes)
{
// Merge into the list item before changes.
let list_item_after_changes = items
.remove(*index_after_changes + 1)
.expect("list item index should be valid");
let list_item_before_changes = items
.get_mut(*index_after_changes)
.expect("list item index should be valid");
(list_item_after_changes, list_item_before_changes)
} else {
// Merge into the list item after changes.
let previous_list_item = items
.remove(*index_after_changes)
.expect("list item index should be valid");
let list_item_after_changes = items
.get_mut(*index_after_changes)
.expect("list item index should be valid");
(previous_list_item, list_item_after_changes)
};
let list_item_replacement = list_item_to_merge_into.add(
removed_list_item.start(),
removed_list_item.len(),
model,
);
if let Some(replacement) = list_item_replacement {
*list_item_to_merge_into = replacement;
replaced_list_items.insert(*index_after_changes);
}
if let Some(added) = list_items_added.checked_sub(1) {
list_items_added = added;
} else {
list_items_removed += 1;
}
}
}
}
let obj = self.obj();
if list_items_removed > 0 || list_items_added > 0 {
let index_at_changes = index_before_changes
.map(|index| index + 1)
.unwrap_or_default();
obj.items_changed(
index_at_changes as u32,
list_items_removed as u32,
list_items_added as u32,
);
}
// Change groups with a single item to singletons.
for index in index_before_changes.into_iter().chain(index_after_changes) {
let mut items = self.items.borrow_mut();
let item = items
.get_mut(index)
.expect("list item index should be valid");
if matches!(item, GroupingListItem::Group(_)) && item.len() == 1 {
*item = GroupingListItem::Singleton(item.start());
replaced_list_items.insert(index);
}
}
for index in replaced_list_items {
obj.items_changed(index as u32, 1, 1);
}
// Generate a list of groups before syncing them, to avoid holding a ref while
// we send signals about changed items.
let groups = self
.items
.borrow()
.range(index_before_changes.unwrap_or_default()..)
.filter_map(|list_item| match list_item {
GroupingListItem::Singleton(_) => None,
GroupingListItem::Group(group) => Some(group.clone()),
})
.collect::<Vec<_>>();
groups.into_iter().for_each(|group| group.process_batch());
}
/// Handle when items were removed in the underlying model.
///
/// Returns a `(position, removed)` tuple if items were removed in this
/// list.
fn items_removed(
&self,
position: u32,
removed: u32,
index_before_changes: Option<usize>,
) -> usize {
if removed == 0 {
// Nothing to do.
return 0;
}
// Index of the list item that contains the item right after the changes in the
// model.
let index_after_changes = position
.checked_add(removed)
.and_then(|position| self.model_position_to_index(position));
let mut items = self.items.borrow_mut();
// Update the range of the list item before changes, if it's not the same as the
// list item after the changes and if it contains removed items.
if let Some(index_before_changes) = index_before_changes.filter(|index| {
index_after_changes.is_none_or(|index_after_changes| index_after_changes > *index)
}) {
items
.get_mut(index_before_changes)
.expect("list item index should be valid")
.handle_removal(position, removed);
}
// Update the range of the list items after the changes.
if let Some(index_after_changes) = index_after_changes {
items
.range_mut(index_after_changes..)
.for_each(|list_item| list_item.handle_removal(position, removed));
}
// If items were removed, we should have at least one list item.
let max_index = items.len() - 1;
let removal_start = if let Some(index_before_changes) = index_before_changes {
// The list items removal starts at the list item after the one before the
// changes.
index_before_changes
.checked_add(1)
.filter(|index| *index <= max_index)
} else {
// There is no list item before, we are at the start of the list items.
Some(0)
};
let removal_end = if let Some(index_after_changes) = index_after_changes {
// The list items removal starts at the list item before the one after the
// changes.
index_after_changes.checked_sub(1)
} else {
// There is no list item after, we are at the end of the list items.
Some(max_index)
};
// Remove list items if needed.
let Some((removal_start, removal_end)) = removal_start
.zip(removal_end)
.filter(|(removal_start, removal_end)| removal_start <= removal_end)
else {
return 0;
};
let is_at_items_start = removal_start == 0;
let is_at_items_end = removal_end == items.len().saturating_sub(1);
// Try to optimize the removal by using the most appropriate `VecDeque` method.
if is_at_items_start && is_at_items_end {
// Remove all items.
items.clear();
} else if is_at_items_end {
// Remove the end of the items.
items.truncate(removal_start);
} else {
// We can only remove each item separately.
for i in (removal_start..=removal_end).rev() {
items.remove(i);
}
}
removal_end - removal_start + 1
}
/// Handle when items were added to the underlying model.
///
/// Returns the number of items that were added, if any.
fn items_added(
&self,
model: &gio::ListModel,
position: u32,
added: u32,
replaced_list_items: &mut HashSet<usize>,
index_before_changes: Option<usize>,
) -> usize {
if added == 0 {
// Nothing to do.
return 0;
}
let mut list_items_added = 0;
let position_before = position.checked_sub(1);
// The previous item in the underlying model and the index of the list item that
// contains it.
let mut previous_item_and_index =
position_before.and_then(|position| model.item(position).zip(index_before_changes));
let group_fn = self.group_fn();
let mut items = self.items.borrow_mut();
for current_position in position..position + added {
let item = model
.item(current_position)
.expect("item position should be valid");
if let Some((previous_item, previous_index)) = &mut previous_item_and_index {
let previous_list_item = items
.get_mut(*previous_index)
.expect("list item index should be valid");
if group_fn(&item, previous_item) {
// Add the position to the list item.
let list_item_replacement =
previous_list_item.add(current_position, 1, model);
if let Some(replacement) = list_item_replacement {
*previous_list_item = replacement;
if current_position == position {
// We will need to send a signal because we replaced a list item
// that already existed.
replaced_list_items.insert(*previous_index);
}
}
// The previous item changed but the list item that contains it is the same.
*previous_item = item;
continue;
} else if previous_list_item.contains(current_position) {
// We need to split the group.
let end_list_item = previous_list_item.split(current_position);
items.insert(*previous_index + 1, end_list_item);
list_items_added += 1;
}
}
// The item is a singleton.
let index = previous_item_and_index
.take()
.map(|(_, index)| index + 1)
.unwrap_or_default();
items.insert(index, GroupingListItem::Singleton(current_position));
list_items_added += 1;
previous_item_and_index = Some((item, index));
}
let (_, last_index_with_changes) =
previous_item_and_index.expect("there should have been at least one addition");
let index_after_changes = last_index_with_changes + 1;
// Update the ranges of the list items after the changes.
if index_after_changes < items.len() {
items
.range_mut(index_after_changes..)
.for_each(|list_item| list_item.handle_addition(position, added));
}
list_items_added
}
}
}
glib::wrapper! {
/// A list model that groups some items according to a function.
pub struct GroupingListModel(ObjectSubclass<imp::GroupingListModel>)
@implements gio::ListModel;
}
impl GroupingListModel {
/// Construct a new `GroupingListModel` with the given function to determine
/// if adjacent items should be grouped.
pub fn new<GroupFn>(group_fn: GroupFn) -> Self
where
GroupFn: Fn(&glib::Object, &glib::Object) -> bool + 'static,
{
let obj = glib::Object::new::<Self>();
// Ignore the error because we cannot `.expect()` when the value is a function.
let _ = obj.imp().group_fn.set(Box::new(group_fn));
obj
}
}
/// An item in the [`GroupingListModel`].
#[derive(Debug, Clone)]
enum GroupingListItem {
/// An item that is not in a group.
Singleton(u32),
/// A group of items.
Group(GroupingListGroup),
}
impl GroupingListItem {
/// Construct a list item with the given range for the given model.
fn with_range(range: RangeInclusive<u32>, model: &gio::ListModel) -> Self {
if range.start() == range.end() {
Self::Singleton(*range.start())
} else {
Self::Group(GroupingListGroup::new(model, range))
}
}
/// Whether this list item contains the given position.
fn contains(&self, position: u32) -> bool {
match self {
Self::Singleton(pos) => *pos == position,
Self::Group(group) => group.contains(position),
}
}
/// The position of the first item in this list item.
fn start(&self) -> u32 {
match self {
Self::Singleton(position) => *position,
Self::Group(group) => group.start(),
}
}
/// The position of the last item in this list item.
fn end(&self) -> u32 {
match self {
Self::Singleton(position) => *position,
Self::Group(group) => group.end(),
}
}
/// The length of the range of this list item.
fn len(&self) -> u32 {
match self {
Self::Singleton(_) => 1,
Self::Group(group) => {
let (start, end) = group.bounds();
end - start + 1
}
}
}
/// Handle the given removal of items that might affect this group.
///
/// This function panics if there would be no items left in this group.
fn handle_removal(&mut self, position: u32, removed: u32) {
match self {
Self::Singleton(pos) => match position.cmp(pos) {
Ordering::Less => *pos -= removed,
Ordering::Equal => panic!("should not remove list item"),
Ordering::Greater => {}
},
Self::Group(group) => group.handle_removal(position, removed),
}
}
/// Handle the given addition of items that might affect this group.
fn handle_addition(&mut self, position: u32, added: u32) {
match self {
Self::Singleton(pos) => {
if position <= *pos {
*pos += added;
}
}
Self::Group(group) => group.handle_addition(position, added),
}
}
/// Add items to this list item.
///
/// Returns the newly created group if this item was a singleton.
///
/// Panics if the added items are not contiguous to the current ones.
fn add(&self, position: u32, added: u32, model: &gio::ListModel) -> Option<Self> {
debug_assert!(
(position + added) >= self.start() || position <= self.end().saturating_add(1),
"items to add should be contiguous"
);
match self {
Self::Singleton(pos) => {
let start = position.min(*pos);
let end = start + added;
Some(Self::with_range(start..=end, model))
}
Self::Group(group) => {
group.add(position, added);
None
}
}
}
/// Split this group at the given position.
///
/// `position` must be greater than the start and lower or equal to the end
/// of this group.
///
/// Returns the new list item containing the second part of the group,
/// starting at `at`.
fn split(&self, position: u32) -> Self {
match self {
Self::Singleton(_) => panic!("singleton cannot be split"),
Self::Group(group) => {
let model = group.model().expect("model should be initialized");
let end = group.end();
group.handle_removal(position, end - position + 1);
Self::with_range(position..=end, &model)
}
}
}
}

3772
src/utils/grouping_list_model/tests.rs

File diff suppressed because it is too large Load Diff

1
src/utils/mod.rs

@ -36,6 +36,7 @@ pub(crate) mod toast;
pub(crate) use self::{
expression_list_model::ExpressionListModel,
grouping_list_model::*,
location::{Location, LocationError, LocationExt},
placeholder_object::PlaceholderObject,
single_item_list_model::SingleItemListModel,

Loading…
Cancel
Save