Browse Source

room-details: Allow to view and change room addresses

merge-requests/1461/merge
Kévin Commaille 2 years ago
parent
commit
b5e76a3b3e
No known key found for this signature in database
GPG Key ID: 29A48C1F03620416
  1. 2
      Cargo.toml
  2. 42
      data/resources/style.css
  3. 3
      po/POTFILES.in
  4. 22
      src/components/copyable_row.rs
  5. 3
      src/components/copyable_row.ui
  6. 2
      src/components/mod.rs
  7. 45
      src/components/removable_row.rs
  8. 3
      src/components/removable_row.ui
  9. 458
      src/components/substring_entry_row.rs
  10. 104
      src/components/substring_entry_row.ui
  11. 7
      src/session/model/mod.rs
  12. 522
      src/session/model/room/aliases.rs
  13. 12
      src/session/model/room/mod.rs
  14. 357
      src/session/view/content/room_details/addresses_subpage/completion_popover.rs
  15. 29
      src/session/view/content/room_details/addresses_subpage/completion_popover.ui
  16. 776
      src/session/view/content/room_details/addresses_subpage/mod.rs
  17. 94
      src/session/view/content/room_details/addresses_subpage/mod.ui
  18. 63
      src/session/view/content/room_details/addresses_subpage/public_address.rs
  19. 143
      src/session/view/content/room_details/general_page/mod.rs
  20. 27
      src/session/view/content/room_details/general_page/mod.ui
  21. 6
      src/session/view/content/room_details/mod.rs
  22. 5
      src/session/view/content/room_history/message_toolbar/completion/room_list.rs
  23. 3
      src/ui-resources.gresource.xml

2
Cargo.toml

@ -59,7 +59,7 @@ gst_gtk = { version = "0.12", package = "gst-plugin-gtk4" }
gst_pbutils = { version = "0.22", package = "gstreamer-pbutils" }
gst_play = { version = "0.22", package = "gstreamer-play" }
gst_video = { version = "0.22", package = "gstreamer-video" }
gtk = { package = "gtk4", version = "0.8", features = ["v4_10"] }
gtk = { package = "gtk4", version = "0.8", features = ["gnome_44"] }
shumate = { package = "libshumate", version = "0.5" }
sourceview = { package = "sourceview5", version = "0.8" }

42
data/resources/style.css

