Browse Source

session: Allow to manage ignored users

View and manage ignored users from the account settings
and the room member page.
fractal-6
Kévin Commaille 2 years ago committed by Kévin Commaille
parent
commit
455306eb37
  1. 7
      data/resources/style.css
  2. 3
      po/POTFILES.in
  3. 274
      src/session/model/ignored_users.rs
  4. 2
      src/session/model/mod.rs
  5. 23
      src/session/model/room/mod.rs
  6. 18
      src/session/model/room/room_type.rs
  7. 8
      src/session/model/session.rs
  8. 6
      src/session/model/sidebar_data/category/category_type.rs
  9. 44
      src/session/model/user.rs
  10. 3
      src/session/view/account_settings/mod.ui
  11. 107
      src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.rs
  12. 32
      src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.ui
  13. 166
      src/session/view/account_settings/security_page/ignored_users_subpage/mod.rs
  14. 86
      src/session/view/account_settings/security_page/ignored_users_subpage/mod.ui
  15. 82
      src/session/view/account_settings/security_page/mod.rs
  16. 30
      src/session/view/account_settings/security_page/mod.ui
  17. 31
      src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs
  18. 2
      src/session/view/sidebar/room_row.rs
  19. 67
      src/session/view/user_page.rs
  20. 13
      src/session/view/user_page.ui
  21. 2
      src/ui-resources.gresource.xml

7
data/resources/style.css

@ -792,3 +792,10 @@ dragoverlay statuspage {
background-color: alpha(@accent_bg_color, 0.5);
color: @accent_fg_color;
}
/* Account Settings */
.account-settings listview {
background: transparent;
}

3
po/POTFILES.in

@ -48,6 +48,9 @@ src/session/view/account_settings/general_page/mod.ui
src/session/view/account_settings/mod.ui
src/session/view/account_settings/notifications_page.rs
src/session/view/account_settings/notifications_page.ui
src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.rs
src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.ui
src/session/view/account_settings/security_page/ignored_users_subpage/mod.ui
src/session/view/account_settings/security_page/import_export_keys_subpage.rs
src/session/view/account_settings/security_page/import_export_keys_subpage.ui
src/session/view/account_settings/security_page/mod.rs

274
src/session/model/ignored_users.rs

