From 81a51fee714a1090e855309de3c780dddd5a19ce 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: Sun, 18 Jan 2026 21:17:41 +0800 Subject: [PATCH 1/3] Show non-member room state across list and room view - keep left/banned rooms visible with status hint - switch input bar to membership footer and stop typing actions - update restore status view for persisted rooms --- src/home/invite_screen.rs | 8 +- src/home/room_screen.rs | 151 ++++++++++++++++++++- src/home/rooms_list.rs | 130 ++++++++++++++---- src/home/rooms_list_entry.rs | 25 +++- src/room/room_input_bar.rs | 211 +++++++++++++++++++++++++++++- src/shared/restore_status_view.rs | 29 +++- src/sliding_sync.rs | 21 +-- 7 files changed, 516 insertions(+), 59 deletions(-) diff --git a/src/home/invite_screen.rs b/src/home/invite_screen.rs index dc412f35..92d3cbcf 100644 --- a/src/home/invite_screen.rs +++ b/src/home/invite_screen.rs @@ -396,7 +396,7 @@ impl Widget for InviteScreen { if !self.is_loaded { let mut restore_status_view = self.view.restore_status_view(ids!(restore_status_view)); if let Some(room_name) = &self.room_name_id { - restore_status_view.set_content(cx, self.all_rooms_loaded, room_name); + restore_status_view.set_content(cx, self.all_rooms_loaded, room_name, None); } return restore_status_view.draw(cx, scope); } @@ -536,11 +536,7 @@ impl InviteScreen { let restore_status_view = self.view.restore_status_view(ids!(restore_status_view)); if !self.is_loaded { - restore_status_view.set_content( - cx, - self.all_rooms_loaded, - room_name_id, - ); + restore_status_view.set_content(cx, self.all_rooms_loaded, room_name_id, None); restore_status_view.set_visible(cx, true); } else { restore_status_view.set_visible(cx, false); diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 5a0799c2..e924f232 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -7,7 +7,7 @@ use bytesize::ByteSize; use imbl::Vector; use makepad_widgets::{image_cache::ImageBuffer, *}; use matrix_sdk::{ - OwnedServerName, RoomDisplayName, media::{MediaFormat, MediaRequestParameters}, room::RoomMember, ruma::{ + OwnedServerName, RoomDisplayName, RoomState, media::{MediaFormat, MediaRequestParameters}, room::RoomMember, ruma::{ EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, events::{ receipt::Receipt, room::{ @@ -16,7 +16,7 @@ use matrix_sdk::{ } }, sticker::{StickerEventContent, StickerMediaSource}, - }, matrix_uri::MatrixId, uint + }, matrix_uri::MatrixId, room::JoinRuleSummary, uint } }; use matrix_sdk_ui::timeline::{ @@ -29,7 +29,7 @@ use crate::{ user_profile::{ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache, }, - room::{BasicRoomDetails, room_input_bar::RoomInputBarState, typing_notice::TypingNoticeWidgetExt}, + room::{BasicRoomDetails, RoomPreviewAction, room_input_bar::RoomInputBarState, typing_notice::TypingNoticeWidgetExt}, shared::{ avatar::{AvatarState, AvatarWidgetRefExt}, callout_tooltip::{CalloutTooltipOptions, TooltipAction, TooltipPosition}, confirmation_modal::ConfirmationModalContent, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupItem, PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt }, @@ -588,6 +588,14 @@ pub struct RoomScreen { #[rust] is_loaded: bool, /// Whether or not all rooms have been loaded (received from the homeserver). #[rust] all_rooms_loaded: bool, + /// Tracks whether the current user is no longer a member of this room. + #[rust] removed_state: Option, + /// Cached join rule for the removed room, used to determine re-join options. + #[rust] removed_join_rule: Option, + /// Whether a room preview request is in flight for removed-room status. + #[rust] removed_preview_requested: bool, + /// Whether a room preview request has already been attempted for this removal. + #[rust] removed_preview_attempted: bool, } impl Drop for RoomScreen { fn drop(&mut self) { @@ -747,6 +755,27 @@ impl Widget for RoomScreen { } } + if let Some(RoomPreviewAction::Fetched(result)) = action.downcast_ref() { + if self.removed_state.is_none() || !self.removed_preview_requested { + continue; + } + match result { + Ok(frp) => { + if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == frp.room_name_id.room_id()) { + self.removed_join_rule = frp.join_rule.clone(); + self.removed_preview_requested = false; + self.removed_preview_attempted = true; + self.update_membership_footer(cx); + } + } + Err(_) => { + self.removed_preview_requested = false; + self.removed_preview_attempted = true; + self.update_membership_footer(cx); + } + } + } + // Handle the highlight animation for a message. let Some(tl) = self.tl_state.as_mut() else { continue }; if let MessageHighlightAnimationState::Pending { item_id } = tl.message_highlight_animation_state { @@ -819,12 +848,36 @@ impl Widget for RoomScreen { self.room_name_id = None; self.set_displayed_room(cx, &room_name_clone); } else { + let mut removed_state = rooms_list_ref.get_removed_room_state(room_name_id.room_id()); + if let Some(client) = get_client() + && let Some(room) = client.get_room(room_name_id.room_id()) + { + let actual_state = room.state(); + let desired_removed_state = match actual_state { + RoomState::Left | RoomState::Banned => Some(actual_state), + _ => None, + }; + if desired_removed_state != removed_state { + rooms_list_ref.set_removed_room_state( + room_name_id.room_id().clone(), + desired_removed_state, + ); + removed_state = desired_removed_state; + } + } + if self.removed_state != removed_state { + self.removed_state = removed_state; + self.removed_join_rule = None; + self.removed_preview_requested = false; + self.removed_preview_attempted = false; + } self.all_rooms_loaded = rooms_list_ref.all_rooms_loaded(); return; } } self.process_timeline_updates(cx, &portal_list); + self.update_membership_footer(cx); // Ideally we would do this elsewhere on the main thread, because it's not room-specific, // but it doesn't hurt to do it here. @@ -983,7 +1036,12 @@ impl Widget for RoomScreen { return DrawStep::done(); }; let mut restore_status_view = self.view.restore_status_view(ids!(restore_status_view)); - restore_status_view.set_content(cx, self.all_rooms_loaded, room_name); + restore_status_view.set_content( + cx, + self.all_rooms_loaded, + room_name, + self.removed_state, + ); return restore_status_view.draw(cx, scope); } if self.tl_state.is_none() { @@ -1166,6 +1224,85 @@ impl RoomScreen { self.room_name_id.as_ref().map(|r| r.room_id()) } + fn update_membership_footer(&mut self, cx: &mut Cx) { + let Some(room_name_id) = &self.room_name_id else { return; }; + if !cx.has_global::() { + return; + } + let room_input_bar = self.view.room_input_bar(ids!(room_input_bar)); + let mut removed_state = { + let rooms_list_ref = cx.get_global::(); + rooms_list_ref.get_removed_room_state(room_name_id.room_id()) + }; + + if let Some(client) = get_client() + && let Some(room) = client.get_room(room_name_id.room_id()) + { + let actual_state = room.state(); + let desired_removed_state = match actual_state { + RoomState::Left | RoomState::Banned => Some(actual_state), + _ => None, + }; + if desired_removed_state != removed_state { + let rooms_list_ref = cx.get_global::(); + rooms_list_ref.set_removed_room_state( + room_name_id.room_id().clone(), + desired_removed_state, + ); + removed_state = desired_removed_state; + } + } + + match removed_state { + Some(state) if matches!(state, RoomState::Left | RoomState::Banned) => { + if self.removed_state != Some(state) { + self.removed_state = Some(state); + self.removed_join_rule = None; + self.removed_preview_requested = false; + self.removed_preview_attempted = false; + } + + if state == RoomState::Left + && self.removed_join_rule.is_none() + && !self.removed_preview_attempted + { + submit_async_request(MatrixRequest::GetRoomPreview { + room_or_alias_id: room_name_id.room_id().clone().into(), + via: Vec::new(), + }); + self.removed_preview_requested = true; + self.removed_preview_attempted = true; + } + + let preview_pending = self.removed_preview_requested; + room_input_bar.update_membership_footer( + cx, + room_name_id, + state, + self.removed_join_rule.clone(), + preview_pending, + ); + } + _ => { + if self.removed_state.is_some() { + self.removed_state = None; + self.removed_join_rule = None; + self.removed_preview_requested = false; + self.removed_preview_attempted = false; + room_input_bar.hide_membership_footer(cx); + if let Some(tl_state) = self.tl_state.as_ref() { + room_input_bar.update_tombstone_footer( + cx, + &tl_state.room_id, + tl_state.tombstone_info.as_ref(), + ); + room_input_bar.update_user_power_levels(cx, tl_state.user_power); + } + } + } + } + } + /// Processes all pending background updates to the currently-shown timeline. /// /// Redraws this RoomScreen view if any updates were applied. @@ -2311,6 +2448,11 @@ impl RoomScreen { if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_name_id.room_id()) { return; } self.hide_timeline(); + self.removed_state = None; + self.removed_join_rule = None; + self.removed_preview_requested = false; + self.removed_preview_attempted = false; + self.view.room_input_bar(ids!(room_input_bar)).hide_membership_footer(cx); // Reset the the state of the inner loading pane. self.loading_pane(ids!(loading_pane)).take_state(); @@ -2326,6 +2468,7 @@ impl RoomScreen { }); self.show_timeline(cx); + self.update_membership_footer(cx); } /// Sends read receipts based on the current scroll position of the timeline. diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index e49a9d1b..b8c99ca0 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -192,12 +192,17 @@ pub enum RoomsListUpdate { room_id: OwnedRoomId, is_direct: bool, }, - /// Remove the given room from the rooms list + /// Remove the given room from the rooms list, or mark it as removed for history. RemoveRoom { room_id: OwnedRoomId, /// The new state of the room (which caused its removal). new_state: RoomState, }, + /// Update the removed state of a room without removing it. + SetRemovedRoomState { + room_id: OwnedRoomId, + removed_state: Option, + }, /// Update the tags for the given room. Tags { room_id: OwnedRoomId, @@ -258,6 +263,8 @@ pub enum RoomsListAction { pub struct JoinedRoomInfo { /// The displayable name of this room (includes room ID for fallback). pub room_name_id: RoomNameId, + /// If set, the user is no longer a member of this room but we keep it for history. + pub removed_state: Option, /// The number of unread messages in this room. pub num_unread_messages: u64, /// The number of unread mentions in this room. @@ -496,6 +503,13 @@ impl RoomsList { None } + /// Returns the removal state of the room, if it was removed (e.g., left or banned). + pub fn get_removed_room_state(&self, room_id: &OwnedRoomId) -> Option { + self.all_joined_rooms + .get(room_id) + .and_then(|jr| jr.removed_state.clone()) + } + /// Handle all pending updates to the list of all rooms. fn handle_rooms_list_updates(&mut self, cx: &mut Cx, _event: &Event, scope: &mut Scope) { let mut num_updates: usize = 0; @@ -506,7 +520,17 @@ impl RoomsList { let room_id = invited_room.room_name_id.room_id().clone(); let should_display = should_display_room!(self, &room_id, &invited_room); let _replaced = self.invited_rooms.borrow_mut().insert(room_id.clone(), invited_room); - if should_display { + if let Some(removed) = self.all_joined_rooms.remove(&room_id) { + let list_to_remove_from = if removed.is_direct { + &mut self.displayed_direct_rooms + } else { + &mut self.displayed_regular_rooms + }; + list_to_remove_from.iter() + .position(|r| r == &room_id) + .map(|index| list_to_remove_from.remove(index)); + } + if should_display && !self.displayed_invited_rooms.contains(&room_id) { self.displayed_invited_rooms.push(room_id); } self.update_status(); @@ -519,9 +543,19 @@ impl RoomsList { let _replaced = self.all_joined_rooms.insert(room_id.clone(), joined_room); if should_display { if is_direct { - self.displayed_direct_rooms.push(room_id.clone()); + if !self.displayed_direct_rooms.contains(&room_id) { + self.displayed_direct_rooms.push(room_id.clone()); + } + if let Some(index) = self.displayed_regular_rooms.iter().position(|r| r == &room_id) { + self.displayed_regular_rooms.remove(index); + } } else { - self.displayed_regular_rooms.push(room_id.clone()); + if !self.displayed_regular_rooms.contains(&room_id) { + self.displayed_regular_rooms.push(room_id.clone()); + } + if let Some(index) = self.displayed_direct_rooms.iter().position(|r| r == &room_id) { + self.displayed_direct_rooms.remove(index); + } } } @@ -663,26 +697,51 @@ impl RoomsList { error!("Error: couldn't find room {room_id} to update is_direct"); } } - RoomsListUpdate::RemoveRoom { room_id, new_state: _ } => { - if let Some(removed) = self.all_joined_rooms.remove(&room_id) { - log!("Removed room {room_id} from the list of all joined rooms"); - let list_to_remove_from = if removed.is_direct { - &mut self.displayed_direct_rooms - } else { - &mut self.displayed_regular_rooms - }; - list_to_remove_from.iter() - .position(|r| r == &room_id) - .map(|index| list_to_remove_from.remove(index)); + RoomsListUpdate::RemoveRoom { room_id, new_state } => { + if matches!(new_state, RoomState::Left | RoomState::Banned) { + if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { + room.removed_state = Some(new_state); + room.num_unread_mentions = 0; + room.num_unread_messages = 0; + } else if let Some(_removed) = self.invited_rooms.borrow_mut().remove(&room_id) { + log!("Removed room {room_id} from the list of all invited rooms"); + self.displayed_invited_rooms.iter() + .position(|r| r == &room_id) + .map(|index| self.displayed_invited_rooms.remove(index)); + } + } else { + if let Some(removed) = self.all_joined_rooms.remove(&room_id) { + log!("Removed room {room_id} from the list of all joined rooms"); + let list_to_remove_from = if removed.is_direct { + &mut self.displayed_direct_rooms + } else { + &mut self.displayed_regular_rooms + }; + list_to_remove_from.iter() + .position(|r| r == &room_id) + .map(|index| list_to_remove_from.remove(index)); + } + else if let Some(_removed) = self.invited_rooms.borrow_mut().remove(&room_id) { + log!("Removed room {room_id} from the list of all invited rooms"); + self.displayed_invited_rooms.iter() + .position(|r| r == &room_id) + .map(|index| self.displayed_invited_rooms.remove(index)); + } + + self.hidden_rooms.remove(&room_id); } - else if let Some(_removed) = self.invited_rooms.borrow_mut().remove(&room_id) { - log!("Removed room {room_id} from the list of all invited rooms"); - self.displayed_invited_rooms.iter() - .position(|r| r == &room_id) - .map(|index| self.displayed_invited_rooms.remove(index)); + self.update_status(); + } + RoomsListUpdate::SetRemovedRoomState { room_id, removed_state } => { + if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { + if room.removed_state != removed_state { + room.removed_state = removed_state; + if room.removed_state.is_some() { + room.num_unread_mentions = 0; + room.num_unread_messages = 0; + } + } } - - self.hidden_rooms.remove(&room_id); self.update_status(); } RoomsListUpdate::ClearRooms => { @@ -1287,7 +1346,10 @@ impl Widget for RoomsList { self.current_active_room.as_ref() == Some(direct_room_id); // Paginate the room if it hasn't been paginated yet. - if PREPAGINATE_VISIBLE_ROOMS && !direct_room.has_been_paginated { + if PREPAGINATE_VISIBLE_ROOMS + && !direct_room.has_been_paginated + && direct_room.removed_state.is_none() + { direct_room.has_been_paginated = true; submit_async_request(MatrixRequest::PaginateRoomTimeline { room_id: direct_room.room_name_id.room_id().clone(), @@ -1322,7 +1384,10 @@ impl Widget for RoomsList { self.current_active_room.as_ref() == Some(regular_room_id); // Paginate the room if it hasn't been paginated yet. - if PREPAGINATE_VISIBLE_ROOMS && !regular_room.has_been_paginated { + if PREPAGINATE_VISIBLE_ROOMS + && !regular_room.has_been_paginated + && regular_room.removed_state.is_none() + { regular_room.has_been_paginated = true; submit_async_request(MatrixRequest::PaginateRoomTimeline { room_id: regular_room.room_name_id.room_id().clone(), @@ -1375,6 +1440,23 @@ impl RoomsListRef { self.borrow()?.get_room_state(room_id) } + /// See [`RoomsList::get_removed_room_state()`]. + pub fn get_removed_room_state(&self, room_id: &OwnedRoomId) -> Option { + self.borrow()?.get_removed_room_state(room_id) + } + + /// Updates the removed state of a room via the RoomsList update queue. + pub fn set_removed_room_state( + &self, + room_id: OwnedRoomId, + removed_state: Option, + ) { + enqueue_rooms_list_update(RoomsListUpdate::SetRemovedRoomState { + room_id, + removed_state, + }); + } + /// Returns the name of the given room, if it is known and loaded. pub fn get_room_name(&self, room_id: &OwnedRoomId) -> Option { let inner = self.borrow()?; diff --git a/src/home/rooms_list_entry.rs b/src/home/rooms_list_entry.rs index aeda32f6..672fa0f2 100644 --- a/src/home/rooms_list_entry.rs +++ b/src/home/rooms_list_entry.rs @@ -1,5 +1,5 @@ use makepad_widgets::*; -use matrix_sdk::ruma::OwnedRoomId; +use matrix_sdk::{RoomState, ruma::OwnedRoomId}; use crate::{ room::FetchedRoomAvatar, shared::{ @@ -297,7 +297,20 @@ impl RoomsListEntryContent { room_info: &JoinedRoomInfo, ) { self.view.label(ids!(room_name)).set_text(cx, &room_info.room_name_id.to_string()); - if let Some((ts, msg)) = room_info.latest.as_ref() { + if let Some(removed_state) = room_info.removed_state.clone() { + let status_text = match removed_state { + RoomState::Banned => "You have been banned from this room.", + RoomState::Left => "You are no longer a member of this room.", + _ => "You are no longer a member of this room.", + }; + self.view.label(ids!(timestamp)).set_text(cx, ""); + self.view + .html_or_plaintext(ids!(latest_message)) + .show_html(cx, status_text); + self.view + .unread_badge(ids!(unread_badge)) + .update_counts(0, 0); + } else if let Some((ts, msg)) = room_info.latest.as_ref() { if let Some(human_readable_date) = relative_format(*ts) { self.view .label(ids!(timestamp)) @@ -308,9 +321,11 @@ impl RoomsListEntryContent { .show_html(cx, msg); } - self.view - .unread_badge(ids!(unread_badge)) - .update_counts(room_info.num_unread_mentions, room_info.num_unread_messages); + if room_info.removed_state.is_none() { + self.view + .unread_badge(ids!(unread_badge)) + .update_counts(room_info.num_unread_mentions, room_info.num_unread_messages); + } self.draw_common(cx, &room_info.room_avatar, room_info.is_selected); // Show tombstone icon if the room is tombstoned self.view.view(ids!(tombstone_icon)).set_visible(cx, room_info.is_tombstoned); diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index 982f430e..fa6966a7 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -13,13 +13,14 @@ //! * The editing pane, which is shown when the user is editing a previous message. //! * A tombstone footer, which is shown if the room has been tombstoned (replaced). //! * A "cannot-send-message" notice, which is shown if the user cannot send messages to the room. +//! * A membership footer, which is shown if the user is no longer a room member. //! use makepad_widgets::*; -use matrix_sdk::room::reply::{EnforceThread, Reply}; +use matrix_sdk::{RoomState, room::reply::{EnforceThread, Reply}}; use matrix_sdk_ui::timeline::{EmbeddedEvent, EventTimelineItem, TimelineEventItemId}; -use ruma::{events::room::message::{LocationMessageEventContent, MessageType, RoomMessageEventContent}, OwnedRoomId}; -use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt}, location_preview::LocationPreviewWidgetExt, room_screen::{populate_preview_of_timeline_item, MessageAction, RoomScreenProps}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}}, location::init_location_subscriber, shared::{avatar::AvatarWidgetRefExt, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{enqueue_popup_notification, PopupItem, PopupKind}, styles::*}, sliding_sync::{submit_async_request, MatrixRequest, UserPowerLevels}, utils}; +use ruma::{events::room::message::{LocationMessageEventContent, MessageType, RoomMessageEventContent}, room::JoinRuleSummary, OwnedRoomId}; +use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt}, location_preview::LocationPreviewWidgetExt, room_screen::{populate_preview_of_timeline_item, MessageAction, RoomScreenProps}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}}, location::init_location_subscriber, shared::{avatar::AvatarWidgetRefExt, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{enqueue_popup_notification, PopupItem, PopupKind}, styles::*}, sliding_sync::{submit_async_request, MatrixRequest, UserPowerLevels}, utils::{self, RoomNameId}}; live_design! { use link::theme::*; @@ -159,6 +160,46 @@ live_design! { } } + membership_footer = { + visible: false + show_bg: true + draw_bg: { + color: (COLOR_SECONDARY) + } + padding: {left: 50, right: 50, top: 20, bottom: 20} + align: {y: 0.5} + width: Fill, height: Fit + flow: Down, + spacing: 8 + + status_text =