@ -99,15 +99,15 @@ button.overlaid {
margin: 3px; /* Make sure the outline is fully visible */
}
.avatar-row-list contents {
.avatar-row-list contents, .string-row-list contents {
padding: 0;
}
.avatar-row-list viewport, .avatar-row-list listview {
.avatar-row-list viewport, .avatar-row-list listview, .string-row-list viewport, .string-row-list listview {
padding: 8px;
}
.avatar-row-list list, .avatar-row-list listview {
.avatar-row-list list, .avatar-row-list listview, .string-row-list list, .string-row-list listview {
background-color: transparent;
}
@ -126,22 +126,33 @@ button.overlaid {
margin-bottom: 0px;
}
.avatar-row-list row:focus {
.string-row-list row {
border-radius: 6px;
margin: 0px;
padding: 6px;
outline-width: 0;
}
.avatar-row-list row:focus, .string-row-list row:focus {
background-color: alpha(currentColor, .07);
}
.avatar-row-list row:hover {
.avatar-row-list row:hover, .string-row-list row:hover {
background-color: alpha(currentColor, .07);
}
.avatar-row-list row:active {
.avatar-row-list row:active, .string-row-list row:active {
background-color: alpha(currentColor, .16);
}
.avatar-row-list row:checked {
.avatar-row-list row:checked, .string-row-list row:checked {
background-color: alpha(currentColor, .1);
}
.entry-row-error-revealer {
margin-top: 6px;
}
/* Components */
@ -218,6 +229,15 @@ spinner-wrapper.large spinner {
min-height: 32px;
}
.substring-entry-row .header .subtitle {
margin-top: 4px;
margin-bottom: -4px;
}
.substring-entry-row .header text placeholder {
opacity: 0.55;
}
/* Login */
@ -864,6 +884,14 @@ dragoverlay statuspage {
color: @accent_fg_color;
}
.public-address-tag {
color: @accent_fg_color;
background-color: @accent_bg_color;
border-radius: 0.4em;
padding: 0.3em 0.5em;
margin-left: 0.5em;
}
/* Account Settings */

3
po/POTFILES.in

@ -71,6 +71,9 @@ src/session/view/content/explore/server_row.ui
src/session/view/content/invite.rs
src/session/view/content/invite.ui
src/session/view/content/mod.ui
src/session/view/content/room_details/addresses_subpage/completion_popover.ui
src/session/view/content/room_details/addresses_subpage/mod.rs
src/session/view/content/room_details/addresses_subpage/mod.ui
src/session/view/content/room_details/general_page/mod.rs
src/session/view/content/room_details/general_page/mod.ui
src/session/view/content/room_details/history_viewer/audio_row.rs

22
src/components/copyable_row.rs

@ -31,6 +31,8 @@ mod imp {
pub struct CopyableRow {
#[template_child]
pub copy_button: TemplateChild<gtk::Button>,
#[template_child]
pub extra_suffix_bin: TemplateChild<adw::Bin>,
/// The tooltip text of the copy button.
#[property(get = Self::copy_button_tooltip_text, set = Self::set_copy_button_tooltip_text, explicit_notify, nullable)]
pub copy_button_tooltip_text: PhantomData<Option<glib::GString>>,
@ -46,6 +48,11 @@ mod imp {
/// CSS class is added.
#[property(get, set = Self::set_main_title, explicit_notify, builder(ActionRowMainTitle::default()))]
pub main_title: Cell<ActionRowMainTitle>,
/// The extra suffix widget of this row.
///
/// The widget is placed before the remove button.
#[property(get = Self::extra_suffix, set = Self::set_extra_suffix, explicit_notify, nullable)]
pub extra_suffix: PhantomData<Option<gtk::Widget>>,
}
#[glib::object_subclass]
@ -128,6 +135,21 @@ mod imp {
self.main_title.set(main_title);
obj.notify_main_title();
}
/// The extra suffix widget of this row.
fn extra_suffix(&self) -> Option<gtk::Widget> {
self.extra_suffix_bin.child()
}
/// Set the extra suffix widget of this row.
fn set_extra_suffix(&self, widget: Option<&gtk::Widget>) {
if self.extra_suffix().as_ref() == widget {
return;
}
self.extra_suffix_bin.set_child(widget);
self.obj().notify_extra_suffix();
}
}
}

3
src/components/copyable_row.ui

@ -2,6 +2,9 @@
<interface>
<template class="CopyableRow" parent="AdwActionRow">
<property name="selectable">False</property>
<child>
<object class="AdwBin" id="extra_suffix_bin"/>
</child>
<child type="suffix">
<object class="GtkBox">
<property name="valign">center</property>

2
src/components/mod.rs

@ -26,6 +26,7 @@ mod room_title;
mod scale_revealer;
mod spinner;
mod spinner_button;
mod substring_entry_row;
mod switch_loading_row;
mod toastable_window;
mod user_page;
@ -62,6 +63,7 @@ pub use self::{
scale_revealer::ScaleRevealer,
spinner::Spinner,
spinner_button::SpinnerButton,
substring_entry_row::SubstringEntryRow,
switch_loading_row::SwitchLoadingRow,
toastable_window::{ToastableWindow, ToastableWindowExt, ToastableWindowImpl},
user_page::UserPage,

45
src/components/removable_row.rs

@ -4,7 +4,7 @@ use gtk::{glib, glib::closure_local, CompositeTemplate};
use super::SpinnerButton;
mod imp {
use std::marker::PhantomData;
use std::{cell::RefCell, marker::PhantomData};
use glib::subclass::{InitializingObject, Signal};
use once_cell::sync::Lazy;
@ -17,12 +17,22 @@ mod imp {
pub struct RemovableRow {
#[template_child]
pub remove_button: TemplateChild<SpinnerButton>,
#[template_child]
pub extra_suffix_bin: TemplateChild<adw::Bin>,
/// The tooltip text of the remove button.
#[property(get = Self::remove_button_tooltip_text, set = Self::set_remove_button_tooltip_text, explicit_notify, nullable)]
pub remove_button_tooltip_text: PhantomData<Option<glib::GString>>,
/// The accessible label of the remove button.
#[property(get, set = Self::set_remove_button_accessible_label, explicit_notify, nullable)]
pub remove_button_accessible_label: RefCell<Option<String>>,
/// Whether this row is loading.
#[property(get = Self::is_loading, set = Self::set_is_loading, explicit_notify)]
pub is_loading: PhantomData<bool>,
/// The extra suffix widget of this row.
///
/// The widget is placed before the remove button.
#[property(get = Self::extra_suffix, set = Self::set_extra_suffix, explicit_notify, nullable)]
pub extra_suffix: PhantomData<Option<gtk::Widget>>,
}
#[glib::object_subclass]
@ -71,6 +81,24 @@ mod imp {
self.obj().notify_remove_button_tooltip_text();
}
/// Set the accessible label of the remove button.
fn set_remove_button_accessible_label(&self, label: Option<String>) {
if *self.remove_button_accessible_label.borrow() == label {
return;
}
if let Some(label) = &label {
self.remove_button
.update_property(&[gtk::accessible::Property::Label(label)]);
} else {
self.remove_button
.reset_property(gtk::AccessibleProperty::Label);
}
self.remove_button_accessible_label.replace(label);
self.obj().notify_remove_button_accessible_label();
}
/// Whether this row is loading.
fn is_loading(&self) -> bool {
self.remove_button.loading()
@ -88,6 +116,21 @@ mod imp {
obj.set_sensitive(!is_loading);
obj.notify_is_loading();
}
/// The extra suffix widget of this row.
fn extra_suffix(&self) -> Option<gtk::Widget> {
self.extra_suffix_bin.child()
}
/// Set the extra suffix widget of this row.
fn set_extra_suffix(&self, widget: Option<&gtk::Widget>) {
if self.extra_suffix().as_ref() == widget {
return;
}
self.extra_suffix_bin.set_child(widget);
self.obj().notify_extra_suffix();
}
}
}

3
src/components/removable_row.ui

@ -2,6 +2,9 @@
<interface>
<template class="RemovableRow" parent="AdwActionRow">
<property name="selectable">False</property>
<child>
<object class="AdwBin" id="extra_suffix_bin"/>
</child>
<child>
<object class="SpinnerButton" id="remove_button">
<property name="content-icon-name">close-symbolic</property>

458
src/components/substring_entry_row.rs

@ -0,0 +1,458 @@
use adw::{prelude::*, subclass::prelude::*};
use gtk::{
glib,
glib::{clone, closure_local},
pango, CompositeTemplate,
};
use super::SpinnerButton;
mod imp {
use std::{
cell::{Cell, RefCell},
marker::PhantomData,
};
use glib::subclass::{InitializingObject, Signal};
use once_cell::sync::Lazy;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(resource = "/org/gnome/Fractal/ui/components/substring_entry_row.ui")]
#[properties(wrapper_type = super::SubstringEntryRow)]
pub struct SubstringEntryRow {
#[template_child]
pub header: TemplateChild<gtk::Box>,
#[template_child]
pub main_content: TemplateChild<gtk::Box>,
#[template_child]
pub entry_box: TemplateChild<gtk::Box>,
#[template_child]
pub text: TemplateChild<gtk::Text>,
#[template_child]
pub title: TemplateChild<gtk::Label>,
#[template_child]
pub edit_icon: TemplateChild<gtk::Image>,
#[template_child]
pub entry_prefix_label: TemplateChild<gtk::Label>,
#[template_child]
pub entry_suffix_label: TemplateChild<gtk::Label>,
#[template_child]
pub add_button: TemplateChild<SpinnerButton>,
/// The input hints of the entry.
#[property(get = Self::input_hints, set = Self::set_input_hints, explicit_notify)]
pub input_hints: PhantomData<gtk::InputHints>,
/// The input purpose of the entry.
#[property(get = Self::input_purpose, set = Self::set_input_purpose, explicit_notify, builder(gtk::InputPurpose::FreeForm))]
pub input_purpose: PhantomData<gtk::InputPurpose>,
/// A list of Pango attributes to apply to the text of the entry.
#[property(get = Self::attributes, set = Self::set_attributes, explicit_notify, nullable)]
pub attributes: PhantomData<Option<pango::AttrList>>,
/// The placeholder text of the entry.
#[property(get = Self::placeholder_text, set = Self::set_placeholder_text, explicit_notify, nullable)]
pub placeholder_text: PhantomData<Option<glib::GString>>,
/// The length of the text of the entry.
#[property(get = Self::text_length)]
pub text_length: PhantomData<u32>,
/// The prefix text of the entry.
#[property(get = Self::prefix_text, set = Self::set_prefix_text, explicit_notify)]
pub prefix_text: PhantomData<glib::GString>,
/// The suffix text of the entry.
#[property(get = Self::suffix_text, set = Self::set_suffix_text, explicit_notify)]
pub suffix_text: PhantomData<glib::GString>,
/// Set the accessible description of the entry.
///
/// If it is not set, the placeholder text will be used.
#[property(get, set = Self::set_accessible_description, explicit_notify, nullable)]
pub accessible_description: RefCell<Option<String>>,
/// The tooltip text of the add button.
#[property(get = Self::add_button_tooltip_text, set = Self::set_add_button_tooltip_text, explicit_notify, nullable)]
pub add_button_tooltip_text: PhantomData<Option<glib::GString>>,
/// The accessible label of the add button.
#[property(get, set = Self::set_add_button_accessible_label, explicit_notify, nullable)]
pub add_button_accessible_label: RefCell<Option<String>>,
/// Whether to prevent the add button from being activated.
#[property(get, set = Self::set_inhibit_add, explicit_notify)]
pub inhibit_add: Cell<bool>,
/// Whether this row is loading.
#[property(get = Self::is_loading, set = Self::set_is_loading, explicit_notify)]
pub is_loading: PhantomData<bool>,
}
#[glib::object_subclass]
impl ObjectSubclass for SubstringEntryRow {
const NAME: &'static str = "SubstringEntryRow";
type Type = super::SubstringEntryRow;
type ParentType = adw::PreferencesRow;
type Interfaces = (gtk::Editable,);
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::Type::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for SubstringEntryRow {
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| vec![Signal::builder("add").build()]);
SIGNALS.as_ref()
}
fn properties() -> &'static [glib::ParamSpec] {
Self::derived_properties()
}
fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
// In case this is a property that's automatically added for Editable
// implementations.
if !self.delegate_set_property(id, value, pspec) {
self.derived_set_property(id, value, pspec)
}
}
fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value {
// In case this is a property that's automatically added for Editable
// implementations.
if let Some(value) = self.delegate_get_property(id, pspec) {
value
} else {
self.derived_property(id, pspec)
}
}
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
obj.init_delegate();
self.text
.buffer()
.connect_length_notify(clone!(@weak obj => move |_| {
obj.notify_text_length();
}));
}
fn dispose(&self) {
self.obj().finish_delegate();
}
}
impl WidgetImpl for SubstringEntryRow {
fn grab_focus(&self) -> bool {
self.text.grab_focus()
}
}
impl ListBoxRowImpl for SubstringEntryRow {}
impl PreferencesRowImpl for SubstringEntryRow {}
impl EditableImpl for SubstringEntryRow {
fn delegate(&self) -> Option<gtk::Editable> {
Some(self.text.clone().upcast())
}
}
impl SubstringEntryRow {
/// The input hints of the entry.
fn input_hints(&self) -> gtk::InputHints {
self.text.input_hints()
}
/// Set the input hints of the entry.
fn set_input_hints(&self, input_hints: gtk::InputHints) {
if self.input_hints() == input_hints {
return;
}
self.text.set_input_hints(input_hints);
self.obj().notify_input_hints();
}
/// The input purpose of the entry.
fn input_purpose(&self) -> gtk::InputPurpose {
self.text.input_purpose()
}
/// Set the input purpose of the entry.
fn set_input_purpose(&self, input_purpose: gtk::InputPurpose) {
if self.input_purpose() == input_purpose {
return;
}
self.text.set_input_purpose(input_purpose);
self.obj().notify_input_purpose();
}
/// A list of Pango attributes to apply to the text of the entry.
fn attributes(&self) -> Option<pango::AttrList> {
self.text.attributes()
}
/// Set the list of Pango attributes to apply to the text of the entry.
fn set_attributes(&self, attributes: Option<&pango::AttrList>) {
if self.attributes().as_ref() == attributes {
return;
}
self.text.set_attributes(attributes);
self.obj().notify_attributes();
}
/// The placeholder text of the entry.
fn placeholder_text(&self) -> Option<glib::GString> {
self.text.placeholder_text()
}
/// Set the placeholder text of the entry.
fn set_placeholder_text(&self, text: Option<glib::GString>) {
if self.placeholder_text() == text {
return;
}
self.text.set_placeholder_text(text.as_deref());
self.update_accessible_description();
self.obj().notify_placeholder_text();
}
/// The length of the text of the entry.
fn text_length(&self) -> u32 {
self.text.text_length().into()
}
/// The prefix text of the entry.
fn prefix_text(&self) -> glib::GString {
self.entry_prefix_label.label()
}
/// Set the prefix text of the entry.
fn set_prefix_text(&self, text: glib::GString) {
if self.prefix_text() == text {
return;
}
self.entry_prefix_label.set_label(&text);
self.obj().notify_prefix_text();
}
/// The suffix text of the entry.
fn suffix_text(&self) -> glib::GString {
self.entry_suffix_label.label()
}
/// Set the suffix text of the entry.
fn set_suffix_text(&self, text: glib::GString) {
if self.suffix_text() == text {
return;
}
self.entry_suffix_label.set_label(&text);
self.obj().notify_suffix_text();
}
/// Set the accessible description of the entry.
fn set_accessible_description(&self, description: Option<String>) {
if *self.accessible_description.borrow() == description {
return;
}
self.accessible_description.replace(description);
self.update_accessible_description();
self.obj().notify_accessible_description();
}
/// The tooltip text of the add button.
fn add_button_tooltip_text(&self) -> Option<glib::GString> {
self.add_button.tooltip_text()
}
/// Set the tooltip text of the add button.
fn set_add_button_tooltip_text(&self, tooltip_text: Option<glib::GString>) {
if self.add_button_tooltip_text() == tooltip_text {
return;
}
self.add_button.set_tooltip_text(tooltip_text.as_deref());
self.obj().notify_add_button_tooltip_text();
}
/// Set the accessible label of the add button.
fn set_add_button_accessible_label(&self, label: Option<String>) {
if *self.add_button_accessible_label.borrow() == label {
return;
}
if let Some(label) = &label {
self.add_button
.update_property(&[gtk::accessible::Property::Label(label)]);
} else {
self.add_button
.reset_property(gtk::AccessibleProperty::Label);
}
self.add_button_accessible_label.replace(label);
self.obj().notify_add_button_accessible_label();
}
/// Set whether to prevent the add button from being activated.
fn set_inhibit_add(&self, inhibit: bool) {
if self.inhibit_add.get() == inhibit {
return;
}
self.inhibit_add.set(inhibit);
let obj = self.obj();
obj.update_add_button();
obj.notify_inhibit_add();
}
/// Whether this row is loading.
fn is_loading(&self) -> bool {
self.add_button.loading()
}
/// Set whether this row is loading.
fn set_is_loading(&self, is_loading: bool) {
if self.is_loading() == is_loading {
return;
}
self.add_button.set_loading(is_loading);
let obj = self.obj();
obj.set_sensitive(!is_loading);
obj.notify_is_loading();
}
/// Update the accessible_description.
fn update_accessible_description(&self) {
let description = self
.accessible_description
.borrow()
.clone()
.or(self.placeholder_text().map(Into::into));
if let Some(description) = description {
self.text
.update_property(&[gtk::accessible::Property::Description(&description)]);
} else {
self.text
.reset_property(gtk::AccessibleProperty::Description);
}
}
}
}
glib::wrapper! {
/// A `AdwPreferencesRow` with an embedded text entry, and a fixed text suffix and prefix.
///
/// It also has a built-in add button, making it an almost drop-in replacement to `AddEntryRow`.
///
/// Inspired from `AdwEntryRow`.
pub struct SubstringEntryRow(ObjectSubclass<imp::SubstringEntryRow>)
@extends gtk::Widget, gtk::ListBoxRow, adw::PreferencesRow,
@implements gtk::Editable, gtk::Accessible;
}
#[gtk::template_callbacks]
impl SubstringEntryRow {
pub fn new() -> Self {
glib::Object::new()
}
/// Whether the GtkText is focused.
fn is_text_focused(&self) -> bool {
let flags = self.imp().text.state_flags();
flags.contains(gtk::StateFlags::FOCUS_WITHIN)
}
/// Update this row when the GtkText flags changed.
#[template_callback]
fn text_state_flags_changed_cb(&self) {
let editing = self.is_text_focused();
if editing {
self.add_css_class("focused");
} else {
self.remove_css_class("focused");
}
self.imp().edit_icon.set_visible(!editing);
}
/// Handle when the key navigation in the GtkText failed.
#[template_callback]
fn text_keynav_failed_cb(&self, direction: gtk::DirectionType) -> bool {
if matches!(
direction,
gtk::DirectionType::Left | gtk::DirectionType::Right
) {
return self.child_focus(direction);
}
// gdk::EVENT_PROPAGATE == 0;
false
}
/// Handle when this row is pressed.
#[template_callback]
fn pressed_cb(&self, _n_press: i32, x: f64, y: f64, gesture: &gtk::Gesture) {
let imp = self.imp();
let picked = self.pick(x, y, gtk::PickFlags::DEFAULT);
if picked.is_some_and(|w| {
&w != self.upcast_ref::<gtk::Widget>()
|| &w != imp.header.upcast_ref::<gtk::Widget>()
|| &w != imp.main_content.upcast_ref::<gtk::Widget>()
|| &w != imp.entry_box.upcast_ref::<gtk::Widget>()
}) {
gesture.set_state(gtk::EventSequenceState::Denied);
return;
}
imp.text.grab_focus_without_selecting();
gesture.set_state(gtk::EventSequenceState::Claimed);
}
/// Whether we can activate the add button.
fn can_add(&self) -> bool {
!self.inhibit_add() && !self.text().is_empty()
}
/// Update the state of the add button.
#[template_callback]
fn update_add_button(&self) {
self.imp().add_button.set_sensitive(self.can_add());
}
/// Emit the `add` signal.
#[template_callback]
fn add(&self) {
if !self.can_add() {
return;
}
self.emit_by_name::<()>("add", &[]);
}
/// Connect to the `add` signal.
pub fn connect_add<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.connect_closure(
"add",
true,
closure_local!(move |obj: Self| {
f(&obj);
}),
)
}
}

104
src/components/substring_entry_row.ui

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="SubstringEntryRow" parent="AdwPreferencesRow">
<property name="activatable">False</property>
<property name="selectable">False</property>
<style>
<class name="substring-entry-row"/>
</style>
<child>
<object class="GtkGestureClick">
<signal name="pressed" handler="pressed_cb" swapped="yes"/>
</object>
</child>
<property name="child">
<object class="GtkBox" id="header">
<property name="valign">center</property>
<style>
<class name="header"/>
</style>
<child>
<object class="GtkBox" id="main_content">
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel" id="title">
<property name="ellipsize">end</property>
<property name="halign">start</property>
<property name="xalign">0</property>
<property name="label" bind-source="SubstringEntryRow" bind-property="title" bind-flags="sync-create"/>
<property name="can-target">False</property>
<style>
<class name="subtitle"/>
</style>
</object>
</child>
<child>
<object class="GtkBox" id="entry_box">
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="entry_prefix_label">
<property name="label">#</property>
<style>
<class name="dim-label"/>
</style>
</object>
</child>
<child>
<object class="GtkText" id="text">
<property name="enable-undo">True</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="max-length">0</property>
<property name="valign">baseline-fill</property>
<property name="accessible-role">text-box</property>
<accessibility>
<relation name="labelled-by">title</relation>
</accessibility>
<signal name="activate" handler="add" swapped="yes"/>
<signal name="state-flags-changed" handler="text_state_flags_changed_cb" swapped="yes"/>
<signal name="keynav-failed" handler="text_keynav_failed_cb" swapped="yes"/>
<signal name="changed" handler="update_add_button" swapped="yes"/>
</object>
</child>
<child>
<object class="GtkImage" id="edit_icon">
<property name="valign">center</property>
<property name="can-target">False</property>
<property name="icon-name">document-edit-symbolic</property>
<property name="accessible-role">presentation</property>
<style>
<class name="edit-icon"/>
</style>
</object>
</child>
<child>
<object class="GtkLabel" id="entry_suffix_label">
<property name="label">:matrix.org</property>
<style>
<class name="dim-label"/>
</style>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="SpinnerButton" id="add_button">
<property name="sensitive">False</property>
<property name="content-icon-name">add-symbolic</property>
<property name="valign">center</property>
<property name="halign">center</property>
<signal name="clicked" handler="add" swapped="true"/>
<style>
<class name="flat"/>
</style>
</object>
</child>
</object>
</property>
<style>
<class name="entry"/>
</style>
</template>
</interface>

7
src/session/model/mod.rs

@ -18,12 +18,7 @@ pub use self::{
},
remote_room::RemoteRoom,
remote_user::RemoteUser,
room::{
content_can_show_header, Event, EventKey, HighlightFlags, Member, MemberList, MemberRole,
Membership, MessageState, PowerLevel, ReactionGroup, ReactionList, Room, RoomType,
Timeline, TimelineItem, TimelineItemExt, TimelineState, TypingList, UserReadReceipt,
VirtualItem, VirtualItemKind, POWER_LEVEL_MAX, POWER_LEVEL_MIN,
},
room::*,
room_list::RoomList,
session::{Session, SessionState},
session_settings::{SessionSettings, StoredSessionSettings},

522
src/session/model/room/aliases.rs

@ -0,0 +1,522 @@
use gtk::{glib, glib::closure_local, prelude::*, subclass::prelude::*};
use matrix_sdk::{deserialized_responses::RawSyncOrStrippedState, reqwest::StatusCode};
use ruma::{
api::client::{
alias::{create_alias, delete_alias},
room,
},
events::{room::canonical_alias::RoomCanonicalAliasEventContent, SyncStateEvent},
OwnedRoomAliasId,
};
use tracing::error;
use super::Room;
use crate::spawn_tokio;
mod imp {
use std::{cell::RefCell, marker::PhantomData};
use glib::subclass::Signal;
use once_cell::sync::Lazy;
use super::*;
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::RoomAliases)]
pub struct RoomAliases {
/// The room these aliases belong to.
#[property(get)]
pub room: glib::WeakRef<Room>,
/// The canonical alias.
pub canonical_alias: RefCell<Option<OwnedRoomAliasId>>,
/// The canonical alias, as a string.
#[property(get = Self::canonical_alias_string)]
canonical_alias_string: PhantomData<Option<String>>,
/// The other aliases.
pub alt_aliases: RefCell<Vec<OwnedRoomAliasId>>,
/// The other aliases, as a `GtkStringList`.
#[property(get)]
pub alt_aliases_model: gtk::StringList,
/// The alias, as a string.
///
/// If the canonical alias is not set, it can be an alt alias.
#[property(get = Self::alias_string)]
alias_string: PhantomData<Option<String>>,
}
#[glib::object_subclass]
impl ObjectSubclass for RoomAliases {
const NAME: &'static str = "RoomAliases";
type Type = super::RoomAliases;
}
#[glib::derived_properties]
impl ObjectImpl for RoomAliases {
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> =
Lazy::new(|| vec![Signal::builder("changed").build()]);
SIGNALS.as_ref()
}
}
impl RoomAliases {
/// Set the canonical alias.
pub(super) fn set_canonical_alias(&self, canonical_alias: Option<OwnedRoomAliasId>) {
if *self.canonical_alias.borrow() == canonical_alias {
return;
}
self.canonical_alias.replace(canonical_alias);
let obj = self.obj();
obj.notify_canonical_alias_string();
obj.notify_alias_string();
}
/// The canonical alias, as a string.
fn canonical_alias_string(&self) -> Option<String> {
self.canonical_alias
.borrow()
.as_ref()
.map(ToString::to_string)
}
/// Set the alt aliases.
pub(super) fn set_alt_aliases(&self, alt_aliases: Vec<OwnedRoomAliasId>) {
let (pos, removed) = {
let old_aliases = &*self.alt_aliases.borrow();
let mut pos = None;
// Check if aliases were changed in the current list.
for (i, old_alias) in old_aliases.iter().enumerate() {
if !alt_aliases.get(i).is_some_and(|alias| alias == old_alias) {
pos = Some(i);
break;
}
}
// Check if aliases were added.
let old_len = old_aliases.len();
if pos.is_none() {
let new_len = alt_aliases.len();
if old_len < new_len {
pos = Some(old_len);
}
}
let Some(pos) = pos else {
return;
};
let removed = old_len.saturating_sub(pos);
(pos, removed)
};
let additions = alt_aliases.get(pos..).unwrap_or_default().to_owned();
let additions_str = additions
.iter()
.map(|alias| alias.as_str())
.collect::<Vec<_>>();
let Ok(pos) = u32::try_from(pos) else {
return;
};
let Ok(removed) = u32::try_from(removed) else {
return;
};
self.alt_aliases.replace(alt_aliases);
self.alt_aliases_model.splice(pos, removed, &additions_str);
self.obj().notify_alias_string();
}
/// The alias, as a string.
fn alias_string(&self) -> Option<String> {
self.canonical_alias_string()
.or_else(|| self.alt_aliases_model.string(0).map(Into::into))
}
}
}
glib::wrapper! {
/// Aliases of a room.
pub struct RoomAliases(ObjectSubclass<imp::RoomAliases>);
}
impl RoomAliases {
pub fn new() -> Self {
glib::Object::new()
}
/// Initialize these aliases with the given room.
pub fn init(&self, room: &Room) {
let imp = self.imp();
self.imp().room.set(Some(room));
let matrix_room = room.matrix_room();
imp.set_canonical_alias(matrix_room.canonical_alias());
imp.set_alt_aliases(matrix_room.alt_aliases());
let obj_weak = glib::SendWeakRef::from(self.downgrade());
matrix_room.add_event_handler(move |_: SyncStateEvent<RoomCanonicalAliasEventContent>| {
let obj_weak = obj_weak.clone();
async move {
let ctx = glib::MainContext::default();
ctx.spawn(async move {
if let Some(obj) = obj_weak.upgrade() {
let Some(room) = obj.room() else {
return;
};
let imp = obj.imp();
let matrix_room = room.matrix_room();
imp.set_canonical_alias(matrix_room.canonical_alias());
imp.set_alt_aliases(matrix_room.alt_aliases());
obj.emit_by_name::<()>("changed", &[]);
}
});
}
});
}
/// Get the content of the canonical alias event from the store.
async fn canonical_alias_event_content(
&self,
) -> Result<Option<RoomCanonicalAliasEventContent>, ()> {
let Some(room) = self.room() else {
return Err(());
};
let matrix_room = room.matrix_room().clone();
let handle = spawn_tokio!(async move {
matrix_room
.get_state_event_static::<RoomCanonicalAliasEventContent>()
.await
});
let raw_event = match handle.await.unwrap() {
Ok(Some(RawSyncOrStrippedState::Sync(raw_event))) => raw_event,
// We shouldn't need to load this is an invited room.
Ok(_) => return Ok(None),
Err(error) => {
error!("Failed to get canonical alias event: {error}");
return Err(());
}
};
match raw_event.deserialize() {
Ok(SyncStateEvent::Original(event)) => Ok(Some(event.content)),
// The redacted event doesn't have a content.
Ok(_) => Ok(None),
Err(error) => {
error!("Failed to deserialize canonical alias event: {error}");
Err(())
}
}
}
/// The canonical alias.
pub fn canonical_alias(&self) -> Option<OwnedRoomAliasId> {
self.imp().canonical_alias.borrow().clone()
}
/// Remove the given canonical alias.
///
/// Checks that the canonical alias is the correct one before proceeding.
pub async fn remove_canonical_alias(&self, alias: &OwnedRoomAliasId) -> Result<(), ()> {
let mut event_content = self
.canonical_alias_event_content()
.await?
.unwrap_or_default();
// Remove the canonical alias, if it is there.
if !event_content.alias.take().is_some_and(|a| a == *alias) {
// Nothing to do.
return Err(());
}
let Some(room) = self.room() else {
return Err(());
};
let matrix_room = room.matrix_room().clone();
let handle = spawn_tokio!(async move { matrix_room.send_state_event(event_content).await });
match handle.await.unwrap() {
Ok(_) => Ok(()),
Err(error) => {
error!("Failed to remove canonical alias: {error}");
Err(())
}
}
}
/// Set the given alias to be the canonical alias.
///
/// Removes the given alias from the alt aliases if it is in the list.
pub async fn set_canonical_alias(&self, alias: OwnedRoomAliasId) -> Result<(), ()> {
let mut event_content = self
.canonical_alias_event_content()
.await?
.unwrap_or_default();
if event_content.alias.as_ref().is_some_and(|a| *a == alias) {
// Nothing to do.
return Err(());
}
let Some(room) = self.room() else {
return Err(());
};
// Remove from the alt aliases, if it is there.
let alt_alias_pos = event_content.alt_aliases.iter().position(|a| *a == alias);
if let Some(pos) = alt_alias_pos {
event_content.alt_aliases.remove(pos);
}
// Set as canonical alias.
if let Some(old_canonical) = event_content.alias.replace(alias) {
// Move the old canonical alias to the alt aliases, if it is not there already.
let has_old_canonical = event_content.alt_aliases.contains(&old_canonical);
if !has_old_canonical {
event_content.alt_aliases.push(old_canonical);
}
}
let matrix_room = room.matrix_room().clone();
let handle = spawn_tokio!(async move { matrix_room.send_state_event(event_content).await });
match handle.await.unwrap() {
Ok(_) => Ok(()),
Err(error) => {
error!("Failed to set canonical alias: {error}");
Err(())
}
}
}
/// The other public aliases.
pub fn alt_aliases(&self) -> Vec<OwnedRoomAliasId> {
self.imp().alt_aliases.borrow().clone()
}
/// Remove the given alt alias.
///
/// Checks that is in the list of alt aliases before proceeding.
pub async fn remove_alt_alias(&self, alias: &OwnedRoomAliasId) -> Result<(), ()> {
let mut event_content = self
.canonical_alias_event_content()
.await?
.unwrap_or_default();
// Remove from the alt aliases, if it is there.
let alt_alias_pos = event_content.alt_aliases.iter().position(|a| a == alias);
if let Some(pos) = alt_alias_pos {
event_content.alt_aliases.remove(pos);
} else {
// Nothing to do.
return Err(());
}
let Some(room) = self.room() else {
return Err(());
};
let matrix_room = room.matrix_room().clone();
let handle = spawn_tokio!(async move { matrix_room.send_state_event(event_content).await });
match handle.await.unwrap() {
Ok(_) => Ok(()),
Err(error) => {
error!("Failed to remove alt alias: {error}");
Err(())
}
}
}
/// Set the given alias to be an alt alias.
///
/// Removes the given alias from the alt aliases if it is in the list.
pub async fn add_alt_alias(&self, alias: OwnedRoomAliasId) -> Result<(), AddAltAliasError> {
let Ok(event_content) = self.canonical_alias_event_content().await else {
return Err(AddAltAliasError::Other);
};
let mut event_content = event_content.unwrap_or_default();
// Do nothing if it is already present.
if event_content.alias.as_ref().is_some_and(|a| *a == alias)
|| event_content.alt_aliases.contains(&alias)
{
error!("Cannot add alias already listed");
return Err(AddAltAliasError::Other);
}
let Some(room) = self.room() else {
return Err(AddAltAliasError::Other);
};
let matrix_room = room.matrix_room().clone();
// Check that the alias exists and points to the proper room.
let client = matrix_room.client();
let alias_clone = alias.clone();
let handle = spawn_tokio!(async move { client.resolve_room_alias(&alias_clone).await });
match handle.await.unwrap() {
Ok(response) => {
if response.room_id != matrix_room.room_id() {
error!("Cannot add alias that points to other room");
return Err(AddAltAliasError::InvalidRoomId);
}
}
Err(error) => {
error!("Failed to check room alias: {error}");
if error
.as_client_api_error()
.is_some_and(|e| e.status_code == StatusCode::NOT_FOUND)
{
return Err(AddAltAliasError::NotRegistered);
} else {
return Err(AddAltAliasError::Other);
}
}
}
// Add as alt alias.
event_content.alt_aliases.push(alias);
let handle = spawn_tokio!(async move { matrix_room.send_state_event(event_content).await });
match handle.await.unwrap() {
Ok(_) => Ok(()),
Err(error) => {
error!("Failed to add alt alias: {error}");
Err(AddAltAliasError::Other)
}
}
}
/// Get the local aliases registered on the homeserver.
pub async fn local_aliases(&self) -> Result<Vec<OwnedRoomAliasId>, ()> {
let Some(room) = self.room() else {
return Err(());
};
let matrix_room = room.matrix_room();
let client = matrix_room.client();
let room_id = matrix_room.room_id().to_owned();
let handle = spawn_tokio!(async move {
client
.send(room::aliases::v3::Request::new(room_id), None)
.await
});
match handle.await.unwrap() {
Ok(response) => Ok(response.aliases),
Err(error) => {
error!("Failed to fetch local room aliases: {error}");
Err(())
}
}
}
/// Unregister the given local alias.
pub async fn unregister_local_alias(&self, alias: OwnedRoomAliasId) -> Result<(), ()> {
let Some(room) = self.room() else {
return Err(());
};
// Check that the alias exists and points to the proper room.
let matrix_room = room.matrix_room();
let client = matrix_room.client();
let request = delete_alias::v3::Request::new(alias);
let handle = spawn_tokio!(async move { client.send(request, None).await });
match handle.await.unwrap() {
Ok(_) => Ok(()),
Err(error) => {
error!("Failed to unregister local alias: {error}");
Err(())
}
}
}
/// Register the given local alias.
pub async fn register_local_alias(
&self,
alias: OwnedRoomAliasId,
) -> Result<(), RegisterLocalAliasError> {
let Some(room) = self.room() else {
return Err(RegisterLocalAliasError::Other);
};
// Check that the alias exists and points to the proper room.
let matrix_room = room.matrix_room();
let client = matrix_room.client();
let room_id = matrix_room.room_id().to_owned();
let request = create_alias::v3::Request::new(alias, room_id);
let handle = spawn_tokio!(async move { client.send(request, None).await });
match handle.await.unwrap() {
Ok(_) => Ok(()),
Err(error) => {
error!("Failed to register local alias: {error}");
if error
.as_client_api_error()
.is_some_and(|e| e.status_code == StatusCode::CONFLICT)
{
Err(RegisterLocalAliasError::AlreadyInUse)
} else {
Err(RegisterLocalAliasError::Other)
}
}
}
}
/// Connect to the signal emitted when the aliases changed.
pub fn connect_changed<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.connect_closure(
"changed",
true,
closure_local!(move |obj: Self| {
f(&obj);
}),
)
}
}
impl Default for RoomAliases {
fn default() -> Self {
Self::new()
}
}
/// All high-level errors that can happen when trying to add an alt alias.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddAltAliasError {
/// The alias is not registered.
NotRegistered,
/// The alias is not registered to this room.
InvalidRoomId,
/// An other error occurred.
Other,
}
/// All high-level errors that can happen when trying to register a local alias.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegisterLocalAliasError {
/// The alias is already registered.
AlreadyInUse,
/// An other error occurred.
Other,
}

