From 7f3941734986335d72d04d189829e2e987ac486a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 19 Jul 2025 15:16:53 +0200 Subject: [PATCH] room-history: Show a banner when there are pending invite requests And the user can accept or deny it. --- .../content/room_details/members_page/mod.rs | 7 +- src/session/view/content/room_details/mod.rs | 107 ++++++++++---- src/session/view/content/room_history/mod.rs | 136 ++++++++++++++---- src/session/view/content/room_history/mod.ui | 8 ++ 4 files changed, 200 insertions(+), 58 deletions(-) diff --git a/src/session/view/content/room_details/members_page/mod.rs b/src/session/view/content/room_details/members_page/mod.rs index a209bd68..5a1f5b5f 100644 --- a/src/session/view/content/room_details/members_page/mod.rs +++ b/src/session/view/content/room_details/members_page/mod.rs @@ -70,7 +70,7 @@ mod imp { impl MembersPage { /// Show the subpage for the list with the given membership. - fn show_membership_list(&self, kind: MembershipListKind) { + pub(super) fn show_membership_list(&self, kind: MembershipListKind) { let tag = kind.as_ref(); if self.navigation_view.find_page(tag).is_some() { @@ -105,4 +105,9 @@ impl MembersPage { .property("members", members) .build() } + + /// Show the subpage for the list with the given membership. + pub(super) fn show_membership_list(&self, kind: MembershipListKind) { + self.imp().show_membership_list(kind); + } } diff --git a/src/session/view/content/room_details/mod.rs b/src/session/view/content/room_details/mod.rs index c30d9524..2c763879 100644 --- a/src/session/view/content/room_details/mod.rs +++ b/src/session/view/content/room_details/mod.rs @@ -35,13 +35,13 @@ use self::{ }; use crate::{ components::UserPage, - session::model::{MemberList, Room}, + session::model::{MemberList, MembershipListKind, Room}, toast, }; /// The possible subpages of the room details. #[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::Variant)] -pub(crate) enum SubpageName { +pub(super) enum SubpageName { /// The page to edit the name, topic and avatar of the room. EditDetails, /// The list of members of the room. @@ -62,6 +62,17 @@ pub(crate) enum SubpageName { JoinRule, } +/// The view to present when opening the room details. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum InitialView { + /// Present the default page. + None, + /// Present the given subpage. + Subpage(SubpageName), + /// Present the members subpage with the given kind. + Members(MembershipListKind), +} + mod imp { use std::{ cell::{OnceCell, RefCell}, @@ -105,11 +116,11 @@ mod imp { klass.install_action( "details.show-subpage", - Some(&String::static_variant_type()), + Some(&SubpageName::static_variant_type()), |obj, _, param| { let subpage = param .and_then(glib::Variant::get::) - .expect("The parameter should be a valid subpage name"); + .expect("parameter should be a valid subpage name"); obj.imp().show_subpage(subpage, false); }, @@ -208,30 +219,65 @@ mod imp { self.timeline.get().expect("timeline should be initialized") } - /// Show the subpage with the given name. - pub(super) fn show_subpage(&self, name: SubpageName, is_initial: bool) { + /// Get the given subpage. + fn subpage(&self, name: SubpageName) -> adw::NavigationPage { let room = self.room.get().expect("room should be initialized"); - let mut subpages = self.subpages.borrow_mut(); - let subpage = subpages.entry(name).or_insert_with(|| match name { - SubpageName::EditDetails => EditDetailsSubpage::new(room).upcast(), - SubpageName::Members => MembersPage::new(room, self.members()).upcast(), - SubpageName::Invite => InviteSubpage::new(room).upcast(), - SubpageName::VisualMediaHistory => { - VisualMediaHistoryViewer::new(self.timeline()).upcast() - } - SubpageName::FileHistory => FileHistoryViewer::new(self.timeline()).upcast(), - SubpageName::AudioHistory => AudioHistoryViewer::new(self.timeline()).upcast(), - SubpageName::Addresses => AddressesSubpage::new(room).upcast(), - SubpageName::Permissions => PermissionsSubpage::new(&room.permissions()).upcast(), - SubpageName::JoinRule => JoinRuleSubpage::new(room).upcast(), - }); + self.subpages + .borrow_mut() + .entry(name) + .or_insert_with(|| match name { + SubpageName::EditDetails => EditDetailsSubpage::new(room).upcast(), + SubpageName::Members => MembersPage::new(room, self.members()).upcast(), + SubpageName::Invite => InviteSubpage::new(room).upcast(), + SubpageName::VisualMediaHistory => { + VisualMediaHistoryViewer::new(self.timeline()).upcast() + } + SubpageName::FileHistory => FileHistoryViewer::new(self.timeline()).upcast(), + SubpageName::AudioHistory => AudioHistoryViewer::new(self.timeline()).upcast(), + SubpageName::Addresses => AddressesSubpage::new(room).upcast(), + SubpageName::Permissions => { + PermissionsSubpage::new(&room.permissions()).upcast() + } + SubpageName::JoinRule => JoinRuleSubpage::new(room).upcast(), + }) + .clone() + } + + /// Show the subpage with the given name. + pub(super) fn show_subpage(&self, name: SubpageName, is_initial: bool) { + let subpage = self.subpage(name); if is_initial { subpage.set_can_pop(false); } - self.obj().push_subpage(subpage); + self.obj().push_subpage(&subpage); + } + + /// Show the members subpage with the given kind. + fn show_members_subpage(&self, kind: MembershipListKind) { + let subpage = self + .subpage(SubpageName::Members) + .downcast::() + .expect("we should have the members subpage"); + + subpage.show_membership_list(kind); + + self.obj().push_subpage(&subpage); + } + + /// Show the given initial view. + pub(super) fn show_initial_view(&self, initial_view: InitialView) { + match initial_view { + InitialView::None => {} + InitialView::Subpage(name) => { + self.show_subpage(name, true); + } + InitialView::Members(kind) => { + self.show_members_subpage(kind); + } + } } } } @@ -245,16 +291,19 @@ glib::wrapper! { impl RoomDetails { /// Construct a `RoomDetails` for the given room with the given parent - /// window. - pub fn new(parent_window: Option<>k::Window>, room: &Room) -> Self { - glib::Object::builder() + /// window, showing the given initial view. + pub(super) fn new( + parent_window: Option<>k::Window>, + room: &Room, + initial_view: InitialView, + ) -> Self { + let obj = glib::Object::builder::() .property("transient-for", parent_window) .property("room", room) - .build() - } + .build(); + + obj.imp().show_initial_view(initial_view); - /// Show the given subpage as the initial page. - pub(crate) fn show_initial_subpage(&self, name: SubpageName) { - self.imp().show_subpage(name, true); + obj } } diff --git a/src/session/view/content/room_history/mod.rs b/src/session/view/content/room_history/mod.rs index 676386b0..06889741 100644 --- a/src/session/view/content/room_history/mod.rs +++ b/src/session/view/content/room_history/mod.rs @@ -6,8 +6,9 @@ use gtk::{CompositeTemplate, gdk, gio, glib, glib::clone, graphene::Point}; use matrix_sdk::ruma::EventId; use matrix_sdk_ui::timeline::TimelineEventItemId; use ruma::{ - OwnedEventId, api::client::receipt::create_receipt::v3::ReceiptType, - events::room::message::MessageType, + OwnedEventId, + api::client::receipt::create_receipt::v3::ReceiptType, + events::room::{message::MessageType, power_levels::PowerLevelAction}, }; use tracing::{error, warn}; @@ -40,10 +41,11 @@ use self::{ use super::{RoomDetails, room_details}; use crate::{ components::{DragOverlay, confirm_leave_room_dialog}, + ngettext_f, prelude::*, session::model::{ - Event, MemberList, Membership, ReceiptPosition, Room, TargetRoomCategory, Timeline, - VirtualItem, VirtualItemKind, + Event, MemberList, Membership, MembershipListKind, ReceiptPosition, Room, + TargetRoomCategory, Timeline, VirtualItem, VirtualItemKind, }, spawn, toast, utils::{BoundObject, GroupingListGroup, GroupingListModel, LoadingState, TemplateCallbacks}, @@ -77,6 +79,8 @@ mod imp { #[template_child] room_menu: TemplateChild, #[template_child] + pending_knocks_banner: TemplateChild, + #[template_child] listview: TemplateChild, #[template_child] content: TemplateChild, @@ -121,9 +125,10 @@ mod imp { scroll_timeout: RefCell>, read_timeout: RefCell>, room_handler: RefCell>, - can_invite_handler: RefCell>, + permissions_handlers: RefCell>, membership_handler: RefCell>, join_rule_handler: RefCell>, + knock_items_changed_handler: RefCell>, } #[glib::object_subclass] @@ -132,6 +137,7 @@ mod imp { type Type = super::RoomHistory; type ParentType = adw::Bin; + #[allow(clippy::too_many_lines)] fn class_init(klass: &mut Self::Class) { VerificationInfoBar::ensure_type(); @@ -152,10 +158,13 @@ mod imp { }); klass.install_action("room-history.details", None, |obj, _, _| { - obj.open_room_details(None); + obj.imp().open_room_details(room_details::InitialView::None); }); klass.install_action("room-history.invite-members", None, |obj, _, _| { - obj.open_room_details(Some(room_details::SubpageName::Invite)); + obj.imp() + .open_room_details(room_details::InitialView::Subpage( + room_details::SubpageName::Invite, + )); }); klass.install_action( @@ -392,9 +401,11 @@ mod imp { room.disconnect(handler); } - if let Some(handler) = self.can_invite_handler.take() { - room.permissions().disconnect(handler); + let permissions = room.permissions(); + for handler in self.permissions_handlers.take() { + permissions.disconnect(handler); } + if let Some(handler) = self.membership_handler.take() { room.own_member().disconnect(handler); } @@ -403,6 +414,14 @@ mod imp { } } + if let Some(members) = self.room_members.take() { + if let Some(handler) = self.knock_items_changed_handler.take() { + members + .membership_list(MembershipListKind::Knock) + .disconnect(handler); + } + } + self.timeline.disconnect_signals(); } @@ -426,8 +445,21 @@ mod imp { // Keep a strong reference to the members list before changing the model, so all // events use the same list. - self.room_members - .replace(Some(room.get_or_create_members())); + let room_members = room.get_or_create_members(); + + let knock_items_changed_handler = room_members + .membership_list(MembershipListKind::Knock) + .connect_items_changed(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _, _| { + imp.update_pending_knocks(); + } + )); + self.knock_items_changed_handler + .replace(Some(knock_items_changed_handler)); + + self.room_members.replace(Some(room_members)); let membership_handler = room.own_member().connect_membership_notify(clone!( #[weak(rename_to = imp)] @@ -454,7 +486,15 @@ mod imp { imp.update_invite_action(); } )); - self.can_invite_handler.replace(Some(can_invite_handler)); + let changed_handler = room.permissions().connect_changed(clone!( + #[weak(rename_to = imp)] + self, + move |_| { + imp.update_pending_knocks(); + } + )); + self.permissions_handlers + .replace(vec![can_invite_handler, changed_handler]); let is_direct_handler = room.connect_is_direct_notify(clone!( #[weak(rename_to = imp)] @@ -505,6 +545,7 @@ mod imp { self.load_more_events_if_needed(); self.update_room_menu(); self.update_invite_action(); + self.update_pending_knocks(); self.obj().notify_timeline(); } @@ -982,6 +1023,41 @@ mod imp { .action_set_enabled("room-history.invite-members", can_invite); } + // Update the pending knocks according to the current state. + fn update_pending_knocks(&self) { + if self.room().is_none_or(|room| { + let permissions = room.permissions(); + !permissions.is_allowed_to(PowerLevelAction::Invite) + && !permissions.is_allowed_to(PowerLevelAction::Kick) + && !permissions.is_allowed_to(PowerLevelAction::Ban) + }) { + // Our user cannot act on the knock. + self.pending_knocks_banner.set_revealed(false); + return; + } + + let Some(members) = self.room_members.borrow().clone() else { + self.pending_knocks_banner.set_revealed(false); + return; + }; + + let n = members.membership_list(MembershipListKind::Knock).n_items(); + let reveal = n > 0; + + if reveal { + self.pending_knocks_banner.set_title(&ngettext_f( + // Translators: Do NOT translate the content between '{' and '}', + // this is a variable name. + "There is a pending invite request", + "There are {n} pending invite requests", + n, + &[("n", &n.to_string())], + )); + } + + self.pending_knocks_banner.set_revealed(reveal); + } + /// The context menu for rows presenting an [`Event`]. pub(super) fn event_context_menu(&self) -> &EventActionsContextMenu { self.event_context_menu.get_or_init(Default::default) @@ -1001,6 +1077,26 @@ mod imp { popover }) } + + /// Opens the room details with the given initial view. + fn open_room_details(&self, initial_view: room_details::InitialView) { + let Some(room) = self.room() else { + return; + }; + + let window = + RoomDetails::new(self.obj().root().and_downcast_ref(), &room, initial_view); + + window.present(); + } + + /// View the list of pending knock requests. + #[template_callback] + fn view_pending_knocks(&self) { + self.open_room_details(room_details::InitialView::Members( + MembershipListKind::Knock, + )); + } } } @@ -1025,22 +1121,6 @@ impl RoomHistory { &self.imp().message_toolbar } - /// Opens the room details. - /// - /// If `subpage_name` is set, the room details will be opened on the given - /// subpage. - pub(crate) fn open_room_details(&self, subpage_name: Option) { - let Some(room) = self.imp().room() else { - return; - }; - - let window = RoomDetails::new(self.root().and_downcast_ref(), &room); - if let Some(subpage_name) = subpage_name { - window.show_initial_subpage(subpage_name); - } - window.present(); - } - /// Enable or disable the mode allowing the room history to stick to the /// bottom based on scrollbar position. pub(crate) fn enable_sticky_mode(&self, enable: bool) { diff --git a/src/session/view/content/room_history/mod.ui b/src/session/view/content/room_history/mod.ui index a94f6bed..64959569 100644 --- a/src/session/view/content/room_history/mod.ui +++ b/src/session/view/content/room_history/mod.ui @@ -163,6 +163,14 @@ + + + + View + suggested + + + crossfade