From 455306eb37379cd0faa344bbfa4cfb3fb09ec833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 30 Dec 2023 11:31:28 +0100 Subject: [PATCH] session: Allow to manage ignored users View and manage ignored users from the account settings and the room member page. --- data/resources/style.css | 7 + po/POTFILES.in | 3 + src/session/model/ignored_users.rs | 274 ++++++++++++++++++ src/session/model/mod.rs | 2 + src/session/model/room/mod.rs | 23 +- src/session/model/room/room_type.rs | 18 +- src/session/model/session.rs | 8 +- .../sidebar_data/category/category_type.rs | 6 +- src/session/model/user.rs | 44 ++- src/session/view/account_settings/mod.ui | 3 + .../ignored_users_subpage/ignored_user_row.rs | 107 +++++++ .../ignored_users_subpage/ignored_user_row.ui | 32 ++ .../ignored_users_subpage/mod.rs | 166 +++++++++++ .../ignored_users_subpage/mod.ui | 86 ++++++ .../account_settings/security_page/mod.rs | 82 +++++- .../account_settings/security_page/mod.ui | 30 ++ .../completion/completion_popover.rs | 31 +- src/session/view/sidebar/room_row.rs | 2 +- src/session/view/user_page.rs | 67 ++++- src/session/view/user_page.ui | 13 + src/ui-resources.gresource.xml | 2 + 21 files changed, 964 insertions(+), 42 deletions(-) create mode 100644 src/session/model/ignored_users.rs create mode 100644 src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.rs create mode 100644 src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.ui create mode 100644 src/session/view/account_settings/security_page/ignored_users_subpage/mod.rs create mode 100644 src/session/view/account_settings/security_page/ignored_users_subpage/mod.ui diff --git a/data/resources/style.css b/data/resources/style.css index 085cf5b5..5eebabc1 100644 --- a/data/resources/style.css +++ b/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; +} \ No newline at end of file diff --git a/po/POTFILES.in b/po/POTFILES.in index a00e1522..3777d773 100644 --- a/po/POTFILES.in +++ b/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 diff --git a/src/session/model/ignored_users.rs b/src/session/model/ignored_users.rs new file mode 100644 index 00000000..1c6b4d76 --- /dev/null +++ b/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, + /// The content of the ignored user list event. + pub list: RefCell>, + abort_handle: RefCell>, + } + + #[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 { + 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) { + 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::() + .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) { + 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) + @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() + } +} diff --git a/src/session/model/mod.rs b/src/session/model/mod.rs index e05b7c99..22b38b13 100644 --- a/src/session/model/mod.rs +++ b/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, }, diff --git a/src/session/model/room/mod.rs b/src/session/model/room/mod.rs index e7552522..5cd996de 100644 --- a/src/session/model/room/mod.rs +++ b/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::().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 diff --git a/src/session/model/room/room_type.rs b/src/session/model/room/room_type.rs index 7715bc1c..315c5862 100644 --- a/src/session/model/room/room_type.rs +++ b/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), } } } diff --git a/src/session/model/session.rs b/src/session/model/session.rs index 26597f1b..6deef826 100644 --- a/src/session/model/session.rs +++ b/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(); diff --git a/src/session/model/sidebar_data/category/category_type.rs b/src/session/model/sidebar_data/category/category_type.rs index 7dd5c835..da4ceca3 100644 --- a/src/session/model/sidebar_data/category/category_type.rs +++ b/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, } } } diff --git a/src/session/model/user.rs b/src/session/model/user.rs index d9d05a4a..7cbdee5c 100644 --- a/src/session/model/user.rs +++ b/src/session/model/user.rs @@ -54,6 +54,10 @@ mod imp { /// this user. #[property(get = Self::allowed_actions)] pub allowed_actions: PhantomData, + /// Whether this user is currently ignored.. + #[property(get)] + pub is_ignored: Cell, + ignored_handler: RefCell>, } #[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 { @@ -320,6 +357,11 @@ pub trait UserExt: IsA { }; })); } + + /// Whether this user is currently ignored. + fn is_ignored(&self) -> bool { + self.upcast_ref().is_ignored() + } } impl> UserExt for T {} diff --git a/src/session/view/account_settings/mod.ui b/src/session/view/account_settings/mod.ui index c8740e48..912ada47 100644 --- a/src/session/view/account_settings/mod.ui +++ b/src/session/view/account_settings/mod.ui @@ -4,6 +4,9 @@ Account Settings false 780 + diff --git a/src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.rs b/src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.rs new file mode 100644 index 00000000..c7e40145 --- /dev/null +++ b/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, + /// The item containing the user ID presented by this row. + #[property(get, set = Self::set_item, explicit_notify, nullable)] + pub item: RefCell>, + /// The current list of ignored users. + #[property(get, set, nullable)] + pub ignored_users: RefCell>, + } + + #[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) { + 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) { + 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) + @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); + } + }) + ); + } +} diff --git a/src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.ui b/src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.ui new file mode 100644 index 00000000..58a371a0 --- /dev/null +++ b/src/session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.ui @@ -0,0 +1,32 @@ + + + + diff --git a/src/session/view/account_settings/security_page/ignored_users_subpage/mod.rs b/src/session/view/account_settings/security_page/ignored_users_subpage/mod.rs new file mode 100644 index 00000000..5b960ef2 --- /dev/null +++ b/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, + #[template_child] + pub search_bar: TemplateChild, + #[template_child] + pub search_entry: TemplateChild, + #[template_child] + pub list_view: TemplateChild, + pub filtered_model: gtk::FilterListModel, + /// The current session. + #[property(get, set = Self::set_session, explicit_notify, nullable)] + pub session: glib::WeakRef, + pub items_changed_handler: RefCell>, + } + + #[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) { + 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::() 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(>k::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) { + 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) + @extends gtk::Widget, adw::NavigationPage; +} + +impl IgnoredUsersSubpage { + pub fn new() -> Self { + glib::Object::new() + } +} diff --git a/src/session/view/account_settings/security_page/ignored_users_subpage/mod.ui b/src/session/view/account_settings/security_page/ignored_users_subpage/mod.ui new file mode 100644 index 00000000..e964d763 --- /dev/null +++ b/src/session/view/account_settings/security_page/ignored_users_subpage/mod.ui @@ -0,0 +1,86 @@ + + + + diff --git a/src/session/view/account_settings/security_page/mod.rs b/src/session/view/account_settings/security_page/mod.rs index a89a5f8d..3a9ee5a6 100644 --- a/src/session/view/account_settings/security_page/mod.rs +++ b/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, + #[template_child] + pub ignored_users_subpage: TemplateChild, + #[template_child] + pub ignored_users_count: TemplateChild, #[template_child] pub import_export_keys_subpage: TemplateChild, #[template_child] @@ -29,6 +36,10 @@ mod imp { pub self_signing_key_status: TemplateChild, #[template_child] pub user_signing_key_status: TemplateChild, + /// The current session. + #[property(get, set = Self::set_session, nullable)] + pub session: glib::WeakRef, + pub ignored_users_count_handler: RefCell>, } #[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) { - 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) { + let Some(window) = self.root().and_downcast::() 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::() - .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::() - .unwrap() - .push_subpage(subpage); + self.push_subpage(subpage); } async fn load_cross_signing_status(&self) { diff --git a/src/session/view/account_settings/security_page/mod.ui b/src/session/view/account_settings/security_page/mod.ui index 2389ba55..fad34e05 100644 --- a/src/session/view/account_settings/security_page/mod.ui +++ b/src/session/view/account_settings/security_page/mod.ui @@ -4,6 +4,33 @@ security-symbolic Security security + + + + + Ignored Users + 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. + True + + + + center + center + presentation + + + + + center + center + go-next-symbolic + presentation + + + + + + @@ -62,6 +89,9 @@ + + + diff --git a/src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs b/src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs index 097c8e76..200958af 100644 --- a/src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs +++ b/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::( - closure!(|_obj: Option, 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::( &[ @@ -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::( + closure!(|_obj: Option, 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(), diff --git a/src/session/view/sidebar/room_row.rs b/src/session/view/sidebar/room_row.rs index d4789992..fe913176 100644 --- a/src/session/view/sidebar/room_row.rs +++ b/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 => {} } } diff --git a/src/session/view/user_page.rs b/src/session/view/user_page.rs index ebcd431c..fe0f6f96 100644 --- a/src/session/view/user_page.rs +++ b/src/session/view/user_page.rs @@ -32,6 +32,10 @@ mod imp { pub verified_stack: TemplateChild, #[template_child] pub verify_button: TemplateChild, + #[template_child] + pub ignored_row: TemplateChild, + #[template_child] + pub ignored_button: TemplateChild, /// The current user. #[property(get, set = Self::set_user, construct_only)] pub user: BoundObject, @@ -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) -> 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); + })); + } } diff --git a/src/session/view/user_page.ui b/src/session/view/user_page.ui index 9bc2e2bf..cf7a95f7 100644 --- a/src/session/view/user_page.ui +++ b/src/session/view/user_page.ui @@ -92,6 +92,7 @@ + False verify_button @@ -129,6 +130,18 @@ + + + False + ignored_button + + + center + + + + + diff --git a/src/ui-resources.gresource.xml b/src/ui-resources.gresource.xml index 8b11abcc..b79ae474 100644 --- a/src/ui-resources.gresource.xml +++ b/src/ui-resources.gresource.xml @@ -39,6 +39,8 @@ session/view/account_settings/general_page/mod.ui session/view/account_settings/mod.ui session/view/account_settings/notifications_page.ui + session/view/account_settings/security_page/ignored_users_subpage/ignored_user_row.ui + session/view/account_settings/security_page/ignored_users_subpage/mod.ui session/view/account_settings/security_page/import_export_keys_subpage.ui session/view/account_settings/security_page/mod.ui session/view/content/explore/mod.ui