12
src/session/model/room/mod.rs

@ -1,3 +1,4 @@
mod aliases;
mod event;
mod highlight_flags;
mod member;
@ -45,6 +46,7 @@ use ruma::{
use tracing::{debug, error, warn};
pub use self::{
aliases::{AddAltAliasError, RegisterLocalAliasError, RoomAliases},
event::*,
highlight_flags::HighlightFlags,
member::{Member, Membership},
@ -88,9 +90,9 @@ mod imp {
/// The ID of this room, as a string.
#[property(get = Self::room_id_string)]
pub room_id_string: PhantomData<String>,
/// The alias of this room, as a string.
#[property(get = Self::alias_string)]
pub alias_string: PhantomData<Option<String>>,
/// The aliases of this room.
#[property(get)]
pub aliases: RoomAliases,
/// The version of this room.
#[property(get = Self::version)]
pub version: PhantomData<String>,
@ -387,6 +389,7 @@ impl Room {
self.set_up_typing();
self.init_timeline();
self.set_up_is_encrypted();
self.aliases().init(self);
spawn!(
glib::Priority::DEFAULT_IDLE,
@ -1259,9 +1262,6 @@ impl Room {
self.emit_by_name::<()>("join-rule-changed", &[]);
self.notify_anyone_can_join();
}
AnySyncStateEvent::RoomCanonicalAlias(_) => {
self.notify_alias_string();
}
_ => {}
}
}

357
src/session/view/content/room_details/addresses_subpage/completion_popover.rs

@ -0,0 +1,357 @@
use adw::prelude::*;
use gtk::{gdk, gio, glib, glib::clone, pango, subclass::prelude::*, CompositeTemplate};
use tracing::error;
use crate::utils::BoundObjectWeakRef;
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_details/addresses_subpage/completion_popover.ui"
)]
#[properties(wrapper_type = super::CompletionPopover)]
pub struct CompletionPopover {
#[template_child]
pub list: TemplateChild<gtk::ListBox>,
/// The parent entry to autocomplete.
#[property(get, set = Self::set_entry, explicit_notify, nullable)]
pub entry: BoundObjectWeakRef<gtk::Editable>,
/// The key controller added to the parent entry.
entry_controller: RefCell<Option<gtk::EventControllerKey>>,
entry_binding: RefCell<Option<glib::Binding>>,
/// The list model to use for completion.
///
/// Only supports `GtkStringObject` items.
#[property(get, set = Self::set_model, explicit_notify, nullable)]
pub model: RefCell<Option<gio::ListModel>>,
/// The string filter.
#[property(get)]
pub filter: gtk::StringFilter,
/// The filtered list model.
#[property(get)]
pub filtered_list: gtk::FilterListModel,
}
#[glib::object_subclass]
impl ObjectSubclass for CompletionPopover {
const NAME: &'static str = "RoomDetailsAddressesSubpageCompletionPopover";
type Type = super::CompletionPopover;
type ParentType = gtk::Popover;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::Type::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for CompletionPopover {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
self.filter
.set_expression(Some(gtk::StringObject::this_expression("string")));
self.filtered_list.set_filter(Some(&self.filter));
self.filtered_list
.connect_items_changed(clone!(@weak obj => move |_,_,_,_| {
obj.update_completion();
}));
self.list.bind_model(Some(&self.filtered_list), |item| {
let Some(item) = item.downcast_ref::<gtk::StringObject>() else {
error!("Completion has item that is not a GtkStringObject");
return adw::Bin::new().upcast();
};
let label = gtk::Label::builder()
.label(item.string())
.ellipsize(pango::EllipsizeMode::End)
.halign(gtk::Align::Start)
.build();
gtk::ListBoxRow::builder().child(&label).build().upcast()
});
}
fn dispose(&self) {
if let Some(entry) = self.entry.obj() {
if let Some(controller) = self.entry_controller.take() {
entry.remove_controller(&controller)
}
}
if let Some(binding) = self.entry_binding.take() {
binding.unbind();
}
}
}
impl WidgetImpl for CompletionPopover {}
impl PopoverImpl for CompletionPopover {}
impl CompletionPopover {
/// Set the parent entry to autocomplete.
fn set_entry(&self, entry: Option<&gtk::Editable>) {
let prev_entry = self.entry.obj();
if prev_entry.as_ref() == entry {
return;
}
let obj = self.obj();
if let Some(entry) = prev_entry {
if let Some(controller) = self.entry_controller.take() {
entry.remove_controller(&controller)
}
obj.unparent();
}
if let Some(binding) = self.entry_binding.take() {
binding.unbind();
}
self.entry.disconnect_signals();
if let Some(entry) = entry {
let key_events = gtk::EventControllerKey::new();
key_events.connect_key_pressed(clone!(@weak obj => @default-return glib::Propagation::Proceed, move |_, key, _, modifier| {
if modifier.is_empty() {
if obj.is_visible() {
let imp = obj.imp();
if matches!(key, gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::ISO_Enter) {
// Activate completion.
obj.activate_selected_row();
return glib::Propagation::Stop;
} else if matches!(key, gdk::Key::Up | gdk::Key::KP_Up) {
// Move up, if possible.
let idx = obj.selected_row_index().unwrap_or_default();
if idx > 0 {
obj.select_row_at_index(Some(idx - 1));
}
return glib::Propagation::Stop;
} else if matches!(key, gdk::Key::Down | gdk::Key::KP_Down) {
// Move down, if possible.
let new_idx = if let Some(idx) = obj.selected_row_index() {
idx + 1
} else {
0
};
let max = imp.filtered_list.n_items() as usize;
if new_idx < max {
obj.select_row_at_index(Some(new_idx));
}
return glib::Propagation::Stop;
} else if matches!(key, gdk::Key::Escape) {
// Close.
obj.popdown();
return glib::Propagation::Stop;
}
} else if matches!(key, gdk::Key::Tab) {
obj.update_completion();
return glib::Propagation::Stop;
}
}
glib::Propagation::Proceed
}));
entry.add_controller(key_events.clone());
self.entry_controller.replace(Some(key_events));
let search_binding = entry
.bind_property("text", &self.filter, "search")
.sync_create()
.build();
self.entry_binding.replace(Some(search_binding));
let changed_handler = entry.connect_changed(clone!(@weak obj => move |_| {
obj.update_completion();
}));
let state_flags_handler =
entry.connect_state_flags_changed(clone!(@weak obj => move |_, _| {
obj.update_completion();
}));
obj.set_parent(entry);
self.entry
.set(entry, vec![changed_handler, state_flags_handler]);
}
self.obj().notify_entry();
}
/// Set the list model to use for completion.
fn set_model(&self, model: Option<gio::ListModel>) {
if *self.model.borrow() == model {
return;
}
self.filtered_list.set_model(model.as_ref());
self.model.replace(model);
self.obj().notify_model();
}
}
}
glib::wrapper! {
/// A popover to auto-complete strings for a `gtk::Editable`.
pub struct CompletionPopover(ObjectSubclass<imp::CompletionPopover>)
@extends gtk::Widget, gtk::Popover, @implements gtk::Accessible;
}
#[gtk::template_callbacks]
impl CompletionPopover {
pub fn new() -> Self {
glib::Object::new()
}
/// Update completion.
fn update_completion(&self) {
let Some(entry) = self.entry() else {
return;
};
let imp = self.imp();
let n_items = imp.filtered_list.n_items();
// Always hide the popover if it's empty.
if n_items == 0 {
if self.is_visible() {
self.popdown();
}
return;
}
// Always hide the popover if it has a single item that is exactly the text of
// the entry.
if n_items == 1 {
if let Some(item) = imp
.filtered_list
.item(0)
.and_downcast::<gtk::StringObject>()
{
if item.string() == entry.text() {
if self.is_visible() {
self.popdown();
}
return;
}
}
}
// Only show the popover if the entry is focused.
let entry_has_focus = entry.state_flags().contains(gtk::StateFlags::FOCUS_WITHIN);
if entry_has_focus {
if !self.is_visible() {
self.popup();
}
} else if self.is_visible() {
self.popdown();
}
}
fn selected_row_index(&self) -> Option<usize> {
let imp = self.imp();
let selected_text = self.selected_text()?;
imp.filtered_list.iter::<glib::Object>().position(|o| {
o.ok()
.and_downcast::<gtk::StringObject>()
.is_some_and(|o| o.string() == selected_text)
})
}
fn select_row_at_index(&self, idx: Option<usize>) {
let imp = self.imp();
if self.selected_row_index() == idx || idx >= Some(imp.filtered_list.n_items() as usize) {
return;
}
let imp = self.imp();
if let Some(row) = idx.and_then(|idx| imp.list.row_at_index(idx as i32)) {
imp.list.select_row(Some(&row));
} else {
imp.list.select_row(None::<&gtk::ListBoxRow>);
}
}
/// The text of the selected row, if any.
pub fn selected_text(&self) -> Option<glib::GString> {
Some(
self.imp()
.list
.selected_row()?
.child()?
.downcast_ref::<gtk::Label>()?
.label(),
)
}
/// Activate the selected row.
///
/// Returns `true` if the row was activated.
pub fn activate_selected_row(&self) -> bool {
if !self.is_visible() {
return false;
}
let Some(entry) = self.entry() else {
return false;
};
let Some(selected_text) = self.selected_text() else {
return false;
};
if selected_text == entry.text() {
// Activating the row would have no effect.
return false;
}
let Some(row) = self.imp().list.selected_row() else {
return false;
};
row.activate();
true
}
/// Handle a row being activated.
#[template_callback]
fn row_activated(&self, row: &gtk::ListBoxRow) {
let Some(label) = row.child().and_downcast::<gtk::Label>() else {
return;
};
let Some(entry) = self.entry() else {
return;
};
entry.set_text(&label.label());
self.popdown();
entry.grab_focus();
}
}
impl Default for CompletionPopover {
fn default() -> Self {
Self::new()
}
}

