Browse Source

content: Implement room history

merge-requests/1327/merge
Julian Sparber 5 years ago
parent
commit
16bba4dc44
  1. 6
      data/resources/resources.gresource.xml
  2. 9
      data/resources/style.css
  3. 35
      data/resources/ui/content-divider-row.ui
  4. 102
      data/resources/ui/content-item-row-menu.ui
  5. 18
      data/resources/ui/content-item.ui
  6. 50
      data/resources/ui/content-message-row.ui
  7. 21
      data/resources/ui/content-state-row.ui
  8. 32
      data/resources/ui/content.ui
  9. 18
      data/resources/ui/context-menu-bin.ui
  10. 188
      src/components/context_menu_bin.rs
  11. 3
      src/components/mod.rs
  12. 1
      src/main.rs
  13. 7
      src/meson.build
  14. 75
      src/session/content/content.rs
  15. 92
      src/session/content/divider_row.rs
  16. 180
      src/session/content/item_row.rs
  17. 416
      src/session/content/message_row.rs
  18. 11
      src/session/content/mod.rs
  19. 80
      src/session/content/state_row.rs
  20. 8
      src/session/mod.rs

6
data/resources/resources.gresource.xml

@ -3,14 +3,20 @@
<gresource prefix="/org/gnome/FractalNext/">
<file compressed="true" preprocess="xml-stripblanks" alias="shortcuts.ui">ui/shortcuts.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content.ui">ui/content.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-item.ui">ui/content-item.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-message-row.ui">ui/content-message-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-divider-row.ui">ui/content-divider-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-state-row.ui">ui/content-state-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="login.ui">ui/login.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="session.ui">ui/session.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="sidebar.ui">ui/sidebar.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="sidebar-item.ui">ui/sidebar-item.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="sidebar-room-row.ui">ui/sidebar-room-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="window.ui">ui/window.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="context-menu-bin.ui">ui/context-menu-bin.ui</file>
<file compressed="true">style.css</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/send-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/welcome.svg</file>
</gresource>
</gresources>

9
data/resources/style.css

@ -51,4 +51,11 @@
background-color: @theme_selected_bg_color;
}
/* Content */
.codeview {
border-radius: 5px;
padding: 6px;
font-family: monospace;
background-color: @text_view_bg;
color: @theme_text_color;
}

35
data/resources/ui/content-divider-row.ui

@ -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>

102
data/resources/ui/content-item-row-menu.ui

@ -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>

18
data/resources/ui/content-item.ui

@ -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>

50
data/resources/ui/content-message-row.ui

@ -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>

21
data/resources/ui/content-state-row.ui

@ -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>

32
data/resources/ui/content.ui

@ -23,7 +23,7 @@
</child>
<child>
<object class="GtkSearchBar" id="room_search">
<property name="search-mode-enabled" bind-source="search_content_button" bind-property="active" />
<property name="search-mode-enabled" bind-source="search_content_button" bind-property="active"/>
<property name="child">
<object class="AdwClamp">
<property name="hexpand">True</property>
@ -35,20 +35,37 @@
</object>
</child>
<child>
<object class="AdwClamp">
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="vexpand">True</property>
<property name="hexpand">True</property>
<property name="hscrollbar-policy">never</property>
<style>
<class name="content"/>
</style>
<child>
<object class="GtkListView" id="room_history">
<property name="child">
<object class="AdwClampScrollable">
<property name="vexpand">True</property>
<property name="hexpand">True</property>
<property name="child">
<object class="GtkListView" id="listview">
<style>
<class name="navigation-sidebar"/>
</style>
<property name="factory">
<object class="GtkBuilderListItemFactory">
<property name="resource">/org/gnome/FractalNext/content-item.ui</property>
</object>
</property>
<accessibility>
<property name="label" translatable="yes">Room History</property>
</accessibility>
</object>
</property>
</object>
</child>
</property>
</object>
</child>
<child>
<object class="GtkSeparator" />
<object class="GtkSeparator"/>
</child>
<child>
<object class="AdwClamp">
@ -86,3 +103,4 @@
</child>
</template>
</interface>

18
data/resources/ui/context-menu-bin.ui

@ -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>

188
src/components/context_menu_bin.rs

@ -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 {}

3
src/components/mod.rs

@ -0,0 +1,3 @@
mod context_menu_bin;
pub use self::context_menu_bin::{ContextMenuBin, ContextMenuBinImpl};

1
src/main.rs

@ -6,6 +6,7 @@ mod application;
#[rustfmt::skip]
mod config;
mod components;
mod login;
mod secret;
mod session;

7
src/meson.build