@ -0,0 +1,274 @@
use futures_util::StreamExt;
use gtk::{
gio,
glib::{self, clone},
prelude::*,
subclass::prelude::*,
};
use indexmap::IndexSet;
use ruma::{events::ignored_user_list::IgnoredUserListEventContent, OwnedUserId};
use tracing::{debug, error, warn};
use super::Session;
use crate::{spawn, spawn_tokio};
mod imp {
use std::cell::RefCell;
use super::*;
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::IgnoredUsers)]
pub struct IgnoredUsers {
/// The current session.
#[property(get, set = Self::set_session, explicit_notify, nullable)]
pub session: glib::WeakRef<Session>,
/// The content of the ignored user list event.
pub list: RefCell<IndexSet<OwnedUserId>>,
abort_handle: RefCell<Option<tokio::task::AbortHandle>>,
}
#[glib::object_subclass]
impl ObjectSubclass for IgnoredUsers {
const NAME: &'static str = "IgnoredUsers";
type Type = super::IgnoredUsers;
type Interfaces = (gio::ListModel,);
}
#[glib::derived_properties]
impl ObjectImpl for IgnoredUsers {
fn dispose(&self) {
if let Some(abort_handle) = self.abort_handle.take() {
abort_handle.abort();
}
}
}
impl ListModelImpl for IgnoredUsers {
fn item_type(&self) -> glib::Type {
gtk::StringObject::static_type()
}
fn n_items(&self) -> u32 {
self.list.borrow().len() as u32
}
fn item(&self, position: u32) -> Option<glib::Object> {
self.list
.borrow()
.get_index(position as usize)
.map(|user_id| gtk::StringObject::new(user_id.as_str()).upcast())
}
}
impl IgnoredUsers {
/// Set the current session.
fn set_session(&self, session: Option<Session>) {
if self.session.upgrade() == session {
return;
}
self.session.set(session.as_ref());
self.init();
self.obj().notify_session();
}
/// Listen to changes of the ignored users list.
fn init(&self) {
if let Some(abort_handle) = self.abort_handle.take() {
abort_handle.abort();
}
let Some(session) = self.session.upgrade() else {
return;
};
let obj = self.obj();
let obj_weak = glib::SendWeakRef::from(obj.downgrade());
let subscriber = session.client().subscribe_to_ignore_user_list_changes();
let fut = subscriber.for_each(move |_| {
let obj_weak = obj_weak.clone();
async move {
let ctx = glib::MainContext::default();
ctx.spawn(async move {
spawn!(async move {
if let Some(obj) = obj_weak.upgrade() {
obj.imp().load_list().await;
}
});
});
}
});
let abort_handle = spawn_tokio!(fut).abort_handle();
self.abort_handle.replace(Some(abort_handle));
spawn!(clone!(@weak self as imp => async move {
imp.load_list().await;
}));
}
/// Load the list from the store and update it.
async fn load_list(&self) {
let Some(session) = self.session.upgrade() else {
return;
};
let client = session.client();
let handle = spawn_tokio!(async move {
client
.account()
.account_data::<IgnoredUserListEventContent>()
.await
});
let raw = match handle.await.unwrap() {
Ok(Some(raw)) => raw,
Ok(None) => {
debug!("Got no ignored users list");
self.update_list(IndexSet::new());
return;
}
Err(error) => {
error!("Failed to get ignored users list: {error}");
return;
}
};
match raw.deserialize() {
Ok(content) => self.update_list(content.ignored_users.into_keys().collect()),
Err(error) => {
error!("Failed to deserialize ignored users list: {error}");
}
}
}
/// Update the list with the given new list.
fn update_list(&self, new_list: IndexSet<OwnedUserId>) {
if *self.list.borrow() == new_list {
return;
}
let old_len = self.n_items();
let new_len = new_list.len() as u32;
let mut pos = 0;
{
let old_list = self.list.borrow();
for old_item in old_list.iter() {
let Some(new_item) = new_list.get_index(pos as usize) else {
break;
};
if old_item != new_item {
break;
}
pos += 1;
}
}
if old_len == new_len && pos == new_len {
// Nothing changed.
return;
}
self.list.replace(new_list);
self.obj().items_changed(
pos,
old_len.saturating_sub(pos),
new_len.saturating_sub(pos),
);
}
}
}
glib::wrapper! {
/// The list of ignored users of a `Session`.
pub struct IgnoredUsers(ObjectSubclass<imp::IgnoredUsers>)
@implements gio::ListModel;
}
impl IgnoredUsers {
pub fn new() -> Self {
glib::Object::new()
}
/// Whether this list contains the given user ID.
pub fn contains(&self, user_id: &OwnedUserId) -> bool {
self.imp().list.borrow().contains(user_id)
}
/// Add the user with the given ID to the list.
pub async fn add(&self, user_id: &OwnedUserId) -> Result<(), ()> {
let Some(session) = self.session() else {
return Err(());
};
if self.contains(user_id) {
warn!("Trying to add `{user_id}` to the ignored users but they are already in the list, ignoring");
return Ok(());
}
let client = session.client();
let user_id_clone = user_id.clone();
let handle =
spawn_tokio!(async move { client.account().ignore_user(&user_id_clone).await });
match handle.await.unwrap() {
Ok(_) => {
let (pos, added) = self.imp().list.borrow_mut().insert_full(user_id.clone());
if added {
self.items_changed(pos as u32, 0, 1);
}
Ok(())
}
Err(error) => {
error!("Failed to add `{user_id}` to the ignored users: {error}");
Err(())
}
}
}
/// Remove the user with the given ID from the list.
pub async fn remove(&self, user_id: &OwnedUserId) -> Result<(), ()> {
let Some(session) = self.session() else {
return Err(());
};
if !self.contains(user_id) {
warn!("Trying to remove `{user_id}` from the ignored users but they are not in the list, ignoring");
return Ok(());
}
let client = session.client();
let user_id_clone = user_id.clone();
let handle =
spawn_tokio!(async move { client.account().unignore_user(&user_id_clone).await });
match handle.await.unwrap() {
Ok(_) => {
let removed = self.imp().list.borrow_mut().shift_remove_full(user_id);
if let Some((pos, _)) = removed {
self.items_changed(pos as u32, 1, 0);
}
Ok(())
}
Err(error) => {
error!("Failed to remove `{user_id}` from the ignored users: {error}");
Err(())
}
}
}
}
impl Default for IgnoredUsers {
fn default() -> Self {
Self::new()
}
}

2
src/session/model/mod.rs

