diff --git a/.gitignore b/.gitignore index 1f891a01..66c84614 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,8 @@ .vscode .DS_Store CLAUDE.md +AGENTS.md proxychains.conf +/specs +ai-docs/ +.claude diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 00000000..a5e742a3 --- /dev/null +++ b/.typos.toml @@ -0,0 +1,14 @@ +[files] +# Extend default configuration +extend-exclude = [ + "*/tests/*", + "*/test_*.rs", + "*_test.rs", +] + +[default.extend-words] +# Add any custom words that should not be flagged as typos +# Test strings used in member_search.rs tests +hel = "hel" +caf = "caf" + diff --git a/src/app.rs b/src/app.rs index 4b4b32c0..1cb62e29 100644 --- a/src/app.rs +++ b/src/app.rs @@ -247,12 +247,13 @@ impl MatchEvent for App { } if let Some(LogoutAction::ClearAppState { on_clear_appstate }) = action.downcast_ref() { - // Clear user profile cache, invited_rooms timeline states + // Clear user profile cache, invited_rooms timeline states clear_all_app_state(cx); // Reset all app state to its default. self.app_state = Default::default(); on_clear_appstate.notify_one(); - continue; + // Don't continue here - let the action propagate to child widgets (e.g., RoomScreen) + // so they can reset their state as well } if let Some(LoginAction::LoginSuccess) = action.downcast_ref() { @@ -436,12 +437,14 @@ impl MatchEvent for App { } /// Clears all thread-local UI caches (user profiles, invited rooms, and timeline states). -/// The `cx` parameter ensures that these thread-local caches are cleared on the main UI thread, +/// The `cx` parameter ensures that these thread-local caches are cleared on the main UI thread, fn clear_all_app_state(cx: &mut Cx) { clear_user_profile_cache(cx); clear_all_invited_rooms(cx); clear_timeline_states(cx); clear_avatar_cache(cx); + // Clear room members digests to ensure members are re-fetched after re-login + crate::sliding_sync::clear_room_members_digests(); } impl AppMain for App { @@ -609,6 +612,23 @@ pub struct AppState { } /// A snapshot of the main dock: all state needed to restore the dock tabs/layout. +/// +/// # Cross-Version Hash Drift Warning +/// +/// The `LiveId` keys in `dock_items` and `open_rooms` are derived from `LiveId::from_str(room_id)`. +/// However, the hash value may change when upgrading Makepad or the Rust toolchain, causing +/// persisted `LiveId`s to no longer match freshly computed ones. This leads to duplicate tabs +/// when restoring state across versions. +/// +/// **Current mitigation**: `MainDesktopUI::find_open_room_live_id` performs a reverse-lookup +/// by `room_id` to find the actual stored `LiveId`, avoiding hash mismatch issues at runtime. +/// +// TODO: A more thorough fix would be to use `room_id` (String) as the persistence key instead +// of `LiveId`, and derive `LiveId` at runtime when needed. This would eliminate cross-version +// hash drift entirely and make the persisted format stable across Makepad/toolchain upgrades. +// +// TODO: Consider using `OwnedRoomId` (String) as the key instead of `LiveId` to avoid +// cross-version hash drift. See struct-level documentation for details. #[derive(Clone, Default, Debug, Serialize, Deserialize)] pub struct SavedDockState { /// All items contained in the dock, keyed by their room or space ID. diff --git a/src/cpu_worker.rs b/src/cpu_worker.rs new file mode 100644 index 00000000..f5e47c74 --- /dev/null +++ b/src/cpu_worker.rs @@ -0,0 +1,63 @@ +//! Lightweight wrapper for CPU-bound tasks. +//! +//! Currently each job is handled by spawning a detached native thread via +//! Makepad's `cx.spawn_thread`. This keeps the implementation simple while +//! still moving CPU-heavy work off the UI thread. +//! +//! ## Future TODOs +//! - TODO: Add task queue with priority and deduplication +//! - TODO: Limit max concurrent tasks (e.g., 2-4 workers) +//! - TODO: Add platform-specific thread pool (desktop only, via #[cfg]) +//! - TODO: Support task cancellation and timeout +//! - TODO: Add progress callbacks for long-running tasks + +use makepad_widgets::{Cx, CxOsApi}; +use std::sync::{atomic::AtomicBool, mpsc::Sender, Arc}; +use crate::{ + room::member_search::{search_room_members_streaming_with_sort, PrecomputedMemberSort}, + shared::mentionable_text_input::SearchResult, +}; +use matrix_sdk::room::RoomMember; + +pub enum CpuJob { + SearchRoomMembers(SearchRoomMembersJob), +} + +pub struct SearchRoomMembersJob { + pub members: Arc>, + pub search_text: String, + pub max_results: usize, + pub sender: Sender, + pub search_id: u64, + pub precomputed_sort: Option>, + pub cancel_token: Option>, +} + +fn run_member_search(params: SearchRoomMembersJob) { + let SearchRoomMembersJob { + members, + search_text, + max_results, + sender, + search_id, + precomputed_sort, + cancel_token, + } = params; + + search_room_members_streaming_with_sort( + members, + search_text, + max_results, + sender, + search_id, + precomputed_sort, + cancel_token, + ); +} + +/// Spawns a CPU-bound job on a detached native thread. +pub fn spawn_cpu_job(cx: &mut Cx, job: CpuJob) { + cx.spawn_thread(move || match job { + CpuJob::SearchRoomMembers(params) => run_member_search(params), + }); +} diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 914611cd..6ebc5766 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -135,22 +135,49 @@ impl Widget for MainDesktopUI { impl MainDesktopUI { /// Focuses on a room if it is already open, otherwise creates a new tab for the room. + /// + /// # Duplicate Tab Prevention (Cross-Version Hash Drift) + /// + /// This method uses [`Self::find_open_room_live_id`] to check if the room is already open, + /// rather than directly comparing `LiveId::from_str(room_id)`. This is necessary because + /// persisted `LiveId`s may differ from freshly computed ones due to **cross-version hash drift**. + /// + /// ## Root Cause + /// + /// The 64-bit value of `LiveId::from_str` depends on Makepad's hash implementation (and + /// potentially compiler/stdlib hash seeds). When upgrading Makepad or the Rust toolchain, + /// the hash algorithm or seed may change. Persisted data (in `open_rooms`/`dock_items`) + /// contains "old hash values," while the new runtime computes "new hash values." This + /// causes `contains_key`/`select_tab` lookups to fail, and the room is incorrectly + /// treated as "not open," resulting in duplicate tabs. + /// + /// ## Current Fix + /// + /// By reverse-looking up the actual stored `LiveId` via `room_id` comparison (using + /// [`Self::find_open_room_live_id`]), we correctly identify already-open rooms regardless + /// of hash drift between versions. + /// + // TODO: A more thorough fix would be to use `room_id` (String) as the persistence key + // instead of `LiveId`, and derive `LiveId` at runtime. This would eliminate cross-version + // hash drift entirely. See `SavedDockState` in `src/app.rs`. fn focus_or_create_tab(&mut self, cx: &mut Cx, room: SelectedRoom) { // Do nothing if the room to select is already created and focused. if self.most_recently_selected_room.as_ref().is_some_and(|r| r == &room) { return; } + // If the room is already open, select (jump to) its existing tab. + // We use `find_open_room_live_id` to look up by room_id, because the dock + // may store LiveIds with a prefix that differs from `LiveId::from_str(room_id)`. let dock = self.view.dock(ids!(dock)); - - // If the room is already open, select (jump to) its existing tab - let room_id_as_live_id = LiveId::from_str(room.room_id().as_str()); - if self.open_rooms.contains_key(&room_id_as_live_id) { - dock.select_tab(cx, room_id_as_live_id); + if let Some(existing_live_id) = self.find_open_room_live_id(room.room_id()) { + dock.select_tab(cx, existing_live_id); self.most_recently_selected_room = Some(room); return; } + let room_id_as_live_id = LiveId::from_str(room.room_id().as_str()); + // Create a new tab for the room let (tab_bar, _pos) = dock.find_tab_bar_of_tab(id!(home_tab)).unwrap(); let (kind, name) = match &room { @@ -201,13 +228,28 @@ impl MainDesktopUI { ); } } + // Only update open_rooms after successful tab creation to avoid orphan entries + self.open_rooms.insert(room_id_as_live_id, room.clone()); + self.most_recently_selected_room = Some(room); cx.action(MainDesktopUiAction::SaveDockIntoAppState); } else { error!("BUG: failed to create tab for {room:?}"); } + } - self.open_rooms.insert(room_id_as_live_id, room.clone()); - self.most_recently_selected_room = Some(room); + /// Finds the `LiveId` of an already-open room by its `room_id`. + /// + /// This reverse-lookup is necessary to handle **cross-version hash drift**: when Makepad + /// or the toolchain is upgraded, `LiveId::from_str(room_id)` may compute a different hash + /// than what was persisted. By matching on the stable `room_id` value instead of the + /// potentially-drifted `LiveId`, we correctly identify rooms regardless of version changes. + /// + /// See [`Self::focus_or_create_tab`] for more details on the root cause. + fn find_open_room_live_id(&self, room_id: &OwnedRoomId) -> Option { + self.open_rooms + .iter() + .find(|(_, selected_room)| selected_room.room_id() == room_id) + .map(|(live_id, _)| *live_id) } /// Closes a tab in the dock and focuses on the latest open room. diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 906ae97e..00af909f 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -30,7 +30,7 @@ use crate::{ user_profile::{AvatarState, ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache, }, - room::{room_input_bar::RoomInputBarState, typing_notice::TypingNoticeWidgetExt}, + room::{member_search::PrecomputedMemberSort, room_input_bar::RoomInputBarState, typing_notice::TypingNoticeWidgetExt}, shared::{ avatar::AvatarWidgetRefExt, callout_tooltip::{CalloutTooltipOptions, TooltipAction, TooltipPosition}, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupItem, PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt }, @@ -564,6 +564,8 @@ pub struct RoomScreen { #[rust] is_loaded: bool, /// Whether or not all rooms have been loaded (received from the homeserver). #[rust] all_rooms_loaded: bool, + /// Room to reload after login (saved during logout to restore user's view) + #[rust] pending_room_to_reload: Option, } impl Drop for RoomScreen { fn drop(&mut self) { @@ -669,6 +671,39 @@ impl Widget for RoomScreen { self.handle_message_actions(cx, actions, &portal_list, &loading_pane); for action in actions { + // Handle logout: clear this RoomScreen's state so it will be reinitialized + // just like a first-time login when the user logs back in. + // We do NOT call show_timeline() here because the Matrix client has been cleared, + // so any requests would fail. Instead, we just clear the state and let the normal + // flow reinitialize it when the user interacts with the room after logging back in. + if let Some(crate::logout::logout_confirm_modal::LogoutAction::ClearAppState { .. }) = action.downcast_ref() { + if let Some(tl) = self.tl_state.take() { + log!("RoomScreen: clearing tl_state for room {} due to logout", tl.room_id); + } + // Save room_name_id for reloading after login, then clear state + let saved_room_name_id = self.room_name_id.take(); + + self.is_loaded = false; + self.all_rooms_loaded = false; + + // Store the room to reload after login + if let Some(room_name_id) = saved_room_name_id { + self.pending_room_to_reload = Some(room_name_id); + log!("RoomScreen: saved room to reload after login"); + } + + log!("RoomScreen: fully reset state due to logout"); + // Don't return - allow other actions to be processed + } + + // Handle login success: reload the room if we were displaying one before logout + if let Some(crate::login::login_screen::LoginAction::LoginSuccess) = action.downcast_ref() { + if let Some(room_name_id) = self.pending_room_to_reload.take() { + log!("RoomScreen: reloading room {} after successful login", room_name_id.room_id()); + self.set_displayed_room(cx, &room_name_id); + } + } + // Handle actions related to restoring the previously-saved state of rooms. if let Some(AppStateAction::RoomLoadedSuccessfully(loaded)) = action.downcast_ref() { if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == loaded.room_id()) { @@ -794,6 +829,9 @@ impl Widget for RoomScreen { let room_props = if let Some(tl) = self.tl_state.as_ref() { let room_id = tl.room_id.clone(); let room_members = tl.room_members.clone(); + let room_members_sort = tl.room_members_sort.clone(); + let room_members_sync_pending = tl.room_members_sync_pending; + let is_direct_room = Self::is_direct_room(cx, &room_id); // Fetch room data once to avoid duplicate expensive lookups let (room_display_name, room_avatar_url) = get_client() @@ -806,17 +844,28 @@ impl Widget for RoomScreen { RoomScreenProps { room_screen_widget_uid, - room_name_id: RoomNameId::new(room_display_name, room_id), + room_name_id: RoomNameId::new(room_display_name.clone(), room_id), room_members, + room_members_sort, + room_members_sync_pending, + room_display_name: Some(room_display_name.to_string()), room_avatar_url, + is_direct_room, } - } else if let Some(room_name) = &self.room_name_id { - // Fallback case: we have a room_name but no tl_state yet + } else if let Some(room_name_id) = &self.room_name_id { + // Fallback case: we have a room_name_id but no tl_state yet. + // This happens after logout clears tl_state but before show_timeline() is called again. + // Set room_members_sync_pending to true to show loading animation in MentionableTextInput. + let is_direct_room = Self::is_direct_room(cx, room_name_id.room_id()); RoomScreenProps { room_screen_widget_uid, - room_name_id: room_name.clone(), + room_name_id: room_name_id.clone(), room_members: None, + room_members_sort: None, + room_members_sync_pending: true, + room_display_name: None, room_avatar_url: None, + is_direct_room, } } else { // No room selected yet, skip event handling that requires room context @@ -832,7 +881,11 @@ impl Widget for RoomScreen { matrix_sdk::ruma::OwnedRoomId::try_from("!dummy:matrix.org").unwrap(), ), room_members: None, + room_members_sort: None, + room_members_sync_pending: false, + room_display_name: None, room_avatar_url: None, + is_direct_room: false, } }; let mut room_scope = Scope::with_props(&room_props); @@ -1098,6 +1151,16 @@ impl Widget for RoomScreen { } } +impl RoomScreen { + fn is_direct_room(cx: &mut Cx, room_id: &OwnedRoomId) -> bool { + if cx.has_global::() { + cx.get_global::().is_direct_room(room_id) + } else { + false + } + } +} + impl RoomScreen { fn current_room_name(&self) -> Option<&RoomNameId> { self.room_name_id.as_ref() @@ -1350,11 +1413,52 @@ impl RoomScreen { // log!("process_timeline_updates(): room members fetched for room {}", tl.room_id); // Here, to be most efficient, we could redraw only the user avatars and names in the timeline, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. + // + // Room members have been synced; unconditionally clear pending flag + // to fix bug where small rooms (< 50 members) would stay in loading state forever + tl.room_members_sync_pending = false; + tl.room_members_remote_synced = true; + + // Notify MentionableTextInput that sync is complete + let has_members = tl + .room_members + .as_ref() + .is_some_and(|members| !members.is_empty()); + + cx.action(MentionableTextInputAction::RoomMembersLoaded { + room_id: tl.room_id.clone(), + sync_in_progress: false, + has_members, + }); + } + TimelineUpdate::RoomMembersListFetched { members, sort, is_local_fetch } => { + // RoomMembersListFetched: Received members for room + // Note: This can be sent from either GetRoomMembers (local cache lookup) + // or SyncRoomMemberList (full server sync). We only clear the sync pending + // flag when we receive RoomMembersSynced (which is only sent after full sync). + let sort_data = sort; + let members = Arc::new(members); + let has_members = !members.is_empty(); + tl.room_members = Some(Arc::clone(&members)); + tl.room_members_sort = Some(Arc::new(sort_data)); + + // For local fetches, check if remote sync is still in progress. + // If remote sync is ongoing, keep sync_in_progress=true to show loading animation. + // This prevents showing incomplete local cache (e.g., after logout) as final results. + let sync_in_progress = if is_local_fetch { + // Local fetch: only clear sync_in_progress if no remote sync is pending + tl.room_members_sync_pending + } else { + // Remote fetch: use the actual sync pending state + tl.room_members_sync_pending + }; + + cx.action(MentionableTextInputAction::RoomMembersLoaded { + room_id: tl.room_id.clone(), + sync_in_progress, + has_members, + }); } - TimelineUpdate::RoomMembersListFetched { members } => { - // Store room members directly in TimelineUiState - tl.room_members = Some(Arc::new(members)); - }, TimelineUpdate::MediaFetched => { log!("process_timeline_updates(): media fetched for room {}", tl.room_id); // Here, to be most efficient, we could redraw only the media items in the timeline, @@ -2003,6 +2107,9 @@ impl RoomScreen { user_power: UserPowerLevels::all(), // Room members start as None and get populated when fetched from the server room_members: None, + room_members_sort: None, + room_members_sync_pending: false, + room_members_remote_synced: false, // We assume timelines being viewed for the first time haven't been fully paginated. fully_paginated: false, items: Vector::new(), @@ -2023,6 +2130,20 @@ impl RoomScreen { (tl_state, true) }; + let has_cached_members = tl_state + .room_members + .as_ref() + .is_some_and(|members| !members.is_empty()); + let mut should_request_local_members = false; + if !has_cached_members { + should_request_local_members = true; + } + + let mut needs_remote_sync = !tl_state.room_members_remote_synced; + if !has_cached_members { + needs_remote_sync = true; + } + // It is possible that this room has already been loaded (received from the server) // but that the RoomsList doesn't yet know about it. // In that case, `is_first_time_being_loaded` will already be `true` here, @@ -2045,6 +2166,17 @@ impl RoomScreen { self.is_loaded = is_loaded_now; } + if is_first_time_being_loaded { + needs_remote_sync = true; + } + + let mut should_request_full_sync = false; + if needs_remote_sync && !tl_state.room_members_sync_pending { + should_request_full_sync = true; + tl_state.room_members_sync_pending = true; + tl_state.room_members_remote_synced = false; + } + self.view.restore_status_view(ids!(restore_status_view)).set_visible(cx, !self.is_loaded); // Kick off a back pagination request if it's the first time loading this room, @@ -2061,11 +2193,6 @@ impl RoomScreen { direction: PaginationDirection::Backwards, }); } - - // Even though we specify that room member profiles should be lazy-loaded, - // the matrix server still doesn't consistently send them to our client properly. - // So we kick off a request to fetch the room members here upon first viewing the room. - submit_async_request(MatrixRequest::SyncRoomMemberList { room_id: room_id.clone() }); } // Hide the typing notice view initially. @@ -2081,13 +2208,14 @@ impl RoomScreen { submit_async_request(MatrixRequest::GetRoomPowerLevels { room_id: room_id.clone(), }); - submit_async_request(MatrixRequest::GetRoomMembers { - room_id: room_id.clone(), - memberships: matrix_sdk::RoomMemberships::JOIN, - // Fetch from the local cache, as we already requested to sync - // the room members from the homeserver above. - local_only: true, - }); + if should_request_local_members { + submit_async_request(MatrixRequest::GetRoomMembers { + room_id: room_id.clone(), + memberships: matrix_sdk::RoomMemberships::JOIN, + // Prefer cached members; background sync will refresh them as needed. + local_only: true, + }); + } submit_async_request(MatrixRequest::SubscribeToTypingNotices { room_id: room_id.clone(), subscribe: true, @@ -2114,6 +2242,10 @@ impl RoomScreen { self.process_timeline_updates(cx, &self.portal_list(ids!(list))); self.redraw(cx); + + if should_request_full_sync { + submit_async_request(MatrixRequest::SyncRoomMemberList { room_id: room_id.clone() }); + } } /// Invoke this when this RoomScreen/timeline is being hidden or no longer being shown. @@ -2157,8 +2289,6 @@ impl RoomScreen { room_input_bar_state: self.room_input_bar(ids!(room_input_bar)).save_state(), }; tl.saved_state = state; - // Clear room_members to avoid wasting memory (in case this room is never re-opened). - tl.room_members = None; // Store this Timeline's `TimelineUiState` in the global map of states. TIMELINE_STATES.with_borrow_mut(|ts| ts.insert(tl.room_id.clone(), tl)); } @@ -2338,7 +2468,11 @@ pub struct RoomScreenProps { pub room_screen_widget_uid: WidgetUid, pub room_name_id: RoomNameId, pub room_members: Option>>, + pub room_members_sort: Option>, + pub room_members_sync_pending: bool, + pub room_display_name: Option, pub room_avatar_url: Option, + pub is_direct_room: bool, } @@ -2434,6 +2568,8 @@ pub enum TimelineUpdate { /// but doesn't provide the actual data. RoomMembersListFetched { members: Vec, + sort: crate::room::member_search::PrecomputedMemberSort, + is_local_fetch: bool, }, /// A notice that one or more requested media items (images, videos, etc.) /// that should be displayed in this timeline have now been fetched and are available. @@ -2484,6 +2620,12 @@ struct TimelineUiState { /// The list of room members for this room. room_members: Option>>, + /// Precomputed sort data for room members to speed up mention search. + room_members_sort: Option>, + /// Whether a full member sync is still pending for this room. + room_members_sync_pending: bool, + /// Whether we've successfully completed a remote member sync at least once. + room_members_remote_synced: bool, /// Whether this room's timeline has been fully paginated, which means /// that the oldest (first) event in the timeline is locally synced and available. @@ -3235,30 +3377,35 @@ fn populate_text_message_content( link_preview_cache: Option<&mut LinkPreviewCache>, ) -> bool { // The message was HTML-formatted rich text. - let mut links = Vec::new(); - if let Some(fb) = formatted_body.as_ref() + let links = if let Some(fb) = formatted_body.as_ref() .and_then(|fb| (fb.format == MessageFormat::Html).then_some(fb)) { + let mut links = Vec::new(); let linkified_html = utils::linkify_get_urls( utils::trim_start_html_whitespace(&fb.body), true, Some(&mut links), ); - message_content_widget.show_html(cx, linkified_html); + message_content_widget.show_html( + cx, + &linkified_html + ); + links } // The message was non-HTML plaintext. else { + let mut links = Vec::new(); let linkified_html = utils::linkify_get_urls(body, false, Some(&mut links)); match linkified_html { Cow::Owned(linkified_html) => message_content_widget.show_html(cx, &linkified_html), Cow::Borrowed(plaintext) => message_content_widget.show_plaintext(cx, plaintext), } + links }; // Populate link previews if all required parameters are provided - if let (Some(link_preview_ref), Some(media_cache), Some(link_preview_cache)) = - (link_preview_ref, media_cache, link_preview_cache) - { + if let (Some(link_preview_ref), Some(media_cache), Some(link_preview_cache)) = + (link_preview_ref, media_cache, link_preview_cache) { link_preview_ref.populate_below_message( cx, &links, @@ -3356,7 +3503,7 @@ fn populate_image_message_content( Err(e) => { error!("Failed to decode blurhash {e:?}"); Err(image_cache::ImageError::EmptyData) - } + } } }); if let Err(e) = show_image_result { @@ -4192,7 +4339,7 @@ impl MessageRef { /// /// This function requires passing in a reference to `Cx`, /// which isn't used, but acts as a guarantee that this function -/// must only be called by the main UI thread. +/// must only be called by the main UI thread. pub fn clear_timeline_states(_cx: &mut Cx) { // Clear timeline states cache TIMELINE_STATES.with_borrow_mut(|states| { diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 2dfbb0f4..c3397fe9 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -925,6 +925,30 @@ impl RoomsList { ) } + /// Returns a room's avatar and displayable name. + pub fn get_room_avatar_and_name(&self, room_id: &OwnedRoomId) -> Option<(FetchedRoomAvatar, Option)> { + self.all_joined_rooms.get(room_id) + .map(|room_info| (room_info.avatar.clone(), room_info.room_name_id.name_for_avatar())) + .or_else(|| { + self.invited_rooms.borrow().get(room_id) + .map(|room_info| (room_info.room_avatar.clone(), room_info.room_name_id.name_for_avatar())) + }) + } + + /// Returns whether the room is marked as direct, if known. + pub fn is_direct_room(&self, room_id: &OwnedRoomId) -> bool { + self.all_joined_rooms + .get(room_id) + .map(|room_info| room_info.is_direct) + .or_else(|| { + self.invited_rooms + .borrow() + .get(room_id) + .map(|room_info| room_info.is_direct) + }) + .unwrap_or(false) + } + /// Handle any incoming updates to spaces' room lists and pagination state. fn handle_space_room_list_action(&mut self, cx: &mut Cx, action: &SpaceRoomListAction) { match action { @@ -1297,6 +1321,20 @@ impl RoomsListRef { inner.is_room_loaded(room_id) } + /// See [`RoomsList::get_room_avatar_and_name()`]. + pub fn get_room_avatar_and_name(&self, room_id: &OwnedRoomId) -> Option<(FetchedRoomAvatar, Option)> { + let inner = self.borrow()?; + inner.get_room_avatar_and_name(room_id) + } + + /// Don't show @room option in direct messages + pub fn is_direct_room(&self, room_id: &OwnedRoomId) -> bool { + let Some(inner) = self.borrow() else { + return false; + }; + inner.is_direct_room(room_id) + } + /// Returns the currently-selected space (the one selected in the SpacesBar). pub fn get_selected_space(&self) -> Option { self.borrow()?.selected_space.clone() diff --git a/src/lib.rs b/src/lib.rs index 3e9fc185..3c44f0e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,7 @@ pub mod avatar_cache; pub mod media_cache; pub mod verification; +pub mod cpu_worker; pub mod utils; pub mod serialization; pub mod temp_storage; diff --git a/src/room/member_search.rs b/src/room/member_search.rs new file mode 100644 index 00000000..99db6911 --- /dev/null +++ b/src/room/member_search.rs @@ -0,0 +1,1213 @@ +//! Room member search functionality for @mentions +//! +//! This module provides efficient searching of room members with streaming results +//! to support responsive UI when users type @mentions. + +use std::collections::BinaryHeap; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::Sender, + Arc, +}; +use matrix_sdk::{room::{RoomMember, RoomMemberRole}, ruma::OwnedUserId}; +use unicode_segmentation::UnicodeSegmentation; +use crate::shared::mentionable_text_input::SearchResult; +use crate::sliding_sync::current_user_id; +use makepad_widgets::log; + +const BATCH_SIZE: usize = 10; // Number of results per streamed batch + +/// Pre-computed member sort key for fast empty search +#[derive(Debug, Clone)] +pub struct MemberSortKey { + /// Power level rank: 0=Admin, 1=Moderator, 2=User + pub power_rank: u8, + /// Name category: 0=Alphabetic, 1=Numeric, 2=Symbols + pub name_category: u8, + /// Normalized lowercase name for sorting + pub sort_key: String, +} + +/// Pre-computed sorted indices and keys for room members +#[derive(Debug, Clone)] +pub struct PrecomputedMemberSort { + /// Sorted indices into the members array + pub sorted_indices: Vec, + /// Pre-computed sort keys (parallel to original members array) + pub member_keys: Vec, +} + +/// Pre-compute sort keys and indices for room members +/// This is called once when members are fetched, avoiding repeated computation +pub fn precompute_member_sort(members: &[RoomMember]) -> PrecomputedMemberSort { + let current_user_id = current_user_id(); + let mut member_keys = Vec::with_capacity(members.len()); + let mut sortable_members = Vec::with_capacity(members.len()); + + for (index, member) in members.iter().enumerate() { + // Skip current user + if let Some(ref current_id) = current_user_id { + if member.user_id() == current_id { + // Add placeholder for current user to maintain index alignment + member_keys.push(MemberSortKey { + power_rank: 255, // Will be filtered out + name_category: 255, + sort_key: String::new(), + }); + continue; + } + } + + // Get power level rank + let power_rank = role_to_rank(member.suggested_role_for_power_level()); + + // Get normalized display name + let raw_name = member + .display_name() + .map(|n| n.trim()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| member.user_id().localpart()); + + // Generate sort key by stripping leading non-alphanumeric + let stripped = raw_name.trim_start_matches(|c: char| !c.is_alphanumeric()); + let sort_key = if stripped.is_empty() { + // Name is all symbols, use original + if raw_name.is_ascii() { + raw_name.to_ascii_lowercase() + } else { + raw_name.to_lowercase() + } + } else { + // Use stripped version for sorting + if stripped.is_ascii() { + stripped.to_ascii_lowercase() + } else { + stripped.to_lowercase() + } + }; + + // Determine name category based on stripped name for consistency + // This makes "!!!alice" categorized as alphabetic, not symbols + let name_category = if !stripped.is_empty() { + // Use first char of stripped name + match stripped.chars().next() { + Some(c) if c.is_alphabetic() => 0, + Some(c) if c.is_numeric() => 1, + _ => 2, + } + } else { + // Name is all symbols, use original first char + match raw_name.chars().next() { + Some(c) if c.is_alphabetic() => 0, // Shouldn't happen if stripped is empty + Some(c) if c.is_numeric() => 1, // Shouldn't happen if stripped is empty + _ => 2, // Symbols + } + }; + + let key = MemberSortKey { + power_rank, + name_category, + sort_key: sort_key.clone(), + }; + + member_keys.push(key.clone()); + sortable_members.push((power_rank, name_category, sort_key, index)); + } + + // Sort all valid members + sortable_members.sort_by(|a, b| match a.0.cmp(&b.0) { + std::cmp::Ordering::Equal => match a.1.cmp(&b.1) { + std::cmp::Ordering::Equal => a.2.cmp(&b.2), + other => other, + }, + other => other, + }); + + // Extract sorted indices + let sorted_indices: Vec = sortable_members + .into_iter() + .map(|(_, _, _, idx)| idx) + .collect(); + + PrecomputedMemberSort { + sorted_indices, + member_keys, + } +} + +/// Maps a member role to a sortable rank (lower value = higher priority) +fn role_to_rank(role: RoomMemberRole) -> u8 { + match role { + RoomMemberRole::Administrator | RoomMemberRole::Creator => 0, + RoomMemberRole::Moderator => 1, + RoomMemberRole::User => 2, + } +} + +fn is_cancelled(token: &Option>) -> bool { + token + .as_ref() + .map(|flag| flag.load(Ordering::Relaxed)) + .unwrap_or(false) +} + +fn send_search_update( + sender: &Sender, + cancel_token: &Option>, + search_id: u64, + search_text: &Arc, + results: Vec, + is_complete: bool, +) -> bool { + if is_cancelled(cancel_token) { + return false; + } + + let search_result = SearchResult { + search_id, + results, + is_complete, + search_text: Arc::clone(search_text), + }; + + if sender.send(search_result).is_err() { + log!("Failed to send search results - receiver dropped"); + return false; + } + + true +} + +fn stream_index_batches( + indices: &[usize], + sender: &Sender, + cancel_token: &Option>, + search_id: u64, + search_text: &Arc, +) -> bool { + if indices.is_empty() { + return send_search_update(sender, cancel_token, search_id, search_text, Vec::new(), true); + } + + let mut start = 0; + while start < indices.len() { + let end = (start + BATCH_SIZE).min(indices.len()); + let batch = indices[start..end].to_vec(); + start = end; + let is_last = start >= indices.len(); + + if !send_search_update(sender, cancel_token, search_id, search_text, batch, is_last) { + return false; + } + } + + true +} + +fn compute_empty_search_indices( + members: &[RoomMember], + max_results: usize, + current_user_id: Option<&OwnedUserId>, + precomputed_sort: Option<&PrecomputedMemberSort>, + cancel_token: &Option>, +) -> Option> { + if is_cancelled(cancel_token) { + return None; + } + + if let Some(sort_data) = precomputed_sort { + let mut indices: Vec = sort_data + .sorted_indices + .iter() + .take(max_results) + .copied() + .collect(); + + if max_results == 0 { + indices.clear(); + } + + return Some(indices); + } + + let mut valid_members: Vec<(u8, u8, usize)> = Vec::with_capacity(members.len()); + + for (index, member) in members.iter().enumerate() { + if is_cancelled(cancel_token) { + return None; + } + + if current_user_id.is_some_and(|id| member.user_id() == id) { + continue; + } + + let power_rank = role_to_rank(member.suggested_role_for_power_level()); + + let raw_name = member + .display_name() + .map(|n| n.trim()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| member.user_id().localpart()); + + let stripped = raw_name.trim_start_matches(|c: char| !c.is_alphanumeric()); + let name_category = if !stripped.is_empty() { + match stripped.chars().next() { + Some(c) if c.is_alphabetic() => 0, + Some(c) if c.is_numeric() => 1, + _ => 2, + } + } else { + 2 + }; + + valid_members.push((power_rank, name_category, index)); + } + + if is_cancelled(cancel_token) { + return None; + } + + valid_members.sort_by(|a, b| match a.0.cmp(&b.0) { + std::cmp::Ordering::Equal => match a.1.cmp(&b.1) { + std::cmp::Ordering::Equal => { + let name_a = members[a.2] + .display_name() + .map(|n| n.trim()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| members[a.2].user_id().localpart()); + let name_b = members[b.2] + .display_name() + .map(|n| n.trim()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| members[b.2].user_id().localpart()); + + name_a + .chars() + .map(|c| c.to_ascii_lowercase()) + .cmp(name_b.chars().map(|c| c.to_ascii_lowercase())) + } + other => other, + }, + other => other, + }); + + if is_cancelled(cancel_token) { + return None; + } + + valid_members.truncate(max_results); + + Some(valid_members.into_iter().map(|(_, _, idx)| idx).collect()) +} + +fn compute_non_empty_search_indices( + members: &[RoomMember], + search_text: &str, + max_results: usize, + current_user_id: Option<&OwnedUserId>, + precomputed_sort: Option<&PrecomputedMemberSort>, + cancel_token: &Option>, +) -> Option> { + if is_cancelled(cancel_token) { + return None; + } + + let mut top_matches: BinaryHeap<(u8, usize)> = BinaryHeap::with_capacity(max_results); + let mut high_priority_count = 0; + let mut best_priority_seen = u8::MAX; + + for (index, member) in members.iter().enumerate() { + if is_cancelled(cancel_token) { + return None; + } + + if current_user_id.is_some_and(|id| member.user_id() == id) { + continue; + } + + if let Some(priority) = match_member_with_priority(member, search_text) { + if priority <= 3 { + high_priority_count += 1; + } + best_priority_seen = best_priority_seen.min(priority); + + if top_matches.len() < max_results { + top_matches.push((priority, index)); + } else if let Some(&(worst_priority, _)) = top_matches.peek() { + if priority < worst_priority { + top_matches.pop(); + top_matches.push((priority, index)); + } + } + + if max_results > 0 + && high_priority_count >= max_results * 2 + && top_matches.len() == max_results + && best_priority_seen == 0 + { + break; + } + } + } + + if is_cancelled(cancel_token) { + return None; + } + + let mut all_matches: Vec<(u8, usize)> = top_matches.into_iter().collect(); + + all_matches.sort_by(|(priority_a, idx_a), (priority_b, idx_b)| { + match priority_a.cmp(priority_b) { + std::cmp::Ordering::Equal => { + if let Some(sort_data) = precomputed_sort { + let key_a = &sort_data.member_keys[*idx_a]; + let key_b = &sort_data.member_keys[*idx_b]; + + match key_a.power_rank.cmp(&key_b.power_rank) { + std::cmp::Ordering::Equal => match key_a.name_category.cmp(&key_b.name_category) { + std::cmp::Ordering::Equal => key_a.sort_key.cmp(&key_b.sort_key), + other => other, + }, + other => other, + } + } else { + let member_a = &members[*idx_a]; + let member_b = &members[*idx_b]; + + let power_a = role_to_rank(member_a.suggested_role_for_power_level()); + let power_b = role_to_rank(member_b.suggested_role_for_power_level()); + + match power_a.cmp(&power_b) { + std::cmp::Ordering::Equal => { + let name_a = member_a + .display_name() + .map(|n| n.trim()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| member_a.user_id().localpart()); + let name_b = member_b + .display_name() + .map(|n| n.trim()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| member_b.user_id().localpart()); + + if name_a.is_ascii() && name_b.is_ascii() { + name_a + .chars() + .map(|c| c.to_ascii_lowercase()) + .cmp(name_b.chars().map(|c| c.to_ascii_lowercase())) + } else { + name_a.to_lowercase().cmp(&name_b.to_lowercase()) + } + } + other => other, + } + } + } + other => other, + } + }); + + if is_cancelled(cancel_token) { + return None; + } + + Some(all_matches.into_iter().map(|(_, idx)| idx).collect()) +} + +/// Search room members with optional pre-computed sort data +pub fn search_room_members_streaming_with_sort( + members: Arc>, + search_text: String, + max_results: usize, + sender: Sender, + search_id: u64, + precomputed_sort: Option>, + cancel_token: Option>, +) { + let current_user_id = current_user_id(); + let search_text_arc = Arc::new(search_text); + + // Early exit if cancelled - send completion signal so UI doesn't wait indefinitely + if is_cancelled(&cancel_token) { + let _ = send_search_update(&sender, &cancel_token, search_id, &search_text_arc, Vec::new(), true); + return; + } + + let search_query = search_text_arc.as_str(); + let precomputed_ref = precomputed_sort.as_deref(); + let cancel_ref = &cancel_token; + let members_slice = members.as_ref(); + + let results = if search_query.is_empty() { + match compute_empty_search_indices( + members_slice, + max_results, + current_user_id.as_ref(), + precomputed_ref, + cancel_ref, + ) { + Some(indices) => indices, + None => { + // Cancelled during computation - send completion signal + let _ = send_search_update(&sender, &cancel_token, search_id, &search_text_arc, Vec::new(), true); + return; + } + } + } else { + match compute_non_empty_search_indices( + members_slice, + search_query, + max_results, + current_user_id.as_ref(), + precomputed_ref, + cancel_ref, + ) { + Some(indices) => indices, + None => { + // Cancelled during computation - send completion signal + let _ = send_search_update(&sender, &cancel_token, search_id, &search_text_arc, Vec::new(), true); + return; + } + } + }; + + let _ = stream_index_batches(&results, &sender, cancel_ref, search_id, &search_text_arc); +} + + +/// Check if search_text appears after a word boundary in text +/// Word boundaries include: punctuation, symbols, and other non-alphanumeric characters +/// For ASCII text, also supports case-insensitive matching +fn check_word_boundary_match(text: &str, search_text: &str, case_insensitive: bool) -> bool { + if search_text.is_empty() { + return false; + } + + if case_insensitive && search_text.is_ascii() { + let search_len = search_text.len(); + for (index, _) in text.char_indices() { + if index == 0 || index + search_len > text.len() { + continue; + } + if substring_eq_ignore_ascii_case(text, index, search_text) { + if let Some(prev_char) = text[..index].chars().last() { + if !prev_char.is_alphanumeric() { + return true; + } + } + } + } + false + } else { + for (index, _) in text.match_indices(search_text) { + if index == 0 { + continue; // Already handled by starts_with checks + } + + if let Some(prev_char) = text[..index].chars().last() { + if !prev_char.is_alphanumeric() { + return true; + } + } + } + false + } +} + +/// Check if a string starts with another string based on grapheme clusters +/// +/// ## What are Grapheme Clusters? +/// +/// A grapheme cluster is what users perceive as a single "character". This is NOT about +/// phonetics/pronunciation, but about visual representation. Examples: +/// +/// - "👨‍👩‍👧‍👦" (family emoji) looks like 1 character but is actually 7 Unicode code points +/// - "é" might be 1 precomposed character or 2 characters (e + ´ combining accent) +/// - "🇺🇸" (flag) is 2 regional indicator symbols that combine into 1 visual character +/// +/// ## Why is this needed? +/// +/// Standard string operations like `starts_with()` work on bytes or chars, which can +/// break these multi-codepoint characters. For @mentions, users expect: +/// - Typing "👨‍👩‍👧‍👦" should match a username starting with that family emoji +/// - Typing "é" should match whether the username uses precomposed or decomposed form +/// +/// ## When is this function called? +/// +/// This function is ONLY used when the search text contains complex Unicode characters +/// (when grapheme count != char count). For regular ASCII or simple Unicode, the +/// standard `starts_with()` is used for better performance. +/// +/// ## Performance Note +/// +/// This function is intentionally not called for common cases (ASCII usernames, +/// simple Chinese characters) to avoid the overhead of grapheme segmentation. +fn grapheme_starts_with(haystack: &str, needle: &str, case_insensitive: bool) -> bool { + if needle.is_empty() { + return true; + } + + let haystack_graphemes: Vec<&str> = haystack.graphemes(true).collect(); + let needle_graphemes: Vec<&str> = needle.graphemes(true).collect(); + + if needle_graphemes.len() > haystack_graphemes.len() { + return false; + } + + for i in 0..needle_graphemes.len() { + let h_grapheme = haystack_graphemes[i]; + let n_grapheme = needle_graphemes[i]; + + let grapheme_matches = if case_insensitive && h_grapheme.is_ascii() && n_grapheme.is_ascii() + { + h_grapheme.to_lowercase() == n_grapheme.to_lowercase() + } else { + h_grapheme == n_grapheme + }; + + if !grapheme_matches { + return false; + } + } + + true +} + +/// Match a member against search text and return priority if matched +/// Returns None if no match, Some(priority) if matched (lower priority = better match) +/// +/// Follows Matrix official recommendations for matching order: +/// 1. Exact display name match +/// 2. Exact user ID match +/// 3. Display name starts with search text +/// 4. User ID starts with search text +/// 5. Display name contains search text (at word boundary) +/// 6. User ID contains search text +fn match_member_with_priority(member: &RoomMember, search_text: &str) -> Option { + if search_text.is_empty() { + return Some(10); + } + + let display_name = member.display_name(); + let user_id = member.user_id().as_str(); + let localpart = member.user_id().localpart(); + let case_insensitive = search_text.is_ascii(); + let search_without_at = search_text.strip_prefix('@').unwrap_or(search_text); + let search_has_at = search_without_at.len() != search_text.len(); + + for matcher in MATCHERS { + if let Some(priority) = reducer( + matcher, + search_text, + display_name, + user_id, + localpart, + case_insensitive, + search_has_at, + ) { + return Some(priority); + } + } + + if !case_insensitive && search_text.graphemes(true).count() != search_text.chars().count() { + if let Some(display) = display_name { + if grapheme_starts_with(display, search_text, false) { + return Some(8); + } + } + } + + None +} + +#[derive(Copy, Clone)] +struct Matcher { + priority: u8, + func: fn( + search_text: &str, + display_name: Option<&str>, + user_id: &str, + localpart: &str, + case_insensitive: bool, + search_has_at: bool, + ) -> bool, +} + +const MATCHERS: &[Matcher] = &[ + Matcher { + priority: 0, + func: |search_text, display_name, _, _, _, _| display_name == Some(search_text), + }, + Matcher { + priority: 1, + func: |search_text, display_name, _, _, case_insensitive, _| { + case_insensitive && display_name.is_some_and(|d| d.eq_ignore_ascii_case(search_text)) + }, + }, + Matcher { + priority: 2, + func: |search_text, _, user_id, _, _, search_has_at| { + user_id == search_text + || (!search_has_at + && user_id.starts_with('@') + && user_id.strip_prefix('@') == Some(search_text)) + }, + }, + Matcher { + priority: 3, + func: |search_text, _, user_id, _, case_insensitive, search_has_at| { + case_insensitive + && (user_id.eq_ignore_ascii_case(search_text) + || (!search_has_at + && user_id.starts_with('@') + && user_id.strip_prefix('@').is_some_and(|id| { + id.eq_ignore_ascii_case(search_text) + }))) + }, + }, + Matcher { + priority: 4, + func: |search_text, display_name, _, _, _, _| { + display_name.is_some_and(|d| d.starts_with(search_text)) + }, + }, + Matcher { + priority: 5, + func: |search_text, display_name, _, _, case_insensitive, _| { + case_insensitive + && display_name.is_some_and(|d| starts_with_ignore_ascii_case(d, search_text)) + }, + }, + Matcher { + priority: 6, + func: |search_text, _, user_id, localpart, _, search_has_at| { + user_id.starts_with(search_text) + || (!search_has_at + && user_id.starts_with('@') + && user_id.strip_prefix('@').is_some_and(|id| id.starts_with(search_text))) + || localpart.starts_with(search_text) + }, + }, + Matcher { + priority: 7, + func: |search_text, _, user_id, localpart, case_insensitive, search_has_at| { + case_insensitive + && (starts_with_ignore_ascii_case(user_id, search_text) + || starts_with_ignore_ascii_case(localpart, search_text) + || (!search_has_at + && user_id.starts_with('@') + && user_id.strip_prefix('@').is_some_and(|id| { + starts_with_ignore_ascii_case(id, search_text) + }))) + }, + }, + Matcher { + priority: 8, + func: |search_text, display_name, _, _, case_insensitive, _| { + display_name.is_some_and(|display| { + check_word_boundary_match(display, search_text, case_insensitive) + || display.contains(search_text) + || (case_insensitive + && contains_ignore_ascii_case(display, search_text)) + }) + }, + }, + Matcher { + priority: 9, + func: |search_text, _, user_id, localpart, case_insensitive, _| { + if case_insensitive { + contains_ignore_ascii_case(user_id, search_text) + || contains_ignore_ascii_case(localpart, search_text) + } else { + user_id.contains(search_text) || localpart.contains(search_text) + } + }, + }, +]; + +fn reducer( + matcher: &Matcher, + search_text: &str, + display_name: Option<&str>, + user_id: &str, + localpart: &str, + case_insensitive: bool, + search_has_at: bool, +) -> Option { + if (matcher.func)( + search_text, + display_name, + user_id, + localpart, + case_insensitive, + search_has_at, + ) { + Some(matcher.priority) + } else { + None + } +} + +/// Returns true if the `haystack` starts with `needle` ignoring ASCII case. +fn starts_with_ignore_ascii_case(haystack: &str, needle: &str) -> bool { + haystack + .get(..needle.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(needle)) +} + +/// Returns true if the `haystack` contains `needle` ignoring ASCII case. +fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool { + if needle.is_empty() { + return true; + } + if !needle.is_ascii() { + return haystack.contains(needle); + } + let needle_len = needle.len(); + for (index, _) in haystack.char_indices() { + if index + needle_len > haystack.len() { + break; + } + if substring_eq_ignore_ascii_case(haystack, index, needle) { + return true; + } + } + false +} + +fn substring_eq_ignore_ascii_case(haystack: &str, start: usize, needle: &str) -> bool { + haystack + .get(start..start.saturating_add(needle.len())) + .is_some_and(|segment| segment.eq_ignore_ascii_case(needle)) +} + +// typos:disable +#[cfg(test)] +mod tests { + use super::*; + use matrix_sdk::room::RoomMemberRole; + use std::sync::mpsc::channel; + + #[test] + fn test_send_search_update_respects_cancellation() { + let (tx, rx) = channel(); + let cancel = Some(Arc::new(AtomicBool::new(true))); + let query = Arc::new("query".to_owned()); + + let result = send_search_update(&tx, &cancel, 1, &query, vec![1], false); + + assert!(!result); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn test_stream_index_batches_emits_completion() { + let (tx, rx) = channel(); + let cancel = None; + let query = Arc::new("abc".to_owned()); + + assert!(stream_index_batches(&[1, 2], &tx, &cancel, 7, &query)); + + let message = rx.recv().expect("expected batched result"); + assert_eq!(message.results, vec![1, 2]); + assert!(message.is_complete); + assert_eq!(message.search_id, 7); + assert_eq!(message.search_text.as_str(), "abc"); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn test_stream_index_batches_cancelled_before_send() { + let (tx, rx) = channel(); + let cancel = Some(Arc::new(AtomicBool::new(true))); + let query = Arc::new("abc".to_owned()); + + assert!(!stream_index_batches(&[1, 2], &tx, &cancel, 3, &query)); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn test_role_to_rank() { + // Verify that admin < moderator < user in terms of rank + assert_eq!(role_to_rank(RoomMemberRole::Administrator), 0); + assert_eq!(role_to_rank(RoomMemberRole::Moderator), 1); + assert_eq!(role_to_rank(RoomMemberRole::User), 2); + + // Verify ordering + assert!( + role_to_rank(RoomMemberRole::Administrator) < role_to_rank(RoomMemberRole::Moderator) + ); + assert!(role_to_rank(RoomMemberRole::Moderator) < role_to_rank(RoomMemberRole::User)); + } + + #[test] + fn test_top_k_selection_correctness() { + use std::collections::BinaryHeap; + + // Simulate Top-K selection with mixed priorities + let test_data = vec![ + (5, "user5"), // priority 5 + (1, "user1"), // priority 1 (better) + (3, "user3"), // priority 3 + (0, "user0"), // priority 0 (best) + (8, "user8"), // priority 8 (worst) + (2, "user2"), // priority 2 + (4, "user4"), // priority 4 + (1, "user1b"), // priority 1 (tie) + ]; + + let max_results = 3; + let mut top_matches: BinaryHeap<(u8, &str)> = BinaryHeap::with_capacity(max_results); + + // Apply the same algorithm as in search + for (priority, name) in test_data { + if top_matches.len() < max_results { + top_matches.push((priority, name)); + } else if let Some(&(worst_priority, _)) = top_matches.peek() { + if priority < worst_priority { + top_matches.pop(); + top_matches.push((priority, name)); + } + } + } + + // Extract and sort results + let mut results: Vec<(u8, &str)> = top_matches.into_iter().collect(); + results.sort_by_key(|&(priority, _)| priority); + + // Verify we got the top 3 with lowest priorities + assert_eq!(results.len(), 3); + assert_eq!(results[0].0, 0); // Best priority + assert_eq!(results[1].0, 1); // Second best + assert_eq!(results[2].0, 1); // Tied second best + + // Verify the worst candidates were excluded + assert!(!results.iter().any(|&(p, _)| p >= 4)); + } + + #[test] + fn test_word_boundary_case_insensitive() { + // Test case-insensitive word boundary matching for ASCII + assert!(check_word_boundary_match("Hello, Alice", "alice", true)); + assert!(check_word_boundary_match("@BOB is here", "bob", true)); + assert!(check_word_boundary_match("Meet CHARLIE!", "charlie", true)); + assert!(check_word_boundary_match("user:David", "david", true)); + + // Should not match in middle of word (case-insensitive) + assert!(!check_word_boundary_match("AliceSmith", "lice", true)); + assert!(!check_word_boundary_match("BOBCAT", "cat", true)); + + // Test case-sensitive mode + assert!(check_word_boundary_match("Hello, alice", "alice", false)); + assert!(!check_word_boundary_match("Hello, Alice", "alice", false)); + + // Test with mixed case in search text + assert!(check_word_boundary_match("Hello, Alice", "Alice", true)); + assert!(check_word_boundary_match("Hello, Alice", "Alice", false)); + } + + #[test] + fn test_name_category_with_stripped_prefix() { + // Helper to determine name category (matching the actual implementation) + fn get_name_category(raw_name: &str) -> u8 { + let stripped = raw_name.trim_start_matches(|c: char| !c.is_alphanumeric()); + if !stripped.is_empty() { + match stripped.chars().next() { + Some(c) if c.is_alphabetic() => 0, + Some(c) if c.is_numeric() => 1, + _ => 2, + } + } else { + 2 // All symbols + } + } + + // Test normal names + assert_eq!(get_name_category("alice"), 0); // Alphabetic + assert_eq!(get_name_category("123user"), 1); // Numeric + assert_eq!(get_name_category("@#$%"), 2); // All symbols + + // Test names with symbol prefixes + assert_eq!(get_name_category("!!!alice"), 0); // Should be alphabetic after stripping + assert_eq!(get_name_category("@bob"), 0); // Should be alphabetic after stripping + assert_eq!(get_name_category("___123"), 1); // Should be numeric after stripping + assert_eq!(get_name_category("#$%alice"), 0); // Should be alphabetic after stripping + + // Test edge cases + assert_eq!(get_name_category(""), 2); // Empty -> symbols + assert_eq!(get_name_category("!!!"), 2); // All symbols -> symbols + } + + #[test] + fn test_grapheme_starts_with_basic() { + // Basic ASCII cases + assert!(grapheme_starts_with("hello", "hel", false)); + assert!(grapheme_starts_with("hello", "hello", false)); + assert!(!grapheme_starts_with("hello", "llo", false)); + assert!(grapheme_starts_with("hello", "", false)); + assert!(!grapheme_starts_with("hi", "hello", false)); + } + + #[test] + fn test_grapheme_starts_with_case_sensitivity() { + // Case-insensitive for ASCII + assert!(grapheme_starts_with("Hello", "hel", true)); + assert!(grapheme_starts_with("HELLO", "hel", true)); + assert!(!grapheme_starts_with("Hello", "hel", false)); + + // Case-insensitive only works for ASCII + assert!(!grapheme_starts_with("Привет", "прив", true)); // Russian + } + + #[test] + fn test_grapheme_starts_with_emojis() { + // Family emoji (multiple code points appearing as single character) + let family = "👨‍👩‍👧‍👦"; // 7 code points, 1 grapheme + assert!(grapheme_starts_with("👨‍👩‍👧‍👦 Smith Family", "👨‍👩‍👧‍👦", false)); + assert!(grapheme_starts_with(family, family, false)); + + // Flag emojis (regional indicators) + assert!(grapheme_starts_with("🇺🇸 USA", "🇺🇸", false)); + assert!(grapheme_starts_with("🇯🇵 Japan", "🇯🇵", false)); + + // Skin tone modifiers + assert!(grapheme_starts_with("👋🏽 Hello", "👋🏽", false)); + assert!(!grapheme_starts_with("👋🏽 Hello", "👋", false)); // Different without modifier + + // Complex emoji sequences + assert!(grapheme_starts_with("🧑‍💻 Developer", "🧑‍💻", false)); + } + + #[test] + fn test_grapheme_starts_with_combining_characters() { + // Precomposed vs decomposed forms + let precomposed = "café"; // é as single character (U+00E9) + let decomposed = "cafe\u{0301}"; // e + combining acute accent (U+0065 + U+0301) + + // Both should work + assert!(grapheme_starts_with(precomposed, "caf", false)); + assert!(grapheme_starts_with(decomposed, "caf", false)); + + // Other combining characters + assert!(grapheme_starts_with("naïve", "naï", false)); // ï with diaeresis + assert!(grapheme_starts_with("piñata", "piñ", false)); // ñ with tilde + } + + #[test] + fn test_grapheme_starts_with_various_scripts() { + // Chinese + assert!(grapheme_starts_with("张三", "张", false)); + + // Japanese (Hiragana + Kanji) + assert!(grapheme_starts_with("こんにちは", "こん", false)); + assert!(grapheme_starts_with("日本語", "日本", false)); + + // Korean + assert!(grapheme_starts_with("안녕하세요", "안녕", false)); + + // Arabic (RTL) + assert!(grapheme_starts_with("مرحبا", "مر", false)); + + // Hindi with complex ligatures + assert!(grapheme_starts_with("नमस्ते", "नम", false)); + + // Thai with combining marks + assert!(grapheme_starts_with("สวัสดี", "สวั", false)); + } + + #[test] + fn test_grapheme_starts_with_zero_width_joiners() { + // Zero-width joiner sequences + let zwj_sequence = "👨‍⚕️"; // Man + ZWJ + Medical symbol + assert!(grapheme_starts_with("👨‍⚕️ Dr. Smith", zwj_sequence, false)); + + // Gender-neutral sequences + assert!(grapheme_starts_with("🧑‍🎓 Student", "🧑‍🎓", false)); + } + + #[test] + fn test_grapheme_starts_with_edge_cases() { + // Empty strings + assert!(grapheme_starts_with("", "", false)); + assert!(!grapheme_starts_with("", "a", false)); + + // Single grapheme vs multiple + assert!(grapheme_starts_with("a", "a", false)); + assert!(!grapheme_starts_with("a", "ab", false)); + + // Whitespace handling + assert!(grapheme_starts_with(" hello", " ", false)); + assert!(grapheme_starts_with("\nhello", "\n", false)); + } + + #[test] + fn test_word_boundary_match() { + // Test case-sensitive word boundary scenarios + assert!(check_word_boundary_match("Hello,alice", "alice", false)); + assert!(check_word_boundary_match("(bob) is here", "bob", false)); + assert!(check_word_boundary_match("user:charlie", "charlie", false)); + assert!(check_word_boundary_match("@david!", "david", false)); + assert!(check_word_boundary_match("eve.smith", "smith", false)); + assert!(check_word_boundary_match("frank-jones", "jones", false)); + + // Test case-insensitive matching (ASCII) + assert!(check_word_boundary_match("Hello,Alice", "alice", true)); + assert!(check_word_boundary_match("(Bob) is here", "bob", true)); + assert!(check_word_boundary_match("USER:Charlie", "charlie", true)); + assert!(check_word_boundary_match("@DAVID!", "david", true)); + + // Should not match in the middle of a word + assert!(!check_word_boundary_match("alice123", "lice", false)); + assert!(!check_word_boundary_match("bobcat", "cat", false)); + assert!(!check_word_boundary_match("Alice123", "lice", true)); + assert!(!check_word_boundary_match("BobCat", "cat", true)); + + // Edge cases + assert!(!check_word_boundary_match("test", "test", false)); // Starts with (handled elsewhere) + assert!(!check_word_boundary_match("", "test", false)); // Empty text + } + + #[test] + fn test_smart_sort_key_generation() { + // Helper function to simulate sort key generation + fn generate_sort_key(raw_name: &str) -> (u8, String) { + let stripped = raw_name.trim_start_matches(|c: char| !c.is_alphanumeric()); + let sort_key = if stripped.is_empty() { + raw_name.to_lowercase() + } else { + stripped.to_lowercase() + }; + + // Three-tier ranking: alphabetic (0), numeric (1), symbols (2) + let rank = match raw_name.chars().next() { + Some(c) if c.is_alphabetic() => 0, + Some(c) if c.is_numeric() => 1, + _ => 2, + }; + + (rank, sort_key) + } + + // Test alphabetic names get rank 0 + assert_eq!(generate_sort_key("alice"), (0, "alice".to_string())); + assert_eq!(generate_sort_key("Bob"), (0, "bob".to_string())); + assert_eq!(generate_sort_key("张三"), (0, "张三".to_string())); + + // Test numeric names get rank 1 + assert_eq!(generate_sort_key("0user"), (1, "0user".to_string())); + assert_eq!(generate_sort_key("123abc"), (1, "123abc".to_string())); + assert_eq!(generate_sort_key("999test"), (1, "999test".to_string())); + + // Test symbol-prefixed names get rank 2 but sort by stripped version + assert_eq!(generate_sort_key("!!!alice"), (2, "alice".to_string())); + assert_eq!(generate_sort_key("@bob"), (2, "bob".to_string())); + assert_eq!(generate_sort_key("___charlie"), (2, "charlie".to_string())); + + // Test pure symbol names + assert_eq!(generate_sort_key("!!!"), (2, "!!!".to_string())); + assert_eq!(generate_sort_key("@@@"), (2, "@@@".to_string())); + + // Test ordering: alphabetic -> numeric -> symbols + let mut names = vec![ + ("!!!alice", generate_sort_key("!!!alice")), + ("0user", generate_sort_key("0user")), + ("alice", generate_sort_key("alice")), + ("123test", generate_sort_key("123test")), + ("@bob", generate_sort_key("@bob")), + ("bob", generate_sort_key("bob")), + ]; + + // Sort by (rank, sort_key) + names.sort_by(|a, b| match a.1.0.cmp(&b.1.0) { + std::cmp::Ordering::Equal => a.1.1.cmp(&b.1.1), + other => other, + }); + + // Verify order: alice, bob, 0user, 123test, !!!alice, @bob + assert_eq!(names[0].0, "alice"); + assert_eq!(names[1].0, "bob"); + assert_eq!(names[2].0, "0user"); + assert_eq!(names[3].0, "123test"); + assert_eq!(names[4].0, "!!!alice"); + assert_eq!(names[5].0, "@bob"); + } + + #[test] + fn test_role_to_rank_mapping() { + assert_eq!(role_to_rank(RoomMemberRole::Administrator), 0); + assert_eq!(role_to_rank(RoomMemberRole::Moderator), 1); + assert_eq!(role_to_rank(RoomMemberRole::User), 2); + } + + #[test] + fn test_top_k_heap_selection_priorities() { + // Simulate the heap logic used in non-empty search: keep K smallest priorities + fn top_k(items: &[(u8, usize)], k: usize) -> Vec<(u8, usize)> { + use std::collections::BinaryHeap; + let mut heap: BinaryHeap<(u8, usize)> = BinaryHeap::with_capacity(k); + for &(p, idx) in items { + if heap.len() < k { + heap.push((p, idx)); + } else if let Some(&(worst_p, _)) = heap.peek() { + if p < worst_p { + let _ = heap.pop(); + heap.push((p, idx)); + } + } + } + let mut out: Vec<(u8, usize)> = heap.into_iter().collect(); + out.sort_by_key(|(p, _)| *p); + out + } + + let items = vec![ + (9, 0), + (3, 1), + (5, 2), + (1, 3), + (2, 4), + (7, 5), + (0, 6), + (4, 7), + (6, 8), + (8, 9), + ]; + + // K = 3 should return priorities [0, 1, 2] + let k3 = top_k(&items, 3); + let priorities: Vec = k3.into_iter().map(|(p, _)| p).collect(); + assert_eq!(priorities, vec![0, 1, 2]); + + // K = 5 should return priorities [0, 1, 2, 3, 4] + let k5 = top_k(&items, 5); + let priorities: Vec = k5.into_iter().map(|(p, _)| p).collect(); + assert_eq!(priorities, vec![0, 1, 2, 3, 4]); + } + + #[test] + fn test_when_grapheme_search_is_used() { + // This test demonstrates when grapheme_starts_with is actually called + // in the user_matches_search function + + // Regular ASCII - grapheme count == char count + assert_eq!("hello".graphemes(true).count(), "hello".chars().count()); + + // Family emoji - grapheme count != char count + assert_ne!("👨‍👩‍👧‍👦".graphemes(true).count(), "👨‍👩‍👧‍👦".chars().count()); + assert_eq!("👨‍👩‍👧‍👦".graphemes(true).count(), 1); + assert_eq!("👨‍👩‍👧‍👦".chars().count(), 7); + + // Combining character - grapheme count != char count + // Using actual decomposed form: e (U+0065) + combining acute accent (U+0301) + let decomposed = "e\u{0301}"; // e + combining acute accent + assert_ne!( + decomposed.graphemes(true).count(), + decomposed.chars().count() + ); + assert_eq!(decomposed.graphemes(true).count(), 1); // Shows as 1 grapheme + assert_eq!(decomposed.chars().count(), 2); // But is 2 chars + + // Simple Chinese - grapheme count == char count + assert_eq!("你好".graphemes(true).count(), "你好".chars().count()); + } +} diff --git a/src/room/mod.rs b/src/room/mod.rs index ef92a1a3..c834bd4e 100644 --- a/src/room/mod.rs +++ b/src/room/mod.rs @@ -8,6 +8,7 @@ pub mod reply_preview; pub mod room_input_bar; pub mod room_display_filter; pub mod typing_notice; +pub mod member_search; pub fn live_design(cx: &mut Cx) { reply_preview::live_design(cx); diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 0ea64a30..5be0aced 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -1,25 +1,169 @@ -//! MentionableTextInput component provides text input with @mention capabilities -//! Can be used in any context where user mentions are needed (message input, editing) +//! MentionableTextInput component provides text input with @mention capabilities. +//! +//! Can be used in any context where user mentions are needed (message input, editing). +//! +//! # Architecture Overview +//! +//! This component uses a **state machine** pattern combined with **background thread execution** +//! to provide responsive @mention search functionality even in large rooms. +//! +//! ## State Machine +//! +//! The search functionality is driven by [`MentionSearchState`], which has four states: +//! +//! ```text +//! ┌──────────────────────────────────────────────────────────────────────────┐ +//! │ State Transitions │ +//! │ │ +//! │ ┌──────┐ user types @ ┌───────────────────┐ │ +//! │ │ Idle │ ─────────────────► │ WaitingForMembers │ (if no cached data) │ +//! │ └──────┘ └─────────┬─────────┘ │ +//! │ ▲ │ │ +//! │ │ │ members loaded │ +//! │ │ ▼ │ +//! │ │ ┌─────────────────────────────────────┐ │ +//! │ │ │ Searching │ │ +//! │ │ │ - receiver: channel for results │ │ +//! │ │ │ - accumulated_results: Vec │ │ +//! │ │ │ - cancel_token: Arc │ │ +//! │ │ └──────────────┬──────────────────────┘ │ +//! │ │ │ │ +//! │ │ ┌───────────────────┼───────────────────┐ │ +//! │ │ │ │ │ │ +//! │ │ ▼ ▼ ▼ │ +//! │ │ search user selects user presses │ +//! │ │ completes a mention ESC │ +//! │ │ │ │ │ │ +//! │ │ │ │ ▼ │ +//! │ │ │ │ ┌───────────────┐ │ +//! │ │ │ │ │ JustCancelled │ │ +//! │ │ │ │ └───────┬───────┘ │ +//! │ │ │ │ │ │ +//! │ └────┴───────────────────┴───────────────────┘ │ +//! │ reset to Idle │ +//! └──────────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! - **Idle**: Default state, no active search +//! - **WaitingForMembers**: Triggered @ detected, waiting for room member data to load +//! - **Searching**: Background search task running, receiving streaming results via channel +//! - **JustCancelled**: ESC pressed, prevents immediate re-trigger on next keystroke +//! +//! ## Background Thread Execution +//! +//! To keep the UI responsive during searches in large rooms, the actual member search +//! is offloaded to a background thread via [`cpu_worker::spawn_cpu_job`]: +//! +//! ```text +//! ┌─────────────────────┐ ┌─────────────────────┐ +//! │ UI Thread │ │ Background Thread │ +//! │ │ │ │ +//! │ update_user_list() │ │ │ +//! │ │ │ │ │ +//! │ ▼ │ │ │ +//! │ spawn_cpu_job() ───┼────────►│ SearchRoomMembers │ +//! │ │ │ │ │ │ +//! │ ▼ │ │ ▼ │ +//! │ cx.new_next_frame()│ │ search members... │ +//! │ │ │ │ │ │ +//! │ ▼ │ MPSC │ ▼ │ +//! │ check_search_ │◄────────┼─ send batch (10) │ +//! │ channel() │ Channel │ │ │ +//! │ │ │ │ ▼ │ +//! │ ▼ │ │ send batch (10) │ +//! │ update UI with │◄────────┼─ │ │ +//! │ streaming results │ │ ▼ │ +//! │ │ │ send completion │ +//! └─────────────────────┘ └─────────────────────┘ +//! ``` +//! +//! Key features: +//! - Results are streamed in batches of 10 for progressive UI updates +//! - Cancellation is supported via `Arc` token +//! - Each search has a unique `search_id` to ignore stale results +//! +//! ## Focus Management +//! +//! The component handles complex focus scenarios: +//! - `pending_popup_cleanup`: Defers popup closure when focus is lost during search +//! - `pending_draw_focus_restore`: Retries focus restoration in draw_walk until successful +//! +//! ## Key Components +//! +//! - [`SearchResult`]: Result type sent through the channel from background thread +//! - [`MentionSearchState`]: State machine enum managing search lifecycle +//! - [`MentionableTextInputAction`]: Actions for external communication (power levels, member updates) //! use crate::avatar_cache::*; use crate::shared::avatar::AvatarWidgetRefExt; use crate::shared::bouncing_dots::BouncingDotsWidgetRefExt; use crate::shared::styles::COLOR_UNKNOWN_ROOM_AVATAR; use crate::utils; - +use crate::cpu_worker::{self, CpuJob, SearchRoomMembersJob}; +use crate::sliding_sync::{submit_async_request, MatrixRequest}; use makepad_widgets::{text::selection::Cursor, *}; -use matrix_sdk::ruma::{events::{room::message::RoomMessageEventContent, Mentions}, OwnedRoomId, OwnedUserId}; -use matrix_sdk::room::RoomMember; +use matrix_sdk::ruma::{ + events::{room::message::RoomMessageEventContent, Mentions}, + OwnedRoomId, OwnedUserId, +}; +use matrix_sdk::RoomMemberships; use std::collections::{BTreeMap, BTreeSet}; use unicode_segmentation::UnicodeSegmentation; use crate::home::room_screen::RoomScreenProps; +// Channel types for member search communication +use std::sync::{mpsc::Receiver, Arc}; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Result type for member search channel communication +#[derive(Debug, Clone)] +pub struct SearchResult { + pub search_id: u64, + pub results: Vec, // indices in members vec + pub is_complete: bool, + pub search_text: Arc, +} + +/// State machine for mention search functionality +#[derive(Debug, Default)] +enum MentionSearchState { + /// Not in search mode + #[default] + Idle, + + /// Waiting for room members data to be loaded + WaitingForMembers { + trigger_position: usize, + pending_search_text: String, + }, + + /// Actively searching with background task + Searching { + trigger_position: usize, + search_text: String, + receiver: Receiver, + accumulated_results: Vec, + search_id: u64, + cancel_token: Arc, + }, + + /// Search was just cancelled (prevents immediate re-trigger) + JustCancelled, +} + +// Default is derived above; Idle is marked as the default variant + // Constants for mention popup height calculations const DESKTOP_ITEM_HEIGHT: f64 = 32.0; const MOBILE_ITEM_HEIGHT: f64 = 64.0; const MOBILE_USERNAME_SPACING: f64 = 0.5; +// Constants for search behavior +const DESKTOP_MAX_VISIBLE_ITEMS: usize = 10; +const MOBILE_MAX_VISIBLE_ITEMS: usize = 5; +const SEARCH_BUFFER_MULTIPLIER: usize = 2; + live_design! { use link::theme::*; use link::shaders::*; @@ -286,60 +430,154 @@ live_design! { // /// from normal `@` characters. // const MENTION_START_STRING: &str = "\u{8288}@\u{8288}"; - #[derive(Debug)] pub enum MentionableTextInputAction { /// Notifies the MentionableTextInput about updated power levels for the room. PowerLevelsUpdated { room_id: OwnedRoomId, can_notify_room: bool, - } + }, + /// Notifies the MentionableTextInput that room members have been loaded. + RoomMembersLoaded { + room_id: OwnedRoomId, + /// Whether member sync is still in progress + sync_in_progress: bool, + /// Whether we currently have cached members + has_members: bool, + }, } /// Widget that extends CommandTextInput with @mention capabilities #[derive(Live, LiveHook, Widget)] pub struct MentionableTextInput { /// Base command text input - #[deref] cmd_text_input: CommandTextInput, + #[deref] + cmd_text_input: CommandTextInput, /// Template for user list items - #[live] user_list_item: Option, + #[live] + user_list_item: Option, /// Template for the @room mention list item - #[live] room_mention_list_item: Option, + #[live] + room_mention_list_item: Option, /// Template for loading indicator - #[live] loading_indicator: Option, + #[live] + loading_indicator: Option, /// Template for no matches indicator - #[live] no_matches_indicator: Option, - /// Position where the @ mention starts - #[rust] current_mention_start_index: Option, + #[live] + no_matches_indicator: Option, /// The set of users that were mentioned (at one point) in this text input. /// Due to characters being deleted/removed, this list is a *superset* /// of possible users who may have been mentioned. /// All of these mentions may not exist in the final text input content; /// this is just a list of users to search the final sent message for /// when adding in new mentions. - #[rust] possible_mentions: BTreeMap, + #[rust] + possible_mentions: BTreeMap, /// Indicates if the `@room` option was explicitly selected. - #[rust] possible_room_mention: bool, - /// Indicates if currently in mention search mode - #[rust] is_searching: bool, + #[rust] + possible_room_mention: bool, /// Whether the current user can notify everyone in the room (@room mention) - #[rust] can_notify_room: bool, - /// Whether the room members are currently being loaded - #[rust] members_loading: bool, + #[rust] + can_notify_room: bool, + /// Current state of the mention search functionality + #[rust] + search_state: MentionSearchState, + /// Last search text to avoid duplicate searches + #[rust] + last_search_text: Option, + /// Next identifier for submitted search jobs + #[rust] + next_search_id: u64, + /// Whether the background search task has pending results + #[rust] + search_results_pending: bool, + /// Active loading indicator widget while we wait for members/results + #[rust] + loading_indicator_ref: Option, + /// Cached text analysis to avoid repeated grapheme parsing + /// Format: (text, graphemes_as_strings, byte_positions) + #[rust] + cached_text_analysis: Option<(String, Vec, Vec)>, + /// Last known member count - used ONLY for change detection (not rendering) + /// Rendering always uses props as source of truth + #[rust] + last_member_count: usize, + /// Last known sync pending state - used ONLY for change detection (not rendering) + #[rust] + last_sync_pending: bool, + /// Whether a deferred popup cleanup is pending after focus loss + #[rust] + pending_popup_cleanup: bool, + /// Whether focus should be restored in the next draw_walk cycle + #[rust] + pending_draw_focus_restore: bool, } - impl Widget for MentionableTextInput { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + // Handle ESC key early before passing to child widgets + if self.is_searching() { + if let Event::KeyUp(key_event) = event { + if key_event.key_code == KeyCode::Escape { + self.cancel_active_search(); + self.search_state = MentionSearchState::JustCancelled; + + // UI cleanup only - do NOT call close_mention_popup() as it resets + // state to Idle via reset_search_state(), losing the JustCancelled marker + let popup = self.cmd_text_input.view(ids!(popup)); + popup.set_visible(cx, false); + self.cmd_text_input.clear_items(); + self.loading_indicator_ref = None; // Clear loading indicator + self.pending_popup_cleanup = false; // Prevent next frame from triggering cleanup + + self.redraw(cx); + return; // Don't process other events + } + } + } + self.cmd_text_input.handle_event(cx, event, scope); // Best practice: Always check Scope first to get current context // Scope represents the current widget context as passed down from parents - let scope_room_id = scope.props.get::() - .expect("BUG: RoomScreenProps should be available in Scope::props for MentionableTextInput") - .room_name_id - .room_id() - .clone(); + let (scope_room_id, scope_member_count) = { + let room_props = scope + .props + .get::() + .expect("RoomScreenProps should be available in scope for MentionableTextInput"); + let member_count = room_props + .room_members + .as_ref() + .map(|members| members.len()) + .unwrap_or(0); + (room_props.room_name_id.room_id().clone(), member_count) + }; + + // Check search channel on every frame if we're searching + if let MentionSearchState::Searching { .. } = &self.search_state { + if let Event::NextFrame(_) = event { + // Only continue requesting frames if we're still waiting for results + if self.check_search_channel(cx, scope) { + cx.new_next_frame(); + } + } + } + // Handle deferred cleanup after focus loss + if let Event::NextFrame(_) = event { + if self.pending_popup_cleanup { + let text_input_ref = self.cmd_text_input.text_input_ref(); + let text_input_area = text_input_ref.area(); + self.pending_popup_cleanup = false; + + // Only close if input still doesn't have focus and we're not actively searching + let has_focus = cx.has_key_focus(text_input_area); + + // If user refocused or is actively typing/searching, don't cleanup + if !has_focus && !self.is_searching() { + self.close_mention_popup(cx); + } + } + } if let Event::Actions(actions) = event { let text_input_ref = self.cmd_text_input.text_input_ref(); @@ -377,31 +615,152 @@ impl Widget for MentionableTextInput { } // Handle MentionableTextInputAction actions - if let Some(MentionableTextInputAction::PowerLevelsUpdated { room_id, can_notify_room }) = action.downcast_ref() { - if &scope_room_id != room_id { - continue; - } + if let Some(action) = action.downcast_ref::() { + match action { + MentionableTextInputAction::PowerLevelsUpdated { + room_id, + can_notify_room, + } => { + if &scope_room_id != room_id { + continue; + } + log!("PowerLevelsUpdated received: room_id={}, can_notify_room={}, scope_room_id={}", + room_id, can_notify_room, scope_room_id); + + if self.can_notify_room != *can_notify_room { + log!("Updating can_notify_room from {} to {}", self.can_notify_room, can_notify_room); + self.can_notify_room = *can_notify_room; + if self.is_searching() && has_focus { + let search_text = + self.cmd_text_input.search_text().to_lowercase(); + self.update_user_list(cx, &search_text, scope); + } else { + self.cmd_text_input.redraw(cx); + } + } + } + MentionableTextInputAction::RoomMembersLoaded { + room_id, + sync_in_progress, + has_members, + } => { + if &scope_room_id != room_id { + continue; + } - if self.can_notify_room != *can_notify_room { - self.can_notify_room = *can_notify_room; - if self.is_searching && has_focus { - let search_text = self.cmd_text_input.search_text().to_lowercase(); - self.update_user_list(cx, &search_text, scope); - } else { - self.redraw(cx); + // CRITICAL: Use locally stored previous state for change detection + // (not from props, which is already the new state in the same frame) + let previous_member_count = self.last_member_count; + let was_sync_pending = self.last_sync_pending; + + // Current state: read fresh props to avoid stale snapshot from handle_event entry + let current_member_count = scope + .props + .get::() + .map(|p| p.room_members.as_ref().map(|m| m.len()).unwrap_or(0)) + .unwrap_or(scope_member_count); + let current_sync_pending = *sync_in_progress; + + // Detect actual changes + let member_count_changed = current_member_count != previous_member_count + && current_member_count > 0 + && previous_member_count > 0; + let sync_just_completed = !current_sync_pending && was_sync_pending; + + // Update local state for next comparison + self.last_member_count = current_member_count; + self.last_sync_pending = current_sync_pending; + + // Skip processing if search was cancelled by ESC + // This prevents async callbacks from reopening the popup + if matches!(self.search_state, MentionSearchState::JustCancelled) { + continue; + } + + if *has_members { + // CRITICAL FIX: Use saved state instead of reading from text input + // Reading from text input causes race condition (text may be empty when members arrive) + // Extract needed values first to avoid borrow checker issues + let action = match &self.search_state { + MentionSearchState::WaitingForMembers { + pending_search_text, + .. + } => Some((true, pending_search_text.clone())), + MentionSearchState::Searching { search_text, .. } => { + Some((false, search_text.clone())) + } + _ => None, + }; + + if let Some((is_waiting, search_text)) = action { + let member_set_updated = member_count_changed + && matches!(self.search_state, MentionSearchState::Searching { .. }); + + if is_waiting { + self.last_search_text = None; + self.update_user_list(cx, &search_text, scope); + } else { + // Already in Searching state + // Check if remote sync just completed or member set changed - need to re-search with full member list + if member_set_updated || sync_just_completed { + self.last_search_text = None; + self.update_user_list(cx, &search_text, scope); + } else { + self.update_ui_with_results(cx, scope, &search_text); + } + } + } else { + // Not in WaitingForMembers or Searching state + // Check if remote sync just completed - if so, refresh UI if there's an active mention trigger + if sync_just_completed { + let text = self.cmd_text_input.text_input_ref().text(); + let cursor_pos = self.cmd_text_input.text_input_ref() + .borrow() + .map_or(0, |p| p.cursor().index); + + if let Some(_trigger_pos) = self.find_mention_trigger_position(&text, cursor_pos) { + let search_text = self.cmd_text_input.search_text().to_lowercase(); + self.last_search_text = None; + self.update_user_list(cx, &search_text, scope); + } + } + } + } else if self.is_searching() { + // Still no members returned yet; keep showing loading indicator. + self.cmd_text_input.clear_items(); + self.show_loading_indicator(cx); + let popup = self.cmd_text_input.view(ids!(popup)); + popup.set_visible(cx, true); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } + } } } } } - // Close popup if focus is lost - if !has_focus && self.cmd_text_input.view(ids!(popup)).visible() { - self.close_mention_popup(cx); + // Close popup and clean up search state if focus is lost while searching + // This prevents background search tasks from continuing when user is no longer interested + if !has_focus && self.is_searching() { + let popup = self.cmd_text_input.view(ids!(popup)); + popup.set_visible(cx, false); + self.pending_popup_cleanup = true; + // Guarantee cleanup executes even if search completes and stops requesting frames + cx.new_next_frame(); } } // Check if we were waiting for members and they're now available - if self.members_loading && self.is_searching { + // When members arrive, always update regardless of focus state + // update_user_list will handle popup visibility based on current focus + if let MentionSearchState::WaitingForMembers { + trigger_position: _, + pending_search_text, + } = &self.search_state + { let room_props = scope .props .get::() @@ -409,79 +768,102 @@ impl Widget for MentionableTextInput { if let Some(room_members) = &room_props.room_members { if !room_members.is_empty() { - // Members are now available, update the list - self.members_loading = false; - let text_input = self.cmd_text_input.text_input(ids!(text_input)); - let text_input_area = text_input.area(); - let is_focused = cx.has_key_focus(text_input_area); - - if is_focused { - let search_text = self.cmd_text_input.search_text().to_lowercase(); - self.update_user_list(cx, &search_text, scope); - } + let search_text = pending_search_text.clone(); + self.update_user_list(cx, &search_text, scope); } } } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - self.cmd_text_input.draw_walk(cx, scope, walk) + let result = self.cmd_text_input.draw_walk(cx, scope, walk); + + // Restore focus after all child drawing is complete. + // This retries until focus is successfully restored, handling cases where + // finger_up events might steal focus after our initial restoration attempt. + if self.pending_draw_focus_restore { + let text_input_ref = self.cmd_text_input.text_input_ref(); + text_input_ref.set_key_focus(cx); + if let Some(mut ti) = text_input_ref.borrow_mut() { + ti.reset_blink_timer(cx); + } + // Check if we successfully got focus + let area = text_input_ref.area(); + if cx.has_key_focus(area) { + // Successfully restored focus, clear the flag + self.pending_draw_focus_restore = false; + } else { + // Focus restoration failed (likely due to finger_up event stealing focus) + // Keep the flag true and request another frame to retry + cx.new_next_frame(); + } + } + + result } } - impl MentionableTextInput { + /// Check if currently in any form of search mode + fn is_searching(&self) -> bool { + matches!( + self.search_state, + MentionSearchState::WaitingForMembers { .. } | MentionSearchState::Searching { .. } + ) + } - /// Check if members are loading and show loading indicator if needed. - /// - /// Returns true if we should return early because we're in the loading state. - fn handle_members_loading_state( - &mut self, - cx: &mut Cx, - room_members: &Option>>, - ) -> bool { - let Some(room_members) = room_members else { - self.members_loading = true; - self.show_loading_indicator(cx); - return true; - }; - - let members_are_empty = room_members.is_empty(); + /// Generate the next unique identifier for a background search job. + fn allocate_search_id(&mut self) -> u64 { + if self.next_search_id == 0 { + self.next_search_id = 1; + } + let id = self.next_search_id; + self.next_search_id = self.next_search_id.wrapping_add(1); + if self.next_search_id == 0 { + self.next_search_id = 1; + } + id + } - if members_are_empty && !self.members_loading { - // Members list is empty and we're not already showing loading - start loading state - self.members_loading = true; - self.show_loading_indicator(cx); - return true; - } else if !members_are_empty && self.members_loading { - // Members have been loaded, stop loading state - self.members_loading = false; - // Reset popup height to ensure proper calculation for user list - let popup = self.cmd_text_input.view(ids!(popup)); - popup.apply_over(cx, live! { height: Fit }); - } else if members_are_empty && self.members_loading { - // Still loading and members are empty - keep showing loading indicator - return true; + /// Get the current trigger position if in search mode + fn get_trigger_position(&self) -> Option { + match &self.search_state { + MentionSearchState::WaitingForMembers { + trigger_position, .. + } + | MentionSearchState::Searching { + trigger_position, .. + } => Some(*trigger_position), + _ => None, } + } - false + /// Check if search was just cancelled + fn is_just_cancelled(&self) -> bool { + matches!(self.search_state, MentionSearchState::JustCancelled) } /// Tries to add the `@room` mention item to the list of selectable popup mentions. /// /// Returns true if @room item was added to the list and will be displayed in the popup. - fn try_search_messages_mention_item( + fn try_add_room_mention_item( &mut self, cx: &mut Cx, search_text: &str, room_props: &RoomScreenProps, is_desktop: bool, ) -> bool { + // Don't show @room option in direct messages + if room_props.is_direct_room { + return false; + } if !self.can_notify_room || !("@room".contains(search_text) || search_text.is_empty()) { return false; } - let Some(ptr) = self.room_mention_list_item else { return false }; + let Some(ptr) = self.room_mention_list_item else { + return false; + }; let room_mention_item = WidgetRef::new_from_ptr(cx, Some(ptr)); let mut room_avatar_shown = false; @@ -491,10 +873,10 @@ impl MentionableTextInput { let room_label = room_props.room_name_id.to_string(); let room_name_first_char = room_label .graphemes(true) - .find(|g| *g != "#" && *g != "!" && *g != "@") + .next() .map(|s| s.to_uppercase()) - .filter(|s| s.chars().all(|c| c.is_alphabetic())) - .unwrap_or_else(|| "R".to_string()); + .filter(|s| s != "@" && s.chars().all(|c| c.is_alphabetic())) + .unwrap_or_default(); if let Some(avatar_url) = &room_props.room_avatar_url { match get_or_fetch_avatar(cx, avatar_url.to_owned()) { @@ -505,102 +887,105 @@ impl MentionableTextInput { }); if result.is_ok() { room_avatar_shown = true; - } else { - log!("Failed to show @room avatar with room avatar image"); } - }, + } AvatarCacheEntry::Requested => { - avatar_ref.show_text(cx, Some(COLOR_UNKNOWN_ROOM_AVATAR), None, &room_name_first_char); + avatar_ref.show_text( + cx, + Some(COLOR_UNKNOWN_ROOM_AVATAR), + None, + &room_name_first_char, + ); room_avatar_shown = true; - }, + } AvatarCacheEntry::Failed => { - log!("Failed to load room avatar for @room"); + // Failed to load room avatar - will use fallback text } } } // If unable to display room avatar, show first character of room name if !room_avatar_shown { - avatar_ref.show_text(cx, Some(COLOR_UNKNOWN_ROOM_AVATAR), None, &room_name_first_char); + avatar_ref.show_text( + cx, + Some(COLOR_UNKNOWN_ROOM_AVATAR), + None, + &room_name_first_char, + ); } // Apply layout and height styling based on device type - let new_height = if is_desktop { DESKTOP_ITEM_HEIGHT } else { MOBILE_ITEM_HEIGHT }; + let new_height = if is_desktop { + DESKTOP_ITEM_HEIGHT + } else { + MOBILE_ITEM_HEIGHT + }; if is_desktop { - room_mention_item.apply_over(cx, live! { - height: (new_height), - flow: Right, - }); + room_mention_item.apply_over( + cx, + live! { + height: (new_height), + flow: Right, + }, + ); } else { - room_mention_item.apply_over(cx, live! { - height: (new_height), - flow: Down, - }); + room_mention_item.apply_over( + cx, + live! { + height: (new_height), + flow: Down, + }, + ); } self.cmd_text_input.add_item(room_mention_item); true } - /// Find and sort matching members based on search text - fn find_and_sort_matching_members( - &self, - search_text: &str, - room_members: &std::sync::Arc>, - max_matched_members: usize, - ) -> Vec<(String, RoomMember)> { - let mut prioritized_members = Vec::new(); - - // Get current user ID to filter out self-mentions - let current_user_id = crate::sliding_sync::current_user_id(); - - for member in room_members.iter() { - if prioritized_members.len() >= max_matched_members { - break; - } - - // Skip the current user - users should not be able to mention themselves - if let Some(ref current_id) = current_user_id { - if member.user_id() == current_id { - continue; - } - } - - // Check if this member matches the search text (including Matrix ID) - if self.user_matches_search(member, search_text) { - let display_name = member - .display_name() - .map(|n| n.to_string()) - .unwrap_or_else(|| member.user_id().to_string()); - - let priority = self.get_match_priority(member, search_text); - prioritized_members.push((priority, display_name, member.clone())); - } - } - - // Sort by priority (lower number = higher priority) - prioritized_members.sort_by_key(|(priority, _, _)| *priority); - - // Convert to the format expected by the rest of the code - prioritized_members - .into_iter() - .map(|(_, display_name, member)| (display_name, member)) - .collect() - } - - /// Add user mention items to the list + /// Add user mention items to the list from search results /// Returns the number of items added - fn add_user_mention_items( + fn add_user_mention_items_from_results( &mut self, cx: &mut Cx, - matched_members: Vec<(String, RoomMember)>, + results: &[usize], user_items_limit: usize, is_desktop: bool, + room_props: &RoomScreenProps, ) -> usize { let mut items_added = 0; - for (index, (display_name, member)) in matched_members.into_iter().take(user_items_limit).enumerate() { - let Some(user_list_item_ptr) = self.user_list_item else { continue }; + // Get the actual members vec from room_props + let Some(members) = &room_props.room_members else { + return 0; + }; + + for (index, &member_idx) in results.iter().take(user_items_limit).enumerate() { + // Get the actual member from the index + let Some(member) = members.get(member_idx) else { + continue; + }; + + // Get display name from member, with better fallback + // Trim whitespace and filter out empty/whitespace-only names + let display_name = member.display_name() + .map(|name| name.trim()) // Remove leading/trailing whitespace + .filter(|name| !name.is_empty()) // Filter out empty or whitespace-only names + .unwrap_or_else(|| member.user_id().localpart()) + .to_owned(); + + // Log warning for extreme cases where we still have no displayable text + #[cfg(debug_assertions)] + if display_name.is_empty() { + log!( + "Warning: Member {} has no displayable name (empty display_name and localpart)", + member.user_id() + ); + } + + let Some(user_list_item_ptr) = self.user_list_item else { + // user_list_item_ptr is None + continue; + }; let item = WidgetRef::new_from_ptr(cx, Some(user_list_item_ptr)); item.label(ids!(user_info.username)).set_text(cx, &display_name); @@ -660,25 +1045,76 @@ impl MentionableTextInput { items_added } - /// Update popup visibility and layout - fn update_popup_visibility(&mut self, cx: &mut Cx, has_items: bool) { + /// Update popup visibility and layout based on current state + fn update_popup_visibility(&mut self, cx: &mut Cx, scope: &mut Scope, has_items: bool) { let popup = self.cmd_text_input.view(ids!(popup)); - if has_items { - popup.set_visible(cx, true); - if self.is_searching { - self.cmd_text_input.text_input_ref().set_key_focus(cx); + // Get current state from props + let room_props = scope + .props + .get::() + .expect("RoomScreenProps should be available in scope"); + let members_sync_pending = room_props.room_members_sync_pending; + let members_available = room_props + .room_members + .as_ref() + .is_some_and(|m| !m.is_empty()); + + match &self.search_state { + MentionSearchState::Idle | MentionSearchState::JustCancelled => { + // Not in search mode, hide popup + popup.apply_over(cx, live! { height: Fit }); + popup.set_visible(cx, false); + } + MentionSearchState::WaitingForMembers { .. } => { + // Waiting for room members to be loaded + self.show_loading_indicator(cx); + popup.set_visible(cx, true); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } + } + MentionSearchState::Searching { + accumulated_results, + .. + } => { + if has_items { + // We have search results to display + popup.set_visible(cx, true); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } + } else if accumulated_results.is_empty() { + if members_sync_pending || self.search_results_pending { + // Still fetching either member list or background search results. + self.show_loading_indicator(cx); + } else if members_available { + // Search completed with no results even though we have members. + self.show_no_matches_indicator(cx); + } else { + // No members available yet. + self.show_loading_indicator(cx); + } + popup.set_visible(cx, true); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } + } else { + // Has accumulated results but no items (should not happen) + popup.set_visible(cx, true); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } + } } - } else if self.is_searching { - // If we're searching but have no items, show "no matches" message - // Keep the popup open so users can correct their search - self.show_no_matches_indicator(cx); - popup.set_visible(cx, true); - self.cmd_text_input.text_input_ref().set_key_focus(cx); - } else { - // Only hide popup if we're not actively searching - popup.apply_over(cx, live! { height: Fit }); - popup.set_visible(cx, false); } } @@ -692,12 +1128,13 @@ impl MentionableTextInput { let current_text = text_input_ref.text(); let head = text_input_ref.borrow().map_or(0, |p| p.cursor().index); - if let Some(start_idx) = self.current_mention_start_index { + if let Some(start_idx) = self.get_trigger_position() { let room_mention_label = selected.label(ids!(user_info.room_mention)); let room_mention_text = room_mention_label.text(); let room_user_id_text = selected.label(ids!(room_user_id)).text(); - let is_room_mention = { room_mention_text == "Notify the entire room" && room_user_id_text == "@room" }; + let is_room_mention = + { room_mention_text == "Notify the entire room" && room_user_id_text == "@room" }; let mention_to_insert = if is_room_mention { // Always set to true, don't reset previously selected @room mentions @@ -708,21 +1145,18 @@ impl MentionableTextInput { let username = selected.label(ids!(user_info.username)).text(); let user_id_str = selected.label(ids!(user_id)).text(); let Ok(user_id): Result = user_id_str.clone().try_into() else { - log!("Failed to parse user_id: {}", user_id_str); + // Invalid user ID format - skip selection return; }; - self.possible_mentions.insert(user_id.clone(), username.clone()); + self.possible_mentions + .insert(user_id.clone(), username.clone()); // Currently, we directly insert the markdown link for user mentions // instead of the user's display name, because we don't yet have a way // to track mentioned display names and replace them later. - format!( - "[{username}]({}) ", - user_id.matrix_to_uri(), - ) + format!("[{username}]({}) ", user_id.matrix_to_uri(),) }; - // Use utility function to safely replace text let new_text = utils::safe_replace_by_byte_indices( ¤t_text, @@ -734,127 +1168,460 @@ impl MentionableTextInput { self.cmd_text_input.set_text(cx, &new_text); // Calculate new cursor position let new_pos = start_idx + mention_to_insert.len(); - text_input_ref.set_cursor(cx, Cursor { index: new_pos, prefer_next_row: false }, false); - + text_input_ref.set_cursor( + cx, + Cursor { + index: new_pos, + prefer_next_row: false, + }, + false, + ); } - self.is_searching = false; - self.current_mention_start_index = None; + self.cancel_active_search(); + self.search_state = MentionSearchState::JustCancelled; + // Clear cleanup flag to prevent next-frame cleanup from interfering with focus + self.pending_popup_cleanup = false; self.close_mention_popup(cx); + // Schedule focus restoration for the draw cycle. + // This will retry until focus is successfully restored, handling cases where + // finger_up events might steal focus after our initial restoration attempt. + self.pending_draw_focus_restore = true; } /// Core text change handler that manages mention context fn handle_text_change(&mut self, cx: &mut Cx, scope: &mut Scope, text: String) { + // If search was just cancelled, clear the flag and don't re-trigger search + if self.is_just_cancelled() { + self.search_state = MentionSearchState::Idle; + return; + } + // Check if text is empty or contains only whitespace let trimmed_text = text.trim(); if trimmed_text.is_empty() { self.possible_mentions.clear(); self.possible_room_mention = false; - if self.is_searching { + if self.is_searching() { self.close_mention_popup(cx); } return; } - let cursor_pos = self.cmd_text_input.text_input_ref().borrow().map_or(0, |p| p.cursor().index); + let cursor_pos = self + .cmd_text_input + .text_input_ref() + .borrow() + .map_or(0, |p| p.cursor().index); // Check if we're currently searching and the @ symbol was deleted - if self.is_searching { - if let Some(start_pos) = self.current_mention_start_index { - // Check if the @ symbol at the start position still exists - if start_pos >= text.len() || text.get(start_pos..start_pos+1).is_some_and(|c| c != "@") { - // The @ symbol was deleted, stop searching - self.close_mention_popup(cx); - return; - } + if let Some(start_pos) = self.get_trigger_position() { + // Check if the @ symbol at the start position still exists + if start_pos >= text.len() + || text.get(start_pos..start_pos + 1).is_some_and(|c| c != "@") + { + // The @ symbol was deleted, stop searching + self.close_mention_popup(cx); + return; } } // Look for trigger position for @ menu if let Some(trigger_pos) = self.find_mention_trigger_position(&text, cursor_pos) { - self.current_mention_start_index = Some(trigger_pos); - self.is_searching = true; + let search_text = + utils::safe_substring_by_byte_indices(&text, trigger_pos + 1, cursor_pos); + + // Check if this is a continuation of existing search or a new one + let is_new_search = self.get_trigger_position() != Some(trigger_pos); - let search_text = utils::safe_substring_by_byte_indices( - &text, - trigger_pos + 1, - cursor_pos - ).to_lowercase(); + if is_new_search { + // This is a new @ mention, reset everything + self.last_search_text = None; + } else { + // User is editing existing mention, don't reset search state + // This allows smooth deletion/modification of search text + // But clear last_search_text if the new text is different to trigger search + if self.last_search_text.as_ref() != Some(&search_text) { + self.last_search_text = None; + } + } // Ensure header view is visible to prevent header disappearing during consecutive @mentions let popup = self.cmd_text_input.view(ids!(popup)); let header_view = self.cmd_text_input.view(ids!(popup.header_view)); header_view.set_visible(cx, true); + // Transition to appropriate state and update user list + // update_user_list will handle state transition properly self.update_user_list(cx, &search_text, scope); + popup.set_visible(cx, true); - } else if self.is_searching { + + // Immediately check for results instead of waiting for next frame + self.check_search_channel(cx, scope); + + // Redraw to ensure UI updates are visible + cx.redraw_all(); + } else if self.is_searching() { self.close_mention_popup(cx); } } - /// Updates the mention suggestion list based on search - fn update_user_list(&mut self, cx: &mut Cx, search_text: &str, scope: &mut Scope) { - // 1. Get Props from Scope - let room_props = scope.props.get::() - .expect("RoomScreenProps should be available in scope for MentionableTextInput"); + /// Check the search channel for new results + /// Returns true if we should continue checking for more results + fn check_search_channel(&mut self, cx: &mut Cx, scope: &mut Scope) -> bool { + // Only check if we're in Searching state + let mut is_complete = false; + let mut search_text: Option> = None; + let mut any_results = false; + let mut should_update_ui = false; + let mut new_results = Vec::new(); + + // Process all available results from the channel + if let MentionSearchState::Searching { + receiver, + accumulated_results, + search_id, + .. + } = &mut self.search_state + { + while let Ok(result) = receiver.try_recv() { + if result.search_id != *search_id { + continue; + } - // 2. Check if members are loading and handle loading state - if self.handle_members_loading_state(cx, &room_props.room_members) { - return; + any_results = true; + search_text = Some(result.search_text.clone()); + is_complete = result.is_complete; + + // Collect results + if !result.results.is_empty() { + new_results.extend(result.results); + should_update_ui = true; + } + } + + if !new_results.is_empty() { + accumulated_results.extend(new_results); + } + } else { + return false; } - // 3. Get room members (we know they exist because handle_members_loading_state returned false) - let room_members = room_props.room_members.as_ref().unwrap(); + // Update UI immediately if we got new results + if should_update_ui { + if matches!( + &self.search_state, + MentionSearchState::Searching { accumulated_results, .. } + if !accumulated_results.is_empty() + ) { + // Results are already sorted in member_search.rs and indices are unique + let query = search_text + .as_ref() + .map(|s| s.as_str()) + .unwrap_or_default(); + self.update_ui_with_results(cx, scope, query); + } + } - // Clear old list items, prepare to populate new list - self.cmd_text_input.clear_items(); + // Handle completion + if is_complete { + self.search_results_pending = false; + // Search is complete - get results for final UI update + if matches!( + &self.search_state, + MentionSearchState::Searching { accumulated_results, .. } + if accumulated_results.is_empty() + ) { + // No user results, but still update UI (may show @room) + let query = search_text + .as_ref() + .map(|s| s.as_str()) + .unwrap_or_default(); + self.update_ui_with_results(cx, scope, query); + } - if !self.is_searching { - return; + // Don't change state here - let update_ui_with_results handle it + } else if !any_results { + // No results received yet - check if channel is still open + let disconnected = + if let MentionSearchState::Searching { receiver, .. } = &self.search_state { + matches!( + receiver.try_recv(), + Err(std::sync::mpsc::TryRecvError::Disconnected) + ) + } else { + false + }; + + if disconnected { + // Channel was closed - search completed or failed + self.search_results_pending = false; + self.handle_search_channel_closed(cx, scope); + // Stop checking - channel is closed, no more results will arrive + return false; + } } + // Return whether we should continue checking for results + !is_complete && matches!(self.search_state, MentionSearchState::Searching { .. }) + } + + /// Common UI update logic for both streaming and non-streaming results + fn update_ui_with_results(&mut self, cx: &mut Cx, scope: &mut Scope, search_text: &str) { + let room_props = scope + .props + .get::() + .expect("RoomScreenProps should be available in scope for MentionableTextInput"); + + // If we're in Searching state, we have local data - always show results + // Don't wait for remote sync to complete + // Remote sync will trigger update when it completes (if data changed) + self.cmd_text_input.clear_items(); + self.loading_indicator_ref = None; + let is_desktop = cx.display_context.is_desktop(); - let max_visible_items = if is_desktop { 10 } else { 5 }; + let max_visible_items: usize = if is_desktop { + DESKTOP_MAX_VISIBLE_ITEMS + } else { + MOBILE_MAX_VISIBLE_ITEMS + }; let mut items_added = 0; // 4. Try to add @room mention item - let has_room_item = self.try_search_messages_mention_item(cx, search_text, room_props, is_desktop); + let has_room_item = self.try_add_room_mention_item(cx, search_text, room_props, is_desktop); if has_room_item { items_added += 1; } - // 5. Find and sort matching members - let max_matched_members = max_visible_items * 2; // Buffer for better UX - let matched_members = self.find_and_sort_matching_members(search_text, room_members, max_matched_members); + // Get accumulated results from current state + let results_to_display = if let MentionSearchState::Searching { + accumulated_results, + .. + } = &self.search_state + { + accumulated_results.clone() + } else { + Vec::new() + }; + + // Add user mention items using the results + if !results_to_display.is_empty() { + let user_items_limit = max_visible_items.saturating_sub(has_room_item as usize); + let user_items_added = self.add_user_mention_items_from_results( + cx, + &results_to_display, + user_items_limit, + is_desktop, + room_props, + ); + items_added += user_items_added; + } + + // If remote sync is still in progress, add loading indicator after results + // This gives visual feedback that more members may be loading + // IMPORTANT: Don't call show_loading_indicator here as it calls clear_items() + // which would remove the user list we just added + if room_props.room_members_sync_pending { + // Add loading indicator widget without clearing existing items + if let Some(ptr) = self.loading_indicator { + let loading_item = WidgetRef::new_from_ptr(cx, Some(ptr)); + self.cmd_text_input.add_item(loading_item.clone()); + self.loading_indicator_ref = Some(loading_item.clone()); + + // Start the loading animation + loading_item + .bouncing_dots(ids!(loading_animation)) + .start_animation(cx); + cx.new_next_frame(); + + items_added += 1; + } + } + + // Update popup visibility based on whether we have items + self.update_popup_visibility(cx, scope, items_added > 0); + + // Force immediate redraw to ensure UI updates are visible + cx.redraw_all(); + } + + /// Updates the mention suggestion list based on search + fn update_user_list(&mut self, cx: &mut Cx, search_text: &str, scope: &mut Scope) { + // Get room_props to read real-time member state from props (single source of truth) + let room_props = scope + .props + .get::() + .expect("RoomScreenProps should be available in scope for MentionableTextInput"); + + // Get trigger position from current state (if in searching mode) + let trigger_pos = match &self.search_state { + MentionSearchState::WaitingForMembers { + trigger_position, .. + } + | MentionSearchState::Searching { + trigger_position, .. + } => *trigger_position, + _ => { + // Not in searching mode, need to determine trigger position + if let Some(pos) = self.find_mention_trigger_position( + &self.cmd_text_input.text_input_ref().text(), + self.cmd_text_input + .text_input_ref() + .borrow() + .map_or(0, |p| p.cursor().index), + ) { + pos + } else { + return; + } + } + }; + + // Skip if search text hasn't changed AND we're already in Searching state + // Don't skip if we're in WaitingForMembers - need to transition to Searching + if self.last_search_text.as_deref() == Some(search_text) { + if matches!(self.search_state, MentionSearchState::Searching { .. }) { + return; // Already searching with same text, skip + } + // In WaitingForMembers with same text -> need to start search now that members arrived + } + + self.last_search_text = Some(search_text.to_string()); + + let is_desktop = cx.display_context.is_desktop(); + let max_visible_items = if is_desktop { + DESKTOP_MAX_VISIBLE_ITEMS + } else { + MOBILE_MAX_VISIBLE_ITEMS + }; + + let cached_members = match &room_props.room_members { + Some(members) if !members.is_empty() => { + // Members available, continue to search + members.clone() + } + _ => { + let already_waiting = matches!( + self.search_state, + MentionSearchState::WaitingForMembers { .. } + ); + + self.cancel_active_search(); + + if !already_waiting { + submit_async_request(MatrixRequest::GetRoomMembers { + room_id: room_props.room_name_id.room_id().clone(), + memberships: RoomMemberships::JOIN, + local_only: true, + }); + } + + self.search_state = MentionSearchState::WaitingForMembers { + trigger_position: trigger_pos, + pending_search_text: search_text.to_string(), + }; + + // Clear old items before showing loading indicator + self.cmd_text_input.clear_items(); + self.show_loading_indicator(cx); + // Request next frame to check when members are loaded + cx.new_next_frame(); + return; // Don't submit search request yet + } + }; - // 6. Add user mention items - let user_items_limit = max_visible_items.saturating_sub(has_room_item as usize); - let user_items_added = self.add_user_mention_items(cx, matched_members, user_items_limit, is_desktop); - items_added += user_items_added; + // We have cached members, ensure popup is visible + let popup = self.cmd_text_input.view(ids!(popup)); + let header_view = self.cmd_text_input.view(ids!(popup.header_view)); + header_view.set_visible(cx, true); + popup.set_visible(cx, true); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } - // 7. Update popup visibility based on whether we have items - self.update_popup_visibility(cx, items_added > 0); + // Create a new channel for this search + let (sender, receiver) = std::sync::mpsc::channel(); + + // Prepare background search job parameters + let search_text_clone = search_text.to_string(); + let max_results = max_visible_items * SEARCH_BUFFER_MULTIPLIER; + let search_id = self.allocate_search_id(); + + // Transition to Searching state with new receiver + self.cancel_active_search(); + let cancel_token = Arc::new(AtomicBool::new(false)); + self.search_state = MentionSearchState::Searching { + trigger_position: trigger_pos, + search_text: search_text.to_string(), + receiver, + accumulated_results: Vec::new(), + search_id, + cancel_token: cancel_token.clone(), + }; + self.search_results_pending = true; + + let precomputed_sort = room_props.room_members_sort.clone(); + let cancel_token_for_job = cancel_token.clone(); + cpu_worker::spawn_cpu_job(cx, CpuJob::SearchRoomMembers(SearchRoomMembersJob { + members: cached_members, + search_text: search_text_clone, + max_results, + sender, + search_id, + precomputed_sort, + cancel_token: Some(cancel_token_for_job), + })); + + // Request next frame to check the channel + cx.new_next_frame(); + + // Try to check immediately for faster response + self.check_search_channel(cx, scope); } /// Detects valid mention trigger positions in text - fn find_mention_trigger_position(&self, text: &str, cursor_pos: usize) -> Option { + fn find_mention_trigger_position(&mut self, text: &str, cursor_pos: usize) -> Option { if cursor_pos == 0 { return None; } + // Check cache and rebuild if text changed (performance optimization) + let (text_graphemes_owned, byte_positions) = if let Some((cached_text, cached_graphemes, cached_positions)) = &self.cached_text_analysis { + if cached_text == text { + // Cache hit - use cached data + (cached_graphemes.clone(), cached_positions.clone()) + } else { + // Cache miss - rebuild and update cache + let graphemes_owned: Vec = text.graphemes(true).map(|s| s.to_string()).collect(); + let positions = utils::build_grapheme_byte_positions(text); + self.cached_text_analysis = Some((text.to_string(), graphemes_owned.clone(), positions.clone())); + (graphemes_owned, positions) + } + } else { + // No cache - build and cache + let graphemes_owned: Vec = text.graphemes(true).map(|s| s.to_string()).collect(); + let positions = utils::build_grapheme_byte_positions(text); + self.cached_text_analysis = Some((text.to_string(), graphemes_owned.clone(), positions.clone())); + (graphemes_owned, positions) + }; + + // Convert owned strings to slices for processing + let text_graphemes: Vec<&str> = text_graphemes_owned.iter().map(|s| s.as_str()).collect(); + // Use utility function to convert byte position to grapheme index let cursor_grapheme_idx = utils::byte_index_to_grapheme_index(text, cursor_pos); - let text_graphemes: Vec<&str> = text.graphemes(true).collect(); - - // Build byte position mapping to facilitate conversion back to byte positions - let byte_positions = utils::build_grapheme_byte_positions(text); // Simple logic: trigger when cursor is immediately after @ symbol // Only trigger if @ is preceded by whitespace or beginning of text if cursor_grapheme_idx > 0 && text_graphemes.get(cursor_grapheme_idx - 1) == Some(&"@") { - let is_preceded_by_whitespace_or_start = cursor_grapheme_idx == 1 || - (cursor_grapheme_idx > 1 && text_graphemes.get(cursor_grapheme_idx - 2).is_some_and(|g| g.trim().is_empty())); + let is_preceded_by_whitespace_or_start = cursor_grapheme_idx == 1 + || (cursor_grapheme_idx > 1 + && text_graphemes + .get(cursor_grapheme_idx - 2) + .is_some_and(|g| g.trim().is_empty())); if is_preceded_by_whitespace_or_start { if let Some(&byte_pos) = byte_positions.get(cursor_grapheme_idx - 1) { return Some(byte_pos); @@ -864,20 +1631,23 @@ impl MentionableTextInput { // Find the last @ symbol before the cursor for search continuation // Only continue if we're already in search mode - if self.is_searching { - let last_at_pos = text_graphemes.get(..cursor_grapheme_idx) - .and_then(|slice| slice.iter() + if self.is_searching() { + let last_at_pos = text_graphemes.get(..cursor_grapheme_idx).and_then(|slice| { + slice + .iter() .enumerate() .filter(|(_, g)| **g == "@") .map(|(i, _)| i) - .next_back()); + .next_back() + }); if let Some(at_idx) = last_at_pos { // Get the byte position of this @ symbol let &at_byte_pos = byte_positions.get(at_idx)?; // Extract the text after the @ symbol up to the cursor position - let mention_text = text_graphemes.get(at_idx + 1..cursor_grapheme_idx) + let mention_text = text_graphemes + .get(at_idx + 1..cursor_grapheme_idx) .unwrap_or(&[]); // Only trigger if this looks like an ongoing mention (contains only alphanumeric and basic chars) @@ -901,116 +1671,36 @@ impl MentionableTextInput { !graphemes.iter().any(|g| g.contains('\n')) } - /// Helper function to check if a user matches the search text - /// Checks both display name and Matrix ID for matching - fn user_matches_search(&self, member: &RoomMember, search_text: &str) -> bool { - let search_text_lower = search_text.to_lowercase(); - - // Check display name - let display_name = member - .display_name() - .map(|n| n.to_string()) - .unwrap_or_else(|| member.user_id().to_string()); - - let display_name_lower = display_name.to_lowercase(); - if display_name_lower.contains(&search_text_lower) { - return true; - } - - // Only match against the localpart (e.g., "mihran" from "@mihran:matrix.org") - // Don't match against the homeserver part to avoid false matches - let localpart = member.user_id().localpart(); - let localpart_lower = localpart.to_lowercase(); - if localpart_lower.contains(&search_text_lower) { - return true; - } - - false - } - - /// Helper function to determine match priority for sorting - /// Lower values = higher priority (better matches shown first) - fn get_match_priority(&self, member: &RoomMember, search_text: &str) -> u8 { - let search_text_lower = search_text.to_lowercase(); - - let display_name = member - .display_name() - .map(|n| n.to_string()) - .unwrap_or_else(|| member.user_id().to_string()); - - let display_name_lower = display_name.to_lowercase(); - let localpart = member.user_id().localpart(); - let localpart_lower = localpart.to_lowercase(); - - // Priority 0: Exact case-sensitive match (highest priority) - if display_name == search_text || localpart == search_text { - return 0; - } - - // Priority 1: Exact match (case-insensitive) - if display_name_lower == search_text_lower || localpart_lower == search_text_lower { - return 1; - } - - // Priority 2: Case-sensitive prefix match - if display_name.starts_with(search_text) || localpart.starts_with(search_text) { - return 2; - } - - // Priority 3: Display name starts with search text (case-insensitive) - if display_name_lower.starts_with(&search_text_lower) { - return 3; - } - - // Priority 4: Localpart starts with search text (case-insensitive) - if localpart_lower.starts_with(&search_text_lower) { - return 4; - } - - // Priority 5: Display name contains search text at word boundary - if let Some(pos) = display_name_lower.find(&search_text_lower) { - // Check if it's at the start of a word (preceded by space or at start) - if pos == 0 || display_name_lower.chars().nth(pos - 1) == Some(' ') { - return 5; - } - } - - // Priority 6: Localpart contains search text at word boundary - if let Some(pos) = localpart_lower.find(&search_text_lower) { - // Check if it's at the start of a word (preceded by non-alphanumeric or at start) - if pos == 0 || !localpart_lower.chars().nth(pos - 1).unwrap_or('a').is_alphanumeric() { - return 6; - } - } - - // Priority 7: Display name contains search text (anywhere) - if display_name_lower.contains(&search_text_lower) { - return 7; - } - - // Priority 8: Localpart contains search text (anywhere) - if localpart_lower.contains(&search_text_lower) { - return 8; + /// Shows the loading indicator when waiting for initial members to be loaded + fn show_loading_indicator(&mut self, cx: &mut Cx) { + // Check if we already have a loading indicator displayed + // Avoid recreating it on every call, which would prevent animation from playing + if let Some(ref existing_indicator) = self.loading_indicator_ref { + // Already showing, just ensure animation is running + existing_indicator + .bouncing_dots(ids!(loading_animation)) + .start_animation(cx); + cx.new_next_frame(); + return; } - // Should not reach here if user_matches_search returned true - u8::MAX - } - - /// Shows the loading indicator when members are being fetched - fn show_loading_indicator(&mut self, cx: &mut Cx) { - // Clear any existing items + // Clear old items before creating new loading indicator self.cmd_text_input.clear_items(); - // Create loading indicator widget - let Some(ptr) = self.loading_indicator else { return }; + // Create fresh loading indicator widget + let Some(ptr) = self.loading_indicator else { + return; + }; let loading_item = WidgetRef::new_from_ptr(cx, Some(ptr)); // Start the loading animation loading_item.bouncing_dots(ids!(loading_animation)).start_animation(cx); - // Add the loading indicator to the popup - self.cmd_text_input.add_item(loading_item); + // Now that the widget is in the UI tree, start the loading animation + loading_item + .bouncing_dots(ids!(loading_animation)) + .start_animation(cx); + cx.new_next_frame(); // Setup popup dimensions for loading state let popup = self.cmd_text_input.view(ids!(popup)); @@ -1023,8 +1713,9 @@ impl MentionableTextInput { // This avoids conflicts with list = { height: Fill } popup.set_visible(cx, true); - // Maintain text input focus - if self.is_searching { + // Maintain text input focus only if it currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if self.is_searching() && cx.has_key_focus(text_input_area) { self.cmd_text_input.text_input_ref().set_key_focus(cx); } } @@ -1035,11 +1726,14 @@ impl MentionableTextInput { self.cmd_text_input.clear_items(); // Create no matches indicator widget - let Some(ptr) = self.no_matches_indicator else { return }; + let Some(ptr) = self.no_matches_indicator else { + return; + }; let no_matches_item = WidgetRef::new_from_ptr(cx, Some(ptr)); // Add the no matches indicator to the popup self.cmd_text_input.add_item(no_matches_item); + self.loading_indicator_ref = None; // Setup popup dimensions for no matches state let popup = self.cmd_text_input.view(ids!(popup)); @@ -1051,20 +1745,85 @@ impl MentionableTextInput { // Let popup auto-size based on content popup.apply_over(cx, live! { height: Fit }); - // Maintain text input focus so user can continue typing - if self.is_searching { + // Maintain text input focus so user can continue typing, but only if currently focused + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if self.is_searching() && cx.has_key_focus(text_input_area) { self.cmd_text_input.text_input_ref().set_key_focus(cx); } } - /// Cleanup helper for closing mention popup - fn close_mention_popup(&mut self, cx: &mut Cx) { - self.current_mention_start_index = None; - self.is_searching = false; - self.members_loading = false; // Reset loading state when closing popup + /// Check if mention search is currently active + pub fn is_mention_searching(&self) -> bool { + self.is_searching() + } + + /// Check if ESC was handled by mention popup + pub fn handled_escape(&self) -> bool { + self.is_just_cancelled() + } + + /// Handle search channel closed event + fn handle_search_channel_closed(&mut self, cx: &mut Cx, scope: &mut Scope) { + // Get accumulated results before changing state + let has_results = if let MentionSearchState::Searching { + accumulated_results, + .. + } = &self.search_state + { + !accumulated_results.is_empty() + } else { + false + }; + + // If no results were shown, show empty state + if !has_results { + self.update_ui_with_results(cx, scope, ""); + } + + // Keep searching state but mark search as complete + // The state will be reset when user types or closes popup + } + + fn cancel_active_search(&mut self) { + match &self.search_state { + MentionSearchState::Searching { cancel_token, .. } => { + cancel_token.store(true, Ordering::Relaxed); + } + MentionSearchState::WaitingForMembers { .. } => { + // WaitingForMembers has no cancel_token, but we need to mark as cancelled. + // The state will be set to JustCancelled by the caller, which prevents + // RoomMembersLoaded from reopening the popup. + } + _ => {} + } + self.search_results_pending = false; + } + + /// Reset all search-related state + fn reset_search_state(&mut self) { + self.cancel_active_search(); - // Clear list items to avoid keeping old content when popup is shown again + // Reset to idle state + self.search_state = MentionSearchState::Idle; + + // Reset last search text to allow new searches + self.last_search_text = None; + self.search_results_pending = false; + self.loading_indicator_ref = None; + + // Reset change detection state + self.last_member_count = 0; + self.last_sync_pending = false; + self.pending_popup_cleanup = false; + + // Clear list items self.cmd_text_input.clear_items(); + } + + /// Cleanup helper for closing mention popup + fn close_mention_popup(&mut self, cx: &mut Cx) { + // Reset all search-related state + self.reset_search_state(); // Get popup and header view references let popup = self.cmd_text_input.view(ids!(popup)); @@ -1083,8 +1842,10 @@ impl MentionableTextInput { // Ensure header view is reset to visible next time it's triggered // This will happen before update_user_list is called in handle_text_change - self.cmd_text_input.request_text_input_focus(); - self.redraw(cx); + // Note: Do NOT call request_text_input_focus() here. + // Focus restoration is handled solely via `pending_draw_focus_restore` in draw_walk + // to avoid race conditions between multiple focus mechanisms. + self.cmd_text_input.redraw(cx); } /// Returns the current text content @@ -1095,12 +1856,9 @@ impl MentionableTextInput { /// Sets the text content pub fn set_text(&mut self, cx: &mut Cx, text: &str) { self.cmd_text_input.text_input_ref().set_text(cx, text); - self.redraw(cx); + self.cmd_text_input.redraw(cx); } - - - /// Sets whether the current user can notify the entire room (@room mention) pub fn set_can_notify_room(&mut self, can_notify: bool) { self.can_notify_room = can_notify; @@ -1110,8 +1868,6 @@ impl MentionableTextInput { pub fn can_notify_room(&self) -> bool { self.can_notify_room } - - } impl MentionableTextInputRef { @@ -1126,13 +1882,23 @@ impl MentionableTextInputRef { .unwrap_or_default() } + /// Check if mention search is currently active + pub fn is_mention_searching(&self) -> bool { + self.borrow() + .is_some_and(|inner| inner.is_mention_searching()) + } + + /// Check if ESC was handled by mention popup + pub fn handled_escape(&self) -> bool { + self.borrow().is_some_and(|inner| inner.handled_escape()) + } + pub fn set_text(&self, cx: &mut Cx, text: &str) { if let Some(mut inner) = self.borrow_mut() { inner.set_text(cx, text); } } - /// Sets whether the current user can notify the entire room (@room mention) pub fn set_can_notify_room(&self, can_notify: bool) { if let Some(mut inner) = self.borrow_mut() { @@ -1145,7 +1911,6 @@ impl MentionableTextInputRef { self.borrow().is_some_and(|inner| inner.can_notify_room()) } - /// Returns the mentions actually present in the given html message content. fn get_real_mentions_in_html_text(&self, html: &str) -> Mentions { let mut mentions = Mentions::new(); @@ -1211,5 +1976,4 @@ impl MentionableTextInputRef { message.add_mentions(self.get_real_mentions_in_markdown_text(entered_text)) } } - } diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 044d49ee..10a937d1 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -19,7 +19,6 @@ pub mod unread_badge; pub mod verification_badge; pub mod restore_status_view; - pub fn live_design(cx: &mut Cx) { // Order matters here, as some widget definitions depend on others. styles::live_design(cx); diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index dfd15fe8..a2f829ea 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -26,12 +26,28 @@ use tokio::{ sync::{mpsc::{Sender, UnboundedReceiver, UnboundedSender}, watch, Notify}, task::JoinHandle, time::error::Elapsed, }; use url::Url; -use std::{cmp::{max, min}, collections::{BTreeMap, BTreeSet}, future::Future, iter::Peekable, ops::{Deref, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; +use std::{cmp::{max, min}, collections::{BTreeMap, BTreeSet}, future::Future, hash::{Hash, Hasher}, iter::Peekable, ops::{Deref, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; +use std::collections::hash_map::DefaultHasher; use std::io; use crate::{ - app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::text_preview_of_timeline_item, home::{ - invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::TimelineUpdate, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails - }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ + app::AppStateAction, + app_data_dir, + avatar_cache::AvatarUpdate, + event_preview::text_preview_of_timeline_item, + home::{ + invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, + link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, + room_screen::TimelineUpdate, + rooms_list::{self, enqueue_rooms_list_update, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate}, + rooms_list_header::RoomsListHeaderAction, + tombstone_footer::SuccessorRoomDetails, + }, + room::member_search::precompute_member_sort, + login::login_screen::LoginAction, + logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{is_logout_in_progress, logout_with_state_machine, LogoutConfig}}, + media_cache::{MediaCacheEntry, MediaCacheEntryRef}, + persistence::{self, load_app_state, ClientSessionPersisted}, + profile::{ user_profile::{AvatarState, UserProfile}, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ @@ -665,8 +681,42 @@ async fn matrix_worker_task( log!("Sending sync room members request for room {room_id}..."); timeline.fetch_members().await; log!("Completed sync room members request for room {room_id}."); - sender.send(TimelineUpdate::RoomMembersSynced).unwrap(); - SignalToUI::set_ui_signal(); + + match timeline.room().members(RoomMemberships::JOIN).await { + Ok(members) => { + let count = members.len(); + let digest = compute_members_digest(&members); + let mut digests = match ROOM_MEMBERS_DIGESTS.lock() { + Ok(lock) => lock, + Err(err) => { + warning!("ROOM_MEMBERS_DIGESTS lock poisoned for room {room_id}: {err}"); + return; + } + }; + let previous = digests.get(&room_id).copied(); + if previous != Some(digest) { + digests.insert(room_id.clone(), digest); + log!("Fetched {count} members for room {room_id} after sync."); + let sort = precompute_member_sort(&members); + if let Err(err) = sender.send(TimelineUpdate::RoomMembersListFetched { members, sort, is_local_fetch: false }) { + warning!("Failed to send RoomMembersListFetched update for room {room_id}: {err:?}"); + } else { + SignalToUI::set_ui_signal(); + } + } else { + log!("Fetched {count} members for room {room_id} after sync (no change, skipping RoomMembersListFetched)."); + } + } + Err(err) => { + warning!("Failed to fetch room members from server for room {room_id}: {err:?}"); + } + } + + if let Err(err) = sender.send(TimelineUpdate::RoomMembersSynced) { + warning!("Failed to send RoomMembersSynced update for room {room_id}: {err:?}"); + } else { + SignalToUI::set_ui_signal(); + } }); } @@ -745,10 +795,31 @@ async fn matrix_worker_task( let send_update = |members: Vec, source: &str| { log!("{} {} members for room {}", source, members.len(), room_id); - sender.send(TimelineUpdate::RoomMembersListFetched { - members - }).unwrap(); - SignalToUI::set_ui_signal(); + let digest = compute_members_digest(&members); + + let mut digests = match ROOM_MEMBERS_DIGESTS.lock() { + Ok(lock) => lock, + Err(err) => { + warning!("ROOM_MEMBERS_DIGESTS lock poisoned for room {room_id}: {err}"); + return; + } + }; + if digests.get(&room_id) == Some(&digest) { + log!("{source} members for room {room_id} (no change, skipping RoomMembersListFetched)."); + return; + } + digests.insert(room_id.clone(), digest); + + let sort = precompute_member_sort(&members); + if let Err(err) = sender.send(TimelineUpdate::RoomMembersListFetched { + members, + sort, + is_local_fetch: true, + }) { + warning!("Failed to send RoomMembersListFetched update for room {room_id}: {err:?}"); + } else { + SignalToUI::set_ui_signal(); + } }; if local_only { @@ -1384,7 +1455,7 @@ async fn matrix_worker_task( error!("Matrix client not available for URL preview: {}", url); UrlPreviewError::ClientNotAvailable })?; - + let token = client.access_token().ok_or_else(|| { error!("Access token not available for URL preview: {}", url); UrlPreviewError::AccessTokenNotAvailable @@ -1394,7 +1465,6 @@ async fn matrix_worker_task( let endpoint_url = client.homeserver().join("/_matrix/client/v1/media/preview_url") .map_err(UrlPreviewError::UrlParse)?; log!("Fetching URL preview from endpoint: {} for URL: {}", endpoint_url, url); - let response = client .http_client() .get(endpoint_url.clone()) @@ -1407,20 +1477,19 @@ async fn matrix_worker_task( error!("HTTP request failed for URL preview {}: {}", url, e); UrlPreviewError::Request(e) })?; - let status = response.status(); log!("URL preview response status for {}: {}", url, status); - + if !status.is_success() && status.as_u16() != 429 { error!("URL preview request failed with status {} for URL: {}", status, url); return Err(UrlPreviewError::HttpStatus(status.as_u16())); } - + let text = response.text().await.map_err(|e| { error!("Failed to read response text for URL preview {}: {}", url, e); UrlPreviewError::Request(e) })?; - + log!("URL preview response body length for {}: {} bytes", url, text.len()); if text.len() > MAX_LOG_RESPONSE_BODY_LENGTH { log!("URL preview response body preview for {}: {}...", url, &text[..MAX_LOG_RESPONSE_BODY_LENGTH]); @@ -1444,7 +1513,6 @@ async fn matrix_worker_task( destination: destination.clone(), update_sender: update_sender.clone(), }); - } } Err(e) => { @@ -1468,7 +1536,7 @@ async fn matrix_worker_task( match &result { Ok(preview_data) => { - log!("Successfully fetched URL preview for {}: title={:?}, site_name={:?}", + log!("Successfully fetched URL preview for {}: title={:?}, site_name={:?}", url, preview_data.title, preview_data.site_name); } Err(e) => { @@ -1616,6 +1684,42 @@ impl Drop for JoinedRoomDetails { /// Information about all joined rooms that our client currently know about. static ALL_JOINED_ROOMS: Mutex> = Mutex::new(BTreeMap::new()); +static ROOM_MEMBERS_DIGESTS: Mutex> = Mutex::new(BTreeMap::new()); + +fn power_rank_for_member(member: &RoomMember) -> u8 { + use matrix_sdk::room::RoomMemberRole; + match member.suggested_role_for_power_level() { + RoomMemberRole::Administrator | RoomMemberRole::Creator => 0, + RoomMemberRole::Moderator => 1, + RoomMemberRole::User => 2, + } +} + +fn compute_members_digest(members: &[RoomMember]) -> u64 { + let mut hasher = DefaultHasher::new(); + for member in members { + member.user_id().hash(&mut hasher); + if let Some(name) = member.display_name() { + name.hash(&mut hasher); + } + if let Some(avatar) = member.avatar_url() { + avatar.as_str().hash(&mut hasher); + } + power_rank_for_member(member).hash(&mut hasher); + } + hasher.finish() +} + +/// Clears the cached room members digests. +/// +/// This must be called during logout to ensure that after re-login, +/// the room members will be properly fetched and sent to the UI, +/// even if the member list hasn't changed. +pub fn clear_room_members_digests() { + if let Ok(mut digests) = ROOM_MEMBERS_DIGESTS.lock() { + digests.clear(); + } +} /// The logged-in Matrix client, which can be freely and cheaply cloned. static CLIENT: Mutex> = Mutex::new(None); @@ -2279,7 +2383,7 @@ async fn update_room( // Then, we check for changes to room data that is only relevant to joined rooms: // including the latest event, tags, unread counts, is_direct, tombstoned state, power levels, etc. // Invited or left rooms don't care about these details. - if matches!(new_room.state, RoomState::Joined) { + if matches!(new_room.state, RoomState::Joined) { // For some reason, the latest event API does not reliably catch *all* changes // to the latest event in a given room, such as redactions. // Thus, we have to re-obtain the latest event on *every* update, regardless of timestamp. @@ -2303,11 +2407,6 @@ async fn update_room( if old_room.num_unread_messages != new_room.num_unread_messages || old_room.num_unread_mentions != new_room.num_unread_mentions { - log!("Updating room {}, unread messages {} --> {}, unread mentions {} --> {}", - new_room_id, - old_room.num_unread_messages, new_room.num_unread_messages, - old_room.num_unread_mentions, new_room.num_unread_mentions, - ); enqueue_rooms_list_update(RoomsListUpdate::UpdateNumUnreadMessages { room_id: new_room_id.clone(), unread_messages: UnreadMessageCount::Known(new_room.num_unread_messages), @@ -2645,7 +2744,7 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { SYNC_INDICATOR_DELAY, SYNC_INDICATOR_HIDE_DELAY ); - + Handle::current().spawn(async move { let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); @@ -3470,18 +3569,180 @@ pub fn shutdown_background_tasks() { } } +/// Validates that a path is safe to delete by ensuring it is strictly within the app data directory. +/// +/// # Security +/// +/// This function prevents arbitrary directory deletion attacks where a malicious or corrupted +/// session file could specify a path outside the app's data directory (e.g., `~` or `/`). +/// +/// The validation performs the following checks: +/// 1. Canonicalizes both the target path and the app data directory to resolve symlinks +/// 2. Verifies the target path starts with the app data directory prefix +/// 3. Ensures the path is not the app data directory itself (must be a subdirectory) +/// +/// # Returns +/// +/// - `Ok(PathBuf)` - The canonicalized path if it's safe to delete +/// - `Err(io::Error)` - If the path is outside the sandbox or canonicalization fails +fn validate_path_within_app_data(path: &Path) -> io::Result { + // Get the app data directory and canonicalize it + let app_data = app_data_dir(); + let canonical_app_data = app_data.canonicalize().map_err(|e| { + io::Error::new( + io::ErrorKind::NotFound, + format!("Failed to canonicalize app data directory: {}", e), + ) + })?; + + // Canonicalize the target path to resolve any symlinks or relative components + // This prevents symlink attacks where the path appears to be inside app_data + // but actually points elsewhere + let canonical_path = path.canonicalize().map_err(|e| { + io::Error::new( + io::ErrorKind::NotFound, + format!("Failed to canonicalize target path {:?}: {}", path, e), + ) + })?; + + // Verify the canonical path starts with the canonical app data directory + if !canonical_path.starts_with(&canonical_app_data) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "Security: Path {:?} is outside app data directory {:?}", + canonical_path, canonical_app_data + ), + )); + } + + // Ensure we're not deleting the app data directory itself + if canonical_path == canonical_app_data { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Security: Cannot delete the app data directory itself", + )); + } + + // Additional check: ensure the path has at least one more component after app_data + // This prevents edge cases with trailing slashes or dot components + let relative = canonical_path.strip_prefix(&canonical_app_data).map_err(|_| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "Security: Failed to compute relative path within app data", + ) + })?; + + if relative.as_os_str().is_empty() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Security: Path resolves to app data directory itself", + )); + } + + Ok(canonical_path) +} + +/// Clears all application state during logout, including the Matrix SDK database. +/// +/// # Why Delete the Matrix SDK Database? +/// +/// Each login creates a **new database directory** with a timestamp-based name +/// (e.g., `db_2025-12-12_10_30_45_123456`). This database stores: +/// - Encryption keys and device information +/// - Room state and membership caches +/// - Message history and sync tokens +/// +/// If we don't delete the old database on logout: +/// 1. **Stale data leakage**: Old room member lists may appear after re-login +/// 2. **Disk space accumulation**: Each login creates a new directory, old ones never cleaned +/// 3. **Data confusion**: Some code paths might read cached data from previous sessions, +/// causing issues like showing wrong user's room members in @mention search +/// +/// # Security Note +/// +/// The database path is read from a session file that could potentially be tampered with. +/// Before deletion, the path is validated via [`validate_path_within_app_data`] to ensure +/// it is strictly within the app's data directory, preventing arbitrary directory deletion. pub async fn clear_app_state(config: &LogoutConfig) -> Result<()> { + // Get the database path before clearing CLIENT + // We need this to delete the Matrix SDK database after logout + let db_path_to_delete = if let Some(user_id) = current_user_id() { + use crate::persistence::session_file_path; + let session_file = session_file_path(&user_id); + if session_file.exists() { + match tokio::fs::read_to_string(&session_file).await { + Ok(serialized_session) => { + match serde_json::from_str::(&serialized_session) { + Ok(session) => { + log!("Will delete Matrix SDK database at: {:?}", session.client_session.db_path); + Some(session.client_session.db_path) + } + Err(e) => { + log!("Failed to parse session file during logout: {}", e); + None + } + } + } + Err(e) => { + log!("Failed to read session file during logout: {}", e); + None + } + } + } else { + log!("Session file not found during logout"); + None + } + } else { + log!("No current user ID found during logout"); + None + }; + // Clear resources normally, allowing them to be properly dropped // This prevents memory leaks when users logout and login again without closing the app CLIENT.lock().unwrap().take(); + log!("Client cleared during logout"); + SYNC_SERVICE.lock().unwrap().take(); + log!("Sync service cleared during logout"); + REQUEST_SENDER.lock().unwrap().take(); + log!("Request sender cleared during logout"); + IGNORED_USERS.lock().unwrap().clear(); ALL_JOINED_ROOMS.lock().unwrap().clear(); + // Delete the Matrix SDK database directory to ensure fresh state on next login + // This prevents stale cached data (like old room members) from appearing after re-login + // + // SECURITY: The db_path comes from a session file which could be tampered with. + // We MUST validate that the path is strictly within our app data directory + // before performing any deletion to prevent arbitrary directory deletion attacks. + if let Some(db_path) = db_path_to_delete { + match validate_path_within_app_data(&db_path) { + Ok(validated_path) => { + match tokio::fs::remove_dir_all(&validated_path).await { + Ok(_) => { + log!("Successfully deleted Matrix SDK database at: {:?}", validated_path); + } + Err(e) => { + // Don't fail logout if database deletion fails + // Just log the error and continue + log!("Warning: Failed to delete Matrix SDK database at {:?}: {}", validated_path, e); + } + } + } + Err(e) => { + // Security validation failed - do NOT delete the path + // This could indicate a tampered session file or symlink attack + error!("Security: Refusing to delete database path {:?}: {}", db_path, e); + } + } + } + let on_clear_appstate = Arc::new(Notify::new()); Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); - + match tokio::time::timeout(config.app_state_cleanup_timeout, on_clear_appstate.notified()).await { Ok(_) => { log!("Received signal that UI-side app state was cleaned successfully");