29
src/session/view/content/room_details/addresses_subpage/completion_popover.ui

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="RoomDetailsAddressesSubpageCompletionPopover" parent="GtkPopover">
<accessibility>
<property name="label" translatable="yes">Address auto-completion</property>
</accessibility>
<style>
<class name="string-row-list"/>
</style>
<property name="autohide">false</property>
<property name="has-arrow">false</property>
<property name="position">bottom</property>
<property name="halign">start</property>
<property name="valign">center</property>
<property name="width-request">260</property>
<property name="child">
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="propagate-natural-height">true</property>
<property name="hscrollbar-policy">never</property>
<property name="max-content-height">280</property>
<property name="child">
<object class="GtkListBox" id="list">
<signal name="row-activated" handler="row_activated" swapped="yes"/>
</object>
</property>
</object>
</property>
</template>
</interface>

776
src/session/view/content/room_details/addresses_subpage/mod.rs

@ -0,0 +1,776 @@
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{gio, glib, glib::clone, pango, CompositeTemplate};
use ruma::RoomAliasId;
use tracing::error;
mod completion_popover;
mod public_address;
use self::{completion_popover::CompletionPopover, public_address::PublicAddress};
use crate::{
components::{EntryAddRow, RemovableRow, SpinnerButton, SubstringEntryRow},
gettext_f,
prelude::*,
session::model::{AddAltAliasError, RegisterLocalAliasError, Room},
spawn, toast,
utils::DummyObject,
};
mod imp {
use std::{
cell::{OnceCell, RefCell},
collections::HashSet,
};
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(
resource = "/org/gnome/Fractal/ui/session/view/content/room_details/addresses_subpage/mod.ui"
)]
#[properties(wrapper_type = super::AddressesSubpage)]
pub struct AddressesSubpage {
#[template_child]
pub public_addresses_list: TemplateChild<gtk::ListBox>,
#[template_child]
pub public_addresses_error_revealer: TemplateChild<gtk::Revealer>,
#[template_child]
pub public_addresses_error: TemplateChild<gtk::Label>,
#[template_child]
pub local_addresses_group: TemplateChild<adw::PreferencesGroup>,
#[template_child]
pub local_addresses_list: TemplateChild<gtk::ListBox>,
#[template_child]
pub local_addresses_error_revealer: TemplateChild<gtk::Revealer>,
#[template_child]
pub local_addresses_error: TemplateChild<gtk::Label>,
#[template_child]
pub public_addresses_add_row: TemplateChild<EntryAddRow>,
#[template_child]
pub local_addresses_add_row: TemplateChild<SubstringEntryRow>,
/// The room users will be invited to.
#[property(get, set = Self::set_room, construct_only)]
pub room: glib::WeakRef<Room>,
/// The full list of public addresses.
pub public_addresses: OnceCell<gio::ListStore>,
/// The full list of local addresses.
pub local_addresses: gtk::StringList,
aliases_changed_handler: RefCell<Option<glib::SignalHandlerId>>,
pub public_addresses_completion: CompletionPopover,
}
#[glib::object_subclass]
impl ObjectSubclass for AddressesSubpage {
const NAME: &'static str = "RoomDetailsAddressesSubpage";
type Type = super::AddressesSubpage;
type ParentType = adw::NavigationPage;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::Type::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for AddressesSubpage {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
let extra_items = gio::ListStore::new::<glib::Object>();
extra_items.append(&DummyObject::new("add"));
// Public addresses.
let public_items = gio::ListStore::new::<glib::Object>();
public_items.append(self.public_addresses());
public_items.append(&extra_items);
let flattened_public_list = gtk::FlattenListModel::new(Some(public_items));
self.public_addresses_list.bind_model(
Some(&flattened_public_list),
clone!(
@weak obj => @default-return { adw::ActionRow::new().upcast() },
move |item| obj.create_public_address_row(item)
),
);
self.public_addresses_add_row
.connect_changed(clone!(@weak obj => move |_| {
obj.update_public_addresses_add_row();
}));
// Filter addresses already in the list.
let new_addresses_filter = gtk::CustomFilter::new(
clone!(@weak self as imp => @default-return false, move |item: &glib::Object| {
let Some(item) = item.downcast_ref::<gtk::StringObject>() else {
return false;
};
let address = item.string();
for public_address in imp.public_addresses().iter::<PublicAddress>() {
let Ok(public_address) = public_address else {
// The iterator is broken.
break;
};
if public_address.alias().as_str() == address {
return false;
}
}
true
}),
);
// Update the filtered list everytime an item changes.
self.public_addresses().connect_items_changed(
clone!(@weak new_addresses_filter => move |_,_,_,_| {
new_addresses_filter.changed(gtk::FilterChange::Different);
}),
);
let new_local_addresses = gtk::FilterListModel::new(
Some(self.local_addresses.clone()),
Some(new_addresses_filter),
);
self.public_addresses_completion
.set_model(Some(new_local_addresses));
self.public_addresses_completion.set_entry(Some(
self.public_addresses_add_row.upcast_ref::<gtk::Editable>(),
));
// Local addresses.
let local_items = gio::ListStore::new::<glib::Object>();
local_items.append(&self.local_addresses);
local_items.append(&extra_items);
let flattened_local_list = gtk::FlattenListModel::new(Some(local_items));
self.local_addresses_list.bind_model(
Some(&flattened_local_list),
clone!(
@weak obj => @default-return { adw::ActionRow::new().upcast() },
move |item| obj.create_local_address_row(item)
),
);
self.local_addresses_add_row
.connect_changed(clone!(@weak obj => move |_| {
obj.update_local_addresses_add_row();
}));
}
fn dispose(&self) {
if let Some(room) = self.room.upgrade() {
if let Some(handler) = self.aliases_changed_handler.take() {
room.aliases().disconnect(handler);
}
}
self.public_addresses_completion.unparent();
}
}
impl WidgetImpl for AddressesSubpage {}
impl NavigationPageImpl for AddressesSubpage {}
impl AddressesSubpage {
pub(super) fn public_addresses(&self) -> &gio::ListStore {
self.public_addresses
.get_or_init(gio::ListStore::new::<PublicAddress>)
}
/// Set the room users will be invited to.
fn set_room(&self, room: Room) {
let aliases = room.aliases();
let aliases_changed_handler =
aliases.connect_changed(clone!(@weak self as imp => move |_| {
imp.update_public_addresses();
}));
self.aliases_changed_handler
.replace(Some(aliases_changed_handler));
self.room.set(Some(&room));
self.obj().notify_room();
self.update_public_addresses();
self.update_local_addresses_server();
spawn!(clone!(@weak self as imp => async move {
imp.update_local_addresses().await;
}));
}
/// Update the list of public addresses.
fn update_public_addresses(&self) {
let Some(room) = self.room.upgrade() else {
return;
};
let aliases = room.aliases();
let canonical_alias = aliases.canonical_alias();
let alt_aliases = aliases.alt_aliases();
// Map of `(alias, is_main)`.
let mut public_aliases = canonical_alias
.into_iter()
.map(|a| (a, true))
.chain(alt_aliases.into_iter().map(|a| (a, false)))
.collect::<Vec<_>>();
let public_addresses = self.public_addresses();
// Remove aliases that are not in the list anymore and update the main alias.
let mut i = 0;
while i < public_addresses.n_items() {
let Some(item) = public_addresses.item(i).and_downcast::<PublicAddress>() else {
break;
};
let position = public_aliases
.iter()
.position(|(alias, _)| item.alias() == alias);
if let Some(position) = position {
// It is in the list, update whether it is the main alias.
let (_, is_main) = public_aliases.remove(position);
item.set_is_main(is_main);
i += 1;
} else {
// It is not in the list, remove.
public_addresses.remove(i);
}
}
// If there are new aliases in the list, append them.
if !public_aliases.is_empty() {
let new_aliases = public_aliases
.into_iter()
.map(|(alias, is_main)| PublicAddress::new(alias, is_main))
.collect::<Vec<_>>();
public_addresses.splice(public_addresses.n_items(), 0, &new_aliases);
}
self.reset_public_addresses_state();
}
/// Reset the public addresses section UI state.
fn reset_public_addresses_state(&self) {
// Reset the list.
self.public_addresses_list.set_sensitive(true);
// Reset the rows loading state.
for i in 0..self.public_addresses().n_items() {
let Some(row) = self
.public_addresses_list
.row_at_index(i as i32)
.and_downcast::<RemovableRow>()
else {
break;
};
row.set_is_loading(false);
if let Some(button) = row.extra_suffix().and_downcast::<SpinnerButton>() {
button.set_loading(false);
}
}
self.public_addresses_add_row.set_is_loading(false);
}
/// Update the server of the local addresses.
fn update_local_addresses_server(&self) {
let Some(room) = self.room.upgrade() else {
return;
};
let own_member = room.own_member();
let server_name = own_member.user_id().server_name();
self.local_addresses_group.set_title(&gettext_f(
// Translators: Do NOT translate the content between '{' and '}',
// this is a variable name.
"Local Addresses on {homeserver}",
&[("homeserver", server_name.as_str())],
));
self.local_addresses_add_row
.set_suffix_text(format!(":{server_name}"));
}
/// Update the list of local addresses.
pub(super) async fn update_local_addresses(&self) {
let Some(room) = self.room.upgrade() else {
return;
};
let aliases = room.aliases();
let Ok(local_aliases) = aliases.local_aliases().await else {
return;
};
let mut local_aliases = local_aliases
.into_iter()
.map(String::from)
.collect::<HashSet<_>>();
// Remove aliases that are not in the list anymore.
let mut i = 0;
while i < self.local_addresses.n_items() {
let Some(item) = self
.local_addresses
.item(i)
.and_downcast::<gtk::StringObject>()
else {
break;
};
let address = String::from(item.string());
if !local_aliases.remove(&address) {
self.local_addresses.remove(i);
} else {
i += 1;
}
}
// If there are new aliases in the list, append them.
if !local_aliases.is_empty() {
let new_aliases = local_aliases.iter().map(|s| s.as_str()).collect::<Vec<_>>();
self.local_addresses
.splice(self.local_addresses.n_items(), 0, &new_aliases);
}
}
}
}
glib::wrapper! {
/// Subpage to invite new members to a room.
pub struct AddressesSubpage(ObjectSubclass<imp::AddressesSubpage>)
@extends gtk::Widget, gtk::Window, adw::NavigationPage, @implements gtk::Accessible;
}
#[gtk::template_callbacks]
impl AddressesSubpage {
pub fn new(room: &Room) -> Self {
glib::Object::builder().property("room", room).build()
}
/// Create a row for the given item in the public addresses section.
fn create_public_address_row(&self, item: &glib::Object) -> gtk::Widget {
let imp = self.imp();
if let Some(address) = item.downcast_ref::<PublicAddress>() {
let alias = address.alias();
let row = RemovableRow::new();
row.set_title(alias.as_str());
row.set_remove_button_tooltip_text(Some(gettext("Remove address")));
row.set_remove_button_accessible_label(Some(gettext_f(
// Translators: Do NOT translate the content between '{' and '}',
// this is a variable name.
"Remove “{address}”",
&[("address", alias.as_str())],
)));
address.connect_is_main_notify(clone!(@weak self as obj, @weak row => move |address| {
obj.update_public_row_is_main(&row, address.is_main());
}));
self.update_public_row_is_main(&row, address.is_main());
row.connect_remove(clone!(@weak self as obj => move |row| {
spawn!(clone!(@weak row => async move {
obj.remove_public_address(&row).await;
}));
}));
row.upcast()
} else {
// It can only be the dummy item to add a new alias.
imp.public_addresses_add_row.clone().upcast()
}
}
/// Update the given row for whether the address it presents is the main
/// address or not.
fn update_public_row_is_main(&self, row: &RemovableRow, is_main: bool) {
if is_main && !public_row_is_main(row) {
let label = gtk::Label::builder()
.label(gettext("Main Address"))
.ellipsize(pango::EllipsizeMode::End)
.build();
let image = gtk::Image::builder()
.icon_name("checkmark-symbolic")
.accessible_role(gtk::AccessibleRole::Presentation)
.build();
let main_box = gtk::Box::builder()
.spacing(6)
.css_classes(["public-address-tag"])
.valign(gtk::Align::Center)
.build();
main_box.append(&image);
main_box.append(&label);
row.update_relation(&[gtk::accessible::Relation::DescribedBy(
&[label.upcast_ref()],
)]);
row.set_extra_suffix(Some(main_box));
} else if !is_main && !row.extra_suffix().is_some_and(|w| w.is::<SpinnerButton>()) {
let button = SpinnerButton::new();
button.set_content_icon_name("checkmark-symbolic");
button.add_css_class("flat");
button.set_tooltip_text(Some(&gettext("Set as main address")));
button.set_valign(gtk::Align::Center);
let accessible_label = gettext_f(
// Translators: Do NOT translate the content between '{' and '}',
// this is a variable name.
"Set “{address}” as main address",
&[("address", &row.title())],
);
button.update_property(&[gtk::accessible::Property::Label(&accessible_label)]);
button.connect_clicked(clone!(@weak self as obj, @weak row => move |_| {
spawn!(async move {
obj.set_main_public_address(&row).await;
});
}));
row.set_extra_suffix(Some(button));
}
}
/// Remove the public address from the given row.
async fn remove_public_address(&self, row: &RemovableRow) {
let Some(room) = self.room() else {
return;
};
let Ok(alias) = RoomAliasId::parse(row.title()) else {
error!("Cannot remove address with invalid alias");
return;
};
let imp = self.imp();
let aliases = room.aliases();
imp.public_addresses_list.set_sensitive(false);
row.set_is_loading(true);
let result = if public_row_is_main(row) {
aliases.remove_canonical_alias(&alias).await
} else {
aliases.remove_alt_alias(&alias).await
};
if result.is_err() {
toast!(self, gettext("Could not remove public address"));
imp.public_addresses_list.set_sensitive(true);
row.set_is_loading(false);
}
}
/// Set the address from the given row as the main public address.
async fn set_main_public_address(&self, row: &RemovableRow) {
let Some(room) = self.room() else {
return;
};
let Some(button) = row.extra_suffix().and_downcast::<SpinnerButton>() else {
return;
};
let Ok(alias) = RoomAliasId::parse(row.title()) else {
error!("Cannot set main public address with invalid alias");
return;
};
let imp = self.imp();
let aliases = room.aliases();
imp.public_addresses_list.set_sensitive(false);
button.set_loading(true);
if aliases.set_canonical_alias(alias).await.is_err() {
toast!(self, gettext("Could not set main public address"));
imp.public_addresses_list.set_sensitive(true);
button.set_loading(false);
}
}
/// Update the public addresses add row for the current state.
fn update_public_addresses_add_row(&self) {
self.imp()
.public_addresses_add_row
.set_inhibit_add(!self.can_add_public_address());
}
/// Activate the auto-completion of the public addresses add row.
#[template_callback]
fn handle_public_addresses_add_row_activated(&self) {
if !self
.imp()
.public_addresses_completion
.activate_selected_row()
{
self.add_public_address();
}
}
/// Add a an address to the public list.
#[template_callback]
fn add_public_address(&self) {
let Some(room) = self.room() else {
return;
};
let imp = self.imp();
if !self.can_add_public_address() {
return;
}
let row = &imp.public_addresses_add_row;
let Ok(alias) = RoomAliasId::parse(row.text()) else {
error!("Cannot add public address with invalid alias");
return;
};
imp.public_addresses_list.set_sensitive(false);
row.set_is_loading(true);
imp.public_addresses_error_revealer.set_reveal_child(false);
let aliases = room.aliases();
spawn!(clone!(@weak self as obj => async move {
let imp = obj.imp();
let row = &imp.public_addresses_add_row;
match aliases.add_alt_alias(alias).await {
Ok(()) => {
row.set_text("");
}
Err(error) => {
toast!(obj, gettext("Could not add public address"));
let label = match error {
AddAltAliasError::NotRegistered => Some(gettext("This address is not registered as a local address")),
AddAltAliasError::InvalidRoomId => Some(gettext("This address does not belong to this room")),
AddAltAliasError::Other => None,
};
if let Some(label) = label {
imp.public_addresses_error.set_label(&label);
imp.public_addresses_error_revealer.set_reveal_child(true);
}
imp.public_addresses_list.set_sensitive(true);
row.set_is_loading(false);
}
}
}));
}
/// Whether the user can add the current address to the public list.
fn can_add_public_address(&self) -> bool {
let imp = self.imp();
let new_address = imp.public_addresses_add_row.text();
// Cannot add an empty address.
if new_address.is_empty() {
return false;
}
// Cannot add an invalid alias.
let Ok(new_alias) = RoomAliasId::parse(new_address) else {
return false;
};
// Cannot add a duplicate address.
for public_address in imp.public_addresses().iter::<PublicAddress>() {
let Ok(public_address) = public_address else {
// The iterator is broken.
return false;
};
if *public_address.alias() == new_alias {
return false;
}
}
true
}
/// Create a row for the given item in the public addresses section.
fn create_local_address_row(&self, item: &glib::Object) -> gtk::Widget {
let imp = self.imp();
if let Some(string_obj) = item.downcast_ref::<gtk::StringObject>() {
let alias = string_obj.string();
let row = RemovableRow::new();
row.set_title(&alias);
row.set_remove_button_tooltip_text(Some(gettext("Unregister local address")));
row.set_remove_button_accessible_label(Some(gettext_f(
// Translators: Do NOT translate the content between '{' and '}',
// this is a variable name.
"Unregister “{address}”",
&[("address", &alias)],
)));
row.connect_remove(clone!(@weak self as obj => move |row| {
spawn!(clone!(@weak row => async move {
obj.unregister_local_address(&row).await;
}));
}));
row.upcast()
} else {
imp.local_addresses_add_row.clone().upcast()
}
}
/// Unregister the local address from the given row.
async fn unregister_local_address(&self, row: &RemovableRow) {
let Some(room) = self.room() else {
return;
};
let Ok(alias) = RoomAliasId::parse(row.title()) else {
error!("Cannot unregister local address with invalid alias");
return;
};
let aliases = room.aliases();
row.set_is_loading(true);
if aliases.unregister_local_alias(alias).await.is_err() {
toast!(self, gettext("Could not unregister local address"));
}
self.imp().update_local_addresses().await;
row.set_is_loading(false);
}
/// The full new address in the public addresses add row.
///
/// Returns `None` if the localpart is empty.
fn new_local_address(&self) -> Option<String> {
let row = &self.imp().local_addresses_add_row;
let localpart = row.text();
if localpart.is_empty() {
return None;
}
let server_name = row.suffix_text();
Some(format!("#{localpart}{server_name}"))
}
/// Update the public addresses add row for the current state.
fn update_local_addresses_add_row(&self) {
let row = &self.imp().local_addresses_add_row;
row.set_inhibit_add(!self.can_register_local_address());
let accessible_label = self.new_local_address().map(|address| {
gettext_f(
// Translators: Do NOT translate the content between '{' and '}',
// this is a variable name.
"Register “{address}”",
&[("address", &address)],
)
});
row.set_add_button_accessible_label(accessible_label);
}
/// Register a local address.
#[template_callback]
fn register_local_address(&self) {
let Some(room) = self.room() else {
return;
};
if !self.can_register_local_address() {
return;
}
let Some(new_address) = self.new_local_address() else {
return;
};
let Ok(alias) = RoomAliasId::parse(new_address) else {
error!("Cannot register local address with invalid alias");
return;
};
let imp = self.imp();
imp.local_addresses_add_row.set_is_loading(true);
imp.local_addresses_error_revealer.set_reveal_child(false);
let aliases = room.aliases();
spawn!(clone!(@weak self as obj => async move {
let imp = obj.imp();
let row = &imp.local_addresses_add_row;
match aliases.register_local_alias(alias).await {
Ok(()) => {
row.set_text("");
}
Err(error) => {
toast!(obj, gettext("Could not register local address"));
if let RegisterLocalAliasError::AlreadyInUse = error {
imp.local_addresses_error.set_label(&gettext("This address is already registered"));
imp.local_addresses_error_revealer.set_reveal_child(true);
}
}
}
imp.update_local_addresses().await;
row.set_is_loading(false);
}));
}
/// Whether the user can add the current address to the local list.
fn can_register_local_address(&self) -> bool {
let imp = self.imp();
// Cannot add an empty address.
let Some(new_address) = self.new_local_address() else {
return false;
};
// Cannot add an invalid alias.
let Ok(new_alias) = RoomAliasId::parse(new_address) else {
return false;
};
// Cannot add a duplicate address.
for local_address in imp.public_addresses().iter::<glib::Object>() {
let Some(local_address) = local_address.ok().and_downcast::<gtk::StringObject>() else {
// The iterator is broken.
return true;
};
if local_address.string() == new_alias.as_str() {
return false;
}
}
true
}
}
/// Whether the given public row contains the main address.
fn public_row_is_main(row: &RemovableRow) -> bool {
row.extra_suffix().is_some_and(|w| w.is::<gtk::Box>())
}