@ -1,4 +1,5 @@
mod avatar_data;
mod ignored_users;
mod notifications;
mod room;
mod room_list;
@ -10,6 +11,7 @@ mod verification;
pub use self::{
avatar_data::{AvatarData, AvatarImage, AvatarUriSource},
ignored_users::IgnoredUsers,
notifications::{
Notifications, NotificationsGlobalSetting, NotificationsRoomSetting, NotificationsSettings,
},

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

@ -52,7 +52,7 @@ pub use self::{
};
use super::{
notifications::NotificationsRoomSetting, room_list::RoomMetainfo, AvatarData, AvatarImage,
AvatarUriSource, IdentityVerification, Session, SidebarItem, SidebarItemImpl,
AvatarUriSource, IdentityVerification, Session, SidebarItem, SidebarItemImpl, User,
};
use crate::{components::Pill, gettext_f, prelude::*, spawn, spawn_tokio};
@ -581,8 +581,7 @@ impl Room {
RoomType::Left => {
matrix_room.leave().await?;
}
RoomType::Outdated => unimplemented!(),
RoomType::Space => unimplemented!(),
RoomType::Outdated | RoomType::Space | RoomType::Ignored => unimplemented!(),
},
RoomState::Joined => match category {
RoomType::Invited => {}
@ -614,8 +613,7 @@ impl Room {
RoomType::Left => {
matrix_room.leave().await?;
}
RoomType::Outdated => unimplemented!(),
RoomType::Space => unimplemented!(),
RoomType::Outdated | RoomType::Space | RoomType::Ignored => unimplemented!(),
},
RoomState::Left => match category {
RoomType::Invited => {}
@ -657,8 +655,7 @@ impl Room {
matrix_room.join().await?;
}
RoomType::Left => {}
RoomType::Outdated => unimplemented!(),
RoomType::Space => unimplemented!(),
RoomType::Outdated | RoomType::Space | RoomType::Ignored => unimplemented!(),
},
}
@ -685,6 +682,10 @@ impl Room {
return;
}
if self.inviter().is_some_and(|i| i.is_ignored()) {
self.set_category_internal(RoomType::Ignored);
}
let matrix_room = self.matrix_room();
match matrix_room.state() {
RoomState::Joined => {
@ -1009,8 +1010,16 @@ impl Room {
let inviter = Member::new(self, inviter_id);
inviter.update_from_room_member(&inviter_member);
inviter.upcast_ref::<User>().connect_is_ignored_notify(
clone!(@weak self as obj => move |_| {
obj.load_category();
}),
);
self.imp().inviter.replace(Some(inviter));
self.notify_inviter();
self.load_category();
}
/// Update the room state based on the new sync response

18
src/session/model/room/room_type.rs

@ -11,14 +11,26 @@ use crate::session::model::CategoryType;
#[repr(u32)]
#[enum_type(name = "RoomType")]
pub enum RoomType {
/// The user was invited to the room.
Invited = 0,
/// The room is joined and has the `m.favourite` tag.
Favorite = 1,
/// The room is joined and has no known tag.
#[default]
Normal = 2,
/// The room is joined and has the `m.lowpriority` tag.
LowPriority = 3,
/// The room was left by the user, or they were kicked or banned.
Left = 4,
/// The room was upgraded and their successor was joined.
Outdated = 5,
/// The room is a space.
Space = 6,
/// The room should be ignored.
///
/// According to the Matrix specification, invites from ignored users
/// should be ignored.
Ignored = 7,
}
impl RoomType {
@ -43,15 +55,14 @@ impl RoomType {
Self::Left => {
matches!(category, Self::Favorite | Self::Normal | Self::LowPriority)
}
Self::Outdated => false,
Self::Space => false,
Self::Ignored | Self::Outdated | Self::Space => false,
}
}
/// Whether this `RoomType` corresponds to the given state.
pub fn is_state(&self, state: RoomState) -> bool {
match self {
RoomType::Invited => state == RoomState::Invited,
RoomType::Invited | RoomType::Ignored => state == RoomState::Invited,
RoomType::Favorite
| RoomType::Normal
| RoomType::LowPriority
@ -92,6 +103,7 @@ impl TryFrom<&CategoryType> for RoomType {
Err("CategoryType::VerificationRequest cannot be a RoomType")
}
CategoryType::Space => Ok(Self::Space),
CategoryType::Ignored => Ok(Self::Ignored),
}
}
}

8
src/session/model/session.rs

@ -23,8 +23,8 @@ use tracing::{debug, error};
use url::Url;
use super::{
AvatarData, ItemList, Notifications, RoomList, SessionSettings, SidebarListModel, User,
VerificationList,
AvatarData, IgnoredUsers, ItemList, Notifications, RoomList, SessionSettings, SidebarListModel,
User, VerificationList,
};
use crate::{
prelude::*,
@ -86,6 +86,9 @@ mod imp {
/// The notifications API for this session.
#[property(get)]
pub notifications: Notifications,
/// The ignored users API for this session.
#[property(get)]
pub ignored_users: IgnoredUsers,
}
#[glib::object_subclass]
@ -101,6 +104,7 @@ mod imp {
self.parent_constructed();
let obj = self.obj();
self.ignored_users.set_session(Some(obj.clone()));
self.notifications.set_session(Some(obj.clone()));
let monitor = gio::NetworkMonitor::default();

6
src/session/model/sidebar_data/category/category_type.rs

@ -19,6 +19,7 @@ pub enum CategoryType {
Left = 5,
Outdated = 6,
Space = 7,
Ignored = 8,
}
impl fmt::Display for CategoryType {
@ -32,7 +33,9 @@ impl fmt::Display for CategoryType {
CategoryType::LowPriority => gettext("Low Priority"),
CategoryType::Left => gettext("Historical"),
// These categories are hidden.
CategoryType::Outdated | CategoryType::Space => unimplemented!(),
CategoryType::Outdated | CategoryType::Space | CategoryType::Ignored => {
unimplemented!()
}
};
f.write_str(&label)
}
@ -54,6 +57,7 @@ impl From<&RoomType> for CategoryType {
RoomType::Left => Self::Left,
RoomType::Outdated => Self::Outdated,
RoomType::Space => Self::Space,
RoomType::Ignored => Self::Ignored,
}
}
}

44
src/session/model/user.rs

@ -54,6 +54,10 @@ mod imp {
/// this user.
#[property(get = Self::allowed_actions)]
pub allowed_actions: PhantomData<UserActions>,
/// Whether this user is currently ignored..
#[property(get)]
pub is_ignored: Cell<bool>,
ignored_handler: RefCell<Option<glib::SignalHandlerId>>,
}
#[glib::object_subclass]
@ -75,6 +79,14 @@ mod imp {
));
self.avatar_data.set(avatar_data).unwrap();
}
fn dispose(&self) {
if let Some(session) = self.session.get() {
if let Some(handler) = self.ignored_handler.take() {
session.ignored_users().disconnect(handler);
}
}
}
}
impl User {
@ -85,13 +97,28 @@ mod imp {
/// Set the ID of this user.
pub fn set_user_id(&self, user_id: OwnedUserId) {
self.user_id.set(user_id).unwrap();
self.user_id.set(user_id.clone()).unwrap();
let obj = self.obj();
obj.bind_property("display-name", &obj.avatar_data(), "display-name")
.sync_create()
.build();
let ignored_users = self.session.get().unwrap().ignored_users();
let ignored_handler = ignored_users.connect_items_changed(
clone!(@weak self as imp => move |ignored_users, _, _, _| {
let user_id = imp.user_id.get().unwrap();
let is_ignored = ignored_users.contains(user_id);
if imp.is_ignored.get() != is_ignored {
imp.is_ignored.set(is_ignored);
imp.obj().notify_is_ignored();
}
}),
);
self.is_ignored.set(ignored_users.contains(&user_id));
self.ignored_handler.replace(Some(ignored_handler));
obj.init_is_verified();
}
@ -239,6 +266,16 @@ impl User {
debug!("Creating direct chat with {user_id}…");
self.create_direct_chat().await.map_err(|_| ())
}
/// Ignore this user.
pub async fn ignore(&self) -> Result<(), ()> {
self.session().ignored_users().add(self.user_id()).await
}
/// Stop ignoring this user.
pub async fn stop_ignoring(&self) -> Result<(), ()> {
self.session().ignored_users().remove(self.user_id()).await
}
}
pub trait UserExt: IsA<User> {
@ -320,6 +357,11 @@ pub trait UserExt: IsA<User> {
};
}));
}
/// Whether this user is currently ignored.
fn is_ignored(&self) -> bool {
self.upcast_ref().is_ignored()
}
}
impl<T: IsA<User>> UserExt for T {}

