20 changed files with 1314 additions and 38 deletions
@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<interface> |
||||
<template class="ContentDividerRow" parent="AdwBin"> |
||||
<property name="can-focus">False</property> |
||||
<style> |
||||
<class name="divider-row"/> |
||||
</style> |
||||
<property name="child"> |
||||
<object class="GtkBox"> |
||||
<property name="spacing">12</property> |
||||
<property name="margin-start">24</property> |
||||
<property name="margin-end">24</property> |
||||
<child> |
||||
<object class="GtkSeparator"> |
||||
<property name="valign">center</property> |
||||
<property name="hexpand">true</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkLabel" id="label"> |
||||
<style> |
||||
<class name="dim-label"/> |
||||
</style> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkSeparator"> |
||||
<property name="valign">center</property> |
||||
<property name="hexpand">true</property> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</property> |
||||
</template> |
||||
</interface> |
||||
@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<interface> |
||||
<object class="GtkPopoverMenu" id="message_menu_popover"> |
||||
<property name="can_focus">False</property> |
||||
<child> |
||||
<object class="GtkBox"> |
||||
<property name="visible">True</property> |
||||
<property name="can_focus">False</property> |
||||
<property name="margin_start">6</property> |
||||
<property name="margin_end">6</property> |
||||
<property name="margin_top">6</property> |
||||
<property name="margin_bottom">6</property> |
||||
<property name="orientation">vertical</property> |
||||
<child> |
||||
<object class="GtkModelButton" id="reply_button"> |
||||
<property name="visible">True</property> |
||||
<property name="can_focus">True</property> |
||||
<property name="receives_default">True</property> |
||||
<property name="action_name">message.reply</property> |
||||
<property name="text" translatable="yes">Reply</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkModelButton" id="open_with_button"> |
||||
<property name="can_focus">True</property> |
||||
<property name="receives_default">True</property> |
||||
<property name="text" translatable="yes">Open With…</property> |
||||
<property name="action_name">message.open_with</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkModelButton" id="save_image_as_button"> |
||||
<property name="can_focus">True</property> |
||||
<property name="receives_default">True</property> |
||||
<property name="text" translatable="yes">Save Image As…</property> |
||||
<property name="action_name">message.save_as</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkModelButton" id="save_video_as_button"> |
||||
<property name="can_focus">True</property> |
||||
<property name="receives_default">True</property> |
||||
<property name="text" translatable="yes">Save Video As…</property> |
||||
<property name="action_name">message.save_as</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkModelButton" id="copy_image_button"> |
||||
<property name="can_focus">True</property> |
||||
<property name="receives_default">True</property> |
||||
<property name="text" translatable="yes">Copy Image</property> |
||||
<property name="action_name">message.copy_image</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkModelButton" id="copy_selected_text_button"> |
||||
<property name="can_focus">True</property> |
||||
<property name="receives_default">True</property> |
||||
<property name="text" translatable="yes">Copy Selection</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkModelButton" id="copy_text_button"> |
||||
<property name="visible">True</property> |
||||
<property name="can_focus">True</property> |
||||
<property name="receives_default">True</property> |
||||
<property name="text" translatable="yes">Copy Text</property> |
||||
<property name="action_name">message.copy_text</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkModelButton" id="view_source_button"> |
||||
<property name="visible">True</property> |
||||
<property name="can_focus">True</property> |
||||
<property name="receives_default">True</property> |
||||
<property name="action_name">message.show_source</property> |
||||
<property name="text" translatable="yes">View Source</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkSeparator" id="message_menu_separator"> |
||||
<property name="visible">True</property> |
||||
<property name="can_focus">False</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkModelButton" id="delete_message_button"> |
||||
<property name="visible">True</property> |
||||
<property name="can_focus">True</property> |
||||
<property name="receives_default">True</property> |
||||
<property name="action_name">message.delete</property> |
||||
<property name="text" translatable="yes">Delete Message</property> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
<packing> |
||||
<property name="submenu">main</property> |
||||
<property name="position">1</property> |
||||
</packing> |
||||
</child> |
||||
</object> |
||||
</interface> |
||||
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<interface> |
||||
<template class="GtkListItem"> |
||||
<property name="activatable">False</property> |
||||
<binding name="selectable"> |
||||
<lookup type="RoomItem" name="selectable"> |
||||
<lookup name="item">GtkListItem</lookup> |
||||
</lookup> |
||||
</binding> |
||||
<property name="child"> |
||||
<object class="ContentItemRow"> |
||||
<binding name="item"> |
||||
<lookup name="item">GtkListItem</lookup> |
||||
</binding> |
||||
</object> |
||||
</property> |
||||
</template> |
||||
</interface> |
||||
@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<interface> |
||||
<template class="ContentMessageRow" parent="AdwBin"> |
||||
<child> |
||||
<object class="GtkBox"> |
||||
<property name="spacing">6</property> |
||||
<child> |
||||
<object class="AdwAvatar" id="avatar"> |
||||
<property name="show-initials">True</property> |
||||
<property name="size">24</property> |
||||
<property name="text" bind-source="display_name" bind-property="label" bind-flags="sync-create"/> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkBox"> |
||||
<property name="spacing">6</property> |
||||
<property name="orientation">vertical</property> |
||||
<child> |
||||
<object class="GtkBox" id="header"> |
||||
<property name="spacing">6</property> |
||||
<child> |
||||
<object class="GtkLabel" id="display_name"> |
||||
<property name="ellipsize">end</property> |
||||
<property name="selectable">True</property> |
||||
<style> |
||||
<class name="displayname"/> |
||||
</style> |
||||
</object> |
||||
</child> |
||||
<child type="end"> |
||||
<object class="GtkLabel" id="timestamp"> |
||||
<style> |
||||
<class name="timestamp"/> |
||||
</style> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="AdwBin" id="content"> |
||||
<property name="hexpand">True</property> |
||||
<property name="vexpand">True</property> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
</template> |
||||
</interface> |
||||
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<interface> |
||||
<template class="ContentStateRow" parent="AdwBin"> |
||||
<property name="child"> |
||||
<object class="GtkBox"> |
||||
<property name="spacing">6</property> |
||||
<property name="orientation">vertical</property> |
||||
<child> |
||||
<object class="AdwBin" id="content" /> |
||||
</child> |
||||
<child type="end"> |
||||
<object class="GtkLabel" id="timestamp"> |
||||
<style> |
||||
<class name="timestamp"/> |
||||
</style> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</property> |
||||
</template> |
||||
</interface> |
||||
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<interface> |
||||
<template class="ContextMenuBin" parent="AdwBin"> |
||||
<property name="focusable">True</property> |
||||
<child> |
||||
<object class="GtkGestureClick" id="click_gesture"> |
||||
<property name="button">3</property> |
||||
<property name="exclusive">True</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkGestureLongPress" id="long_press_gesture"> |
||||
<property name="touch_only">True</property> |
||||
<property name="exclusive">True</property> |
||||
</object> |
||||
</child> |
||||
</template> |
||||
</interface> |
||||
@ -0,0 +1,188 @@
|
||||
use adw::subclass::prelude::*; |
||||
use gtk::prelude::*; |
||||
use gtk::subclass::prelude::*; |
||||
use gtk::{gdk, gio, glib, glib::clone, CompositeTemplate}; |
||||
use log::debug; |
||||
|
||||
mod imp { |
||||
use super::*; |
||||
use glib::subclass::InitializingObject; |
||||
|
||||
#[derive(Debug, CompositeTemplate)] |
||||
#[template(resource = "/org/gnome/FractalNext/context-menu-bin.ui")] |
||||
pub struct ContextMenuBin { |
||||
#[template_child] |
||||
pub click_gesture: TemplateChild<gtk::GestureClick>, |
||||
#[template_child] |
||||
pub long_press_gesture: TemplateChild<gtk::GestureLongPress>, |
||||
pub popover: gtk::PopoverMenu, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for ContextMenuBin { |
||||
const NAME: &'static str = "ContextMenuBin"; |
||||
type Type = super::ContextMenuBin; |
||||
type ParentType = adw::Bin; |
||||
|
||||
fn new() -> Self { |
||||
Self { |
||||
click_gesture: TemplateChild::default(), |
||||
long_press_gesture: TemplateChild::default(), |
||||
// WORKAROUND: there is some issue with creating the popover from the template
|
||||
popover: gtk::PopoverMenuBuilder::new() |
||||
.position(gtk::PositionType::Bottom) |
||||
.has_arrow(false) |
||||
.halign(gtk::Align::Start) |
||||
.build(), |
||||
} |
||||
} |
||||
|
||||
fn class_init(klass: &mut Self::Class) { |
||||
Self::bind_template(klass); |
||||
|
||||
klass.install_action("context-menu.activate", None, move |widget, _, _| { |
||||
widget.open_menu_at(0, 0) |
||||
}); |
||||
klass.add_binding_action( |
||||
gdk::keys::constants::F10, |
||||
gdk::ModifierType::SHIFT_MASK, |
||||
"context-menu.activate", |
||||
None, |
||||
); |
||||
klass.add_binding_action( |
||||
gdk::keys::constants::Menu, |
||||
gdk::ModifierType::empty(), |
||||
"context-menu.activate", |
||||
None, |
||||
); |
||||
} |
||||
|
||||
fn instance_init(obj: &InitializingObject<Self>) { |
||||
obj.init_template(); |
||||
} |
||||
} |
||||
|
||||
impl ObjectImpl for ContextMenuBin { |
||||
fn properties() -> &'static [glib::ParamSpec] { |
||||
use once_cell::sync::Lazy; |
||||
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { |
||||
vec![glib::ParamSpec::new_object( |
||||
"context-menu", |
||||
"Context Menu", |
||||
"The context menu", |
||||
gio::MenuModel::static_type(), |
||||
glib::ParamFlags::READWRITE, |
||||
)] |
||||
}); |
||||
|
||||
PROPERTIES.as_ref() |
||||
} |
||||
|
||||
fn set_property( |
||||
&self, |
||||
obj: &Self::Type, |
||||
_id: usize, |
||||
value: &glib::Value, |
||||
pspec: &glib::ParamSpec, |
||||
) { |
||||
match pspec.name() { |
||||
"context-menu" => { |
||||
let context_menu = value |
||||
.get::<Option<gio::MenuModel>>() |
||||
.expect("type conformity checked by `Object::set_property`"); |
||||
obj.set_context_menu(context_menu); |
||||
} |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
|
||||
fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { |
||||
match pspec.name() { |
||||
"context-menu" => obj.context_menu().to_value(), |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
|
||||
fn constructed(&self, obj: &Self::Type) { |
||||
self.popover.set_parent(obj); |
||||
self.long_press_gesture |
||||
.connect_pressed(clone!(@weak obj => move |gesture, x, y| { |
||||
gesture.set_state(gtk::EventSequenceState::Claimed); |
||||
gesture.reset(); |
||||
obj.open_menu_at(x as i32, y as i32); |
||||
})); |
||||
|
||||
self.click_gesture.connect_released( |
||||
clone!(@weak obj => move |gesture, n_press, x, y| { |
||||
if n_press > 1 { |
||||
return; |
||||
} |
||||
|
||||
gesture.set_state(gtk::EventSequenceState::Claimed); |
||||
obj.open_menu_at(x as i32, y as i32); |
||||
}), |
||||
); |
||||
self.parent_constructed(obj); |
||||
} |
||||
|
||||
fn dispose(&self, _obj: &Self::Type) { |
||||
self.popover.unparent(); |
||||
} |
||||
} |
||||
|
||||
impl WidgetImpl for ContextMenuBin {} |
||||
|
||||
impl BinImpl for ContextMenuBin {} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
pub struct ContextMenuBin(ObjectSubclass<imp::ContextMenuBin>) |
||||
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible; |
||||
} |
||||
|
||||
/// A Bin widget that adds a conext menu
|
||||
impl ContextMenuBin { |
||||
pub fn new() -> Self { |
||||
glib::Object::new(&[]).expect("Failed to create ContextMenuBin") |
||||
} |
||||
|
||||
pub fn set_context_menu(&self, menu: Option<gio::MenuModel>) { |
||||
let priv_ = imp::ContextMenuBin::from_instance(self); |
||||
priv_.popover.set_menu_model(menu.as_ref()); |
||||
} |
||||
|
||||
pub fn context_menu(&self) -> Option<gio::MenuModel> { |
||||
let priv_ = imp::ContextMenuBin::from_instance(self); |
||||
priv_.popover.menu_model() |
||||
} |
||||
|
||||
fn open_menu_at(&self, x: i32, y: i32) { |
||||
let priv_ = imp::ContextMenuBin::from_instance(self); |
||||
let popover = &priv_.popover; |
||||
|
||||
debug!("Context menu was activated"); |
||||
|
||||
if popover.menu_model().is_none() { |
||||
return; |
||||
} |
||||
|
||||
popover.set_pointing_to(&gdk::Rectangle { |
||||
x, |
||||
y, |
||||
width: 0, |
||||
height: 0, |
||||
}); |
||||
popover.popup(); |
||||
} |
||||
} |
||||
|
||||
unsafe impl<T: ContextMenuBinImpl> IsSubclassable<T> for ContextMenuBin { |
||||
fn class_init(class: &mut glib::Class<Self>) { |
||||
<glib::Object as IsSubclassable<T>>::class_init(class); |
||||
} |
||||
fn instance_init(instance: &mut glib::subclass::InitializingObject<T>) { |
||||
<glib::Object as IsSubclassable<T>>::instance_init(instance); |
||||
} |
||||
} |
||||
|
||||
pub trait ContextMenuBinImpl: BinImpl {} |
||||
@ -0,0 +1,3 @@
|
||||
mod context_menu_bin; |
||||
|
||||
pub use self::context_menu_bin::{ContextMenuBin, ContextMenuBinImpl}; |
||||
@ -0,0 +1,92 @@
|
||||
use adw::subclass::prelude::*; |
||||
use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate}; |
||||
|
||||
mod imp { |
||||
use super::*; |
||||
use glib::subclass::InitializingObject; |
||||
|
||||
#[derive(Debug, Default, CompositeTemplate)] |
||||
#[template(resource = "/org/gnome/FractalNext/content-divider-row.ui")] |
||||
pub struct DividerRow { |
||||
#[template_child] |
||||
pub label: TemplateChild<gtk::Label>, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for DividerRow { |
||||
const NAME: &'static str = "ContentDividerRow"; |
||||
type Type = super::DividerRow; |
||||
type ParentType = adw::Bin; |
||||
|
||||
fn class_init(klass: &mut Self::Class) { |
||||
Self::bind_template(klass); |
||||
} |
||||
|
||||
fn instance_init(obj: &InitializingObject<Self>) { |
||||
obj.init_template(); |
||||
} |
||||
} |
||||
|
||||
impl ObjectImpl for DividerRow { |
||||
fn properties() -> &'static [glib::ParamSpec] { |
||||
use once_cell::sync::Lazy; |
||||
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { |
||||
vec![glib::ParamSpec::new_string( |
||||
"label", |
||||
"Label", |
||||
"The label for this divider", |
||||
None, |
||||
glib::ParamFlags::READWRITE, |
||||
)] |
||||
}); |
||||
|
||||
PROPERTIES.as_ref() |
||||
} |
||||
|
||||
fn set_property( |
||||
&self, |
||||
obj: &Self::Type, |
||||
_id: usize, |
||||
value: &glib::Value, |
||||
pspec: &glib::ParamSpec, |
||||
) { |
||||
match pspec.name() { |
||||
"label" => { |
||||
let label = value.get().unwrap(); |
||||
obj.set_label(label); |
||||
} |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
|
||||
fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { |
||||
match pspec.name() { |
||||
"label" => obj.label().to_value(), |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
} |
||||
impl WidgetImpl for DividerRow {} |
||||
impl BinImpl for DividerRow {} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
pub struct DividerRow(ObjectSubclass<imp::DividerRow>) |
||||
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible; |
||||
} |
||||
|
||||
impl DividerRow { |
||||
pub fn new(label: String) -> Self { |
||||
glib::Object::new(&[("label", &label)]).expect("Failed to create DividerRow") |
||||
} |
||||
|
||||
pub fn set_label(&self, label: &str) { |
||||
let priv_ = imp::DividerRow::from_instance(self); |
||||
priv_.label.set_text(label); |
||||
} |
||||
|
||||
pub fn label(&self) -> String { |
||||
let priv_ = imp::DividerRow::from_instance(self); |
||||
priv_.label.text().as_str().to_owned() |
||||
} |
||||
} |
||||
@ -0,0 +1,180 @@
|
||||
use adw::{prelude::*, subclass::prelude::*}; |
||||
use chrono::{offset::Local, Datelike}; |
||||
use gettextrs::gettext; |
||||
use gtk::{glib, prelude::*, subclass::prelude::*}; |
||||
|
||||
use crate::components::{ContextMenuBin, ContextMenuBinImpl}; |
||||
use crate::session::content::{DividerRow, MessageRow, StateRow}; |
||||
use crate::session::room::{Item, ItemType}; |
||||
use matrix_sdk::events::AnyRoomEvent; |
||||
|
||||
mod imp { |
||||
use super::*; |
||||
use std::cell::RefCell; |
||||
|
||||
#[derive(Debug, Default)] |
||||
pub struct ItemRow { |
||||
pub item: RefCell<Option<Item>>, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for ItemRow { |
||||
const NAME: &'static str = "ContentItemRow"; |
||||
type Type = super::ItemRow; |
||||
type ParentType = ContextMenuBin; |
||||
} |
||||
|
||||
impl ObjectImpl for ItemRow { |
||||
fn properties() -> &'static [glib::ParamSpec] { |
||||
use once_cell::sync::Lazy; |
||||
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { |
||||
vec![glib::ParamSpec::new_object( |
||||
"item", |
||||
"item", |
||||
"The item represented by this row", |
||||
Item::static_type(), |
||||
glib::ParamFlags::READWRITE, |
||||
)] |
||||
}); |
||||
|
||||
PROPERTIES.as_ref() |
||||
} |
||||
|
||||
fn set_property( |
||||
&self, |
||||
obj: &Self::Type, |
||||
_id: usize, |
||||
value: &glib::Value, |
||||
pspec: &glib::ParamSpec, |
||||
) { |
||||
match pspec.name() { |
||||
"item" => { |
||||
let item = value.get::<Option<Item>>().unwrap(); |
||||
obj.set_item(item); |
||||
} |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
|
||||
fn property(&self, _obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { |
||||
match pspec.name() { |
||||
"item" => self.item.borrow().to_value(), |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
|
||||
fn constructed(&self, obj: &Self::Type) { |
||||
self.parent_constructed(obj); |
||||
} |
||||
} |
||||
|
||||
impl WidgetImpl for ItemRow {} |
||||
impl BinImpl for ItemRow {} |
||||
impl ContextMenuBinImpl for ItemRow {} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
pub struct ItemRow(ObjectSubclass<imp::ItemRow>) |
||||
@extends gtk::Widget, ContextMenuBin, adw::Bin, @implements gtk::Accessible; |
||||
} |
||||
|
||||
// TODO:
|
||||
// - [ ] Add context menu for operations
|
||||
// - [ ] Don't show rows for items that don't have a visible UI
|
||||
impl ItemRow { |
||||
pub fn new() -> Self { |
||||
glib::Object::new(&[]).expect("Failed to create ItemRow") |
||||
} |
||||
|
||||
/// This method sets this row to a new `Item`.
|
||||
///
|
||||
/// It tries to reuse the widget and only update the content whenever possible, but it will
|
||||
/// create a new widget and drop the old one if it has to.
|
||||
fn set_item(&self, item: Option<Item>) { |
||||
let priv_ = imp::ItemRow::from_instance(&self); |
||||
|
||||
if let Some(ref item) = item { |
||||
match item.type_() { |
||||
ItemType::Event(event) => match event.matrix_event() { |
||||
AnyRoomEvent::Message(_message) => { |
||||
let child = if let Some(Ok(child)) = |
||||
self.child().map(|w| w.downcast::<MessageRow>()) |
||||
{ |
||||
child |
||||
} else { |
||||
let child = MessageRow::new(); |
||||
self.set_child(Some(&child)); |
||||
child |
||||
}; |
||||
child.set_event(event.clone()); |
||||
} |
||||
AnyRoomEvent::State(state) => { |
||||
let child = if let Some(Ok(child)) = |
||||
self.child().map(|w| w.downcast::<StateRow>()) |
||||
{ |
||||
child |
||||
} else { |
||||
let child = StateRow::new(); |
||||
self.set_child(Some(&child)); |
||||
child |
||||
}; |
||||
|
||||
child.update(&state); |
||||
} |
||||
AnyRoomEvent::RedactedMessage(_) => { |
||||
let child = if let Some(Ok(child)) = |
||||
self.child().map(|w| w.downcast::<MessageRow>()) |
||||
{ |
||||
child |
||||
} else { |
||||
let child = MessageRow::new(); |
||||
self.set_child(Some(&child)); |
||||
child |
||||
}; |
||||
child.set_event(event.clone()); |
||||
} |
||||
AnyRoomEvent::RedactedState(_) => { |
||||
let child = if let Some(Ok(child)) = |
||||
self.child().map(|w| w.downcast::<MessageRow>()) |
||||
{ |
||||
child |
||||
} else { |
||||
let child = MessageRow::new(); |
||||
self.set_child(Some(&child)); |
||||
child |
||||
}; |
||||
child.set_event(event.clone()); |
||||
} |
||||
}, |
||||
ItemType::DayDivider(date) => { |
||||
let fmt = if date.year() == Local::today().year() { |
||||
// Translators: This is a date format in the day divider without the year
|
||||
gettext("%A, %B %e") |
||||
} else { |
||||
// Translators: This is a date format in the day divider with the year
|
||||
gettext("%A, %B %e, %Y") |
||||
}; |
||||
let date = date.format(&fmt).to_string(); |
||||
|
||||
if let Some(Ok(child)) = self.child().map(|w| w.downcast::<DividerRow>()) { |
||||
child.set_label(&date); |
||||
} else { |
||||
let child = DividerRow::new(date); |
||||
self.set_child(Some(&child)); |
||||
}; |
||||
} |
||||
ItemType::NewMessageDivider => { |
||||
let label = gettext("New Messages"); |
||||
|
||||
if let Some(Ok(child)) = self.child().map(|w| w.downcast::<DividerRow>()) { |
||||
child.set_label(&label); |
||||
} else { |
||||
let child = DividerRow::new(label); |
||||
self.set_child(Some(&child)); |
||||
}; |
||||
} |
||||
} |
||||
} |
||||
priv_.item.replace(item); |
||||
} |
||||
} |
||||
@ -0,0 +1,416 @@
|
||||
use adw::{prelude::*, subclass::prelude::*}; |
||||
use gtk::{ |
||||
glib, glib::clone, glib::signal::SignalHandlerId, prelude::*, subclass::prelude::*, |
||||
CompositeTemplate, |
||||
}; |
||||
use html2pango::{ |
||||
block::{markup_html, HtmlBlock}, |
||||
html_escape, markup_links, |
||||
}; |
||||
use log::warn; |
||||
use matrix_sdk::events::{ |
||||
room::message::MessageFormat, |
||||
room::message::{FormattedBody, MessageType}, |
||||
room::redaction::RedactionEventContent, |
||||
AnyMessageEvent, AnyMessageEventContent, AnyRoomEvent, |
||||
}; |
||||
|
||||
use crate::session::room::Event; |
||||
|
||||
mod imp { |
||||
use super::*; |
||||
use glib::subclass::InitializingObject; |
||||
use once_cell::sync::Lazy; |
||||
use std::cell::RefCell; |
||||
|
||||
#[derive(Debug, Default, CompositeTemplate)] |
||||
#[template(resource = "/org/gnome/FractalNext/content-message-row.ui")] |
||||
pub struct MessageRow { |
||||
#[template_child] |
||||
pub avatar: TemplateChild<adw::Avatar>, |
||||
#[template_child] |
||||
pub header: TemplateChild<gtk::Box>, |
||||
#[template_child] |
||||
pub display_name: TemplateChild<gtk::Label>, |
||||
#[template_child] |
||||
pub timestamp: TemplateChild<gtk::Label>, |
||||
#[template_child] |
||||
pub content: TemplateChild<adw::Bin>, |
||||
pub relates_to_changed_handler: RefCell<Option<SignalHandlerId>>, |
||||
pub bindings: RefCell<Vec<glib::Binding>>, |
||||
pub event: RefCell<Option<Event>>, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for MessageRow { |
||||
const NAME: &'static str = "ContentMessageRow"; |
||||
type Type = super::MessageRow; |
||||
type ParentType = adw::Bin; |
||||
|
||||
fn class_init(klass: &mut Self::Class) { |
||||
Self::bind_template(klass); |
||||
} |
||||
|
||||
fn instance_init(obj: &InitializingObject<Self>) { |
||||
obj.init_template(); |
||||
} |
||||
} |
||||
|
||||
impl ObjectImpl for MessageRow { |
||||
fn properties() -> &'static [glib::ParamSpec] { |
||||
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { |
||||
vec![ |
||||
glib::ParamSpec::new_boolean( |
||||
"show-header", |
||||
"Show Header", |
||||
"Whether this item should show a header or not. This does do nothing if this event doesn't have a header. ", |
||||
false, |
||||
glib::ParamFlags::READWRITE, |
||||
), |
||||
] |
||||
}); |
||||
|
||||
PROPERTIES.as_ref() |
||||
} |
||||
|
||||
fn set_property( |
||||
&self, |
||||
obj: &Self::Type, |
||||
_id: usize, |
||||
value: &glib::Value, |
||||
pspec: &glib::ParamSpec, |
||||
) { |
||||
match pspec.name() { |
||||
"show-header" => { |
||||
let show_header = value.get().unwrap(); |
||||
let _ = obj.set_show_header(show_header); |
||||
} |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
|
||||
fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { |
||||
match pspec.name() { |
||||
"show-header" => obj.show_header().to_value(), |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
} |
||||
impl WidgetImpl for MessageRow {} |
||||
impl BinImpl for MessageRow {} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
pub struct MessageRow(ObjectSubclass<imp::MessageRow>) |
||||
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible; |
||||
} |
||||
|
||||
//TODO
|
||||
// - [] Implement widgets to show message events
|
||||
impl MessageRow { |
||||
pub fn new() -> Self { |
||||
glib::Object::new(&[]).expect("Failed to create MessageRow") |
||||
} |
||||
|
||||
pub fn show_header(&self) -> bool { |
||||
let priv_ = imp::MessageRow::from_instance(self); |
||||
priv_.avatar.is_visible() && priv_.header.is_visible() |
||||
} |
||||
|
||||
pub fn set_show_header(&self, visible: bool) { |
||||
let priv_ = imp::MessageRow::from_instance(self); |
||||
priv_.avatar.set_visible(visible); |
||||
priv_.header.set_visible(visible); |
||||
self.notify("show-header"); |
||||
} |
||||
|
||||
pub fn set_event(&self, event: Event) { |
||||
let priv_ = imp::MessageRow::from_instance(self); |
||||
// Remove signals and bindings from the previous event
|
||||
if let Some(event) = priv_.event.take() { |
||||
if let Some(relates_to_changed_handler) = priv_.relates_to_changed_handler.take() { |
||||
event.disconnect(relates_to_changed_handler); |
||||
} |
||||
|
||||
while let Some(binding) = priv_.bindings.borrow_mut().pop() { |
||||
binding.unbind(); |
||||
} |
||||
} |
||||
|
||||
//TODO: bind the user's avatar to the message row
|
||||
let display_name_binding = event |
||||
.sender() |
||||
.bind_property("display-name", &priv_.display_name.get(), "label") |
||||
.flags(glib::BindingFlags::SYNC_CREATE) |
||||
.build() |
||||
.unwrap(); |
||||
|
||||
let show_header_binding = event |
||||
.bind_property("show-header", self, "show-header") |
||||
.flags(glib::BindingFlags::SYNC_CREATE) |
||||
.build() |
||||
.unwrap(); |
||||
|
||||
priv_ |
||||
.bindings |
||||
.borrow_mut() |
||||
.append(&mut vec![display_name_binding, show_header_binding]); |
||||
|
||||
priv_ |
||||
.relates_to_changed_handler |
||||
.replace(Some(event.connect_relates_to_changed( |
||||
clone!(@weak self as obj => move |event| { |
||||
obj.update_content(&event); |
||||
}), |
||||
))); |
||||
self.update_content(&event); |
||||
priv_.event.replace(Some(event)); |
||||
} |
||||
|
||||
fn find_last_event(&self, event: &Event) -> Event { |
||||
if let Some(replacement_event) = event.relates_to().iter().rev().find(|event| { |
||||
let matrix_event = event.matrix_event(); |
||||
match matrix_event { |
||||
AnyRoomEvent::Message(AnyMessageEvent::RoomMessage(message)) => { |
||||
message.content.new_content.is_some() |
||||
} |
||||
AnyRoomEvent::Message(AnyMessageEvent::RoomRedaction(_)) => true, |
||||
_ => false, |
||||
} |
||||
}) { |
||||
if !replacement_event.relates_to().is_empty() { |
||||
self.find_last_event(replacement_event) |
||||
} else { |
||||
replacement_event.clone() |
||||
} |
||||
} else { |
||||
event.clone() |
||||
} |
||||
} |
||||
/// Find the content we need to display
|
||||
fn find_content(&self, event: &Event) -> AnyMessageEventContent { |
||||
match self.find_last_event(event).matrix_event() { |
||||
AnyRoomEvent::Message(message) => message.content(), |
||||
AnyRoomEvent::RedactedMessage(message) => { |
||||
if let Some(ref redaction_event) = message.unsigned().redacted_because { |
||||
AnyMessageEvent::RoomRedaction(*redaction_event.clone()).content() |
||||
} else { |
||||
AnyMessageEventContent::RoomRedaction(RedactionEventContent { reason: None }) |
||||
} |
||||
} |
||||
AnyRoomEvent::RedactedState(state) => { |
||||
if let Some(ref redaction_event) = state.unsigned().redacted_because { |
||||
AnyMessageEvent::RoomRedaction(*redaction_event.clone()).content() |
||||
} else { |
||||
AnyMessageEventContent::RoomRedaction(RedactionEventContent { reason: None }) |
||||
} |
||||
} |
||||
_ => panic!("This event isn't a room message event or redacted event"), |
||||
} |
||||
} |
||||
|
||||
fn update_content(&self, event: &Event) { |
||||
let priv_ = imp::MessageRow::from_instance(self); |
||||
let content = self.find_content(event); |
||||
|
||||
// TODO: create widgets for all event types
|
||||
// TODO: display reaction events from event.relates_to()
|
||||
match content { |
||||
AnyMessageEventContent::RoomMessage(message) => { |
||||
let msgtype = if let Some(new_message) = message.new_content { |
||||
new_message.msgtype |
||||
} else { |
||||
message.msgtype |
||||
}; |
||||
match msgtype { |
||||
MessageType::Audio(_message) => {} |
||||
MessageType::Emote(message) => { |
||||
let text = if let Some(formatted) = message |
||||
.formatted |
||||
.filter(|m| m.format == MessageFormat::Html) |
||||
{ |
||||
markup_links(&html_escape(&formatted.body)) |
||||
} else { |
||||
message.body |
||||
}; |
||||
// TODO we need to bind the display name to the sender
|
||||
self.show_label_with_markup(&format!( |
||||
"<b>{}</b> {}", |
||||
event.sender().display_name(), |
||||
text |
||||
)); |
||||
} |
||||
MessageType::File(_message) => {} |
||||
MessageType::Image(_message) => {} |
||||
MessageType::Location(_message) => {} |
||||
MessageType::Notice(message) => { |
||||
// TODO: we should reuse the already present child widgets when possible
|
||||
let child = if let Some(html_blocks) = |
||||
parse_formatted_body(message.formatted.as_ref()) |
||||
{ |
||||
create_widget_for_html_message(html_blocks) |
||||
} else { |
||||
let child = gtk::Label::new(Some(&message.body)); |
||||
set_label_styles(&child); |
||||
child.upcast::<gtk::Widget>() |
||||
}; |
||||
|
||||
priv_.content.set_child(Some(&child)); |
||||
} |
||||
MessageType::ServerNotice(message) => { |
||||
self.show_label_with_text(&message.body); |
||||
} |
||||
MessageType::Text(message) => { |
||||
// TODO: we should reuse the already present child widgets when possible
|
||||
let child = if let Some(html_blocks) = |
||||
parse_formatted_body(message.formatted.as_ref()) |
||||
{ |
||||
create_widget_for_html_message(html_blocks) |
||||
} else { |
||||
let child = gtk::Label::new(Some(&message.body)); |
||||
set_label_styles(&child); |
||||
child.upcast::<gtk::Widget>() |
||||
}; |
||||
|
||||
priv_.content.set_child(Some(&child)); |
||||
} |
||||
MessageType::Video(_message) => {} |
||||
MessageType::VerificationRequest(_message) => {} |
||||
_ => { |
||||
warn!("Event not supported: {:?}", msgtype) |
||||
} |
||||
} |
||||
} |
||||
AnyMessageEventContent::RoomRedaction(_) => { |
||||
self.show_label_with_text("This message was removed."); |
||||
} |
||||
_ => warn!("Event not supported: {:?}", content), |
||||
} |
||||
} |
||||
|
||||
fn show_label_with_text(&self, text: &str) { |
||||
let priv_ = imp::MessageRow::from_instance(self); |
||||
if let Some(Ok(child)) = priv_.content.child().map(|w| w.downcast::<gtk::Label>()) { |
||||
child.set_text(&text); |
||||
} else { |
||||
let child = gtk::Label::new(Some(&text)); |
||||
set_label_styles(&child); |
||||
priv_.content.set_child(Some(&child)); |
||||
} |
||||
} |
||||
|
||||
fn show_label_with_markup(&self, text: &str) { |
||||
let priv_ = imp::MessageRow::from_instance(self); |
||||
if let Some(Ok(child)) = priv_.content.child().map(|w| w.downcast::<gtk::Label>()) { |
||||
child.set_markup(&text); |
||||
} else { |
||||
let child = gtk::Label::new(None); |
||||
child.set_markup(&text); |
||||
set_label_styles(&child); |
||||
priv_.content.set_child(Some(&child)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
fn parse_formatted_body(formatted: Option<&FormattedBody>) -> Option<Vec<HtmlBlock>> { |
||||
formatted |
||||
.filter(|m| m.format == MessageFormat::Html) |
||||
.filter(|formatted| !formatted.body.contains("<!-- raw HTML omitted -->")) |
||||
.and_then(|formatted| markup_html(&formatted.body).ok()) |
||||
} |
||||
|
||||
fn create_widget_for_html_message(blocks: Vec<HtmlBlock>) -> gtk::Widget { |
||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 6); |
||||
for block in blocks { |
||||
let widget = create_widget_for_html_block(&block); |
||||
container.append(&widget); |
||||
} |
||||
container.upcast::<gtk::Widget>() |
||||
} |
||||
|
||||
fn set_label_styles(w: >k::Label) { |
||||
w.set_wrap(true); |
||||
w.set_justify(gtk::Justification::Left); |
||||
w.set_xalign(0.0); |
||||
w.set_valign(gtk::Align::Start); |
||||
w.set_halign(gtk::Align::Fill); |
||||
w.set_selectable(true); |
||||
} |
||||
|
||||
fn create_widget_for_html_block(block: &HtmlBlock) -> gtk::Widget { |
||||
match block { |
||||
HtmlBlock::Heading(n, s) => { |
||||
let w = gtk::Label::new(None); |
||||
set_label_styles(&w); |
||||
w.set_markup(&s); |
||||
w.add_css_class(&format!("h{}", n)); |
||||
w.upcast::<gtk::Widget>() |
||||
} |
||||
HtmlBlock::UList(elements) => { |
||||
let bx = gtk::Box::new(gtk::Orientation::Vertical, 6); |
||||
bx.set_margin_end(6); |
||||
bx.set_margin_start(6); |
||||
|
||||
for li in elements.iter() { |
||||
let h_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); |
||||
let bullet = gtk::Label::new(Some("•")); |
||||
bullet.set_valign(gtk::Align::Start); |
||||
let w = gtk::Label::new(None); |
||||
set_label_styles(&w); |
||||
h_box.append(&bullet); |
||||
h_box.append(&w); |
||||
w.set_markup(&li); |
||||
bx.append(&h_box); |
||||
} |
||||
|
||||
bx.upcast::<gtk::Widget>() |
||||
} |
||||
HtmlBlock::OList(elements) => { |
||||
let bx = gtk::Box::new(gtk::Orientation::Vertical, 6); |
||||
bx.set_margin_end(6); |
||||
bx.set_margin_start(6); |
||||
|
||||
for (i, ol) in elements.iter().enumerate() { |
||||
let h_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); |
||||
let bullet = gtk::Label::new(Some(&format!("{}.", i + 1))); |
||||
bullet.set_valign(gtk::Align::Start); |
||||
let w = gtk::Label::new(None); |
||||
set_label_styles(&w); |
||||
h_box.append(&bullet); |
||||
h_box.append(&w); |
||||
w.set_markup(&ol); |
||||
bx.append(&h_box); |
||||
} |
||||
|
||||
bx.upcast::<gtk::Widget>() |
||||
} |
||||
HtmlBlock::Code(s) => { |
||||
use sourceview::BufferExt; |
||||
let scrolled = gtk::ScrolledWindow::new(); |
||||
scrolled.set_policy(gtk::PolicyType::Automatic, gtk::PolicyType::Never); |
||||
let buffer = sourceview::Buffer::new(None); |
||||
buffer.set_highlight_matching_brackets(false); |
||||
buffer.set_text(&s); |
||||
let view = sourceview::View::with_buffer(&buffer); |
||||
view.set_editable(false); |
||||
view.add_css_class("codeview"); |
||||
scrolled.set_child(Some(&view)); |
||||
scrolled.upcast::<gtk::Widget>() |
||||
} |
||||
HtmlBlock::Quote(blocks) => { |
||||
let bx = gtk::Box::new(gtk::Orientation::Vertical, 6); |
||||
bx.add_css_class("quote"); |
||||
for block in blocks.iter() { |
||||
let w = create_widget_for_html_block(block); |
||||
bx.append(&w); |
||||
} |
||||
bx.upcast::<gtk::Widget>() |
||||
} |
||||
HtmlBlock::Text(s) => { |
||||
let w = gtk::Label::new(None); |
||||
set_label_styles(&w); |
||||
w.set_markup(&s); |
||||
w.upcast::<gtk::Widget>() |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
|
||||
mod content; |
||||
mod divider_row; |
||||
mod item_row; |
||||
mod message_row; |
||||
mod state_row; |
||||
|
||||
pub use self::content::Content; |
||||
use self::divider_row::DividerRow; |
||||
use self::item_row::ItemRow; |
||||
use self::message_row::MessageRow; |
||||
use self::state_row::StateRow; |
||||
@ -0,0 +1,80 @@
|
||||
use adw::{prelude::*, subclass::prelude::*}; |
||||
use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate}; |
||||
use matrix_sdk::events::{AnyStateEvent, AnyStateEventContent}; |
||||
|
||||
mod imp { |
||||
use super::*; |
||||
use glib::subclass::InitializingObject; |
||||
|
||||
#[derive(Debug, Default, CompositeTemplate)] |
||||
#[template(resource = "/org/gnome/FractalNext/content-state-row.ui")] |
||||
pub struct StateRow { |
||||
#[template_child] |
||||
pub timestamp: TemplateChild<gtk::Label>, |
||||
#[template_child] |
||||
pub content: TemplateChild<adw::Bin>, |
||||
} |
||||
|
||||
#[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) { |
||||
Self::bind_template(klass); |
||||
} |
||||
|
||||
fn instance_init(obj: &InitializingObject<Self>) { |
||||
obj.init_template(); |
||||
} |
||||
} |
||||
|
||||
impl ObjectImpl for StateRow {} |
||||
impl WidgetImpl for StateRow {} |
||||
impl BinImpl for StateRow {} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
pub struct StateRow(ObjectSubclass<imp::StateRow>) |
||||
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible; |
||||
} |
||||
|
||||
//TODO
|
||||
// - [] Implement widgets to show state events
|
||||
impl StateRow { |
||||
pub fn new() -> Self { |
||||
glib::Object::new(&[]).expect("Failed to create StateRow") |
||||
} |
||||
|
||||
pub fn update(&self, state: &AnyStateEvent) { |
||||
let _priv_ = imp::StateRow::from_instance(self); |
||||
// We may want to show more state events in the future
|
||||
// For a full list of state events see:
|
||||
// https://matrix-org.github.io/matrix-rust-sdk/matrix_sdk/events/enum.AnyStateEventContent.html
|
||||
let message = match state.content() { |
||||
AnyStateEventContent::RoomCreate(_event) => format!("The beginning of this room."), |
||||
AnyStateEventContent::RoomEncryption(_event) => format!("This room is now encrypted."), |
||||
AnyStateEventContent::RoomMember(_event) => { |
||||
// TODO: fully implement this state event
|
||||
format!("A member did change something: state, avatar, name ...") |
||||
} |
||||
AnyStateEventContent::RoomThirdPartyInvite(event) => { |
||||
format!("{} was invited.", event.display_name) |
||||
} |
||||
AnyStateEventContent::RoomTombstone(event) => { |
||||
format!("The room was upgraded: {}", event.body) |
||||
// Todo: add button for new room with acction session.show_room::room_id
|
||||
} |
||||
_ => { |
||||
format!("Unsupported Event: this shouldn't be shown.") |
||||
} |
||||
}; |
||||
if let Some(Ok(child)) = self.child().map(|w| w.downcast::<gtk::Label>()) { |
||||
child.set_text(&message); |
||||
} else { |
||||
let child = gtk::Label::new(Some(&message)); |
||||
self.set_child(Some(&child)); |
||||
}; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue