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/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/room_screen.rs b/src/home/room_screen.rs index 6e6312d9..dde98bc3 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::{precompute_member_sort, PrecomputedMemberSort}, room_input_bar::RoomInputBarState, typing_notice::TypingNoticeWidgetExt}, shared::{ avatar::AvatarWidgetRefExt, callout_tooltip::TooltipAction, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{enqueue_popup_notification, PopupItem, PopupKind}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt }, @@ -491,7 +491,7 @@ live_design! { draw_bg: { color: (COLOR_PRIMARY_DARKER) } - + restore_status_view = {} // Widgets within this view will get shifted upwards when the on-screen keyboard is shown. @@ -792,6 +792,8 @@ 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; // Fetch room data once to avoid duplicate expensive lookups let (room_display_name, room_avatar_url) = get_client() @@ -806,6 +808,8 @@ impl Widget for RoomScreen { room_screen_widget_uid, room_id, room_members, + room_members_sort, + room_members_sync_pending, room_display_name, room_avatar_url, } @@ -815,6 +819,8 @@ impl Widget for RoomScreen { room_screen_widget_uid, room_id, room_members: None, + room_members_sort: None, + room_members_sync_pending: false, room_display_name: None, room_avatar_url: None, } @@ -829,6 +835,8 @@ impl Widget for RoomScreen { room_screen_widget_uid, room_id: 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, } @@ -1325,11 +1333,31 @@ 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; + + // Notify MentionableTextInput that sync is complete + cx.action(MentionableTextInputAction::RoomMembersLoaded { + room_id: tl.room_id.clone(), + sync_in_progress: false, + }); } TimelineUpdate::RoomMembersListFetched { members } => { - // Store room members directly in TimelineUiState + // 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 = precompute_member_sort(&members); tl.room_members = Some(Arc::new(members)); - }, + tl.room_members_sort = Some(Arc::new(sort_data)); + // Notify with current sync state, don't modify it here + cx.action(MentionableTextInputAction::RoomMembersLoaded { + room_id: tl.room_id.clone(), + sync_in_progress: tl.room_members_sync_pending, + }); + } 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, @@ -1915,7 +1943,6 @@ impl RoomScreen { // and search our locally-known timeline history for the replied-to message. } self.redraw(cx); - } /// Shows the user profile sliding pane with the given avatar info. @@ -1974,6 +2001,8 @@ 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, // We assume timelines being viewed for the first time haven't been fully paginated. fully_paginated: false, items: Vector::new(), @@ -2034,6 +2063,16 @@ impl RoomScreen { // 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. + tl_state.room_members_sync_pending = true; + submit_async_request(MatrixRequest::SyncRoomMemberList { room_id: room_id.clone() }); + } else if tl_state + .room_members + .as_ref() + .map(|members| members.is_empty()) + .unwrap_or(true) + { + // Room reopened but we lack cached members; trigger a sync to refresh data. + tl_state.room_members_sync_pending = true; submit_async_request(MatrixRequest::SyncRoomMemberList { room_id: room_id.clone() }); } @@ -2044,7 +2083,7 @@ impl RoomScreen { // show/hide UI elements based on the user's permissions. // 2. Get the list of members in this room (from the SDK's local cache). // 3. Subscribe to our own user's read receipts so that we can update the - // read marker and properly send read receipts while scrolling through the timeline. + // read marker and properly send read receipts while scrolling through the timeline. // 4. Subscribe to typing notices again, now that the room is being shown. if self.is_loaded { submit_async_request(MatrixRequest::GetRoomPowerLevels { @@ -2053,10 +2092,9 @@ impl RoomScreen { 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. + // 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, @@ -2126,8 +2164,10 @@ impl RoomScreen { room_input_bar_state: self.room_input_bar(id!(room_input_bar)).save_state(), }; tl.saved_state = state; - // Clear room_members to avoid wasting memory (in case this room is never re-opened). + // Clear cached room member data to avoid wasting memory (in case this room is never re-opened). tl.room_members = None; + tl.room_members_sort = None; + tl.room_members_sync_pending = false; // Store this Timeline's `TimelineUiState` in the global map of states. TIMELINE_STATES.with_borrow_mut(|ts| ts.insert(tl.room_id.clone(), tl)); } @@ -2308,6 +2348,8 @@ pub struct RoomScreenProps { pub room_screen_widget_uid: WidgetUid, pub room_id: OwnedRoomId, 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, } @@ -2459,6 +2501,10 @@ 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 this room's timeline has been fully paginated, which means /// that the oldest (first) event in the timeline is locally synced and available. @@ -3210,30 +3256,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, @@ -3331,7 +3382,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 { @@ -4167,10 +4218,10 @@ 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| { states.clear(); }); -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 0f32ea84..84e9acdd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,7 @@ pub mod avatar_cache; pub mod media_cache; pub mod verification; +pub mod cpu_worker; pub mod utils; pub mod temp_storage; pub mod location; diff --git a/src/room/member_search.rs b/src/room/member_search.rs new file mode 100644 index 00000000..5294aec2 --- /dev/null +++ b/src/room/member_search.rs @@ -0,0 +1,1222 @@ +//! 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 in background thread with streaming support (backward compatible) +pub fn search_room_members_streaming( + members: Arc>, + search_text: String, + max_results: usize, + sender: Sender, + search_id: u64, +) { + search_room_members_streaming_with_sort( + members, + search_text, + max_results, + sender, + search_id, + None, + None, + ) +} + +/// 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(); + + if is_cancelled(&cancel_token) { + return; + } + + let search_text_arc = Arc::new(search_text); + 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 => 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 => 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 79185b4f..ae016742 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 2d2a91f8..a4bba155 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -6,20 +6,71 @@ 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, // Kept for debugging/future use + 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,59 +337,113 @@ 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, + }, } /// 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, + /// Tracks whether we have a populated member list to avoid showing empty-state too early + #[rust] + members_available: 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, + /// Whether the room is still syncing its full member list + #[rust] + members_sync_pending: bool, + /// Active loading indicator widget while we wait for members/results + #[rust] + loading_indicator_ref: Option, } - 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; + self.close_mention_popup(cx); + 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_id - .clone(); + let scope_room_id = { + let room_props = scope + .props + .get::() + .expect("RoomScreenProps should be available in scope for MentionableTextInput"); + self.members_sync_pending = room_props.room_members_sync_pending; + room_props.room_id.clone() + }; + + // 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(); + } + } + } if let Event::Actions(actions) = event { let text_input_ref = self.cmd_text_input.text_input_ref(); @@ -346,6 +451,9 @@ impl Widget for MentionableTextInput { let text_input_area = text_input_ref.area(); let has_focus = cx.has_key_focus(text_input_area); + // ESC key is now handled in the main event handler using KeyUp event + // This avoids conflicts with escaped() method being consumed by other components + // Handle item selection from mention popup if let Some(selected) = self.cmd_text_input.item_selected(actions) { self.on_user_selected(cx, scope, selected); @@ -354,8 +462,15 @@ impl Widget for MentionableTextInput { // Handle build items request if self.cmd_text_input.should_build_items(actions) { if has_focus { - let search_text = self.cmd_text_input.search_text().to_lowercase(); - self.update_user_list(cx, &search_text, scope); + // Only update if we're still searching + if self.is_searching() { + let search_text = self.cmd_text_input.search_text(); + self.update_user_list(cx, &search_text, scope); + } + // TODO: Replace direct access to internal popup view with public API method + // Suggested improvement: Use self.cmd_text_input.is_popup_visible() instead + // This requires adding is_popup_visible() method to CommandTextInput in makepad + // See: https://github.com/makepad/makepad/widgets/src/command_text_input.rs } else if self.cmd_text_input.view(id!(popup)).visible() { self.close_mention_popup(cx); } @@ -376,46 +491,92 @@ 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; + } - 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); + 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.cmd_text_input.redraw(cx); + } + } + } + MentionableTextInputAction::RoomMembersLoaded { + room_id, + sync_in_progress, + } => { + if &scope_room_id != room_id { + continue; + } + + let room_props = scope + .props + .get::() + .expect("RoomScreenProps should be available in scope"); + let has_members = room_props + .room_members + .as_ref() + .is_some_and(|members| !members.is_empty()); + + // Trust the sync state from room_screen, don't override based on member count + self.members_sync_pending = *sync_in_progress; + self.members_available = has_members; + + if self.members_available && self.is_searching() { + // Force a fresh search now that members are available + let search_text = self.cmd_text_input.search_text(); + 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(id!(popup)); + popup.set_visible(cx, true); + 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(id!(popup)).visible() { + // Close popup if focus is lost while searching + if !has_focus && self.is_searching() { self.close_mention_popup(cx); } } // Check if we were waiting for members and they're now available - if self.members_loading && self.is_searching { + if let MentionSearchState::WaitingForMembers { + trigger_position: _, + pending_search_text, + } = &self.search_state + { let room_props = scope .props .get::() .expect("RoomScreenProps should be available in scope"); + self.members_sync_pending = room_props.room_members_sync_pending; 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(id!(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(); + let search_text = pending_search_text.clone(); self.update_user_list(cx, &search_text, scope); } } @@ -428,42 +589,44 @@ impl Widget for MentionableTextInput { } } - 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(id!(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. @@ -480,14 +643,17 @@ impl MentionableTextInput { 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; let avatar_ref = room_mention_item.avatar(id!(user_info.room_avatar)); // Get room avatar fallback text from room display name - let room_name_first_char = room_props.room_display_name + let room_name_first_char = room_props + .room_display_name .as_ref() .and_then(|name| name.graphemes(true).next().map(|s| s.to_uppercase())) .filter(|s| s != "@" && s.chars().all(|c| c.is_alphabetic())) @@ -502,105 +668,109 @@ 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(id!(user_info.username)).set_text(cx, &display_name); + item.label(id!(user_info.username)) + .set_text(cx, &display_name); // Use the full user ID string let user_id_str = member.user_id().as_str(); @@ -657,25 +827,49 @@ impl MentionableTextInput { items_added } - /// Update popup visibility and layout + /// Update popup visibility and layout based on current state fn update_popup_visibility(&mut self, cx: &mut Cx, has_items: bool) { let popup = self.cmd_text_input.view(id!(popup)); - if has_items { - popup.set_visible(cx, true); - if self.is_searching { + 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); 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); + MentionSearchState::Searching { + accumulated_results, + .. + } => { + if has_items { + // We have search results to display + popup.set_visible(cx, true); + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } else if accumulated_results.is_empty() { + if self.members_sync_pending || self.search_results_pending { + // Still fetching either member list or background search results. + self.show_loading_indicator(cx); + } else if self.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); + 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); + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } + } } } @@ -689,12 +883,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(id!(user_info.room_mention)); let room_mention_text = room_mention_label.text(); let room_user_id_text = selected.label(id!(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 @@ -705,21 +900,18 @@ impl MentionableTextInput { let username = selected.label(id!(user_info.username)).text(); let user_id_str = selected.label(id!(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, @@ -731,107 +923,411 @@ 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; self.close_mention_popup(cx); } /// 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(id!(popup)); let header_view = self.cmd_text_input.view(id!(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 { + // Get accumulated results from state for UI update + let results_for_ui = if let MentionSearchState::Searching { + accumulated_results, + .. + } = &self.search_state + { + accumulated_results.clone() + } else { + Vec::new() + }; - // Clear old list items, prepare to populate new list - self.cmd_text_input.clear_items(); + if !results_for_ui.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); + } + } + + // Handle completion + if is_complete { + self.search_results_pending = false; + // Search is complete - get results for final UI update + let final_results = if let MentionSearchState::Searching { + accumulated_results, + .. + } = &self.search_state + { + accumulated_results.clone() + } else { + Vec::new() + }; + + if final_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); + } + + // 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); + } + } - if !self.is_searching { + // 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"); + + // Check if we still need to show loading indicator + // Show loading while sync is in progress, regardless of partial member data + // room_screen will clear members_sync_pending when sync completes + let still_loading = self.members_sync_pending; + + if still_loading { + // Don't clear items if we're going to show loading again + // Just ensure loading indicator is showing + if self.loading_indicator_ref.is_none() { + self.cmd_text_input.clear_items(); + self.show_loading_indicator(cx); + } + self.cmd_text_input.text_input_ref().set_key_focus(cx); return; } + // We're done loading, safe to clear and reset + 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 + // Try to add @room mention item 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() + }; - // 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; + // 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; + } - // 7. Update popup visibility based on whether we have items + // Update popup visibility based on whether we have items self.update_popup_visibility(cx, 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 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 (simple debounce) + if self.last_search_text.as_deref() == Some(search_text) { + return; + } + + self.last_search_text = Some(search_text.to_string()); + + let room_props = scope + .props + .get::() + .expect("RoomScreenProps should be available in scope for MentionableTextInput"); + + 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() => { + self.members_available = true; + // Trust the sync state from room_screen via props + self.members_sync_pending = room_props.room_members_sync_pending; + members.clone() + } + _ => { + let already_waiting = matches!( + self.search_state, + MentionSearchState::WaitingForMembers { .. } + ); + + self.cancel_active_search(); + self.members_available = false; + self.members_sync_pending = true; + + if !already_waiting { + submit_async_request(MatrixRequest::GetRoomMembers { + room_id: room_props.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 + } + }; + + // We have cached members, ensure popup is visible and focused + let popup = self.cmd_text_input.view(id!(popup)); + let header_view = self.cmd_text_input.view(id!(popup.header_view)); + header_view.set_visible(cx, true); + popup.set_visible(cx, true); + self.cmd_text_input.text_input_ref().set_key_focus(cx); + + // 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 @@ -850,8 +1346,11 @@ impl MentionableTextInput { // 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); @@ -861,20 +1360,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) @@ -898,116 +1400,38 @@ 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(id!(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(id!(loading_animation)).start_animation(cx); + // IMPORTANT: Add the widget to the UI tree FIRST before starting animation + // This ensures the widget is properly initialized and can respond to animator commands + self.cmd_text_input.add_item(loading_item.clone()); + self.loading_indicator_ref = Some(loading_item.clone()); - // 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(id!(loading_animation)) + .start_animation(cx); + cx.new_next_frame(); // Setup popup dimensions for loading state let popup = self.cmd_text_input.view(id!(popup)); @@ -1021,7 +1445,7 @@ impl MentionableTextInput { popup.set_visible(cx, true); // Maintain text input focus - if self.is_searching { + if self.is_searching() { self.cmd_text_input.text_input_ref().set_key_focus(cx); } } @@ -1032,11 +1456,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(id!(popup)); @@ -1049,19 +1476,74 @@ impl MentionableTextInput { popup.apply_over(cx, live! { height: Fit }); // Maintain text input focus so user can continue typing - if self.is_searching { + if self.is_searching() { 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) { + if let MentionSearchState::Searching { cancel_token, .. } = &self.search_state { + cancel_token.store(true, Ordering::Relaxed); + } + self.search_results_pending = false; + } + + /// Reset all search-related state + fn reset_search_state(&mut self) { + self.cancel_active_search(); + + // 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.members_sync_pending = false; + + // Mark members as unavailable until we fetch them again + self.members_available = false; + self.loading_indicator_ref = None; - // Clear list items to avoid keeping old content when popup is shown again + // 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(id!(popup)); @@ -1081,7 +1563,7 @@ impl MentionableTextInput { // This will happen before update_user_list is called in handle_text_change self.cmd_text_input.request_text_input_focus(); - self.redraw(cx); + self.cmd_text_input.redraw(cx); } /// Returns the current text content @@ -1092,12 +1574,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; @@ -1107,8 +1586,6 @@ impl MentionableTextInput { pub fn can_notify_room(&self) -> bool { self.can_notify_room } - - } impl MentionableTextInputRef { @@ -1123,13 +1600,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() { @@ -1142,7 +1629,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(); @@ -1208,5 +1694,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 db00dd67..e93dbaf5 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -676,8 +676,27 @@ async fn async_worker( 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(); + log!("Fetched {count} members for room {room_id} after sync."); + if let Err(err) = sender.send(TimelineUpdate::RoomMembersListFetched { members }) { + warning!("Failed to send RoomMembersListFetched update for room {room_id}: {err:?}"); + } else { + SignalToUI::set_ui_signal(); + } + } + 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(); + } }); } @@ -1395,7 +1414,7 @@ async fn async_worker( 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 @@ -1405,7 +1424,6 @@ async fn async_worker( 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()) @@ -1418,20 +1436,19 @@ async fn async_worker( 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]); @@ -1455,7 +1472,6 @@ async fn async_worker( destination: destination.clone(), update_sender: update_sender.clone(), }); - } } Err(e) => { @@ -1479,7 +1495,7 @@ async fn async_worker( 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) => { @@ -2225,7 +2241,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. @@ -2593,7 +2609,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); @@ -3420,19 +3436,19 @@ pub async fn clean_app_state(config: &LogoutConfig) -> Result<()> { // 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(); - + 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 app state was cleaned successfully");