From 85fe68bcf0a0c89938d6b836ff7538947814064d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tyrese=20Luo=20=28=E7=BE=85=E5=81=A5=E5=B3=AF=29?= Date: Sat, 11 Apr 2026 20:08:21 +0800 Subject: [PATCH] Fix people profile interactions and member sync - fix feedback loop when opening profile from People list cards - open user profile with room-member context from people list and timeline avatars - add permission-gated power-level update in user profile pane - refresh People members on panel open and membership-change timeline events --- src/home/room_screen.rs | 148 ++++++++++++++++++++++++++---------- src/profile/user_profile.rs | 89 ++++++++++++++++++++++ src/sliding_sync.rs | 91 +++++++++++++++++++++- 3 files changed, 286 insertions(+), 42 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 7e6ee4f3..622dc094 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -2489,6 +2489,18 @@ impl Widget for RoomInfoSlidingPane { } if let Event::Actions(actions) = event { + for action in actions { + if action.as_widget_action().widget_uid_eq(self.widget_uid()).is_none() + && let RoomInfoPaneAction::OpenPeopleProfile(user_id) = action.as_widget_action().cast() + { + cx.widget_action( + self.widget_uid(), + RoomInfoPaneAction::OpenPeopleProfile(user_id.clone()), + ); + break; + } + } + if self.button(cx, ids!(header.back_button)).clicked(actions) { self.show_people_page = false; self.redraw(cx); @@ -3234,9 +3246,7 @@ impl Widget for RoomScreen { } } RoomInfoPaneAction::ShowPeoplePage => { - if let Some(tl) = self.tl_state.as_ref() - && tl.room_members.is_none() - { + if let Some(tl) = self.tl_state.as_ref() { submit_async_request(MatrixRequest::GetRoomMembers { timeline_kind: tl.kind.clone(), memberships: matrix_sdk::RoomMemberships::JOIN, @@ -3256,6 +3266,8 @@ impl Widget for RoomScreen { .as_ref() .and_then(|member| member.avatar_url().map(ToOwned::to_owned)) ); + let can_change_room_power_levels = self.tl_state.as_ref() + .is_some_and(|tl| tl.user_power.can_change_room_power_levels()); self.show_user_profile( cx, &user_profile_sliding_pane, @@ -3270,6 +3282,7 @@ impl Widget for RoomScreen { }, room_name: room_name_id.to_string(), room_member, + can_change_room_power_levels, }, ); } @@ -3792,6 +3805,22 @@ impl Widget for RoomScreen { // Handle the action that requests to show the user profile sliding pane. if let ShowUserProfileAction::ShowUserProfile(profile_and_room_id) = action.as_widget_action().cast() { + let mut profile_and_room_id = profile_and_room_id; + let room_member = self.tl_state.as_ref() + .and_then(|tl| tl.room_members.as_ref()) + .and_then(|members| members.iter().find(|member| member.user_id() == profile_and_room_id.user_id).cloned()); + if let Some(room_member) = room_member.as_ref() { + if profile_and_room_id.username.is_none() { + profile_and_room_id.username = room_member.display_name().map(ToOwned::to_owned); + } + if !profile_and_room_id.avatar_state.has_avatar() { + profile_and_room_id.avatar_state = AvatarState::Known( + room_member.avatar_url().map(ToOwned::to_owned) + ); + } + } + let can_change_room_power_levels = self.tl_state.as_ref() + .is_some_and(|tl| tl.user_power.can_change_room_power_levels()); self.show_user_profile( cx, &user_profile_sliding_pane, @@ -3801,7 +3830,8 @@ impl Widget for RoomScreen { || tr_key(self.app_language, "room_screen.fallback.unnamed_room").to_string(), |r| r.to_string(), ), - room_member: None, + room_member, + can_change_room_power_levels, }, ); } @@ -4312,8 +4342,30 @@ impl RoomScreen { self.close_leave_room_confirm_modal(cx); } + fn resolved_app_service_bot_user_id( + &self, + app_state: &AppState, + room_id: &OwnedRoomId, + ) -> Option { + if let Some(bot_user_id) = app_state.bot_settings.bound_bot_user_id(room_id.as_ref()) { + return Some(bot_user_id.to_owned()); + } + + self.tl_state + .as_ref() + .filter(|tl| tl.kind.room_id() == room_id) + .and_then(|tl| tl.room_members.as_ref()) + .and_then(|members| + detected_bot_binding_for_members( + app_state, + room_id, + members.as_ref(), + ) + ) + } + fn is_app_service_room_bound(&self, app_state: &AppState, room_id: &OwnedRoomId) -> bool { - app_state.bot_settings.is_room_bound(room_id) + self.resolved_app_service_bot_user_id(app_state, room_id).is_some() } fn send_app_service_feedback_message(&self, message: impl Into) { @@ -4357,7 +4409,8 @@ impl RoomScreen { ); return false; } - if !self.is_app_service_room_bound(app_state, &room_id) { + let bound_bot_user_id = self.resolved_app_service_bot_user_id(app_state, &room_id); + if bound_bot_user_id.is_none() { self.send_app_service_feedback_message( tr_key(self.app_language, "room_screen.popup.bot.bind_before_commands"), ); @@ -4368,10 +4421,7 @@ impl RoomScreen { timeline_kind, message: RoomMessageEventContent::text_plain(command), replied_to: None, - target_user_id: app_state - .bot_settings - .bound_bot_user_id(room_id.as_ref()) - .map(ToOwned::to_owned), + target_user_id: bound_bot_user_id, #[cfg(feature = "tsp")] sign_with_tsp: false, }); @@ -4608,35 +4658,44 @@ impl RoomScreen { } } - if !self.pending_invited_users.is_empty() { - let start = changed_indices.start.min(new_items.len()); - let end = changed_indices.end.min(new_items.len()); - let mut accepted_users: Vec = Vec::new(); - for idx in start..end { - let Some(new_item) = new_items.get(idx) else { continue }; - let TimelineItemKind::Event(event_tl_item) = new_item.kind() else { continue }; - let TimelineItemContent::MembershipChange(membership_change) = event_tl_item.content() else { continue }; - let accepted = matches!( - membership_change.change(), - Some(MembershipChange::InvitationAccepted) - | Some(MembershipChange::Joined) - ); - if accepted { - let invited_user_id = event_tl_item.sender().to_owned(); - if self.pending_invited_users.contains(&invited_user_id) { - accepted_users.push(invited_user_id); - } - } + let start = changed_indices.start.min(new_items.len()); + let end = changed_indices.end.min(new_items.len()); + let mut accepted_users: Vec = Vec::new(); + let mut room_members_changed = false; + for idx in start..end { + let Some(new_item) = new_items.get(idx) else { continue }; + let TimelineItemKind::Event(event_tl_item) = new_item.kind() else { continue }; + let TimelineItemContent::MembershipChange(membership_change) = event_tl_item.content() else { continue }; + if is_append { + room_members_changed = true; } - for accepted_user in accepted_users { - self.pending_invited_users.remove(&accepted_user); - enqueue_popup_notification( - format!("{accepted_user} accepted the invite and joined."), - PopupKind::Success, - Some(4.0), - ); + let accepted = matches!( + membership_change.change(), + Some(MembershipChange::InvitationAccepted) + | Some(MembershipChange::Joined) + ); + if accepted { + let invited_user_id = event_tl_item.sender().to_owned(); + if self.pending_invited_users.contains(&invited_user_id) { + accepted_users.push(invited_user_id); + } } } + if room_members_changed { + submit_async_request(MatrixRequest::GetRoomMembers { + timeline_kind: tl.kind.clone(), + memberships: matrix_sdk::RoomMemberships::JOIN, + local_only: false, + }); + } + for accepted_user in accepted_users { + self.pending_invited_users.remove(&accepted_user); + enqueue_popup_notification( + format!("{accepted_user} accepted the invite and joined."), + PopupKind::Success, + Some(4.0), + ); + } if prior_items_changed { // If this RoomScreen is showing the loading pane and has an ongoing backwards pagination request, @@ -5060,6 +5119,16 @@ impl RoomScreen { let Some(room_name_id) = self.room_name_id.as_ref() else { return false; }; + let room_member = self.tl_state.as_ref() + .and_then(|tl| tl.room_members.as_ref()) + .and_then(|members| members.iter().find(|member| member.user_id() == user_id).cloned()); + let username = room_member.as_ref() + .and_then(|member| member.display_name().map(ToOwned::to_owned)); + let avatar_state = room_member.as_ref() + .and_then(|member| member.avatar_url().map(ToOwned::to_owned)) + .map_or(AvatarState::Unknown, |avatar_url| AvatarState::Known(Some(avatar_url))); + let can_change_room_power_levels = self.tl_state.as_ref() + .is_some_and(|tl| tl.user_power.can_change_room_power_levels()); // There is no synchronous way to get the user's full profile info // including the details of their room membership, // so we fill in with the details we *do* know currently, @@ -5073,14 +5142,15 @@ impl RoomScreen { profile_and_room_id: UserProfileAndRoomId { user_profile: UserProfile { user_id: user_id.to_owned(), - username: None, - avatar_state: AvatarState::Unknown, + username, + avatar_state, }, room_id: room_name_id.room_id().clone(), }, room_name: room_name_id.to_string(), // TODO: use the extra `via` parameters - room_member: None, + room_member, + can_change_room_power_levels, }, ); true diff --git a/src/profile/user_profile.rs b/src/profile/user_profile.rs index fd338911..e51b5ec9 100644 --- a/src/profile/user_profile.rs +++ b/src/profile/user_profile.rs @@ -161,6 +161,52 @@ script_mod! { } text: "Unknown" } + + power_level_controls := View { + visible: false, + width: Fill, + height: Fit, + flow: Down, + spacing: 6, + margin: Inset{top: 8} + + power_level_label := Label { + margin: Inset{ left: 7 } + width: Fill, height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + text: "Power Level" + } + + power_level_dropdown := DropDownFlat { + width: Fill + height: 40 + align: Align{y: 0.5} + padding: Inset{left: 12, top: 11, bottom: 11, right: 30} + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11.5 } + color: #333 + color_hover: uniform(#222) + color_focus: uniform(#222) + color_down: uniform(#222) + } + draw_bg +: { + color: uniform(#fff) + color_hover: uniform(#F0F0F2) + color_focus: uniform(#F0F0F2) + color_down: uniform(#E8E8EA) + border_color: uniform(#CCC) + border_color_hover: uniform(#AAA) + border_color_focus: uniform((COLOR_ACTIVE_PRIMARY)) + arrow_color: uniform(#888) + arrow_color_hover: uniform(#555) + } + labels: ["Default", "Moderator", "Admin"] + } + } } LineH { padding: 15 } @@ -299,6 +345,7 @@ pub struct UserProfilePaneInfo { pub profile_and_room_id: UserProfileAndRoomId, pub room_name: String, pub room_member: Option, + pub can_change_room_power_levels: bool, } impl Deref for UserProfilePaneInfo { type Target = UserProfileAndRoomId; @@ -463,6 +510,33 @@ impl Widget for UserProfileSlidingPane { let Some(info) = self.info.as_ref() else { return }; if let Event::Actions(actions) = event { + let power_level_dropdown = self.drop_down(cx, ids!(power_level_dropdown)); + if power_level_dropdown.changed(actions).is_some() + && info.can_change_room_power_levels + && let Some(room_member) = info.room_member.as_ref() + && !room_member.is_account_user() + { + let selected_item = power_level_dropdown.selected_item(); + let selected_role = match selected_item { + 0 => None, + 1 => Some(RoomMemberRole::Moderator), + 2 => Some(RoomMemberRole::Administrator), + _ => None, + }; + let current_selected_item = match room_member.suggested_role_for_power_level() { + RoomMemberRole::Creator | RoomMemberRole::Administrator => 2, + RoomMemberRole::Moderator => 1, + RoomMemberRole::User => 0, + }; + if selected_item != current_selected_item { + submit_async_request(MatrixRequest::SetRoomMemberPowerLevel { + room_id: info.room_id.clone(), + user_id: info.user_id.clone(), + room_member_role: selected_role, + }); + } + } + if self.button(cx, ids!(direct_message_button)).clicked(actions) { let create_encrypted = scope .data @@ -566,6 +640,21 @@ impl Widget for UserProfileSlidingPane { .map(|rm| rm.is_account_user()) .unwrap_or_else(|| current_user_id().is_some_and(|uid| uid == info.user_id)); + let show_power_level_controls = info.can_change_room_power_levels + && !is_pane_showing_current_account + && info.room_member.is_some(); + self.view(cx, ids!(power_level_controls)).set_visible(cx, show_power_level_controls); + if show_power_level_controls + && let Some(room_member) = info.room_member.as_ref() + { + let selected_item = match room_member.suggested_role_for_power_level() { + RoomMemberRole::Creator | RoomMemberRole::Administrator => 2, + RoomMemberRole::Moderator => 1, + RoomMemberRole::User => 0, + }; + self.drop_down(cx, ids!(power_level_dropdown)).set_selected_item(cx, selected_item); + } + self.button(cx, ids!(direct_message_button)).set_visible(cx, !is_pane_showing_current_account); let ignore_user_button = self.button(cx, ids!(ignore_user_button)); diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 456f5d58..82ff50af 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -9,7 +9,7 @@ use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use mime::{IMAGE_JPEG, IMAGE_PNG}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ - config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, ListThreadsOptions, RelationsOptions, RoomMember}, ruma::{ + config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, ListThreadsOptions, RelationsOptions, RoomMember, RoomMemberRole}, ruma::{ api::{Direction, client::{ account::register::v3::Request as RegistrationRequest, room::create_room::v3::{Request as CreateRoomRequest, RoomPreset}, @@ -25,7 +25,7 @@ use matrix_sdk::{ }, space::{child::SpaceChildEventContent, parent::SpaceParentEventContent}, InitialStateEvent, MessageLikeEventType, StateEventType - }, matrix_uri::MatrixId, EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, uint + }, matrix_uri::MatrixId, EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, int, uint }, sliding_sync::VersionBuilder, Client, ClientBuildError, Error, OwnedServerName, Room, RoomDisplayName, RoomMemberships, RoomState, SessionChange, SuccessorRoom }; use matrix_sdk_ui::{ @@ -885,6 +885,14 @@ pub enum MatrixRequest { /// which is only needed because it isn't present in the `RoomMember` object. room_id: OwnedRoomId, }, + /// Request to change the room-member power level for a user. + SetRoomMemberPowerLevel { + room_id: OwnedRoomId, + user_id: OwnedUserId, + /// * `None` means reset to the room's default user power level. + /// * `Some` means set a role preset. + room_member_role: Option, + }, /// Request to upload and set the avatar of the current user's account. UploadAvatar { /// The path to a local PNG or JPEG image file. @@ -2418,6 +2426,78 @@ async fn matrix_worker_task( }); } + MatrixRequest::SetRoomMemberPowerLevel { room_id, user_id, room_member_role } => { + let Some(client) = get_client() else { continue }; + let _set_room_member_power_level_task = Handle::current().spawn(async move { + let Some(room) = client.get_room(&room_id) else { + enqueue_popup_notification( + format!("Failed to update power level for {user_id}: room {room_id} not found."), + PopupKind::Error, + None, + ); + return; + }; + let Some(acting_user_id) = client.user_id() else { + enqueue_popup_notification( + "Failed to update power level: not logged in.", + PopupKind::Error, + None, + ); + return; + }; + + let power_levels = match room.power_levels().await { + Ok(power_levels) => power_levels, + Err(e) => { + enqueue_popup_notification( + format!("Failed to load current power levels for room {room_id}: {e}"), + PopupKind::Error, + None, + ); + return; + } + }; + + if !power_levels.user_can_change_user_power_level(acting_user_id, user_id.as_ref()) { + enqueue_popup_notification( + format!("You do not have permission to change power level for {user_id}."), + PopupKind::Error, + None, + ); + return; + } + + let new_level = match room_member_role { + Some(RoomMemberRole::Moderator) => int!(50), + Some(RoomMemberRole::Creator | RoomMemberRole::Administrator) => int!(100), + Some(RoomMemberRole::User) | None => power_levels.users_default, + }; + + match room.update_power_levels(vec![(user_id.as_ref(), new_level)]).await { + Ok(_) => { + enqueue_popup_notification( + format!("Updated power level for {user_id}."), + PopupKind::Success, + Some(3.0), + ); + if let Ok(Some(new_room_member)) = room.get_member(user_id.as_ref()).await { + enqueue_user_profile_update(UserProfileUpdate::RoomMemberOnly { + room_id: room_id.clone(), + room_member: new_room_member, + }); + } + } + Err(e) => { + enqueue_popup_notification( + format!("Failed to update power level for {user_id}: {e}"), + PopupKind::Error, + None, + ); + } + } + }); + } + MatrixRequest::SendTypingNotice { room_id, typing } => { let Some(main_room_timeline) = get_room_timeline(&room_id) else { log!("BUG: skipping send typing notice request for not-yet-known room {room_id}"); @@ -5761,7 +5841,7 @@ bitflags! { // const RoomMember = 1 << 46; // const RoomName = 1 << 47; const RoomPinnedEvents = 1 << 48; - // const RoomPowerLevels = 1 << 49; + const RoomPowerLevels = 1 << 49; // const RoomServerAcl = 1 << 50; // const RoomThirdPartyInvite = 1 << 51; // const RoomTombstone = 1 << 52; @@ -5789,6 +5869,7 @@ impl UserPowerLevels { retval.set(UserPowerLevels::RoomRedaction, user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction)); retval.set(UserPowerLevels::Sticker, user_power >= power_levels.for_message(MessageLikeEventType::Sticker)); retval.set(UserPowerLevels::RoomPinnedEvents, user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents)); + retval.set(UserPowerLevels::RoomPowerLevels, power_levels.user_can_send_state(user_id, StateEventType::RoomPowerLevels)); retval } @@ -5850,6 +5931,10 @@ impl UserPowerLevels { pub fn can_pin(self) -> bool { self.contains(UserPowerLevels::RoomPinnedEvents) } + + pub fn can_change_room_power_levels(self) -> bool { + self.contains(UserPowerLevels::RoomPowerLevels) + } }