94
src/session/view/content/room_details/addresses_subpage/mod.ui

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="RoomDetailsAddressesSubpage" parent="AdwNavigationPage">
<property name="title" translatable="yes">Edit Room Addresses</property>
<property name="child">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="AdwPreferencesPage">
D<child>
<object class="AdwPreferencesGroup" id="public_addresses_group">
<property name="title" translatable="yes">Public Addresses</property>
<property name="description" translatable="yes">Public addresses are advertised for all users in the room and the main address is used to identify a room publicly. Before adding an address to this list, it must be registered as a local address.</property>
<child>
<object class="GtkListBox" id="public_addresses_list">
<style>
<class name="boxed-list"/>
</style>
</object>
</child>
<child>
<object class="GtkRevealer" id="public_addresses_error_revealer">
<style>
<class name="entry-row-error-revealer"/>
</style>
<property name="child">
<object class="GtkLabel" id="public_addresses_error">
<property name="accessible-role">status</property>
<style>
<class name="caption"/>
<class name="error"/>
</style>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
</object>
</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup" id="local_addresses_group">
<property name="description" translatable="yes">Local addresses can only be registered with your own homeserver. If they are not made public, only people on your homeserver can discover them.</property>
<child>
<object class="GtkListBox" id="local_addresses_list">
<style>
<class name="boxed-list"/>
</style>
</object>
</child>
<child>
<object class="GtkRevealer" id="local_addresses_error_revealer">
<style>
<class name="entry-row-error-revealer"/>
</style>
<property name="child">
<object class="GtkLabel" id="local_addresses_error">
<property name="accessible-role">status</property>
<style>
<class name="caption"/>
<class name="error"/>
</style>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</template>
<object class="EntryAddRow" id="public_addresses_add_row">
<property name="title" translatable="yes">Add Public Address…</property>
<property name="add-button-tooltip-text" translatable="yes">Add Public Address</property>
<signal name="add" handler="add_public_address" swapped="yes"/>
<signal name="entry-activated" handler="handle_public_addresses_add_row_activated" swapped="yes"/>
</object>
<object class="SubstringEntryRow" id="local_addresses_add_row">
<property name="title" translatable="yes">Register Local Address</property>
<property name="placeholder-text" translatable="yes">my-room</property>
<property name="accessible-description" translatable="yes">First part of the address, for example “my-room”</property>
<property name="prefix-text">#</property>
<property name="add-button-tooltip-text" translatable="yes">Register Local Address</property>
<signal name="add" handler="register_local_address" swapped="yes"/>
</object>
</interface>