3
src/session/view/account_settings/mod.ui

@ -4,6 +4,9 @@
<property name="title" translatable="yes">Account Settings</property>
<property name="search-enabled">false</property>
<property name="default-height">780</property>
<style>
<class name="account-settings"/>
</style>
<child>
<object class="AccountSettingsGeneralPage" id="general_page">
<property name="session" bind-source="AccountSettings" bind-property="session" bind-flags="sync-create"/>

107
src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.rs

@ -0,0 +1,107 @@
use gettextrs::gettext;
use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
use ruma::UserId;
use crate::{components::SpinnerButton, session::model::IgnoredUsers, spawn, toast};
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/account_settings/security_page/ignored_users_subpage/ignored_user_row.ui"
)]
#[properties(wrapper_type = super::IgnoredUserRow)]
pub struct IgnoredUserRow {
#[template_child]
pub stop_ignoring_button: TemplateChild<SpinnerButton>,
/// The item containing the user ID presented by this row.
#[property(get, set = Self::set_item, explicit_notify, nullable)]
pub item: RefCell<Option<gtk::StringObject>>,
/// The current list of ignored users.
#[property(get, set, nullable)]
pub ignored_users: RefCell<Option<IgnoredUsers>>,
}
#[glib::object_subclass]
impl ObjectSubclass for IgnoredUserRow {
const NAME: &'static str = "IgnoredUserRow";
type Type = super::IgnoredUserRow;
type ParentType = gtk::Box;
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 IgnoredUserRow {}
impl WidgetImpl for IgnoredUserRow {}
impl BoxImpl for IgnoredUserRow {}
impl IgnoredUserRow {
/// Set the item containing the user ID presented by this row.
fn set_item(&self, item: Option<gtk::StringObject>) {
if *self.item.borrow() == item {
return;
}
self.item.replace(item);
self.obj().notify_item();
// Reset the state of the button.
self.stop_ignoring_button.set_loading(false);
}
}
}
glib::wrapper! {
/// A row presenting an ignored user.
pub struct IgnoredUserRow(ObjectSubclass<imp::IgnoredUserRow>)
@extends gtk::Widget, gtk::Box, @implements gtk::Accessible;
}
#[gtk::template_callbacks]
impl IgnoredUserRow {
pub fn new(ignored_users: &IgnoredUsers) -> Self {
glib::Object::builder()
.property("ignored-users", ignored_users)
.build()
}
/// Stop ignoring the user of this row.
#[template_callback]
fn stop_ignoring_user(&self) {
let Some(user_id) = self
.item()
.map(|i| i.string())
.and_then(|s| UserId::parse(&s).ok())
else {
return;
};
let Some(ignored_users) = self.ignored_users() else {
return;
};
self.imp().stop_ignoring_button.set_loading(true);
spawn!(
clone!(@weak self as obj, @weak ignored_users => async move {
if ignored_users.remove(&user_id).await.is_err() {
toast!(obj, gettext("Failed to stop ignoring user"));
obj.imp().stop_ignoring_button.set_loading(false);
}
})
);
}
}

