Browse Source
View and manage ignored users from the account settings and the room member page.fractal-6
21 changed files with 964 additions and 42 deletions
@ -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() |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
}) |
||||
); |
||||
} |
||||
} |
||||
@ -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> |
||||
@ -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(>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<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() |
||||
} |
||||
} |
||||
@ -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> |
||||
Loading…
Reference in new issue