63
src/session/view/content/room_details/addresses_subpage/public_address.rs

@ -0,0 +1,63 @@
use adw::subclass::prelude::*;
use gtk::{glib, prelude::*};
use ruma::OwnedRoomAliasId;
mod imp {
use std::cell::{Cell, OnceCell};
use super::*;
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::PublicAddress)]
pub struct PublicAddress {
/// The room alias.
pub alias: OnceCell<OwnedRoomAliasId>,
/// Whether this is the main address.
#[property(get, set = Self::set_is_main, explicit_notify)]
pub is_main: Cell<bool>,
}
#[glib::object_subclass]
impl ObjectSubclass for PublicAddress {
const NAME: &'static str = "RoomDetailsAddressesSubpagePublicAddress";
type Type = super::PublicAddress;
}
#[glib::derived_properties]
impl ObjectImpl for PublicAddress {}
impl PublicAddress {
/// Set whether this is the main address.
fn set_is_main(&self, is_main: bool) {
if self.is_main.get() == is_main {
return;
}
self.is_main.set(is_main);
self.obj().notify_is_main();
}
}
}
glib::wrapper! {
/// A public address.
pub struct PublicAddress(ObjectSubclass<imp::PublicAddress>);
}
impl PublicAddress {
/// Constructs a new `PublicAddress`.
pub fn new(alias: OwnedRoomAliasId, is_main: bool) -> Self {
let obj = glib::Object::builder::<Self>()
.property("is-main", is_main)
.build();
obj.imp().alias.set(alias).unwrap();
obj
}
/// The room alias.
pub fn alias(&self) -> &OwnedRoomAliasId {
self.imp().alias.get().unwrap()
}
}