32
src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.ui

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="IgnoredUserRow" parent="GtkBox">
<property name="spacing">12</property>
<style>
<class name="header"/>
</style>
<child>
<object class="GtkLabel" id="title">
<property name="halign">start</property>
<property name="hexpand">True</property>
<property name="ellipsize">end</property>
<binding name="label">
<lookup name="string">
<lookup name="item">IgnoredUserRow</lookup>
</lookup>
</binding>
<style>
<class name="title"/>
</style>
</object>
</child>
<child>
<object class="SpinnerButton" id="stop_ignoring_button">
<property name="halign">end</property>
<property name="valign">center</property>
<property name="label" translatable="yes">Stop Ignoring</property>
<signal name="clicked" handler="stop_ignoring_user" swapped="yes"/>
</object>
</child>
</template>
</interface>

166
src/session/view/account_settings/security_page/ignored_users_subpage/mod.rs

@ -0,0 +1,166 @@
use adw::{prelude::*, subclass::prelude::*};
use gtk::{glib, glib::clone, CompositeTemplate};
use tracing::error;
mod ignored_user_row;
use self::ignored_user_row::IgnoredUserRow;
use crate::session::model::Session;
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/account_settings/security_page/ignored_users_subpage/mod.ui"
)]
#[properties(wrapper_type = super::IgnoredUsersSubpage)]
pub struct IgnoredUsersSubpage {
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
#[template_child]
pub search_bar: TemplateChild<gtk::SearchBar>,
#[template_child]
pub search_entry: TemplateChild<gtk::SearchEntry>,
#[template_child]
pub list_view: TemplateChild<gtk::ListView>,
pub filtered_model: gtk::FilterListModel,
/// The current session.
#[property(get, set = Self::set_session, explicit_notify, nullable)]
pub session: glib::WeakRef<Session>,
pub items_changed_handler: RefCell<Option<glib::SignalHandlerId>>,
}
#[glib::object_subclass]
impl ObjectSubclass for IgnoredUsersSubpage {
const NAME: &'static str = "IgnoredUsersSubpage";
type Type = super::IgnoredUsersSubpage;
type ParentType = adw::NavigationPage;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for IgnoredUsersSubpage {
fn constructed(&self) {
self.parent_constructed();
// Needed because the GtkSearchEntry is not the direct child of the
// GtkSearchBar.
self.search_bar.connect_entry(&*self.search_entry);
let search_filter = gtk::StringFilter::builder()
.match_mode(gtk::StringFilterMatchMode::Substring)
.expression(gtk::StringObject::this_expression("string"))
.ignore_case(true)
.build();
self.search_entry
.bind_property("text", &search_filter, "search")
.sync_create()
.build();
self.filtered_model.set_filter(Some(&search_filter));
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(clone!(@weak self as imp => move |_, item| {
let Some(session) = imp.session.upgrade() else {
return;
};
let Some(item) = item.downcast_ref::<gtk::ListItem>() else {
error!("List item factory did not receive a list item: {item:?}");
return;
};
let row = IgnoredUserRow::new(&session.ignored_users());
item.set_child(Some(&row));
item.bind_property("item", &row, "item").build();
item.set_activatable(false);
item.set_selectable(false);
}));
self.list_view.set_factory(Some(&factory));
self.list_view.set_model(Some(&gtk::NoSelection::new(Some(
self.filtered_model.clone(),
))));
}
fn dispose(&self) {
if let Some(session) = self.session.upgrade() {
if let Some(handler) = self.items_changed_handler.take() {
session.ignored_users().disconnect(handler);
}
}
}
}
impl WidgetImpl for IgnoredUsersSubpage {}
impl NavigationPageImpl for IgnoredUsersSubpage {}
impl IgnoredUsersSubpage {
/// Set the current session.
fn set_session(&self, session: Option<Session>) {
let prev_session = self.session.upgrade();
if prev_session == session {
return;
}
if let Some(session) = prev_session {
if let Some(handler) = self.items_changed_handler.take() {
session.ignored_users().disconnect(handler);
}
}
let ignored_users = session.as_ref().map(|s| s.ignored_users());
if let Some(ignored_users) = &ignored_users {
let items_changed_handler = ignored_users.connect_items_changed(
clone!(@weak self as imp => move |_, _, _, _| {
imp.update_visible_page();
}),
);
self.items_changed_handler
.replace(Some(items_changed_handler));
}
self.filtered_model.set_model(ignored_users.as_ref());
self.session.set(session.as_ref());
self.obj().notify_session();
self.update_visible_page();
}
/// Update the visible page according to the current state.
fn update_visible_page(&self) {
let has_users = self
.session
.upgrade()
.is_some_and(|s| s.ignored_users().n_items() > 0);
let page = if has_users { "list" } else { "empty" };
self.stack.set_visible_child_name(page);
}
}
}
glib::wrapper! {
/// A subpage with the list of ignored users.
pub struct IgnoredUsersSubpage(ObjectSubclass<imp::IgnoredUsersSubpage>)
@extends gtk::Widget, adw::NavigationPage;
}
impl IgnoredUsersSubpage {
pub fn new() -> Self {
glib::Object::new()
}
}