@ -32,7 +32,12 @@ sources = files(
'session/categories/category.rs',
'session/categories/category_type.rs',
'session/categories/mod.rs',
'session/content.rs',
'session/content/content.rs',
'session/content/divider_row.rs',
'session/content/item_row.rs',
'session/content/message_row.rs',
'session/content/mod.rs',
'session/content/state_row.rs',
'session/room/event.rs',
'session/room/highlight_flags.rs',
'session/room/item.rs',

75
src/session/content.rs → src/session/content/content.rs

@ -1,23 +1,26 @@
use adw;
use adw::subclass::prelude::BinImpl;
use gtk::subclass::prelude::*;
use gtk::{self, prelude::*};
use gtk::{glib, glib::SyncSender, CompositeTemplate};
use matrix_sdk::identifiers::RoomId;
use adw::subclass::prelude::*;
use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
use crate::session::{
content::ItemRow,
room::{Room, Timeline},
};
mod imp {
use super::*;
use glib::subclass::InitializingObject;
use std::cell::Cell;
#[derive(Debug, CompositeTemplate)]
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/org/gnome/FractalNext/content.ui")]
pub struct Content {
pub compact: Cell<bool>,
#[template_child]
pub headerbar: TemplateChild<adw::HeaderBar>,
#[template_child]
pub room_history: TemplateChild<gtk::ListView>,
pub listview: TemplateChild<gtk::ListView>,
#[template_child]
pub scrolled_window: TemplateChild<gtk::ScrolledWindow>,
}
#[glib::object_subclass]
@ -26,16 +29,10 @@ mod imp {
type Type = super::Content;
type ParentType = adw::Bin;
fn new() -> Self {
Self {
compact: Cell::new(false),
headerbar: TemplateChild::default(),
room_history: TemplateChild::default(),
}
}
fn class_init(klass: &mut Self::Class) {
ItemRow::static_type();
Self::bind_template(klass);
klass.set_accessible_role(gtk::AccessibleRole::Group);
}
fn instance_init(obj: &InitializingObject<Self>) {
@ -68,9 +65,7 @@ mod imp {
) {
match pspec.name() {
"compact" => {
let compact = value
.get()
.expect("type conformity checked by `Object::set_property`");
let compact = value.get().unwrap();
self.compact.set(compact);
}
_ => unimplemented!(),
@ -83,6 +78,23 @@ mod imp {
_ => unimplemented!(),
}
}
fn constructed(&self, obj: &Self::Type) {
let adj = self.scrolled_window.vadjustment().unwrap();
// TODO: make sure that we have enough messages to fill at least to scroll pages, if the room history is long enough
adj.connect_value_changed(clone!(@weak obj => move |adj| {
// Load more message when the user gets close to the end of the known room history
// Use the page size twice to detect if the user gets close the end
if adj.value() < adj.page_size() * 2.0 {
if let Some(room) = obj.room() {
room.load_previous_events();
}
}
}));
self.parent_constructed(obj);
}
}
impl WidgetImpl for Content {}
@ -99,13 +111,22 @@ impl Content {
glib::Object::new(&[]).expect("Failed to create Content")
}
/// Sets up the required channel to recive async updates from the `Client`
pub fn setup_channel(&self) -> SyncSender<RoomId> {
let (sender, receiver) = glib::MainContext::sync_channel::<RoomId>(Default::default(), 100);
receiver.attach(None, move |_room_id| {
//TODO: actually do something: update the message GListModel
glib::Continue(true)
});
sender
pub fn set_room(&self, room: &Room) {
let priv_ = imp::Content::from_instance(self);
// TODO: use gtk::MultiSelection to allow selection
priv_
.listview
.set_model(Some(&gtk::NoSelection::new(Some(room.timeline()))));
}
fn room(&self) -> Option<Room> {
let priv_ = imp::Content::from_instance(self);
priv_
.listview
.model()
.and_then(|model| model.downcast::<gtk::NoSelection>().ok())
.and_then(|model| model.model())
.and_then(|model| model.downcast::<Timeline>().ok())
.map(|timeline| timeline.room().to_owned())
}
}

92
src/session/content/divider_row.rs

@ -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()
}
}

180
src/session/content/item_row.rs

@ -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);
}
}

416
src/session/content/message_row.rs

@ -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: &gtk::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>()
}
}
}

11
src/session/content/mod.rs

@ -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;

80
src/session/content/state_row.rs

@ -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));
};
}
}

8
src/session/mod.rs

@ -326,9 +326,13 @@ impl Session {
secret::store_session(homeserver, session)
}
// TODO: handle show room
fn handle_show_room_action(&self, room_id: RoomId) {
warn!("TODO: implement room action: {:?}", room_id);
let priv_ = imp::Session::from_instance(self);
if let Some(room) = priv_.rooms.borrow().get(&room_id) {
priv_.content.set_room(room);
} else {
warn!("No room with {} was found", room_id);
}
}
fn handle_sync_reposne(&self, response: SyncResponse) {

Loading…
Cancel
Save