diff --git a/data/resources/style.css b/data/resources/style.css index 54004274..f7d2d606 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -182,25 +182,29 @@ button.overlaid { background-color: @accent_bg_color; } -power-level-badge { +role-badge { color: @dark_5; background-color: @light_3; border-radius: 0.4em; padding: 0.1em 0.5em; font-size: 0.8em; - margin-left: 0.5em; } -power-level-badge.admin { +role-badge.admin { color: @error_fg_color; background-color: @error_bg_color; } -power-level-badge.mod { +role-badge.mod { color: @warning_fg_color; background-color: @warning_bg_color; } +role-badge.muted { + color: @light_1; + background-color: @dark_2; +} + media-viewer toolbarview headerbar { background: black; color: white; @@ -238,6 +242,39 @@ spinner-wrapper.large spinner { opacity: 0.55; } +.role-selection-row popover viewport > box { + padding: 6px; +} + +.role-selection-row popover list row.spin { + padding: 0; +} + +.role-selection-row popover row.spin > box { + min-height: 30px; +} + +.role-selection-row popover row.spin spinbutton > button { + margin-top: 6px; + margin-bottom: 6px; +} + +.role-selection-row popover row.spin button.spin-confirm { + min-height: 22px; + min-width: 22px; + padding: 0; + margin-left: 2px; +} + +.role-selection-row popover row.spin button.spin-confirm:dir(rtl) { + margin-left: 0; + margin-right: 2px; +} + +.role-selection-row popover row.spin button.spin-confirm image { + padding: 7px; +} + /* Login */ diff --git a/po/POTFILES.in b/po/POTFILES.in index 05cdea3e..112cefdb 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -20,6 +20,7 @@ src/components/location_viewer.rs src/components/media_content_viewer.rs src/components/reaction_chooser.ui src/components/rows/loading_row.ui +src/components/rows/power_level_selection_row.ui src/components/spinner.rs src/components/user_page.rs src/components/user_page.ui @@ -41,8 +42,8 @@ src/login/sso_page.ui src/secret/linux.rs src/session/model/session.rs src/session/model/room/join_rule.rs -src/session/model/room/member_role.rs src/session/model/room/mod.rs +src/session/model/room/permissions.rs src/session/model/room_list/mod.rs src/session/model/sidebar_data/category/category_type.rs src/session/model/sidebar_data/icon_item.rs diff --git a/src/components/mod.rs b/src/components/mod.rs index 3e28729d..98210e89 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -14,8 +14,8 @@ mod location_viewer; mod media_content_viewer; mod overlapping_avatars; mod pill; -mod power_level_badge; mod reaction_chooser; +mod role_badge; mod room_title; mod rows; mod scale_revealer; @@ -44,8 +44,8 @@ pub use self::{ media_content_viewer::{ContentType, MediaContentViewer}, overlapping_avatars::OverlappingAvatars, pill::*, - power_level_badge::PowerLevelBadge, reaction_chooser::ReactionChooser, + role_badge::RoleBadge, room_title::RoomTitle, rows::*, scale_revealer::ScaleRevealer, diff --git a/src/components/power_level_badge.rs b/src/components/power_level_badge.rs deleted file mode 100644 index e7a71aec..00000000 --- a/src/components/power_level_badge.rs +++ /dev/null @@ -1,94 +0,0 @@ -use adw::{prelude::*, subclass::prelude::*}; -use gtk::glib; - -use crate::session::model::{MemberRole, PowerLevel, POWER_LEVEL_MAX, POWER_LEVEL_MIN}; - -mod imp { - use std::cell::Cell; - - use super::*; - - #[derive(Debug, Default, glib::Properties)] - #[properties(wrapper_type = super::PowerLevelBadge)] - pub struct PowerLevelBadge { - pub label: gtk::Label, - /// The power level displayed by this badge. - #[property(get, set = Self::set_power_level, explicit_notify, minimum = POWER_LEVEL_MIN, maximum = POWER_LEVEL_MAX)] - pub power_level: Cell, - } - - #[glib::object_subclass] - impl ObjectSubclass for PowerLevelBadge { - const NAME: &'static str = "PowerLevelBadge"; - type Type = super::PowerLevelBadge; - type ParentType = adw::Bin; - - fn class_init(klass: &mut Self::Class) { - klass.set_css_name("power-level-badge"); - } - } - - #[glib::derived_properties] - impl ObjectImpl for PowerLevelBadge { - fn constructed(&self) { - self.parent_constructed(); - let obj = self.obj(); - - obj.set_child(Some(&self.label)); - } - } - - impl WidgetImpl for PowerLevelBadge {} - impl BinImpl for PowerLevelBadge {} - - impl PowerLevelBadge { - /// Set the power level this badge displays. - fn set_power_level(&self, power_level: PowerLevel) { - let obj = self.obj(); - obj.update_badge(power_level); - - self.power_level.set(power_level); - obj.notify_power_level(); - } - } -} - -glib::wrapper! { - /// Inline widget displaying a badge with a power level. - /// - /// The badge displays admin for a power level of 100 and mod for levels - /// over or equal to 50. - pub struct PowerLevelBadge(ObjectSubclass) - @extends gtk::Widget, adw::Bin; -} - -impl PowerLevelBadge { - pub fn new() -> Self { - glib::Object::new() - } - - /// Update the badge for the given power level. - fn update_badge(&self, power_level: PowerLevel) { - let label = &self.imp().label; - let role = MemberRole::from(power_level); - - match role { - MemberRole::Admin => { - label.set_text(&format!("{role} {power_level}")); - self.add_css_class("admin"); - self.remove_css_class("mod"); - } - MemberRole::Mod => { - label.set_text(&format!("{role} {power_level}")); - self.add_css_class("mod"); - self.remove_css_class("admin"); - } - MemberRole::Peasant => { - label.set_text(&power_level.to_string()); - self.remove_css_class("admin"); - self.remove_css_class("mod"); - } - }; - self.set_visible(power_level != 0); - } -} diff --git a/src/components/role_badge.rs b/src/components/role_badge.rs new file mode 100644 index 00000000..d093438f --- /dev/null +++ b/src/components/role_badge.rs @@ -0,0 +1,112 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gtk::glib; + +use crate::session::model::MemberRole; + +mod imp { + use std::cell::Cell; + + use super::*; + + #[derive(Debug, Default, glib::Properties)] + #[properties(wrapper_type = super::RoleBadge)] + pub struct RoleBadge { + pub label: gtk::Label, + /// The role displayed by this badge. + #[property(get, set = Self::set_role, explicit_notify, builder(MemberRole::default()))] + pub role: Cell, + /// Whether the role displayed by this badge is the default role. + #[property(get)] + pub is_default_role: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for RoleBadge { + const NAME: &'static str = "RoleBadge"; + type Type = super::RoleBadge; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.set_css_name("role-badge"); + } + } + + #[glib::derived_properties] + impl ObjectImpl for RoleBadge { + fn constructed(&self) { + self.parent_constructed(); + let obj = self.obj(); + + obj.set_child(Some(&self.label)); + self.update_badge(); + self.update_is_default_role(); + } + } + + impl WidgetImpl for RoleBadge {} + impl BinImpl for RoleBadge {} + + impl RoleBadge { + /// Set the role displayed by this badge. + fn set_role(&self, role: MemberRole) { + if self.role.get() == role { + return; + } + + self.role.set(role); + self.update_badge(); + self.update_is_default_role(); + self.obj().notify_role(); + } + + /// Update whether the role displayed by this badge is the default role. + fn update_is_default_role(&self) { + let is_default = self.role.get() == MemberRole::Default; + + if self.is_default_role.get() == is_default { + return; + } + + self.is_default_role.set(is_default); + self.obj().notify_is_default_role(); + } + + /// Update the badge for the current state. + fn update_badge(&self) { + let obj = self.obj(); + let role = self.role.get(); + + self.label.set_text(&role.to_string()); + + if role == MemberRole::Administrator { + obj.add_css_class("admin"); + } else { + obj.remove_css_class("admin"); + } + + if role == MemberRole::Moderator { + obj.add_css_class("mod"); + } else { + obj.remove_css_class("mod"); + } + + if role == MemberRole::Muted { + obj.add_css_class("muted"); + } else { + obj.remove_css_class("muted"); + } + } + } +} + +glib::wrapper! { + /// Inline widget displaying a badge with the role of a room member. + pub struct RoleBadge(ObjectSubclass) + @extends gtk::Widget, adw::Bin; +} + +impl RoleBadge { + pub fn new() -> Self { + glib::Object::new() + } +} diff --git a/src/components/rows/mod.rs b/src/components/rows/mod.rs index 4f5cb890..92f1ad4d 100644 --- a/src/components/rows/mod.rs +++ b/src/components/rows/mod.rs @@ -7,6 +7,7 @@ mod combo_loading_row; mod copyable_row; mod entry_add_row; mod loading_row; +mod power_level_selection_row; mod removable_row; mod substring_entry_row; mod switch_loading_row; @@ -14,6 +15,7 @@ mod switch_loading_row; pub use self::{ button_count_row::ButtonCountRow, button_row::ButtonRow, check_loading_row::CheckLoadingRow, combo_loading_row::ComboLoadingRow, copyable_row::CopyableRow, entry_add_row::EntryAddRow, - loading_row::LoadingRow, removable_row::RemovableRow, substring_entry_row::SubstringEntryRow, + loading_row::LoadingRow, power_level_selection_row::PowerLevelSelectionRow, + removable_row::RemovableRow, substring_entry_row::SubstringEntryRow, switch_loading_row::SwitchLoadingRow, }; diff --git a/src/components/rows/power_level_selection_row.rs b/src/components/rows/power_level_selection_row.rs new file mode 100644 index 00000000..4e972d25 --- /dev/null +++ b/src/components/rows/power_level_selection_row.rs @@ -0,0 +1,385 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gtk::{glib, glib::clone, CompositeTemplate}; + +use crate::{ + components::{LoadingBin, RoleBadge}, + session::model::{Permissions, PowerLevel, POWER_LEVEL_ADMIN, POWER_LEVEL_MOD}, + utils::BoundObject, +}; + +mod imp { + use std::{cell::Cell, marker::PhantomData}; + + use glib::subclass::InitializingObject; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate, glib::Properties)] + #[template(resource = "/org/gnome/Fractal/ui/components/rows/power_level_selection_row.ui")] + #[properties(wrapper_type = super::PowerLevelSelectionRow)] + pub struct PowerLevelSelectionRow { + #[template_child] + pub selected_box: TemplateChild, + #[template_child] + pub selected_level_label: TemplateChild, + #[template_child] + pub selected_role_badge: TemplateChild, + #[template_child] + pub loading_bin: TemplateChild, + #[template_child] + pub popover: TemplateChild, + #[template_child] + pub admin_row: TemplateChild, + #[template_child] + pub admin_selected: TemplateChild, + #[template_child] + pub mod_row: TemplateChild, + #[template_child] + pub mod_selected: TemplateChild, + #[template_child] + pub default_row: TemplateChild, + #[template_child] + pub default_pl_label: TemplateChild, + #[template_child] + pub default_selected: TemplateChild, + #[template_child] + pub muted_row: TemplateChild, + #[template_child] + pub muted_pl_label: TemplateChild, + #[template_child] + pub muted_selected: TemplateChild, + #[template_child] + pub custom_row: TemplateChild, + #[template_child] + pub custom_adjustment: TemplateChild, + #[template_child] + pub custom_confirm: TemplateChild, + /// The permissions to watch. + #[property(get, set = Self::set_permissions, explicit_notify, nullable)] + pub permissions: BoundObject, + /// The selected power level. + #[property(get, set = Self::set_selected_power_level, explicit_notify)] + pub selected_power_level: Cell, + /// Whether the row is loading. + #[property(get = Self::is_loading, set = Self::set_is_loading)] + pub is_loading: PhantomData, + } + + #[glib::object_subclass] + impl ObjectSubclass for PowerLevelSelectionRow { + const NAME: &'static str = "PowerLevelSelectionRow"; + type Type = super::PowerLevelSelectionRow; + type ParentType = adw::ActionRow; + + 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 PowerLevelSelectionRow { + fn constructed(&self) { + self.parent_constructed(); + + self.obj() + .reset_relation(gtk::AccessibleRelation::DescribedBy); + } + } + + impl WidgetImpl for PowerLevelSelectionRow {} + impl ListBoxRowImpl for PowerLevelSelectionRow {} + impl PreferencesRowImpl for PowerLevelSelectionRow {} + + impl ActionRowImpl for PowerLevelSelectionRow { + fn activate(&self) { + if !self.is_loading() { + self.popover.popup(); + } + } + } + + impl PowerLevelSelectionRow { + /// Set the permissions to watch. + fn set_permissions(&self, permissions: Option) { + if self.permissions.obj() == permissions { + return; + } + let obj = self.obj(); + + self.permissions.disconnect_signals(); + + if let Some(permissions) = permissions { + let own_pl_handler = permissions.connect_own_power_level_notify( + clone!(@weak self as imp => move |_| { + imp.update(); + }), + ); + let default_pl_handler = permissions.connect_default_power_level_notify( + clone!(@weak self as imp => move |_| { + imp.update_default(); + imp.update_muted(); + imp.update_selection(); + }), + ); + let muted_pl_handler = permissions.connect_mute_power_level_notify( + clone!(@weak self as imp => move |_| { + imp.update_muted(); + imp.update_selection(); + }), + ); + + self.permissions.set( + permissions, + vec![own_pl_handler, default_pl_handler, muted_pl_handler], + ); + } + + self.update(); + self.update_selected_label(); + obj.notify_permissions(); + } + + /// Update the label of the selected power level. + fn update_selected_label(&self) { + let Some(permissions) = self.permissions.obj() else { + return; + }; + let obj = self.obj(); + + let power_level = self.selected_power_level.get(); + let role = permissions.role(power_level); + + self.selected_role_badge.set_role(role); + self.selected_level_label + .set_label(&power_level.to_string()); + + let role_string = format!("{power_level} {role}"); + obj.update_property(&[gtk::accessible::Property::Description(&role_string)]); + } + + /// Set the selected power level. + fn set_selected_power_level(&self, power_level: PowerLevel) { + if self.selected_power_level.get() == power_level { + return; + } + + self.selected_power_level.set(power_level); + + self.update_selected_label(); + self.update_selection(); + self.update_custom(); + self.obj().notify_selected_power_level(); + } + + /// Whether the row is loading. + fn is_loading(&self) -> bool { + self.loading_bin.is_loading() + } + + /// Set whether the row is loading. + fn set_is_loading(&self, loading: bool) { + if self.is_loading() == loading { + return; + } + + self.loading_bin.set_is_loading(loading); + self.obj().notify_is_loading(); + } + + /// Update the rows. + fn update(&self) { + self.update_admin(); + self.update_mod(); + self.update_default(); + self.update_muted(); + self.update_custom(); + self.update_selection(); + } + + /// Update the admin row. + fn update_admin(&self) { + let Some(permissions) = self.permissions.obj() else { + return; + }; + + let can_change_to_admin = permissions.own_power_level() >= POWER_LEVEL_ADMIN; + + if can_change_to_admin { + self.admin_row.set_sensitive(true); + self.admin_row.set_activatable(true); + } else { + self.admin_row.set_sensitive(false); + self.admin_row.set_activatable(false); + } + } + + /// Update the moderator row. + fn update_mod(&self) { + let Some(permissions) = self.permissions.obj() else { + return; + }; + + let can_change_to_mod = permissions.own_power_level() >= POWER_LEVEL_MOD; + + if can_change_to_mod { + self.mod_row.set_sensitive(true); + self.mod_row.set_activatable(true); + } else { + self.mod_row.set_sensitive(false); + self.mod_row.set_activatable(false); + } + } + + /// Update the default row. + fn update_default(&self) { + let Some(permissions) = self.permissions.obj() else { + return; + }; + + let default = permissions.default_power_level(); + self.default_pl_label.set_label(&default.to_string()); + + let can_change_to_default = permissions.own_power_level() >= default; + + if can_change_to_default { + self.default_row.set_sensitive(true); + self.default_row.set_activatable(true); + } else { + self.default_row.set_sensitive(false); + self.default_row.set_activatable(false); + } + } + + /// Update the muted row. + fn update_muted(&self) { + let Some(permissions) = self.permissions.obj() else { + return; + }; + + let mute = permissions.mute_power_level(); + let default = permissions.default_power_level(); + + if mute >= default { + // There is no point in having the muted row since all users are muted by + // default. + self.muted_row.set_visible(false); + return; + } + + self.muted_pl_label.set_label(&mute.to_string()); + + let can_change_to_muted = permissions.own_power_level() >= mute; + + if can_change_to_muted { + self.muted_row.set_sensitive(true); + self.muted_row.set_activatable(true); + } else { + self.muted_row.set_sensitive(false); + self.muted_row.set_activatable(false); + } + + self.muted_row.set_visible(true); + } + + /// Update the custom row. + fn update_custom(&self) { + let Some(permissions) = self.permissions.obj() else { + return; + }; + + self.custom_adjustment + .set_upper(permissions.own_power_level() as f64); + self.custom_adjustment + .set_value(self.selected_power_level.get() as f64); + } + + /// Update the selected row. + fn update_selection(&self) { + let Some(permissions) = self.permissions.obj() else { + return; + }; + + let power_level = self.selected_power_level.get(); + + self.admin_selected + .set_opacity((power_level == POWER_LEVEL_ADMIN).into()); + self.mod_selected + .set_opacity((power_level == POWER_LEVEL_MOD).into()); + self.default_selected + .set_opacity((power_level == permissions.default_power_level()).into()); + self.muted_selected + .set_opacity((power_level == permissions.mute_power_level()).into()); + } + } +} + +glib::wrapper! { + /// An `AdwActionRow` behaving like a combo box to select a room member's power level. + pub struct PowerLevelSelectionRow(ObjectSubclass) + @extends gtk::Widget, gtk::ListBoxRow, adw::PreferencesRow, adw::ActionRow, + @implements gtk::Actionable, gtk::Accessible; +} + +#[gtk::template_callbacks] +impl PowerLevelSelectionRow { + pub fn new() -> Self { + glib::Object::new() + } + + /// The custom value changed. + #[template_callback] + fn custom_value_changed(&self) { + let imp = self.imp(); + let power_level = imp.custom_adjustment.value() as PowerLevel; + let can_confirm = power_level != self.selected_power_level(); + + imp.custom_confirm.set_sensitive(can_confirm); + } + + /// The custom value was confirmed. + #[template_callback] + fn custom_value_confirmed(&self) { + let imp = self.imp(); + let power_level = imp.custom_adjustment.value() as PowerLevel; + + imp.popover.popdown(); + self.set_selected_power_level(power_level); + } + + /// A row was activated. + #[template_callback] + fn row_activated(&self, row: >k::ListBoxRow) { + let Some(permissions) = self.permissions() else { + return; + }; + let imp = self.imp(); + + let power_level = match row.index() { + 0 => POWER_LEVEL_ADMIN, + 1 => POWER_LEVEL_MOD, + 2 => permissions.default_power_level(), + 3 => permissions.mute_power_level(), + _ => return, + }; + + imp.popover.popdown(); + self.set_selected_power_level(power_level); + } + + /// The popover's visibility changed. + #[template_callback] + fn popover_visible(&self) { + let is_visible = self.imp().popover.is_visible(); + + if is_visible { + self.add_css_class("has-open-popup"); + } else { + self.remove_css_class("has-open-popup"); + } + } +} diff --git a/src/components/rows/power_level_selection_row.ui b/src/components/rows/power_level_selection_row.ui new file mode 100644 index 00000000..c232c6fb --- /dev/null +++ b/src/components/rows/power_level_selection_row.ui @@ -0,0 +1,228 @@ + + + + \ No newline at end of file diff --git a/src/components/user_page.rs b/src/components/user_page.rs index 6e5aaab2..eaf5dbab 100644 --- a/src/components/user_page.rs +++ b/src/components/user_page.rs @@ -7,15 +7,18 @@ use gtk::{ }; use ruma::{events::room::power_levels::PowerLevelUserAction, OwnedEventId}; -use super::{Avatar, SpinnerButton}; +use super::{Avatar, PowerLevelSelectionRow, SpinnerButton}; use crate::{ i18n::gettext_f, ngettext_f, prelude::*, - session::model::{Member, MemberRole, Membership, Room, User}, + session::model::{Member, Membership, Permissions, Room, User}, spawn, toast, utils::{ - message_dialog::{confirm_room_member_destructive_action, RoomMemberDestructiveAction}, + message_dialog::{ + confirm_mute_room_member, confirm_room_member_destructive_action, + confirm_set_room_member_power_level_same_as_own, RoomMemberDestructiveAction, + }, BoundObject, }, Window, @@ -48,13 +51,11 @@ mod imp { #[template_child] pub room_title: TemplateChild, #[template_child] - pub role_row: TemplateChild, + pub membership_row: TemplateChild, #[template_child] - pub role_group: TemplateChild, + pub membership_label: TemplateChild, #[template_child] - pub role_label: TemplateChild, - #[template_child] - pub power_level_label: TemplateChild, + pub power_level_row: TemplateChild, #[template_child] pub invite_button: TemplateChild, #[template_child] @@ -158,6 +159,7 @@ mod imp { binding.unbind(); } self.user.disconnect_signals(); + self.power_level_row.set_permissions(None::); if let Some(user) = user { let title_binding = user @@ -168,28 +170,28 @@ mod imp { .bind_property("avatar-data", &*self.avatar, "data") .sync_create() .build(); - self.bindings.replace(vec![title_binding, avatar_binding]); + let bindings = vec![title_binding, avatar_binding]; - let mut handlers = Vec::new(); - handlers.push(user.connect_verified_notify(clone!(@weak obj => move |_| { + let verified_handler = user.connect_verified_notify(clone!(@weak obj => move |_| { obj.update_verified(); - }))); - handlers.push( + })); + let ignored_handler = user.connect_is_ignored_notify(clone!(@weak obj => move |_| { obj.update_direct_chat(); obj.update_ignored(); - })), - ); + })); + let mut handlers = vec![verified_handler, ignored_handler]; if let Some(member) = user.downcast_ref::() { let room = member.room(); + let permissions = room.permissions(); let permissions_handler = - room.permissions() - .connect_changed(clone!(@weak obj => move |_| { - obj.update_room(); - })); + permissions.connect_changed(clone!(@weak obj => move |_| { + obj.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 obj => move |_| { @@ -198,20 +200,19 @@ mod imp { self.room_display_name_handler .replace(Some(room_display_name_handler)); - handlers.push(member.connect_membership_notify( - clone!(@weak obj => move |member| { + let membership_handler = + member.connect_membership_notify(clone!(@weak obj => move |member| { if member.membership() == Membership::Leave { obj.emit_by_name::<()>("close", &[]); } else { obj.update_room(); } - }), - )); - handlers.push(member.connect_power_level_notify( - clone!(@weak obj => move |_| { + })); + let power_level_handler = + member.connect_power_level_notify(clone!(@weak obj => move |_| { obj.update_room(); - }), - )); + })); + handlers.extend([membership_handler, power_level_handler]); } // We don't need to listen to changes of the property, it never changes after @@ -220,6 +221,7 @@ mod imp { self.ignored_row.set_visible(!is_own_user); self.user.set(user, handlers); + self.bindings.replace(bindings); } obj.load_direct_chat(); @@ -340,45 +342,45 @@ impl UserPage { match membership { Membership::Leave => unreachable!(), Membership::Join => { - let power_level = member.power_level(); - let role = MemberRole::from(power_level).to_string(); - - imp.role_label.set_label(&role); - imp.power_level_label.set_label(&power_level.to_string()); - imp.role_group - .update_property(&[gtk::accessible::Property::Label(&format!( - "{role} {power_level}" - ))]); + // Nothing to update, it should show the role row. } Membership::Invite => { - // Translators: As in, 'The room member was invited'. - imp.role_label.set_label(&pgettext("member", "Invited")); + imp.membership_label + // Translators: As in, 'The room member was invited'. + .set_label(&pgettext("member", "Invited")); } Membership::Ban => { - // Translators: As in, 'The room member was banned'. - imp.role_label.set_label(&pgettext("member", "Banned")); + imp.membership_label + // Translators: As in, 'The room member was banned'. + .set_label(&pgettext("member", "Banned")); } Membership::Knock => { - // Translators: As in, 'The room member knocked to request access to the room'. - imp.role_label.set_label(&pgettext("member", "Knocked")); + imp.membership_label + // Translators: As in, 'The room member knocked to request access to the room'. + .set_label(&pgettext("member", "Knocked")); } Membership::Custom => { - // Translators: As in, 'The room member has an unknown role'. - imp.role_label.set_label(&pgettext("member", "Unknown")); + imp.membership_label + // Translators: As in, 'The room member has an unknown role'. + .set_label(&pgettext("member", "Unknown")); } } - if matches!(membership, Membership::Ban) { - imp.role_label.add_css_class("error"); - } else { - imp.role_label.remove_css_class("error"); - } - imp.power_level_label - .set_visible(matches!(membership, Membership::Join)); + let is_role = membership == Membership::Join; + imp.membership_row.set_visible(!is_role); + imp.power_level_row.set_visible(is_role); let permissions = room.permissions(); let user_id = member.user_id(); + imp.power_level_row.set_is_loading(false); + imp.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); + imp.power_level_row.set_sensitive(can_change_power_level); + let can_invite = matches!(membership, Membership::Knock) && permissions.can_invite(); if can_invite { imp.invite_button.set_content_label(gettext("Allow Access")); @@ -438,6 +440,56 @@ impl UserPage { imp.remove_messages_button.set_sensitive(true); } + /// Set the power level of the user. + #[template_callback] + fn set_power_level(&self) { + let Some(member) = self.user().and_downcast::() else { + return; + }; + + let row = &self.imp().power_level_row; + let power_level = row.selected_power_level(); + let old_power_level = member.power_level(); + + if old_power_level == power_level { + // Nothing to do. + return; + } + + row.set_is_loading(true); + row.set_sensitive(false); + + spawn!(clone!(@weak self as obj => async move { + let permissions = member.room().permissions(); + + // Warn if user is muted but was not before. + 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(&member, &obj).await { + obj.update_room(); + return; + } + + // Warn if power level is set at same level as own power level. + 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(&member, &obj).await { + obj.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("Failed to change the role")); + obj.update_room(); + } + })); + } + /// Invite the user to the room. #[template_callback] fn invite_user(&self) { diff --git a/src/components/user_page.ui b/src/components/user_page.ui index a9d68e37..7b1f3d6d 100644 --- a/src/components/user_page.ui +++ b/src/components/user_page.ui @@ -108,29 +108,25 @@ - + False Role - role_group + membership_label - - group - True - 6 - - - end - - - - - + + end + + + Power Level + + + diff --git a/src/session/model/room/member.rs b/src/session/model/room/member.rs index da571db9..5440028e 100644 --- a/src/session/model/room/member.rs +++ b/src/session/model/room/member.rs @@ -43,7 +43,7 @@ impl From for Membership { } mod imp { - use std::cell::{Cell, OnceCell}; + use std::cell::{Cell, OnceCell, RefCell}; use super::*; @@ -51,17 +51,21 @@ mod imp { #[properties(wrapper_type = super::Member)] pub struct Member { /// The room of the member. - #[property(get, construct_only)] + #[property(get, set = Self::set_room, construct_only)] pub room: OnceCell, /// The power level of the member. #[property(get, minimum = POWER_LEVEL_MIN, maximum = POWER_LEVEL_MAX)] pub power_level: Cell, + /// The role of the member. + #[property(get, builder(MemberRole::default()))] + pub role: Cell, /// This member's membership state. #[property(get, builder(Membership::default()))] pub membership: Cell, /// The timestamp of the latest activity of this member. #[property(get, set = Self::set_latest_activity, explicit_notify)] pub latest_activity: Cell, + power_level_handlers: RefCell>, } #[glib::object_subclass] @@ -72,7 +76,15 @@ mod imp { } #[glib::derived_properties] - impl ObjectImpl for Member {} + impl ObjectImpl for Member { + fn dispose(&self) { + if let Some(room) = self.room.get() { + for handler in self.power_level_handlers.take() { + room.permissions().disconnect(handler); + } + } + } + } impl PillSourceImpl for Member { fn identifier(&self) -> String { @@ -81,6 +93,26 @@ mod imp { } impl Member { + /// Set the room of the member. + fn set_room(&self, room: Room) { + let obj = self.obj(); + + let default_pl_handler = room.permissions().connect_default_power_level_notify( + clone!(@weak obj => move |_| { + obj.update_role(); + }), + ); + let mute_pl_handler = + room.permissions() + .connect_mute_power_level_notify(clone!(@weak obj => move |_| { + obj.update_role(); + })); + self.power_level_handlers + .replace(vec![default_pl_handler, mute_pl_handler]); + + self.room.set(room).unwrap(); + } + /// Set the timestamp of the latest activity of this member. fn set_latest_activity(&self, activity: u64) { if self.latest_activity.get() >= activity { @@ -115,24 +147,22 @@ impl Member { if self.power_level() == power_level { return; } - self.imp().power_level.replace(power_level); - self.notify_power_level(); - } - pub fn role(&self) -> MemberRole { - self.power_level().into() + self.imp().power_level.set(power_level); + self.update_role(); + self.notify_power_level(); } - pub fn is_admin(&self) -> bool { - self.role().is_admin() - } + /// Update the role of the member. + fn update_role(&self) { + let role = self.room().permissions().role(self.power_level()); - pub fn is_mod(&self) -> bool { - self.role().is_mod() - } + if self.role() == role { + return; + } - pub fn is_peasant(&self) -> bool { - self.role().is_peasant() + self.imp().role.set(role); + self.notify_role(); } /// Set this member's membership state. @@ -140,6 +170,7 @@ impl Member { if self.membership() == membership { return; } + let imp = self.imp(); imp.membership.replace(membership); self.notify_membership(); diff --git a/src/session/model/room/member_role.rs b/src/session/model/room/member_role.rs deleted file mode 100644 index 4f47c084..00000000 --- a/src/session/model/room/member_role.rs +++ /dev/null @@ -1,55 +0,0 @@ -use std::fmt; - -use gettextrs::gettext; -use gtk::glib; - -use super::PowerLevel; - -/// Role of a room member, like admin or moderator. -#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)] -#[repr(u32)] -#[enum_type(name = "MemberRole")] -pub enum MemberRole { - /// An administrator. - Admin = 1, - /// A moderator. - Mod = 2, - /// A regular room member. - Peasant = 0, -} - -impl MemberRole { - pub fn is_admin(&self) -> bool { - matches!(*self, Self::Admin) - } - - pub fn is_mod(&self) -> bool { - matches!(*self, Self::Mod) - } - - pub fn is_peasant(&self) -> bool { - matches!(*self, Self::Peasant) - } -} - -impl From for MemberRole { - fn from(power_level: PowerLevel) -> Self { - if (100..).contains(&power_level) { - Self::Admin - } else if (50..100).contains(&power_level) { - Self::Mod - } else { - Self::Peasant - } - } -} - -impl fmt::Display for MemberRole { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { - Self::Admin => write!(f, "{}", gettext("Admin")), - Self::Mod => write!(f, "{}", gettext("Moderator")), - _ => write!(f, "{}", gettext("Normal user")), - } - } -} diff --git a/src/session/model/room/mod.rs b/src/session/model/room/mod.rs index 05969134..445dc9cb 100644 --- a/src/session/model/room/mod.rs +++ b/src/session/model/room/mod.rs @@ -40,7 +40,6 @@ mod highlight_flags; mod join_rule; mod member; mod member_list; -mod member_role; mod permissions; mod room_type; mod timeline; @@ -53,8 +52,7 @@ pub use self::{ join_rule::{JoinRule, JoinRuleValue}, member::{Member, Membership}, member_list::MemberList, - member_role::MemberRole, - permissions::{Permissions, PowerLevel, POWER_LEVEL_MAX, POWER_LEVEL_MIN}, + permissions::*, room_type::RoomType, timeline::*, typing_list::TypingList, diff --git a/src/session/model/room/permissions.rs b/src/session/model/room/permissions.rs index 8175e941..7a9c8eb1 100644 --- a/src/session/model/room/permissions.rs +++ b/src/session/model/room/permissions.rs @@ -1,3 +1,6 @@ +use std::fmt; + +use gettextrs::gettext; use gtk::{ glib, glib::{clone, closure_local}, @@ -14,7 +17,7 @@ use ruma::{ }, MessageLikeEventType, StateEventType, SyncStateEvent, }, - UserId, + Int, OwnedUserId, UserId, }; use tracing::error; @@ -25,9 +28,56 @@ use crate::{prelude::*, spawn, spawn_tokio}; /// /// Is usually in the range (0..=100), but can be any JS integer. pub type PowerLevel = i64; -// Same value as MAX_SAFE_INT from js_int. -pub const POWER_LEVEL_MAX: i64 = 0x001F_FFFF_FFFF_FFFF; -pub const POWER_LEVEL_MIN: i64 = -POWER_LEVEL_MAX; + +/// The maximum power level that can be set, according to the Matrix +/// specification. +/// +/// This is the same value as `MAX_SAFE_INT` from the `js_int` crate. +pub const POWER_LEVEL_MAX: PowerLevel = 0x001F_FFFF_FFFF_FFFF; +/// The minimum power level that can be set, according to the Matrix +/// specification. +/// +/// This is the same value as `MIN_SAFE_INT` from the `js_int` crate. +pub const POWER_LEVEL_MIN: PowerLevel = -POWER_LEVEL_MAX; +/// The minimum power level to have the role of Administrator, according to the +/// Matrix specification. +pub const POWER_LEVEL_ADMIN: PowerLevel = 100; +/// The minimum power level to have the role of Moderator, according to the +/// Matrix specification. +pub const POWER_LEVEL_MOD: PowerLevel = 50; + +/// Role of a room member, like admin or moderator. +#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)] +#[enum_type(name = "MemberRole")] +pub enum MemberRole { + /// A room member with the default power level. + #[default] + Default, + /// A room member with a non-default power level, but lower than and a + /// moderator. + Custom, + /// A moderator. + Moderator, + /// An administrator. + Administrator, + /// A room member that cannot send messages. + Muted, +} + +impl fmt::Display for MemberRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + // Translators: As in 'Default power level'. + Self::Default => write!(f, "{}", gettext("Default")), + // Translators: As in, 'Custom power level'. + Self::Custom => write!(f, "{}", gettext("Custom")), + Self::Moderator => write!(f, "{}", gettext("Moderator")), + Self::Administrator => write!(f, "{}", gettext("Admin")), + // Translators: As in 'Muted room member', a member that cannot send messages. + Self::Muted => write!(f, "{}", gettext("Muted")), + } + } +} mod imp { use std::cell::{Cell, OnceCell, RefCell}; @@ -48,6 +98,15 @@ mod imp { power_levels_drop_guard: OnceCell, /// Whether our own member is joined. pub is_joined: Cell, + /// The power level of our own member. + #[property(get)] + pub own_power_level: Cell, + /// The default power level for members. + #[property(get)] + pub default_power_level: Cell, + /// The power level to mute members. + #[property(get)] + pub mute_power_level: Cell, /// Whether our own member can change the room's avatar. #[property(get)] pub can_change_avatar: Cell, @@ -81,6 +140,9 @@ mod imp { power_levels: RefCell::new(RoomPowerLevelsEventContent::default().into()), power_levels_drop_guard: Default::default(), is_joined: Default::default(), + own_power_level: Default::default(), + default_power_level: Default::default(), + mute_power_level: Default::default(), can_change_avatar: Default::default(), can_change_name: Default::default(), can_change_topic: Default::default(), @@ -213,6 +275,9 @@ mod imp { /// Trigger updates when the permissions changed. fn permissions_changed(&self) { + self.update_own_power_level(); + self.update_default_power_level(); + self.update_mute_power_level(); self.update_can_change_avatar(); self.update_can_change_name(); self.update_can_change_topic(); @@ -224,6 +289,58 @@ mod imp { self.obj().emit_by_name::<()>("changed", &[]); } + /// Update the power level of our own member. + fn update_own_power_level(&self) { + let Some(room) = self.room.upgrade() else { + return; + }; + let own_member = room.own_member(); + + let power_level = self + .power_levels + .borrow() + .for_user(own_member.user_id()) + .into(); + + if self.own_power_level.get() == power_level { + return; + } + + self.own_power_level.set(power_level); + self.obj().notify_own_power_level(); + } + + /// Update the default power level for members. + fn update_default_power_level(&self) { + let power_level = self.power_levels.borrow().users_default.into(); + + if self.default_power_level.get() == power_level { + return; + } + + self.default_power_level.set(power_level); + self.obj().notify_default_power_level(); + } + + /// Update the power level to mute members. + fn update_mute_power_level(&self) { + // To mute user they must not have enough power to send messages. + let power_levels = self.power_levels.borrow(); + let message_power_level = power_levels + .events + .get(&MessageLikeEventType::RoomMessage.into()) + .copied() + .unwrap_or(power_levels.events_default); + let power_level = (-1).min(message_power_level.into()); + + if self.mute_power_level.get() == power_level { + return; + } + + self.mute_power_level.set(power_level); + self.obj().notify_mute_power_level(); + } + /// Whether our own member is allowed to do the given action. pub(super) fn is_allowed_to(&self, room_action: PowerLevelAction) -> bool { if !self.is_joined.get() { @@ -364,6 +481,24 @@ impl Permissions { imp.init_power_levels().await; } + /// The current [`MemberRole`] for the given power level. + pub fn role(&self, power_level: PowerLevel) -> MemberRole { + if power_level >= POWER_LEVEL_ADMIN { + MemberRole::Administrator + } else if power_level >= POWER_LEVEL_MOD { + MemberRole::Moderator + } else if power_level == self.default_power_level() { + MemberRole::Default + } else if power_level < self.default_power_level() && power_level <= self.mute_power_level() + { + // Only set role as muted for members below default, to avoid visual noise in + // rooms where muted is the default. + MemberRole::Muted + } else { + MemberRole::Custom + } + } + /// Whether our own member is allowed to do the given action. pub fn is_allowed_to(&self, room_action: PowerLevelAction) -> bool { self.imp().is_allowed_to(room_action) @@ -395,6 +530,33 @@ impl Permissions { power_levels.user_can_do_to_user(own_user_id, user_id, action) } + /// Set the power level of the room member with the given user ID. + pub async fn set_user_power_level( + &self, + user_id: OwnedUserId, + power_level: PowerLevel, + ) -> Result<(), ()> { + let Some(room) = self.room() else { + return Err(()); + }; + + let matrix_room = room.matrix_room().clone(); + let handle = spawn_tokio!(async move { + let power_level = Int::new_saturating(power_level); + matrix_room + .update_power_levels(vec![(&user_id, power_level)]) + .await + }); + + match handle.await.unwrap() { + Ok(_) => Ok(()), + Err(error) => { + error!("Failed to set user power level: {error}"); + Err(()) + } + } + } + /// Connect to the signal emitted when the permissions changed. pub fn connect_changed(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( diff --git a/src/session/view/content/room_details/members_page/members_list_view/member_row.rs b/src/session/view/content/room_details/members_page/members_list_view/member_row.rs index 8e8460ec..fe7488cf 100644 --- a/src/session/view/content/room_details/members_page/members_list_view/member_row.rs +++ b/src/session/view/content/room_details/members_page/members_list_view/member_row.rs @@ -1,7 +1,7 @@ use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate}; use crate::{ - components::{Avatar, PowerLevelBadge}, + components::{Avatar, RoleBadge}, session::model::Member, }; @@ -31,7 +31,7 @@ mod imp { fn class_init(klass: &mut Self::Class) { Avatar::static_type(); - PowerLevelBadge::static_type(); + RoleBadge::static_type(); Self::bind_template(klass); } diff --git a/src/session/view/content/room_details/members_page/members_list_view/member_row.ui b/src/session/view/content/room_details/members_page/members_list_view/member_row.ui index d8d21318..1d85b20e 100644 --- a/src/session/view/content/room_details/members_page/members_list_view/member_row.ui +++ b/src/session/view/content/room_details/members_page/members_list_view/member_row.ui @@ -24,7 +24,7 @@ - 3 + 6 start @@ -54,9 +54,10 @@ - - - + + + + ContentMemberRow diff --git a/src/ui-resources.gresource.xml b/src/ui-resources.gresource.xml index 7a57de5c..64f26566 100644 --- a/src/ui-resources.gresource.xml +++ b/src/ui-resources.gresource.xml @@ -28,6 +28,7 @@ components/rows/copyable_row.ui components/rows/entry_add_row.ui components/rows/loading_row.ui + components/rows/power_level_selection_row.ui components/rows/removable_row.ui components/rows/substring_entry_row.ui components/rows/switch_loading_row.ui diff --git a/src/utils/message_dialog.rs b/src/utils/message_dialog.rs index 3432642e..0b0809ba 100644 --- a/src/utils/message_dialog.rs +++ b/src/utils/message_dialog.rs @@ -291,3 +291,68 @@ pub struct ConfirmRoomMemberDestructiveActionResponse { /// Whether we can remove the events. pub remove_events: bool, } + +/// Show a dialog to confirm muting a room member. +pub async fn confirm_mute_room_member(member: &Member, parent: &impl IsA) -> bool { + let heading = gettext_f( + // Translators: Do NOT translate the content between '{' and '}', + // this is a variable name. + "Mute {user}?", + &[("user", &member.display_name())], + ); + let body = gettext_f( + // Translators: Do NOT translate the content between '{' and '}', + // this is a variable name. + "Are you sure you want to mute {user_id}? They will not be able to send new messages.", + &[("user_id", member.user_id().as_str())], + ); + + // Ask for confirmation. + let confirm_dialog = adw::AlertDialog::builder() + .default_response("cancel") + .heading(heading) + .body(body) + .build(); + confirm_dialog.add_responses(&[ + ("cancel", &gettext("Cancel")), + // Translators: In this string, 'Mute' is a verb, as in 'Mute room member'. + ("mute", &gettext("Mute")), + ]); + confirm_dialog.set_response_appearance("mute", adw::ResponseAppearance::Destructive); + + confirm_dialog.choose_future(parent).await == "mute" +} + +/// Show a dialog to confirm setting the power level of a room member with the +/// same value as our own. +pub async fn confirm_set_room_member_power_level_same_as_own( + member: &Member, + parent: &impl IsA, +) -> bool { + let heading = gettext_f( + // Translators: Do NOT translate the content between '{' and '}', + // this is a variable name. + "Promote {user}?", + &[("user", &member.display_name())], + ); + let body = gettext_f( + // Translators: Do NOT translate the content between '{' and '}', + // this is a variable name. + "If you promote {user_id} to the same level as yours, you will not be able to demote them in the future.", + &[("user_id", member.user_id().as_str())], + ); + + // Ask for confirmation. + let confirm_dialog = adw::AlertDialog::builder() + .default_response("cancel") + .heading(heading) + .body(body) + .build(); + confirm_dialog.add_responses(&[ + ("cancel", &gettext("Cancel")), + ("promote", &gettext("Promote")), + ]); + confirm_dialog.set_response_appearance("promote", adw::ResponseAppearance::Destructive); + + confirm_dialog.choose_future(parent).await == "promote" +}