86
src/session/view/account_settings/security_page/ignored_users_subpage/mod.ui

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="IgnoredUsersSubpage" parent="AdwNavigationPage">
<property name="title" translatable="yes">Ignored Users</property>
<property name="tag">ignored-users</property>
<child>
<object class="GtkStack" id="stack">
<child>
<object class="GtkStackPage">
<property name="name">list</property>
<property name="child">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar">
<child type="end">
<object class="GtkToggleButton" id="search_button">
<property name="icon-name">system-search-symbolic</property>
<property name="tooltip-text" translatable="yes">Toggle Ignored Users Search</property>
</object>
</child>
</object>
</child>
<child type="top">
<object class="GtkSearchBar" id="search_bar">
<property name="search-mode-enabled" bind-source="search_button" bind-property="active"/>
<property name="child">
<object class="AdwClamp">
<property name="hexpand">True</property>
<property name="maximum-size">750</property>
<property name="tightening-threshold">550</property>
<child>
<object class="GtkSearchEntry" id="search_entry">
<property name="placeholder-text" translatable="yes">Search for ignored users</property>
</object>
</child>
</object>
</property>
</object>
</child>
<property name="content">
<object class="GtkScrolledWindow">
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="hscrollbar-policy">never</property>
<property name="propagate-natural-height">True</property>
<property name="child">
<object class="AdwClampScrollable">
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="child">
<object class="GtkListView" id="list_view">
<property name="show-separators">True</property>
</object>
</property>
</object>
</property>
</object>
</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">empty</property>
<property name="child">
<object class="AdwToolbarView">
<child type="top">
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="AdwStatusPage">
<property name="icon-name">users-symbolic</property>
<property name="title" translatable="yes">No Ignored Users</property>
<property name="description" translatable="yes">You can add users to this list from their room member profile.</property>
<property name="vexpand">true</property>
</object>
</property>
</object>
</property>
</object>
</child>
</object>
</child>
</template>
</interface>

82
src/session/view/account_settings/security_page/mod.rs