143
src/session/view/content/room_details/general_page/mod.rs

@ -5,7 +5,7 @@ use gettextrs::{gettext, ngettext};
use gtk::{
gio,
glib::{self, clone},
CompositeTemplate,
pango, CompositeTemplate,
};
use matrix_sdk::RoomState;
use ruma::{
@ -84,6 +84,14 @@ mod imp {
#[template_child]
pub notifications_mute_row: TemplateChild<CheckLoadingRow>,
#[template_child]
pub addresses_group: TemplateChild<adw::PreferencesGroup>,
#[template_child]
pub edit_addresses_button: TemplateChild<gtk::Button>,
#[template_child]
pub no_addresses_label: TemplateChild<gtk::Label>,
pub canonical_alias_row: RefCell<Option<CopyableRow>>,
pub alt_aliases_rows: RefCell<Vec<CopyableRow>>,
#[template_child]
pub upgrade_button: TemplateChild<SpinnerButton>,
#[template_child]
pub room_federated: TemplateChild<adw::ActionRow>,
@ -103,6 +111,8 @@ mod imp {
pub notifications_settings_handlers: RefCell<Vec<glib::SignalHandlerId>>,
pub membership_handler: RefCell<Option<glib::SignalHandlerId>>,
pub permissions_handler: RefCell<Option<glib::SignalHandlerId>>,
pub canonical_alias_handler: RefCell<Option<glib::SignalHandlerId>>,
pub alt_aliases_handler: RefCell<Option<glib::SignalHandlerId>>,
pub capabilities: RefCell<Capabilities>,
}
@ -171,9 +181,25 @@ mod imp {
room.permissions()
.connect_changed(clone!(@weak obj => move |_| {
obj.update_upgrade_button();
obj.update_edit_addresses_button();
}));
self.permissions_handler.replace(Some(permissions_handler));
let aliases = room.aliases();
let canonical_alias_handler =
aliases.connect_canonical_alias_string_notify(clone!(@weak obj => move |_| {
obj.update_addresses();
}));
self.canonical_alias_handler
.replace(Some(canonical_alias_handler));
let alt_aliases_handler = aliases.alt_aliases_model().connect_items_changed(
clone!(@weak obj => move |_,_,_,_| {
obj.update_addresses();
}),
);
self.alt_aliases_handler.replace(Some(alt_aliases_handler));
let room_handler_ids = vec![
room.connect_name_notify(clone!(@weak obj => move |room| {
obj.name_changed(room.name());
@ -219,6 +245,8 @@ mod imp {
}
obj.update_notifications();
obj.update_edit_addresses_button();
obj.update_addresses();
obj.update_federated();
obj.update_sections();
obj.update_upgrade_button();
@ -712,6 +740,14 @@ impl GeneralPage {
if let Some(handler) = imp.permissions_handler.take() {
room.permissions().disconnect(handler);
}
let aliases = room.aliases();
if let Some(handler) = imp.canonical_alias_handler.take() {
aliases.disconnect(handler);
}
if let Some(handler) = imp.alt_aliases_handler.take() {
aliases.alt_aliases_model().disconnect(handler);
}
}
imp.room.disconnect_signals();
@ -793,6 +829,111 @@ impl GeneralPage {
);
}
/// Update the button to edit addresses.
fn update_edit_addresses_button(&self) {
let Some(room) = self.room() else {
return;
};
let can_edit = room.is_joined()
&& room
.permissions()
.is_allowed_to(PowerLevelAction::SendState(StateEventType::RoomPowerLevels));
self.imp().edit_addresses_button.set_visible(can_edit);
}
/// Update the addresses group.
fn update_addresses(&self) {
let Some(room) = self.room() else {
return;
};
let imp = self.imp();
let aliases = room.aliases();
let canonical_alias_string = aliases.canonical_alias_string();
let has_canonical_alias = canonical_alias_string.is_some();
if let Some(canonical_alias_string) = canonical_alias_string {
let mut row_borrow = imp.canonical_alias_row.borrow_mut();
let row = row_borrow.get_or_insert_with(|| {
// We want the main alias always at the top but cannot add a row at the top so
// we have to remove the other rows first.
self.remove_alt_aliases_rows();
let row = CopyableRow::new();
row.set_copy_button_tooltip_text(Some(gettext("Copy address")));
row.set_toast_text(Some(gettext("Address copied to clipboard")));
// Mark the main alias with a tag.
let label = gtk::Label::builder()
.label(gettext("Main Address"))
.ellipsize(pango::EllipsizeMode::End)
.css_classes(["public-address-tag"])
.valign(gtk::Align::Center)
.build();
row.update_relation(&[gtk::accessible::Relation::DescribedBy(&[
label.upcast_ref()
])]);
row.set_extra_suffix(Some(label));
imp.addresses_group.add(&row);
row
});
row.set_title(&canonical_alias_string);
} else if let Some(row) = imp.canonical_alias_row.take() {
imp.addresses_group.remove(&row);
}
let alt_aliases = aliases.alt_aliases_model();
let alt_aliases_count = alt_aliases.n_items() as usize;
if alt_aliases_count == 0 {
self.remove_alt_aliases_rows();
} else {
let mut rows = imp.alt_aliases_rows.borrow_mut();
for (pos, alt_alias) in alt_aliases.iter::<glib::Object>().enumerate() {
let Some(alt_alias) = alt_alias.ok().and_downcast::<gtk::StringObject>() else {
break;
};
let row = rows.get(pos).cloned().unwrap_or_else(|| {
let row = CopyableRow::new();
row.set_copy_button_tooltip_text(Some(gettext("Copy address")));
row.set_toast_text(Some(gettext("Address copied to clipboard")));
imp.addresses_group.add(&row);
rows.push(row.clone());
row
});
row.set_title(&alt_alias.string());
}
let rows_count = rows.len();
if alt_aliases_count < rows_count {
for _ in alt_aliases_count..rows_count {
if let Some(row) = rows.pop() {
imp.addresses_group.remove(&row);
}
}
}
}
imp.no_addresses_label
.set_visible(!has_canonical_alias && alt_aliases_count == 0);
}
fn remove_alt_aliases_rows(&self) {
let imp = self.imp();
for row in imp.alt_aliases_rows.take() {
imp.addresses_group.remove(&row);
}
}
/// Update the room upgrade button.
fn update_upgrade_button(&self) {
let Some(room) = self.room() else {

27
src/session/view/content/room_details/general_page/mod.ui

@ -245,6 +245,33 @@
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup" id="addresses_group">
<property name="title" translatable="yes">Public Addresses</property>
<child type="header-suffix">
<object class="GtkButton" id="edit_addresses_button">
<!-- Translators: In this string, 'Edit' is a verb. -->
<property name="label" translatable="yes" context="room details">Edit</property>
<property name="action-name">details.show-subpage</property>
<property name="action-target">'addresses'</property>
<accessibility>
<property name="description" translatable="yes">Edit Public Addresses</property>
</accessibility>
</object>
</child>
<child>
<object class="GtkLabel" id="no_addresses_label">
<property name="wrap">true</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<property name="label" translatable="yes">This room has no public addresses</property>
<style>
<class name="dim-label"/>
</style>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Advanced Information</property>

6
src/session/view/content/room_details/mod.rs

@ -1,3 +1,4 @@
mod addresses_subpage;
mod general_page;
mod history_viewer;
mod invite_subpage;
@ -9,7 +10,8 @@ use std::convert::From;
use adw::{prelude::*, subclass::prelude::*};
use gtk::{glib, CompositeTemplate};
pub use self::{
use self::{
addresses_subpage::AddressesSubpage,
general_page::GeneralPage,
history_viewer::{
AudioHistoryViewer, FileHistoryViewer, HistoryViewerTimeline, MediaHistoryViewer,
@ -26,6 +28,7 @@ pub enum SubpageName {
MediaHistory,
FileHistory,
AudioHistory,
Addresses,
}
mod imp {
@ -142,6 +145,7 @@ impl RoomDetails {
SubpageName::MediaHistory => MediaHistoryViewer::new(&self.timeline()).upcast(),
SubpageName::FileHistory => FileHistoryViewer::new(&self.timeline()).upcast(),
SubpageName::AudioHistory => AudioHistoryViewer::new(&self.timeline()).upcast(),
SubpageName::Addresses => AddressesSubpage::new(&room).upcast(),
});
if is_initial {

5
src/session/view/content/room_history/message_toolbar/completion/room_list.rs

@ -1,7 +1,7 @@
use gtk::{glib, glib::closure, prelude::*, subclass::prelude::*};
use crate::{
session::model::{Member, Membership, Room, RoomList, RoomType},
session::model::{Member, Membership, Room, RoomAliases, RoomList, RoomType},
utils::{expression, ExpressionListModel},
};
@ -85,7 +85,8 @@ mod imp {
.build();
// Setup the search filter.
let alias_expr = Room::this_expression("alias-string");
let alias_expr =
Room::this_expression("aliases").chain_property::<RoomAliases>("alias-string");
let room_search_string_expr = gtk::ClosureExpression::new::<String>(
&[alias_expr.clone(), display_name_expr.clone()],
closure!(

3
src/ui-resources.gresource.xml

@ -27,6 +27,7 @@
<file compressed="true" preprocess="xml-stripblanks">components/reaction_chooser.ui</file>
<file compressed="true" preprocess="xml-stripblanks">components/removable_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">components/room_title.ui</file>
<file compressed="true" preprocess="xml-stripblanks">components/substring_entry_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">components/switch_loading_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">components/toastable_window.ui</file>
<file compressed="true" preprocess="xml-stripblanks">components/user_page.ui</file>
@ -59,6 +60,8 @@
<file compressed="true" preprocess="xml-stripblanks">session/view/content/explore/servers_popover.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/invite.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_details/addresses_subpage/completion_popover.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_details/addresses_subpage/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_details/general_page/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_details/history_viewer/audio.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/room_details/history_viewer/audio_row.ui</file>

Loading…
Cancel
Save