use adw::{prelude::*, subclass::prelude::*};
use gettextrs::{gettext, pgettext};
use gtk::{
glib,
glib::{clone, closure_local},
CompositeTemplate,
};
use ruma::{events::room::power_levels::PowerLevelUserAction, OwnedEventId};
use super::{Avatar, LoadingButton, LoadingButtonRow, PowerLevelSelectionRow};
use crate::{
components::{
confirm_mute_room_member_dialog, confirm_room_member_destructive_action_dialog,
confirm_set_room_member_power_level_same_as_own_dialog, RoomMemberDestructiveAction,
},
i18n::gettext_f,
ngettext_f,
prelude::*,
session::model::{Member, Membership, Permissions, Room, User},
toast,
utils::BoundObject,
Window,
};
mod imp {
use std::{cell::RefCell, sync::LazyLock};
use glib::subclass::{InitializingObject, Signal};
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(resource = "/org/gnome/Fractal/ui/components/user_page.ui")]
#[properties(wrapper_type = super::UserPage)]
pub struct UserPage {
#[template_child]
avatar: TemplateChild<Avatar>,
#[template_child]
direct_chat_box: TemplateChild<gtk::ListBox>,
#[template_child]
direct_chat_button: TemplateChild<LoadingButtonRow>,
#[template_child]
verified_row: TemplateChild<adw::ActionRow>,
#[template_child]
verified_stack: TemplateChild<gtk::Stack>,
#[template_child]
verify_button: TemplateChild<LoadingButton>,
#[template_child]
room_box: TemplateChild<gtk::Box>,
#[template_child]
room_title: TemplateChild<gtk::Label>,
#[template_child]
membership_row: TemplateChild<adw::ActionRow>,
#[template_child]
membership_label: TemplateChild<gtk::Label>,
#[template_child]
power_level_row: TemplateChild<PowerLevelSelectionRow>,
#[template_child]
invite_button: TemplateChild<LoadingButtonRow>,
#[template_child]
kick_button: TemplateChild<LoadingButtonRow>,
#[template_child]
ban_button: TemplateChild<LoadingButtonRow>,
#[template_child]
unban_button: TemplateChild<LoadingButtonRow>,
#[template_child]
remove_messages_button: TemplateChild<LoadingButtonRow>,
#[template_child]
ignored_row: TemplateChild<adw::ActionRow>,
#[template_child]
ignored_button: TemplateChild<LoadingButton>,
#[property(get, set = Self::set_user, explicit_notify, nullable)]
user: BoundObject<User>,
bindings: RefCell<Vec<glib::Binding>>,
permissions_handler: RefCell<Option<glib::SignalHandlerId>>,
room_handlers: RefCell<Vec<glib::SignalHandlerId>>,
}
#[glib::object_subclass]
impl ObjectSubclass for UserPage {
const NAME: &'static str = "UserPage";
type Type = super::UserPage;
type ParentType = adw::NavigationPage;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
klass.set_css_name("user-page");
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for UserPage {
fn signals() -> &'static [Signal] {
static SIGNALS: LazyLock<Vec<Signal>> =
LazyLock::new(|| vec![Signal::builder("close").build()]);
SIGNALS.as_ref()
}
fn dispose(&self) {
self.disconnect_signals();
}
}
impl WidgetImpl for UserPage {}
impl NavigationPageImpl for UserPage {}
#[gtk::template_callbacks]
impl UserPage {
fn set_user(&self, user: Option<User>) {
if self.user.obj() == user {
return;
}
let obj = self.obj();
self.disconnect_signals();
self.power_level_row.set_permissions(None::<Permissions>);
if let Some(user) = user {
let title_binding = user
.bind_property("display-name", &*obj, "title")
.sync_create()
.build();
let avatar_binding = user
.bind_property("avatar-data", &*self.avatar, "data")
.sync_create()
.build();
let bindings = vec![title_binding, avatar_binding];
let verified_handler = user.connect_verified_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_verified();
}
));
let ignored_handler = user.connect_is_ignored_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_direct_chat();
imp.update_ignored();
}
));
let mut handlers = vec![verified_handler, ignored_handler];
if let Some(member) = user.downcast_ref::<Member>() {
let room = member.room();
let permissions = room.permissions();
let permissions_handler = permissions.connect_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_room();
}
));
self.permissions_handler.replace(Some(permissions_handler));
self.power_level_row.set_permissions(Some(permissions));
let room_display_name_handler = room.connect_display_name_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_room();
}
));
let room_direct_member_handler = room.connect_direct_member_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_direct_chat();
}
));
self.room_handlers
.replace(vec![room_display_name_handler, room_direct_member_handler]);
let membership_handler = member.connect_membership_notify(clone!(
#[weak(rename_to = imp)]
self,
move |member| {
if member.membership() == Membership::Leave {
imp.obj().emit_by_name::<()>("close", &[]);
} else {
imp.update_room();
}
}
));
let power_level_handler = member.connect_power_level_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_room();
}
));
handlers.extend([membership_handler, power_level_handler]);
}
let is_own_user = user.is_own_user();
self.ignored_row.set_visible(!is_own_user);
self.user.set(user, handlers);
self.bindings.replace(bindings);
}
self.load_direct_chat();
self.update_direct_chat();
self.update_room();
self.update_verified();
self.update_ignored();
obj.notify_user();
}
fn disconnect_signals(&self) {
if let Some(member) = self.user.obj().and_downcast::<Member>() {
let room = member.room();
for handler in self.room_handlers.take() {
room.disconnect(handler);
}
if let Some(handler) = self.permissions_handler.take() {
room.permissions().disconnect(handler);
}
}
for binding in self.bindings.take() {
binding.unbind();
}
self.user.disconnect_signals();
}
#[template_callback]
fn copy_user_id(&self) {
let Some(user) = self.user.obj() else {
return;
};
let obj = self.obj();
obj.clipboard().set_text(user.user_id().as_str());
toast!(obj, gettext("Matrix user ID copied to clipboard"));
}
fn update_direct_chat(&self) {
let user = self.user.obj();
let is_other_user = user
.as_ref()
.is_some_and(|u| !u.is_own_user() && !u.is_ignored());
let is_direct_chat = user
.and_downcast::<Member>()
.is_some_and(|m| m.room().direct_member().is_some());
self.direct_chat_box
.set_visible(is_other_user && !is_direct_chat);
}
fn load_direct_chat(&self) {
self.direct_chat_button.set_is_loading(true);
let Some(user) = self.user.obj() else {
return;
};
let direct_chat = user.direct_chat();
let title = if direct_chat.is_some() {
gettext("Open Direct Chat")
} else {
gettext("Create Direct Chat")
};
self.direct_chat_button.set_title(&title);
self.direct_chat_button.set_is_loading(false);
}
#[template_callback]
async fn open_direct_chat(&self) {
let Some(user) = self.user.obj() else {
return;
};
self.direct_chat_button.set_is_loading(true);
let obj = self.obj();
let Ok(room) = user.get_or_create_direct_chat().await else {
toast!(obj, &gettext("Could not create a new Direct Chat"));
self.direct_chat_button.set_is_loading(false);
return;
};
let Some(parent_window) = obj.root().and_downcast::<gtk::Window>() else {
return;
};
if let Some(main_window) = parent_window.transient_for().and_downcast::<Window>() {
main_window.show_room(user.session().session_id(), room.room_id());
}
parent_window.close();
}
fn update_room(&self) {
let Some(member) = self.user.obj().and_downcast::<Member>() else {
self.room_box.set_visible(false);
return;
};
let membership = member.membership();
if membership == Membership::Leave {
self.room_box.set_visible(false);
return;
}
let room = member.room();
let room_title = gettext_f("In {room_name}", &[("room_name", &room.display_name())]);
self.room_title.set_label(&room_title);
let label = match membership {
Membership::Leave => unreachable!(),
Membership::Join => {
None
}
Membership::Invite => {
Some(pgettext("member", "Invited"))
}
Membership::Ban => {
Some(pgettext("member", "Banned"))
}
Membership::Knock => {
Some(pgettext("member", "Knocked"))
}
Membership::Unsupported => {
Some(pgettext("member", "Unknown"))
}
};
if let Some(label) = label {
self.membership_label.set_label(&label);
}
let is_role = membership == Membership::Join;
self.membership_row.set_visible(!is_role);
self.power_level_row.set_visible(is_role);
let permissions = room.permissions();
let user_id = member.user_id();
self.power_level_row.set_is_loading(false);
self.power_level_row
.set_selected_power_level(member.power_level());
let can_change_power_level = !member.is_own_user()
&& permissions.can_do_to_user(user_id, PowerLevelUserAction::ChangePowerLevel);
self.power_level_row.set_read_only(!can_change_power_level);
let can_invite = matches!(membership, Membership::Knock) && permissions.can_invite();
if can_invite {
self.invite_button.set_title(&gettext("Allow Access"));
self.invite_button.set_visible(true);
} else {
self.invite_button.set_visible(false);
}
let can_kick = matches!(
membership,
Membership::Join | Membership::Invite | Membership::Knock
) && permissions.can_do_to_user(user_id, PowerLevelUserAction::Kick);
if can_kick {
let label = match membership {
Membership::Invite => gettext("Revoke Invite"),
Membership::Knock => gettext("Deny Access"),
_ => gettext("Kick"),
};
self.kick_button.set_title(&label);
}
self.kick_button.set_visible(can_kick);
let can_ban = membership != Membership::Ban
&& permissions.can_do_to_user(user_id, PowerLevelUserAction::Ban);
self.ban_button.set_visible(can_ban);
let can_unban = matches!(membership, Membership::Ban)
&& permissions.can_do_to_user(user_id, PowerLevelUserAction::Unban);
self.unban_button.set_visible(can_unban);
let can_redact = !member.is_own_user() && permissions.can_redact_other();
self.remove_messages_button.set_visible(can_redact);
self.room_box.set_visible(true);
}
fn reset_room(&self) {
self.kick_button.set_is_loading(false);
self.kick_button.set_sensitive(true);
self.invite_button.set_is_loading(false);
self.invite_button.set_sensitive(true);
self.ban_button.set_is_loading(false);
self.ban_button.set_sensitive(true);
self.unban_button.set_is_loading(false);
self.unban_button.set_sensitive(true);
self.remove_messages_button.set_is_loading(false);
self.remove_messages_button.set_sensitive(true);
}
#[template_callback]
async fn set_power_level(&self) {
let Some(member) = self.user.obj().and_downcast::<Member>() else {
return;
};
let row = &self.power_level_row;
let power_level = row.selected_power_level();
let old_power_level = member.power_level();
if old_power_level == power_level {
return;
}
row.set_is_loading(true);
row.set_read_only(true);
let obj = self.obj();
let permissions = member.room().permissions();
let mute_power_level = permissions.mute_power_level();
let is_muted = power_level <= mute_power_level && old_power_level > mute_power_level;
if is_muted && !confirm_mute_room_member_dialog(&member, &*obj).await {
self.update_room();
return;
}
let is_own_power_level = power_level == permissions.own_power_level();
if is_own_power_level
&& !confirm_set_room_member_power_level_same_as_own_dialog(&member, &*obj).await
{
self.update_room();
return;
}
let user_id = member.user_id().clone();
if permissions
.set_user_power_level(user_id, power_level)
.await
.is_err()
{
toast!(obj, gettext("Could not change the role"));
self.update_room();
}
}
#[template_callback]
async fn invite_user(&self) {
let Some(member) = self.user.obj().and_downcast::<Member>() else {
return;
};
self.invite_button.set_is_loading(true);
self.kick_button.set_sensitive(false);
self.ban_button.set_sensitive(false);
self.unban_button.set_sensitive(false);
let room = member.room();
let user_id = member.user_id().clone();
if room.invite(&[user_id]).await.is_err() {
let obj = self.obj();
toast!(obj, gettext("Could not invite user"));
}
self.reset_room();
}
#[template_callback]
async fn kick_user(&self) {
let Some(member) = self.user.obj().and_downcast::<Member>() else {
return;
};
let obj = self.obj();
self.kick_button.set_is_loading(true);
self.invite_button.set_sensitive(false);
self.ban_button.set_sensitive(false);
self.unban_button.set_sensitive(false);
let Some(response) = confirm_room_member_destructive_action_dialog(
&member,
RoomMemberDestructiveAction::Kick,
&*obj,
)
.await
else {
self.reset_room();
return;
};
let room = member.room();
let user_id = member.user_id().clone();
if room.kick(&[(user_id, response.reason)]).await.is_err() {
let error = match member.membership() {
Membership::Invite => gettext("Could not revoke invite of user"),
Membership::Knock => gettext("Could not deny access to user"),
_ => gettext("Could not kick user"),
};
toast!(obj, error);
self.reset_room();
}
}
#[template_callback]
async fn ban_user(&self) {
let Some(member) = self.user.obj().and_downcast::<Member>() else {
return;
};
let obj = self.obj();
self.ban_button.set_is_loading(true);
self.invite_button.set_sensitive(false);
self.kick_button.set_sensitive(false);
self.unban_button.set_sensitive(false);
let permissions = member.room().permissions();
let redactable_events = if permissions.can_redact_other() {
member.redactable_events()
} else {
vec![]
};
let Some(response) = confirm_room_member_destructive_action_dialog(
&member,
RoomMemberDestructiveAction::Ban(redactable_events.len()),
&*obj,
)
.await
else {
self.reset_room();
return;
};
let room = member.room();
let user_id = member.user_id().clone();
if room
.ban(&[(user_id, response.reason.clone())])
.await
.is_err()
{
toast!(obj, gettext("Could not ban user"));
}
if response.remove_events {
self.remove_known_messages_inner(
&member.room(),
redactable_events,
response.reason,
)
.await;
}
self.reset_room();
}
#[template_callback]
async fn unban_user(&self) {
let Some(member) = self.user.obj().and_downcast::<Member>() else {
return;
};
self.unban_button.set_is_loading(true);
self.invite_button.set_sensitive(false);
self.kick_button.set_sensitive(false);
self.ban_button.set_sensitive(false);
let room = member.room();
let user_id = member.user_id().clone();
if room.unban(&[(user_id, None)]).await.is_err() {
let obj = self.obj();
toast!(obj, gettext("Could not unban user"));
}
self.reset_room();
}
#[template_callback]
async fn remove_messages(&self) {
let Some(member) = self.user.obj().and_downcast::<Member>() else {
return;
};
self.remove_messages_button.set_is_loading(true);
let redactable_events = member.redactable_events();
let Some(response) = confirm_room_member_destructive_action_dialog(
&member,
RoomMemberDestructiveAction::RemoveMessages(redactable_events.len()),
&*self.obj(),
)
.await
else {
self.reset_room();
return;
};
self.remove_known_messages_inner(&member.room(), redactable_events, response.reason)
.await;
self.reset_room();
}
async fn remove_known_messages_inner(
&self,
room: &Room,
events: Vec<OwnedEventId>,
reason: Option<String>,
) {
if let Err(events) = room.redact(&events, reason).await {
let n = u32::try_from(events.len()).unwrap_or(u32::MAX);
let obj = self.obj();
toast!(
obj,
ngettext_f(
"Could not remove 1 message sent by the user",
"Could not remove {n} messages sent by the user",
n,
&[("n", &n.to_string())]
)
);
}
}
fn update_verified(&self) {
let Some(user) = self.user.obj() else {
return;
};
if user.verified() {
self.verified_row.set_title(&gettext("Identity verified"));
self.verified_stack.set_visible_child_name("icon");
self.verify_button.set_sensitive(false);
} else {
self.verify_button.set_sensitive(true);
self.verified_stack.set_visible_child_name("button");
self.verified_row
.set_title(&gettext("Identity not verified"));
}
}
#[template_callback]
async fn verify_user(&self) {
let Some(user) = self.user.obj() else {
return;
};
let obj = self.obj();
self.verify_button.set_is_loading(true);
let Ok(verification) = user.verify_identity().await else {
toast!(obj, gettext("Could not start user verification"));
self.verify_button.set_is_loading(false);
return;
};
let Some(parent_window) = obj.root().and_downcast::<gtk::Window>() else {
return;
};
if let Some(main_window) = parent_window.transient_for().and_downcast::<Window>() {
main_window.show_identity_verification(user.session().session_id(), verification);
}
parent_window.close();
}
fn update_ignored(&self) {
let Some(user) = self.user.obj() else {
return;
};
if user.is_ignored() {
self.ignored_row.set_title(&gettext("Ignored"));
self.ignored_button
.set_content_label(gettext("Stop Ignoring"));
self.ignored_button.remove_css_class("destructive-action");
} else {
self.ignored_row.set_title(&gettext("Not Ignored"));
self.ignored_button.set_content_label(gettext("Ignore"));
self.ignored_button.add_css_class("destructive-action");
}
}
#[template_callback]
async fn toggle_ignored(&self) {
let Some(user) = self.user.obj() else {
return;
};
let obj = self.obj();
self.ignored_button.set_is_loading(true);
if user.is_ignored() {
if user.stop_ignoring().await.is_err() {
toast!(obj, gettext("Could not stop ignoring user"));
}
} else if user.ignore().await.is_err() {
toast!(obj, gettext("Could not ignore user"));
}
self.ignored_button.set_is_loading(false);
}
}
}
glib::wrapper! {
pub struct UserPage(ObjectSubclass<imp::UserPage>)
@extends gtk::Widget, adw::NavigationPage, @implements gtk::Accessible;
}
impl UserPage {
pub fn new(user: &impl IsA<User>) -> Self {
glib::Object::builder().property("user", user).build()
}
pub fn connect_close<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.connect_closure(
"close",
true,
closure_local!(|obj: Self| {
f(&obj);
}),
)
}
}