@ -2,12 +2,18 @@ use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{glib, glib::clone, CompositeTemplate};
use crate::{components::ButtonRow, session::model::Session, spawn, spawn_tokio};
mod ignored_users_subpage;
mod import_export_keys_subpage;
use import_export_keys_subpage::{ImportExportKeysSubpage, KeysSubpageMode};
use self::{
ignored_users_subpage::IgnoredUsersSubpage,
import_export_keys_subpage::{ImportExportKeysSubpage, KeysSubpageMode},
};
use crate::{components::ButtonRow, session::model::Session, spawn, spawn_tokio};
mod imp {
use std::cell::RefCell;
use glib::subclass::InitializingObject;
use super::*;
@ -18,9 +24,10 @@ mod imp {
)]
#[properties(wrapper_type = super::SecurityPage)]
pub struct SecurityPage {
/// The current session.
#[property(get, set = Self::set_session, nullable)]
pub session: glib::WeakRef<Session>,
#[template_child]
pub ignored_users_subpage: TemplateChild<IgnoredUsersSubpage>,
#[template_child]
pub ignored_users_count: TemplateChild<gtk::Label>,
#[template_child]
pub import_export_keys_subpage: TemplateChild<ImportExportKeysSubpage>,
#[template_child]
@ -29,6 +36,10 @@ mod imp {
pub self_signing_key_status: TemplateChild<gtk::Label>,
#[template_child]
pub user_signing_key_status: TemplateChild<gtk::Label>,
/// The current session.
#[property(get, set = Self::set_session, nullable)]
pub session: glib::WeakRef<Session>,
pub ignored_users_count_handler: RefCell<Option<glib::SignalHandlerId>>,
}
#[glib::object_subclass]
@ -39,6 +50,7 @@ mod imp {
fn class_init(klass: &mut Self::Class) {
ButtonRow::static_type();
Self::bind_template(klass);
Self::Type::bind_template_callbacks(klass);
}
@ -49,7 +61,15 @@ mod imp {
}
#[glib::derived_properties]
impl ObjectImpl for SecurityPage {}
impl ObjectImpl for SecurityPage {
fn dispose(&self) {
if let Some(session) = self.session.upgrade() {
if let Some(handler) = self.ignored_users_count_handler.take() {
session.ignored_users().disconnect(handler);
}
}
}
}
impl WidgetImpl for SecurityPage {}
impl PreferencesPageImpl for SecurityPage {}
@ -57,11 +77,33 @@ mod imp {
impl SecurityPage {
/// Set the current session.
fn set_session(&self, session: Option<Session>) {
if self.session.upgrade() == session {
let prev_session = self.session.upgrade();
if prev_session == session {
return;
}
let obj = self.obj();
if let Some(session) = prev_session {
if let Some(handler) = self.ignored_users_count_handler.take() {
session.ignored_users().disconnect(handler);
}
}
if let Some(session) = &session {
let ignored_users = session.ignored_users();
let ignored_users_count_handler = ignored_users.connect_items_changed(
clone!(@weak self as imp => move |ignored_users, _, _, _| {
imp.ignored_users_count.set_label(&ignored_users.n_items().to_string());
}),
);
self.ignored_users_count
.set_label(&ignored_users.n_items().to_string());
self.ignored_users_count_handler
.replace(Some(ignored_users_count_handler));
}
self.session.set(session.as_ref());
obj.notify_session();
@ -84,24 +126,32 @@ impl SecurityPage {
glib::Object::builder().property("session", session).build()
}
fn push_subpage(&self, subpage: &impl IsA<adw::NavigationPage>) {
let Some(window) = self.root().and_downcast::<adw::PreferencesWindow>() else {
return;
};
window.push_subpage(subpage)
}
#[template_callback]
pub fn show_ignored_users_page(&self) {
let subpage = &*self.imp().ignored_users_subpage;
self.push_subpage(subpage);
}
#[template_callback]
pub fn show_export_keys_page(&self) {
let subpage = &*self.imp().import_export_keys_subpage;
subpage.set_mode(KeysSubpageMode::Export);
self.root()
.and_downcast_ref::<adw::PreferencesWindow>()
.unwrap()
.push_subpage(subpage);
self.push_subpage(subpage);
}
#[template_callback]
fn handle_import_keys(&self) {
let subpage = &*self.imp().import_export_keys_subpage;
subpage.set_mode(KeysSubpageMode::Import);
self.root()
.and_downcast_ref::<adw::PreferencesWindow>()
.unwrap()
.push_subpage(subpage);
self.push_subpage(subpage);
}
async fn load_cross_signing_status(&self) {

30
src/session/view/account_settings/security_page/mod.ui

@ -4,6 +4,33 @@
<property name="icon-name">security-symbolic</property>
<property name="title" translatable="yes">Security</property>
<property name="name">security</property>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="AdwActionRow">
<property name="title" translatable="yes">Ignored Users</property>
<property name="subtitle" translatable="yes">All messages or invitations sent by these users will be ignored. You will still see some of their activity, like when they join or leave a room.</property>
<property name="activatable">True</property>
<signal name="activated" handler="show_ignored_users_page" swapped="yes"/>
<child type="suffix">
<object class="GtkLabel" id="ignored_users_count">
<property name="valign">center</property>
<property name="halign">center</property>
<property name="accessible-role">presentation</property>
</object>
</child>
<child type="suffix">
<object class="GtkImage">
<property name="valign">center</property>
<property name="halign">center</property>
<property name="icon-name">go-next-symbolic</property>
<property name="accessible-role">presentation</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<!-- Translators: 'Room encryption keys' are encryption keys for all rooms. -->
@ -62,6 +89,9 @@
</object>
</child>
</template>
<object class="IgnoredUsersSubpage" id="ignored_users_subpage">
<property name="session" bind-source="SecurityPage" bind-property="session" bind-flags="sync-create"/>
</object>
<object class="ImportExportKeysSubpage" id="import_export_keys_subpage">
<property name="session" bind-source="SecurityPage" bind-property="session" bind-flags="sync-create"/>
</object>

31
src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs

@ -82,14 +82,10 @@ mod imp {
self.parent_constructed();
let obj = self.obj();
// Filter the members that are joined and that are not our user.
let joined_expr = Member::this_expression("membership").chain_closure::<bool>(
closure!(|_obj: Option<glib::Object>, membership: Membership| {
membership == Membership::Join
}),
);
let joined = gtk::BoolFilter::new(Some(&joined_expr));
// Filter the members, the criteria:
// - not our user
// - not ignored
// - joined
let not_own_user = gtk::BoolFilter::builder()
.expression(gtk::ClosureExpression::new::<bool>(
&[
@ -103,9 +99,25 @@ mod imp {
),
))
.build();
let ignored_expr = Member::this_expression("is-ignored");
let not_ignored = gtk::BoolFilter::builder()
.expression(&ignored_expr)
.invert(true)
.build();
let joined_expr = Member::this_expression("membership").chain_closure::<bool>(
closure!(|_obj: Option<glib::Object>, membership: Membership| {
membership == Membership::Join
}),
);
let joined = gtk::BoolFilter::new(Some(&joined_expr));
let filter = gtk::EveryFilter::new();
filter.append(joined);
filter.append(not_own_user);
filter.append(not_ignored);
filter.append(joined);
let first_model = gtk::FilterListModel::builder()
.filter(&filter)
.model(&self.members_expr)
@ -152,6 +164,7 @@ mod imp {
self.filtered_members.set_model(Some(&second_model));
self.members_expr.set_expressions(vec![
ignored_expr.upcast(),
joined_expr.upcast(),
latest_activity_expr.upcast(),
display_name_expr.upcast(),

2
src/session/view/sidebar/room_row.rs

@ -291,7 +291,7 @@ impl RoomRow {
self.action_set_enabled("room-row.forget", true);
return;
}
RoomType::Outdated | RoomType::Space => {}
RoomType::Outdated | RoomType::Space | RoomType::Ignored => {}
}
}

67
src/session/view/user_page.rs

@ -32,6 +32,10 @@ mod imp {
pub verified_stack: TemplateChild<gtk::Stack>,
#[template_child]
pub verify_button: TemplateChild<SpinnerButton>,
#[template_child]
pub ignored_row: TemplateChild<adw::ActionRow>,
#[template_child]
pub ignored_button: TemplateChild<SpinnerButton>,
/// The current user.
#[property(get, set = Self::set_user, construct_only)]
pub user: BoundObject<User>,
@ -46,6 +50,7 @@ mod imp {
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::Type::bind_template_callbacks(klass);
klass.install_action_async(
"user-page.open-direct-chat",
@ -95,17 +100,25 @@ mod imp {
let is_verified_handler = user.connect_verified_notify(clone!(@weak obj => move |_| {
obj.update_verified();
}));
let is_ignored_handler = user.connect_is_ignored_notify(clone!(@weak obj => move |_| {
obj.update_direct_chat();
obj.update_ignored();
}));
// We don't need to listen to changes of the property, it never changes after
// construction.
self.direct_chat_button.set_visible(!user.is_own_user());
let is_own_user = user.is_own_user();
self.ignored_row.set_visible(!is_own_user);
self.user.set(user, vec![is_verified_handler]);
self.user
.set(user, vec![is_verified_handler, is_ignored_handler]);
spawn!(clone!(@weak obj => async move {
obj.load_direct_chat().await;
}));
obj.update_direct_chat();
obj.update_verified();
obj.update_ignored();
}
}
}
@ -116,12 +129,21 @@ glib::wrapper! {
@extends gtk::Widget, adw::NavigationPage, @implements gtk::Accessible;
}
#[gtk::template_callbacks]
impl UserPage {
/// Construct a new `UserPage` for the given user.
pub fn new(user: &impl IsA<User>) -> Self {
glib::Object::builder().property("user", user).build()
}
/// Update the visibility of the direct chat button.
fn update_direct_chat(&self) {
let is_visible = self
.user()
.is_some_and(|u| !u.is_own_user() && !u.is_ignored());
self.imp().direct_chat_button.set_visible(is_visible);
}
/// Load whether the current user has a direct chat or not.
async fn load_direct_chat(&self) {
self.set_direct_chat_loading(true);
@ -224,4 +246,45 @@ impl UserPage {
parent_window.close();
}
/// Update the ignored row.
fn update_ignored(&self) {
let Some(user) = self.user() else {
return;
};
let imp = self.imp();
if user.is_ignored() {
imp.ignored_row.set_title(&gettext("Ignored"));
imp.ignored_button.set_label(gettext("Stop Ignoring"));
imp.ignored_button.remove_css_class("destructive-action");
} else {
imp.ignored_row.set_title(&gettext("Not Ignored"));
imp.ignored_button.set_label(gettext("Ignore"));
imp.ignored_button.add_css_class("destructive-action");
}
}
/// Toggle whether the user is ignored or not.
#[template_callback]
fn toggle_ignored(&self) {
let Some(user) = self.user() else {
return;
};
let is_ignored = user.is_ignored();
self.imp().ignored_button.set_loading(true);
spawn!(clone!(@weak self as obj, @weak user => async move {
if is_ignored {
if user.stop_ignoring().await.is_err() {
toast!(obj, gettext("Failed to stop ignoring user"));
}
} else if user.ignore().await.is_err() {
toast!(obj, gettext("Failed to ignore user"));
}
obj.imp().ignored_button.set_loading(false);
}));
}
}

13
src/session/view/user_page.ui

@ -92,6 +92,7 @@
</style>
<child>
<object class="AdwActionRow" id="verified_row">
<property name="selectable">False</property>
<property name="activatable-widget">verify_button</property>
<child>
<object class="GtkStack" id="verified_stack">
@ -129,6 +130,18 @@
</child>
</object>
</child>
<child>
<object class="AdwActionRow" id="ignored_row">
<property name="selectable">False</property>
<property name="activatable-widget">ignored_button</property>
<child>
<object class="SpinnerButton" id="ignored_button">
<property name="valign">center</property>
<signal name="clicked" handler="toggle_ignored" swapped="yes"/>
</object>
</child>
</object>
</child>
</object>
</child>
</object>

2
src/ui-resources.gresource.xml

@ -39,6 +39,8 @@
<file compressed="true" preprocess="xml-stripblanks">session/view/account_settings/general_page/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/account_settings/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/account_settings/notifications_page.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/account_settings/security_page/ignored_users_subpage/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/account_settings/security_page/import_export_keys_subpage.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/account_settings/security_page/mod.ui</file>
<file compressed="true" preprocess="xml-stripblanks">session/view/content/explore/mod.ui</file>

Loading…
Cancel
Save