From 2fcd9edd302a4ccc6b781c0ce73625b5970babf9 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Wed, 22 Oct 2025 11:31:18 +0800 Subject: [PATCH 01/20] Streamlined @mention member search: precompute sort, stream results, reuse BouncingDots --- src/home/room_screen.rs | 1547 ++++++++++++++++--------- src/room/member_search.rs | 1108 ++++++++++++++++++ src/room/mod.rs | 2 +- src/shared/mentionable_text_input.rs | 996 ++++++++++------ src/shared/mod.rs | 1 - src/sliding_sync.rs | 1558 ++++++++++++++++---------- 6 files changed, 3698 insertions(+), 1514 deletions(-) create mode 100644 src/room/member_search.rs diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 0f95cb8b..dbcce4e7 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1,40 +1,94 @@ //! A room screen is the UI view that displays a single Room's timeline of events/messages //! along with a message input bar at the bottom. -use std::{borrow::Cow, cell::RefCell, collections::BTreeMap, ops::{DerefMut, Range}, sync::Arc}; +use std::{ + borrow::Cow, + cell::RefCell, + collections::BTreeMap, + ops::{DerefMut, Range}, + sync::Arc, +}; use bytesize::ByteSize; use imbl::Vector; use makepad_widgets::{image_cache::ImageBuffer, *}; use matrix_sdk::{ - room::RoomMember, ruma::{ + room::RoomMember, + ruma::{ events::{ receipt::Receipt, room::{ message::{ - AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent + AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, + FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, + LocationMessageEventContent, MessageFormat, MessageType, + NoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent, }, - ImageInfo, MediaSource + ImageInfo, MediaSource, }, sticker::{StickerEventContent, StickerMediaSource}, }, - matrix_uri::MatrixId, uint, EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId - }, OwnedServerName, SuccessorRoom + matrix_uri::MatrixId, + uint, EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, + }, + OwnedServerName, SuccessorRoom, }; use matrix_sdk_ui::timeline::{ - self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, MemberProfileChange, MsgLikeContent, MsgLikeKind, OtherMessageLike, PollState, RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem + self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, + MemberProfileChange, MsgLikeContent, MsgLikeKind, OtherMessageLike, PollState, + RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, TimelineItemContent, + TimelineItemKind, VirtualTimelineItem, }; use crate::{ - app::AppStateAction, avatar_cache, event_preview::{plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_message_like, text_preview_of_other_state, text_preview_of_redacted_message, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, rooms_list::RoomsListRef}, media_cache::{MediaCache, MediaCacheEntry}, profile::{ - user_profile::{AvatarState, ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, + app::AppStateAction, + avatar_cache, + event_preview::{ + plaintext_body_of_timeline_item, text_preview_of_encrypted_message, + text_preview_of_member_profile_change, text_preview_of_other_message_like, + text_preview_of_other_state, text_preview_of_redacted_message, + text_preview_of_room_membership_change, text_preview_of_timeline_item, + }, + home::{ + edited_indicator::EditedIndicatorWidgetRefExt, + link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, + loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, + rooms_list::RoomsListRef, + }, + media_cache::{MediaCache, MediaCacheEntry}, + profile::{ + 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 + 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, + }, + sliding_sync::{ + get_client, submit_async_request, take_timeline_endpoints, + BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, + TimelineRequestSender, UserPowerLevels, + }, + utils::{ + self, room_name_or_id, unix_time_millis_to_datetime, ImageFormat, MEDIA_THUMBNAIL_FORMAT, }, - sliding_sync::{get_client, submit_async_request, take_timeline_endpoints, BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineRequestSender, UserPowerLevels}, utils::{self, room_name_or_id, unix_time_millis_to_datetime, ImageFormat, MEDIA_THUMBNAIL_FORMAT} }; use crate::home::event_reaction_list::ReactionListWidgetRefExt; use crate::home::room_read_receipt::AvatarRowWidgetRefExt; @@ -43,7 +97,12 @@ use crate::shared::mentionable_text_input::MentionableTextInputAction; use rangemap::RangeSet; -use super::{event_reaction_list::ReactionData, loading_pane::LoadingPaneRef, new_message_context_menu::{MessageAbilities, MessageDetails}, room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}}; +use super::{ + event_reaction_list::ReactionData, + loading_pane::LoadingPaneRef, + new_message_context_menu::{MessageAbilities, MessageDetails}, + room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}, +}; /// The maximum number of timeline items to search through /// when looking for a particular event. @@ -55,7 +114,6 @@ const MAX_ITEMS_TO_SEARCH_THROUGH: usize = 100; /// The max size (width or height) of a blurhash image to decode. const BLURHASH_IMAGE_MAX_SIZE: u32 = 500; - live_design! { use link::theme::*; use link::shaders::*; @@ -491,7 +549,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. @@ -548,20 +606,27 @@ live_design! { /// The main widget that displays a single Matrix room. #[derive(Live, Widget)] pub struct RoomScreen { - #[deref] view: View, + #[deref] + view: View, /// The room ID of the currently-shown room. - #[rust] room_id: Option, + #[rust] + room_id: Option, /// The display name of the currently-shown room. - #[rust] room_name: String, + #[rust] + room_name: String, /// The persistent UI-relevant states for the room that this widget is currently displaying. - #[rust] tl_state: Option, + #[rust] + tl_state: Option, /// The set of pinned events in this room. - #[rust] pinned_events: Vec, + #[rust] + pinned_events: Vec, /// Whether this room has been successfully loaded (received from the homeserver). - #[rust] is_loaded: bool, + #[rust] + is_loaded: bool, /// Whether or not all rooms have been loaded (received from the homeserver). - #[rust] all_rooms_loaded: bool, + #[rust] + all_rooms_loaded: bool, } impl Drop for RoomScreen { fn drop(&mut self) { @@ -589,7 +654,8 @@ impl Widget for RoomScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { let room_screen_widget_uid = self.widget_uid(); let portal_list = self.portal_list(id!(timeline.list)); - let user_profile_sliding_pane = self.user_profile_sliding_pane(id!(user_profile_sliding_pane)); + let user_profile_sliding_pane = + self.user_profile_sliding_pane(id!(user_profile_sliding_pane)); let loading_pane = self.loading_pane(id!(loading_pane)); // Handle actions here before processing timeline updates. @@ -604,14 +670,30 @@ impl Widget for RoomScreen { widget_rect, bg_color, reaction_data, - } = reaction_list.hover_in(actions) { - let Some(_tl_state) = self.tl_state.as_ref() else { continue }; - let tooltip_text_arr: Vec = reaction_data.reaction_senders.iter().map(|(sender, _react_info)| { - user_profile_cache::get_user_profile_and_room_member(cx, sender.clone(), &reaction_data.room_id, true).0 + } = reaction_list.hover_in(actions) + { + let Some(_tl_state) = self.tl_state.as_ref() else { + continue; + }; + let tooltip_text_arr: Vec = reaction_data + .reaction_senders + .iter() + .map(|(sender, _react_info)| { + user_profile_cache::get_user_profile_and_room_member( + cx, + sender.clone(), + &reaction_data.room_id, + true, + ) + .0 .map(|user_profile| user_profile.displayable_name().to_string()) .unwrap_or_else(|| sender.to_string()) - }).collect(); - let mut tooltip_text = utils::human_readable_list(&tooltip_text_arr, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT); + }) + .collect(); + let mut tooltip_text = utils::human_readable_list( + &tooltip_text_arr, + MAX_VISIBLE_AVATARS_IN_READ_RECEIPT, + ); tooltip_text.push_str(&format!(" reacted with: {}", reaction_data.reaction)); cx.widget_action( room_screen_widget_uid, @@ -621,24 +703,24 @@ impl Widget for RoomScreen { text: tooltip_text, text_color: None, bg_color, - } + }, ); } if reaction_list.hover_out(actions) { - cx.widget_action( - room_screen_widget_uid, - &scope.path, - TooltipAction::HoverOut - ); + cx.widget_action(room_screen_widget_uid, &scope.path, TooltipAction::HoverOut); } let avatar_row_ref = wr.avatar_row(id!(avatar_row)); if let RoomScreenTooltipActions::HoverInReadReceipt { widget_rect, bg_color, - read_receipts - } = avatar_row_ref.hover_in(actions) { - let Some(room_id) = &self.room_id else { return; }; - let tooltip_text= room_read_receipt::populate_tooltip(cx, read_receipts, room_id); + read_receipts, + } = avatar_row_ref.hover_in(actions) + { + let Some(room_id) = &self.room_id else { + return; + }; + let tooltip_text = + room_read_receipt::populate_tooltip(cx, read_receipts, room_id); cx.widget_action( room_screen_widget_uid, &scope.path, @@ -647,15 +729,11 @@ impl Widget for RoomScreen { text: tooltip_text, bg_color, text_color: None, - } + }, ); } if avatar_row_ref.hover_out(actions) { - cx.widget_action( - room_screen_widget_uid, - &scope.path, - TooltipAction::HoverOut - ); + cx.widget_action(room_screen_widget_uid, &scope.path, TooltipAction::HoverOut); } } @@ -663,7 +741,8 @@ impl Widget for RoomScreen { for action in actions { // Handle actions related to restoring the previously-saved state of rooms. - if let Some(AppStateAction::RoomLoadedSuccessfully(room_id)) = action.downcast_ref() { + if let Some(AppStateAction::RoomLoadedSuccessfully(room_id)) = action.downcast_ref() + { if self.room_id.as_ref().is_some_and(|r| r == room_id) { // `set_displayed_room()` does nothing if the room_id is unchanged, so we clear it first. self.room_id = None; @@ -672,8 +751,12 @@ impl Widget for RoomScreen { } } // Handle the highlight animation. - let Some(tl) = self.tl_state.as_mut() else { continue }; - if let MessageHighlightAnimationState::Pending { item_id } = tl.message_highlight_animation_state { + let Some(tl) = self.tl_state.as_mut() else { + continue; + }; + if let MessageHighlightAnimationState::Pending { item_id } = + tl.message_highlight_animation_state + { if portal_list.smooth_scroll_reached(actions) { cx.widget_action( room_screen_widget_uid, @@ -687,9 +770,15 @@ impl Widget for RoomScreen { } // Handle the action that requests to show the user profile sliding pane. - if let ShowUserProfileAction::ShowUserProfile(profile_and_room_id) = action.as_widget_action().cast() { + if let ShowUserProfileAction::ShowUserProfile(profile_and_room_id) = + action.as_widget_action().cast() + { // Only show the user profile in room that this avatar belongs to - if self.room_id.as_ref().is_some_and(|r| r == &profile_and_room_id.room_id) { + if self + .room_id + .as_ref() + .is_some_and(|r| r == &profile_and_room_id.room_id) + { self.show_user_profile( cx, &user_profile_sliding_pane, @@ -717,18 +806,19 @@ impl Widget for RoomScreen { self.send_user_read_receipts_based_on_scroll_pos(cx, actions, &portal_list); // Handle the jump to bottom button: update its visibility, and handle clicks. - self.jump_to_bottom_button(id!(jump_to_bottom)).update_from_actions( - cx, - &portal_list, - actions, - ); + self.jump_to_bottom_button(id!(jump_to_bottom)) + .update_from_actions(cx, &portal_list, actions); } // Currently, a Signal event is only used to tell this widget: // 1. to check if the room has been loaded from the homeserver yet, or // 2. that its timeline events have been updated in the background. if let Event::Signal = event { - if let (false, Some(room_id), true) = (self.is_loaded, &self.room_id, cx.has_global::()) { + if let (false, Some(room_id), true) = ( + self.is_loaded, + &self.room_id, + cx.has_global::(), + ) { let rooms_list_ref = cx.get_global::(); if rooms_list_ref.is_room_loaded(room_id) { let same_room_id = room_id.clone(); @@ -766,14 +856,12 @@ impl Widget for RoomScreen { if is_interactive_hit { loading_pane.handle_event(cx, event, scope); } - } - else if user_profile_sliding_pane.is_currently_shown(cx) { + } else if user_profile_sliding_pane.is_currently_shown(cx) { is_pane_shown = true; if is_interactive_hit { user_profile_sliding_pane.handle_event(cx, event, scope); } - } - else { + } else { is_pane_shown = false; } @@ -788,20 +876,24 @@ 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(); // Fetch room data once to avoid duplicate expensive lookups let (room_display_name, room_avatar_url) = get_client() .and_then(|client| client.get_room(&room_id)) - .map(|room| ( - room.cached_display_name().map(|name| name.to_string()), - room.avatar_url() - )) + .map(|room| { + ( + room.cached_display_name().map(|name| name.to_string()), + room.avatar_url(), + ) + }) .unwrap_or((None, None)); RoomScreenProps { room_screen_widget_uid, room_id, room_members, + room_members_sort, room_display_name, room_avatar_url, } @@ -811,12 +903,15 @@ impl Widget for RoomScreen { room_screen_widget_uid, room_id, room_members: None, + room_members_sort: None, room_display_name: None, room_avatar_url: None, } } else { // No room selected yet, skip event handling that requires room context - log!("RoomScreen handling event with no room_id and no tl_state, skipping room-dependent event handling"); + log!( + "RoomScreen handling event with no room_id and no tl_state, skipping room-dependent event handling" + ); if !is_pane_shown || !is_interactive_hit { return; } @@ -825,19 +920,18 @@ 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_display_name: None, room_avatar_url: None, } }; let mut room_scope = Scope::with_props(&room_props); - // Forward the event to the inner timeline view, but capture any actions it produces // such that we can handle the ones relevant to only THIS RoomScreen widget right here and now, // ensuring they are not mistakenly handled by other RoomScreen widget instances. - let mut actions_generated_within_this_room_screen = cx.capture_actions(|cx| - self.view.handle_event(cx, event, &mut room_scope) - ); + let mut actions_generated_within_this_room_screen = + cx.capture_actions(|cx| self.view.handle_event(cx, event, &mut room_scope)); // Here, we handle and remove any general actions that are relevant to only this RoomScreen. // Removing the handled actions ensures they are not mistakenly handled by other RoomScreen widget instances. actions_generated_within_this_room_screen.retain(|action| { @@ -893,7 +987,6 @@ impl Widget for RoomScreen { } } - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // If the room isn't loaded yet, we show the restore status label only. if !self.is_loaded { @@ -907,14 +1000,15 @@ impl Widget for RoomScreen { return DrawStep::done(); } - let room_screen_widget_uid = self.widget_uid(); while let Some(subview) = self.view.draw_walk(cx, scope, walk).step() { // Here, we only need to handle drawing the portal list. let portal_list_ref = subview.as_portal_list(); let Some(mut list_ref) = portal_list_ref.borrow_mut() else { - error!("!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else"); + error!( + "!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else" + ); continue; }; let Some(tl_state) = self.tl_state.as_mut() else { @@ -948,81 +1042,90 @@ impl Widget for RoomScreen { }; let (item, item_new_draw_status) = match timeline_item.kind() { TimelineItemKind::Event(event_tl_item) => match event_tl_item.content() { - TimelineItemContent::MsgLike(msg_like_content) => match &msg_like_content.kind { - MsgLikeKind::Message(_) | MsgLikeKind::Sticker(_) => { - let prev_event = tl_idx.checked_sub(1).and_then(|i| tl_items.get(i)); - populate_message_view( + TimelineItemContent::MsgLike(msg_like_content) => { + match &msg_like_content.kind { + MsgLikeKind::Message(_) | MsgLikeKind::Sticker(_) => { + let prev_event = + tl_idx.checked_sub(1).and_then(|i| tl_items.get(i)); + populate_message_view( + cx, + list, + item_id, + room_id, + event_tl_item, + msg_like_content, + prev_event, + &mut tl_state.media_cache, + &mut tl_state.link_preview_cache, + &tl_state.user_power, + &self.pinned_events, + item_drawn_status, + room_screen_widget_uid, + ) + } + // TODO: properly implement `Poll` as a regular Message-like timeline item. + MsgLikeKind::Poll(poll_state) => populate_small_state_event( cx, list, item_id, room_id, event_tl_item, - msg_like_content, - prev_event, - &mut tl_state.media_cache, - &mut tl_state.link_preview_cache, - &tl_state.user_power, - &self.pinned_events, + poll_state, item_drawn_status, - room_screen_widget_uid, - ) - }, - // TODO: properly implement `Poll` as a regular Message-like timeline item. - MsgLikeKind::Poll(poll_state) => populate_small_state_event( - cx, - list, - item_id, - room_id, - event_tl_item, - poll_state, - item_drawn_status, - ), - MsgLikeKind::Redacted => populate_small_state_event( - cx, - list, - item_id, - room_id, - event_tl_item, - &RedactedMessageEventMarker, - item_drawn_status, - ), - MsgLikeKind::UnableToDecrypt(utd) => populate_small_state_event( + ), + MsgLikeKind::Redacted => populate_small_state_event( + cx, + list, + item_id, + room_id, + event_tl_item, + &RedactedMessageEventMarker, + item_drawn_status, + ), + MsgLikeKind::UnableToDecrypt(utd) => { + populate_small_state_event( + cx, + list, + item_id, + room_id, + event_tl_item, + utd, + item_drawn_status, + ) + } + MsgLikeKind::Other(other) => populate_small_state_event( + cx, + list, + item_id, + room_id, + event_tl_item, + other, + item_drawn_status, + ), + } + } + TimelineItemContent::MembershipChange(membership_change) => { + populate_small_state_event( cx, list, item_id, room_id, event_tl_item, - utd, + membership_change, item_drawn_status, - ), - MsgLikeKind::Other(other) => populate_small_state_event( + ) + } + TimelineItemContent::ProfileChange(profile_change) => { + populate_small_state_event( cx, list, item_id, room_id, event_tl_item, - other, + profile_change, item_drawn_status, - ), - }, - TimelineItemContent::MembershipChange(membership_change) => populate_small_state_event( - cx, - list, - item_id, - room_id, - event_tl_item, - membership_change, - item_drawn_status, - ), - TimelineItemContent::ProfileChange(profile_change) => populate_small_state_event( - cx, - list, - item_id, - room_id, - event_tl_item, - profile_change, - item_drawn_status, - ), + ) + } TimelineItemContent::OtherState(other) => populate_small_state_event( cx, list, @@ -1034,10 +1137,11 @@ impl Widget for RoomScreen { ), unhandled => { let item = list.item(cx, item_id, live_id!(SmallStateEvent)); - item.label(id!(content)).set_text(cx, &format!("[Unsupported] {:?}", unhandled)); + item.label(id!(content)) + .set_text(cx, &format!("[Unsupported] {:?}", unhandled)); (item, ItemDrawnStatus::both_drawn()) } - } + }, TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(millis)) => { let item = list.item(cx, item_id, live_id!(DateDivider)); let text = unix_time_millis_to_datetime(*millis) @@ -1059,10 +1163,14 @@ impl Widget for RoomScreen { // Now that we've drawn the item, add its index to the set of drawn items. if item_new_draw_status.content_drawn { - tl_state.content_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); + tl_state + .content_drawn_since_last_update + .insert(tl_idx..tl_idx + 1); } if item_new_draw_status.profile_drawn { - tl_state.profile_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); + tl_state + .profile_drawn_since_last_update + .insert(tl_idx..tl_idx + 1); } item }; @@ -1072,7 +1180,11 @@ impl Widget for RoomScreen { // If the list is not filling the viewport, we need to back paginate the timeline // until we have enough events items to fill the viewport. if !tl_state.fully_paginated && !list.is_filling_viewport() { - log!("Automatically paginating timeline to fill viewport for room \"{}\" ({})", self.room_name, room_id); + log!( + "Automatically paginating timeline to fill viewport for room \"{}\" ({})", + self.room_name, + room_id + ); submit_async_request(MatrixRequest::PaginateRoomTimeline { room_id: room_id.clone(), num_events: 50, @@ -1093,7 +1205,9 @@ impl RoomScreen { let jump_to_bottom = self.jump_to_bottom_button(id!(jump_to_bottom)); let curr_first_id = portal_list.first_id(); let ui = self.widget_uid(); - let Some(tl) = self.tl_state.as_mut() else { return }; + let Some(tl) = self.tl_state.as_mut() else { + return; + }; let mut done_loading = false; let mut should_continue_backwards_pagination = false; @@ -1114,10 +1228,19 @@ impl RoomScreen { tl.items = initial_items; done_loading = true; } - TimelineUpdate::NewItems { new_items, changed_indices, is_append, clear_cache } => { + TimelineUpdate::NewItems { + new_items, + changed_indices, + is_append, + clear_cache, + } => { if new_items.is_empty() { if !tl.items.is_empty() { - log!("process_timeline_updates(): timeline (had {} items) was cleared for room {}", tl.items.len(), tl.room_id); + log!( + "process_timeline_updates(): timeline (had {} items) was cleared for room {}", + tl.items.len(), + tl.room_id + ); // For now, we paginate a cleared timeline in order to be able to show something at least. // A proper solution would be what's described below, which would be to save a few event IDs // and then either focus on them (if we're not close to the end of the timeline) @@ -1151,9 +1274,12 @@ impl RoomScreen { if new_items.len() == tl.items.len() { // log!("process_timeline_updates(): no jump necessary for updated timeline of same length: {}", items.len()); - } - else if curr_first_id > new_items.len() { - log!("process_timeline_updates(): jumping to bottom: curr_first_id {} is out of bounds for {} new items", curr_first_id, new_items.len()); + } else if curr_first_id > new_items.len() { + log!( + "process_timeline_updates(): jumping to bottom: curr_first_id {} is out of bounds for {} new items", + curr_first_id, + new_items.len() + ); portal_list.set_first_id_and_scroll(new_items.len().saturating_sub(1), 0.0); portal_list.set_tail_range(true); jump_to_bottom.update_visibility(cx, true); @@ -1162,19 +1288,32 @@ impl RoomScreen { // in the timeline viewport so that we can maintain the scroll position of that item, // which ensures that the timeline doesn't jump around unexpectedly and ruin the user's experience. else if let Some((curr_item_idx, new_item_idx, new_item_scroll, _event_id)) = - prior_items_changed.then(|| - find_new_item_matching_current_item(cx, portal_list, curr_first_id, &tl.items, &new_items) - ) - .flatten() + prior_items_changed + .then(|| { + find_new_item_matching_current_item( + cx, + portal_list, + curr_first_id, + &tl.items, + &new_items, + ) + }) + .flatten() { if curr_item_idx != new_item_idx { - log!("process_timeline_updates(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}"); + log!( + "process_timeline_updates(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}" + ); portal_list.set_first_id_and_scroll(new_item_idx, new_item_scroll); tl.prev_first_index = Some(new_item_idx); // Set scrolled_past_read_marker false when we jump to a new event tl.scrolled_past_read_marker = false; // Hide the tooltip when the timeline jumps, as a hover-out event won't occur. - cx.widget_action(ui, &HeapLiveIdPath::default(), RoomScreenTooltipActions::HoverOut); + cx.widget_action( + ui, + &HeapLiveIdPath::default(), + RoomScreenTooltipActions::HoverOut, + ); } } // @@ -1188,7 +1327,9 @@ impl RoomScreen { if is_append && !portal_list.is_at_end() { // Immediately show the unread badge with no count while we fetch the actual count in the background. jump_to_bottom.show_unread_message_badge(cx, UnreadMessageCount::Unknown); - submit_async_request(MatrixRequest::GetNumberUnreadMessages{ room_id: tl.room_id.clone() }); + submit_async_request(MatrixRequest::GetNumberUnreadMessages { + room_id: tl.room_id.clone(), + }); } if prior_items_changed { @@ -1199,10 +1340,15 @@ impl RoomScreen { let loading_pane = self.view.loading_pane(id!(loading_pane)); let mut loading_pane_state = loading_pane.take_state(); if let LoadingPaneState::BackwardsPaginateUntilEvent { - events_paginated, target_event_id, .. - } = &mut loading_pane_state { + events_paginated, + target_event_id, + .. + } = &mut loading_pane_state + { *events_paginated += new_items.len().saturating_sub(tl.items.len()); - log!("While finding target event {target_event_id}, we have now loaded {events_paginated} messages..."); + log!( + "While finding target event {target_event_id}, we have now loaded {events_paginated} messages..." + ); // Here, we assume that we have not yet found the target event, // so we need to continue paginating backwards. // If the target event has already been found, it will be handled @@ -1219,8 +1365,10 @@ impl RoomScreen { tl.profile_drawn_since_last_update.clear(); tl.fully_paginated = false; } else { - tl.content_drawn_since_last_update.remove(changed_indices.clone()); - tl.profile_drawn_since_last_update.remove(changed_indices.clone()); + tl.content_drawn_since_last_update + .remove(changed_indices.clone()); + tl.profile_drawn_since_last_update + .remove(changed_indices.clone()); // log!("process_timeline_updates(): changed_indices: {changed_indices:?}, items len: {}\ncontent drawn: {:#?}\nprofile drawn: {:#?}", items.len(), tl.content_drawn_since_last_update, tl.profile_drawn_since_last_update); } tl.items = new_items; @@ -1229,7 +1377,10 @@ impl RoomScreen { TimelineUpdate::NewUnreadMessagesCount(unread_messages_count) => { jump_to_bottom.show_unread_message_badge(cx, unread_messages_count); } - TimelineUpdate::TargetEventFound { target_event_id, index } => { + TimelineUpdate::TargetEventFound { + target_event_id, + index, + } => { // log!("Target event found in room {}: {target_event_id}, index: {index}", tl.room_id); tl.request_sender.send_if_modified(|requests| { requests.retain(|r| r.room_id != tl.room_id); @@ -1239,10 +1390,10 @@ impl RoomScreen { // sanity check: ensure the target event is in the timeline at the given `index`. let item = tl.items.get(index); - let is_valid = item.is_some_and(|item| + let is_valid = item.is_some_and(|item| { item.as_event() .is_some_and(|ev| ev.event_id() == Some(&target_event_id)) - ); + }); let loading_pane = self.view.loading_pane(id!(loading_pane)); // log!("TargetEventFound: is_valid? {is_valid}. room {}, event {target_event_id}, index {index} of {}\n --> item: {item:?}", tl.room_id, tl.items.len()); @@ -1261,19 +1412,24 @@ impl RoomScreen { // appear beneath the top of the viewport. portal_list.smooth_scroll_to(cx, index.saturating_sub(1), speed, None); // start highlight animation. - tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { - item_id: index - }; - } - else { + tl.message_highlight_animation_state = + MessageHighlightAnimationState::Pending { item_id: index }; + } else { // Here, the target event was not found in the current timeline, // or we found it previously but it is no longer in the timeline (or has moved), // which means we encountered an error and are unable to jump to the target event. - error!("Target event index {index} of {} is out of bounds for room {}", tl.items.len(), tl.room_id); + error!( + "Target event index {index} of {} is out of bounds for room {}", + tl.items.len(), + tl.room_id + ); // Show this error in the loading pane, which should already be open. - loading_pane.set_state(cx, LoadingPaneState::Error( - String::from("Unable to find related message; it may have been deleted.") - )); + loading_pane.set_state( + cx, + LoadingPaneState::Error(String::from( + "Unable to find related message; it may have been deleted.", + )), + ); } should_continue_backwards_pagination = false; @@ -1290,7 +1446,10 @@ impl RoomScreen { } } TimelineUpdate::PaginationError { error, direction } => { - error!("Pagination error ({direction}) in room \"{}\", {}: {error:?}", self.room_name, tl.room_id); + error!( + "Pagination error ({direction}) in room \"{}\", {}: {error:?}", + self.room_name, tl.room_id + ); enqueue_popup_notification(PopupItem { message: utils::stringify_pagination_error(&error, &self.room_name), auto_dismissal_duration: None, @@ -1298,7 +1457,10 @@ impl RoomScreen { }); done_loading = true; } - TimelineUpdate::PaginationIdle { fully_paginated, direction } => { + TimelineUpdate::PaginationIdle { + fully_paginated, + direction, + } => { if direction == PaginationDirection::Backwards { // Don't set `done_loading` to `true` here, because we want to keep the top space visible // (with the "loading" message) until the corresponding `NewItems` update is received. @@ -1310,9 +1472,12 @@ impl RoomScreen { error!("Unexpected PaginationIdle update in the Forwards direction"); } } - TimelineUpdate::EventDetailsFetched {event_id, result } => { + TimelineUpdate::EventDetailsFetched { event_id, result } => { if let Err(_e) = result { - error!("Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}", tl.room_id); + error!( + "Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}", + tl.room_id + ); } // Here, to be most efficient, we could redraw only the updated event, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. @@ -1323,37 +1488,64 @@ impl RoomScreen { // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. } TimelineUpdate::RoomMembersListFetched { members } => { - // Store room members directly in TimelineUiState + // Store room members and precomputed sort data for fast @mention search + let sort_data = precompute_member_sort(&members); tl.room_members = Some(Arc::new(members)); - }, + tl.room_members_sort = Some(Arc::new(sort_data)); + + // Notify mentionable text inputs that members are ready + cx.action(MentionableTextInputAction::RoomMembersLoaded { + room_id: tl.room_id.clone(), + }); + } TimelineUpdate::MediaFetched => { - log!("process_timeline_updates(): media fetched for room {}", tl.room_id); + 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, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. } - TimelineUpdate::MessageEdited { timeline_event_id, result } => { - self.view.room_input_bar(id!(room_input_bar)) + TimelineUpdate::MessageEdited { + timeline_event_id, + result, + } => { + self.view + .room_input_bar(id!(room_input_bar)) .handle_edit_result(cx, timeline_event_id, result); } TimelineUpdate::PinResult { result, pin, .. } => { let (message, auto_dismissal_duration, kind) = match &result { Ok(true) => ( - format!("Successfully {} event.", if pin { "pinned" } else { "unpinned" }), + format!( + "Successfully {} event.", + if pin { "pinned" } else { "unpinned" } + ), Some(4.0), - PopupKind::Success + PopupKind::Success, ), Ok(false) => ( - format!("Message was already {}.", if pin { "pinned" } else { "unpinned" }), + format!( + "Message was already {}.", + if pin { "pinned" } else { "unpinned" } + ), Some(4.0), - PopupKind::Info + PopupKind::Info, ), Err(e) => ( - format!("Failed to {} event. Error: {e}", if pin { "pin" } else { "unpin" }), + format!( + "Failed to {} event. Error: {e}", + if pin { "pin" } else { "unpin" } + ), None, - PopupKind::Error + PopupKind::Error, ), }; - enqueue_popup_notification(PopupItem { message, auto_dismissal_duration, kind, }); + enqueue_popup_notification(PopupItem { + message, + auto_dismissal_duration, + kind, + }); } TimelineUpdate::TypingUsers { users } => { // This update loop should be kept tight & fast, so all we do here is @@ -1374,7 +1566,8 @@ impl RoomScreen { } TimelineUpdate::UserPowerLevels(user_power_levels) => { tl.user_power = user_power_levels; - self.view.room_input_bar(id!(room_input_bar)) + self.view + .room_input_bar(id!(room_input_bar)) .update_user_power_levels(cx, user_power_levels); // Update the @room mention capability based on the user's power level cx.action(MentionableTextInputAction::PowerLevelsUpdated { @@ -1390,7 +1583,8 @@ impl RoomScreen { tl.latest_own_user_receipt = Some(receipt); } TimelineUpdate::Tombstoned(successor_room) => { - self.view.room_input_bar(id!(room_input_bar)) + self.view + .room_input_bar(id!(room_input_bar)) .update_tombstone_footer(cx, &tl.room_id, successor_room.as_ref()); tl.tombstone_info = successor_room; } @@ -1421,7 +1615,6 @@ impl RoomScreen { } } - /// Handles a link being clicked in any child widgets of this RoomScreen. /// /// Returns `true` if the given `action` was handled as a link click. @@ -1466,7 +1659,7 @@ impl RoomScreen { enqueue_popup_notification(PopupItem { message: "You are already viewing that room.".into(), kind: PopupKind::Error, - auto_dismissal_duration: None + auto_dismissal_duration: None, }); return true; } @@ -1500,8 +1693,7 @@ impl RoomScreen { let mut link_was_handled = false; if let Ok(matrix_to_uri) = MatrixToUri::parse(&url) { link_was_handled |= handle_matrix_link(matrix_to_uri.id(), matrix_to_uri.via()); - } - else if let Ok(matrix_uri) = MatrixUri::parse(&url) { + } else if let Ok(matrix_uri) = MatrixUri::parse(&url) { link_was_handled |= handle_matrix_link(matrix_uri.id(), matrix_uri.via()); } @@ -1512,13 +1704,18 @@ impl RoomScreen { enqueue_popup_notification(PopupItem { message: format!("Could not open URL: {url}"), kind: PopupKind::Error, - auto_dismissal_duration: None + auto_dismissal_duration: None, }); } } true - } - else if let RobrixHtmlLinkAction::ClickedMatrixLink { url, matrix_id, via, .. } = action.as_widget_action().cast() { + } else if let RobrixHtmlLinkAction::ClickedMatrixLink { + url, + matrix_id, + via, + .. + } = action.as_widget_action().cast() + { let link_was_handled = handle_matrix_link(&matrix_id, &via); if !link_was_handled { log!("Opening URL \"{}\"", url); @@ -1527,13 +1724,12 @@ impl RoomScreen { enqueue_popup_notification(PopupItem { message: format!("Could not open URL: {url}"), kind: PopupKind::Error, - auto_dismissal_duration: None + auto_dismissal_duration: None, }); } } true - } - else { + } else { false } } @@ -1548,9 +1744,15 @@ impl RoomScreen { ) { let room_screen_widget_uid = self.widget_uid(); for action in actions { - match action.as_widget_action().widget_uid_eq(room_screen_widget_uid).cast() { + match action + .as_widget_action() + .widget_uid_eq(room_screen_widget_uid) + .cast() + { MessageAction::React { details, reaction } => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; let mut success = false; if let Some(timeline_item) = tl.items.get(details.item_id) { if let Some(event_tl_item) = timeline_item.as_event() { @@ -1569,9 +1771,10 @@ impl RoomScreen { enqueue_popup_notification(PopupItem { message: "Couldn't find message in timeline to react to.".to_string(), kind: PopupKind::Error, - auto_dismissal_duration: None + auto_dismissal_duration: None, }); - error!("MessageAction::React: couldn't find event [{}] {:?} to react to in room {}", + error!( + "MessageAction::React: couldn't find event [{}] {:?} to react to in room {}", details.item_id, details.event_id.as_deref(), tl.room_id, @@ -1579,18 +1782,29 @@ impl RoomScreen { } } MessageAction::Reply(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(event_tl_item) = tl.items.get(details.item_id) + let Some(tl) = self.tl_state.as_ref() else { + return; + }; + if let Some(event_tl_item) = tl + .items + .get(details.item_id) .and_then(|tl_item| tl_item.as_event().cloned()) .filter(|ev| ev.event_id() == details.event_id.as_deref()) { let replied_to_info = EmbeddedEvent::from_timeline_item(&event_tl_item); - self.view.room_input_bar(id!(room_input_bar)) + self.view + .room_input_bar(id!(room_input_bar)) .show_replying_to(cx, (event_tl_item, replied_to_info), &tl.room_id); - } - else { - enqueue_popup_notification(PopupItem { message: "Could not find message in timeline to reply to. Please try again!".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); - error!("MessageAction::Reply: couldn't find event [{}] {:?} to reply to in room {:?}", + } else { + enqueue_popup_notification(PopupItem { + message: + "Could not find message in timeline to reply to. Please try again!" + .to_string(), + kind: PopupKind::Error, + auto_dismissal_duration: None, + }); + error!( + "MessageAction::Reply: couldn't find event [{}] {:?} to reply to in room {:?}", details.item_id, details.event_id.as_deref(), self.room_id, @@ -1598,17 +1812,28 @@ impl RoomScreen { } } MessageAction::Edit(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(event_tl_item) = tl.items.get(details.item_id) + let Some(tl) = self.tl_state.as_ref() else { + return; + }; + if let Some(event_tl_item) = tl + .items + .get(details.item_id) .and_then(|tl_item| tl_item.as_event().cloned()) .filter(|ev| ev.event_id() == details.event_id.as_deref()) { - self.view.room_input_bar(id!(room_input_bar)) + self.view + .room_input_bar(id!(room_input_bar)) .show_editing_pane(cx, event_tl_item, tl.room_id.clone()); - } - else { - enqueue_popup_notification(PopupItem { message: "Could not find message in timeline to edit. Please try again!".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); - error!("MessageAction::Edit: couldn't find event [{}] {:?} to edit in room {:?}", + } else { + enqueue_popup_notification(PopupItem { + message: + "Could not find message in timeline to edit. Please try again!" + .to_string(), + kind: PopupKind::Error, + auto_dismissal_duration: None, + }); + error!( + "MessageAction::Edit: couldn't find event [{}] {:?} to edit in room {:?}", details.item_id, details.event_id.as_deref(), self.room_id, @@ -1616,17 +1841,20 @@ impl RoomScreen { } } MessageAction::EditLatest => { - let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(latest_sent_msg) = tl.items + let Some(tl) = self.tl_state.as_ref() else { + return; + }; + if let Some(latest_sent_msg) = tl + .items .iter() .rev() .take(MAX_ITEMS_TO_SEARCH_THROUGH) .find_map(|item| item.as_event().filter(|ev| ev.is_editable()).cloned()) { - self.view.room_input_bar(id!(room_input_bar)) + self.view + .room_input_bar(id!(room_input_bar)) .show_editing_pane(cx, latest_sent_msg, tl.room_id.clone()); - } - else { + } else { enqueue_popup_notification(PopupItem { message: "No recent message available to edit.".to_string(), kind: PopupKind::Warning, @@ -1635,7 +1863,9 @@ impl RoomScreen { } } MessageAction::Pin(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_id) = details.event_id { submit_async_request(MatrixRequest::PinEvent { event_id, @@ -1651,7 +1881,9 @@ impl RoomScreen { } } MessageAction::Unpin(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_id) = details.event_id { submit_async_request(MatrixRequest::PinEvent { event_id, @@ -1667,16 +1899,19 @@ impl RoomScreen { } } MessageAction::CopyText(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(text) = tl.items + let Some(tl) = self.tl_state.as_ref() else { + return; + }; + if let Some(text) = tl + .items .get(details.item_id) .and_then(|tl_item| tl_item.as_event().map(plaintext_body_of_timeline_item)) { cx.copy_to_clipboard(&text); - } - else { + } else { enqueue_popup_notification(PopupItem { message: "Could not find message in timeline to copy text from. Please try again!".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None}); - error!("MessageAction::CopyText: couldn't find event [{}] {:?} to copy text from in room {}", + error!( + "MessageAction::CopyText: couldn't find event [{}] {:?} to copy text from in room {}", details.item_id, details.event_id.as_deref(), tl.room_id, @@ -1684,26 +1919,54 @@ impl RoomScreen { } } MessageAction::CopyHtml(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; // The logic for getting the formatted body of a message is the same // as the logic used in `populate_message_view()`. let mut success = false; - if let Some(event_tl_item) = tl.items + if let Some(event_tl_item) = tl + .items .get(details.item_id) .and_then(|tl_item| tl_item.as_event()) .filter(|ev| ev.event_id() == details.event_id.as_deref()) { if let Some(message) = event_tl_item.content().as_message() { match message.msgtype() { - MessageType::Text(TextMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Notice(NoticeMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Emote(EmoteMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Image(ImageMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::File(FileMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Audio(AudioMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Video(VideoMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::VerificationRequest(KeyVerificationRequestEventContent { formatted: Some(FormattedBody { body, .. }), .. }) => - { + MessageType::Text(TextMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Notice(NoticeMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Emote(EmoteMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Image(ImageMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::File(FileMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Audio(AudioMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Video(VideoMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::VerificationRequest( + KeyVerificationRequestEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }, + ) => { cx.copy_to_clipboard(body); success = true; } @@ -1713,7 +1976,8 @@ impl RoomScreen { } if !success { enqueue_popup_notification(PopupItem { message: "Could not find message in timeline to copy HTML from. Please try again!".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); - error!("MessageAction::CopyHtml: couldn't find event [{}] {:?} to copy HTML from in room {}", + error!( + "MessageAction::CopyHtml: couldn't find event [{}] {:?} to copy HTML from in room {}", details.item_id, details.event_id.as_deref(), tl.room_id, @@ -1721,13 +1985,20 @@ impl RoomScreen { } } MessageAction::CopyLink(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_id) = details.event_id { let matrix_to_uri = tl.room_id.matrix_to_event_uri(event_id); cx.copy_to_clipboard(&matrix_to_uri.to_string()); } else { - enqueue_popup_notification(PopupItem { message: "Couldn't create permalink to message.".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); - error!("MessageAction::CopyLink: no `event_id`: [{}] {:?} in room {}", + enqueue_popup_notification(PopupItem { + message: "Couldn't create permalink to message.".to_string(), + kind: PopupKind::Error, + auto_dismissal_duration: None, + }); + error!( + "MessageAction::CopyLink: no `event_id`: [{}] {:?} in room {}", details.item_id, details.event_id.as_deref(), tl.room_id, @@ -1735,7 +2006,11 @@ impl RoomScreen { } } MessageAction::ViewSource(_details) => { - enqueue_popup_notification(PopupItem { message: "Viewing an event's source is not yet implemented.".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); + enqueue_popup_notification(PopupItem { + message: "Viewing an event's source is not yet implemented.".to_string(), + kind: PopupKind::Error, + auto_dismissal_duration: None, + }); // TODO: re-use Franco's implementation below: // let Some(tl) = self.tl_state.as_mut() else { continue }; @@ -1765,7 +2040,9 @@ impl RoomScreen { } MessageAction::JumpToRelated(details) => { let Some(related_event_id) = details.related_event_id.as_ref() else { - error!("BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}"); + error!( + "BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}" + ); continue; }; self.jump_to_event( @@ -1773,20 +2050,16 @@ impl RoomScreen { related_event_id, Some(details.item_id), portal_list, - loading_pane + loading_pane, ); } MessageAction::JumpToEvent(event_id) => { - self.jump_to_event( - cx, - &event_id, - None, - portal_list, - loading_pane - ); + self.jump_to_event(cx, &event_id, None, portal_list, loading_pane); } MessageAction::Redact { details, reason } => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; let mut success = false; if let Some(timeline_item) = tl.items.get(details.item_id) { if let Some(event_tl_item) = timeline_item.as_event() { @@ -1802,8 +2075,13 @@ impl RoomScreen { } } if !success { - enqueue_popup_notification(PopupItem { message: "Couldn't find message in timeline to delete.".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); - error!("MessageAction::Redact: couldn't find event [{}] {:?} to react to in room {}", + enqueue_popup_notification(PopupItem { + message: "Couldn't find message in timeline to delete.".to_string(), + kind: PopupKind::Error, + auto_dismissal_duration: None, + }); + error!( + "MessageAction::Redact: couldn't find event [{}] {:?} to react to in room {}", details.item_id, details.event_id.as_deref(), tl.room_id, @@ -1816,14 +2094,14 @@ impl RoomScreen { // } // This is handled within the Message widget itself. - MessageAction::HighlightMessage(..) => { } + MessageAction::HighlightMessage(..) => {} // This is handled by the top-level App itself. - MessageAction::OpenMessageContextMenu { .. } => { } + MessageAction::OpenMessageContextMenu { .. } => {} // This isn't yet handled, as we need to completely redesign it. - MessageAction::ActionBarOpen { .. } => { } + MessageAction::ActionBarOpen { .. } => {} // This isn't yet handled, as we need to completely redesign it. - MessageAction::ActionBarClose => { } - MessageAction::None => { } + MessageAction::ActionBarClose => {} + MessageAction::None => {} } } } @@ -1841,14 +2119,17 @@ impl RoomScreen { portal_list: &PortalListRef, loading_pane: &LoadingPaneRef, ) { - let Some(tl) = self.tl_state.as_mut() else { return }; + let Some(tl) = self.tl_state.as_mut() else { + return; + }; let max_tl_idx = max_tl_idx.unwrap_or_else(|| tl.items.len()); // Attempt to find the index of replied-to message in the timeline. // Start from the current item's index (`tl_idx`) and search backwards, // since we know the related message must come before the current item. let mut num_items_searched = 0; - let related_msg_tl_index = tl.items + let related_msg_tl_index = tl + .items .focus() .narrow(..max_tl_idx) .into_iter() @@ -1871,11 +2152,13 @@ impl RoomScreen { // appear beneath the top of the viewport. portal_list.smooth_scroll_to(cx, index.saturating_sub(1), speed, None); // start highlight animation. - tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { - item_id: index - }; + tl.message_highlight_animation_state = + MessageHighlightAnimationState::Pending { item_id: index }; } else { - log!("The related event {target_event_id} wasn't immediately available in room {}, searching for it in the background...", tl.room_id); + log!( + "The related event {target_event_id} wasn't immediately available in room {}, searching for it in the background...", + tl.room_id + ); // Here, we set the state of the loading pane and display it to the user. // The main logic will be handled in `process_timeline_updates()`, which is the only // place where we can receive updates to the timeline from the background tasks. @@ -1911,7 +2194,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. @@ -1929,7 +2211,9 @@ impl RoomScreen { /// Invoke this when this timeline is being shown, /// e.g., when the user navigates to this timeline. fn show_timeline(&mut self, cx: &mut Cx) { - let room_id = self.room_id.clone() + let room_id = self + .room_id + .clone() .expect("BUG: Timeline::show_timeline(): no room_id was set."); let state_opt = TIMELINE_STATES.with_borrow_mut(|ts| ts.remove(&room_id)); @@ -1938,8 +2222,11 @@ impl RoomScreen { } else { let Some(timeline_endpoints) = take_timeline_endpoints(&room_id) else { if !self.is_loaded && self.all_rooms_loaded { - panic!("BUG: timeline is not loaded, but room_id {:?} \ - was not waiting for its timeline to be loaded.", room_id); + panic!( + "BUG: timeline is not loaded, but room_id {:?} \ + was not waiting for its timeline to be loaded.", + room_id + ); } return; }; @@ -1959,6 +2246,7 @@ 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, // We assume timelines being viewed for the first time haven't been fully paginated. fully_paginated: false, items: Vector::new(), @@ -1993,22 +2281,30 @@ impl RoomScreen { let rooms_list_ref = cx.get_global::(); let is_loaded_now = rooms_list_ref.is_room_loaded(&room_id); if is_loaded_now && !self.is_loaded { - log!("Detected that room \"{}\" ({}) is now loaded for the first time", - self.room_name, room_id, + log!( + "Detected that room \"{}\" ({}) is now loaded for the first time", + self.room_name, + room_id, ); is_first_time_being_loaded = true; } self.is_loaded = is_loaded_now; } - self.view.restore_status_view(id!(restore_status_view)).set_visible(cx, !self.is_loaded); + self.view + .restore_status_view(id!(restore_status_view)) + .set_visible(cx, !self.is_loaded); // Kick off a back pagination request if it's the first time loading this room, // because we want to show the user some messages as soon as possible // when they first open the room, and there might not be any messages yet. if is_first_time_being_loaded { if !tl_state.fully_paginated { - log!("Sending a first-time backwards pagination request for room \"{}\" {}", self.room_name, room_id); + log!( + "Sending a first-time backwards pagination request for room \"{}\" {}", + self.room_name, + room_id + ); submit_async_request(MatrixRequest::PaginateRoomTimeline { room_id: room_id.clone(), num_events: 50, @@ -2019,7 +2315,9 @@ 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. - submit_async_request(MatrixRequest::SyncRoomMemberList { room_id: room_id.clone() }); + submit_async_request(MatrixRequest::SyncRoomMemberList { + room_id: room_id.clone(), + }); } // Hide the typing notice view initially. @@ -2029,7 +2327,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 { @@ -2041,7 +2339,7 @@ impl RoomScreen { // Fetch from the local cache, as we already requested to sync // the room members from the homeserver above. local_only: true, - }); + }); submit_async_request(MatrixRequest::SubscribeToTypingNotices { room_id: room_id.clone(), subscribe: true, @@ -2072,7 +2370,9 @@ impl RoomScreen { /// Invoke this when this RoomScreen/timeline is being hidden or no longer being shown. fn hide_timeline(&mut self) { - let Some(room_id) = self.room_id.clone() else { return }; + let Some(room_id) = self.room_id.clone() else { + return; + }; self.save_state(); @@ -2101,7 +2401,10 @@ impl RoomScreen { /// Note: after calling this function, the widget's `tl_state` will be `None`. fn save_state(&mut self) { let Some(mut tl) = self.tl_state.take() else { - error!("Timeline::save_state(): skipping due to missing state, room {:?}", self.room_id); + error!( + "Timeline::save_state(): skipping due to missing state, room {:?}", + self.room_id + ); return; }; @@ -2111,8 +2414,9 @@ 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; // Store this Timeline's `TimelineUiState` in the global map of states. TIMELINE_STATES.with_borrow_mut(|ts| ts.insert(tl.room_id.clone(), tl)); } @@ -2155,7 +2459,9 @@ impl RoomScreen { room_name: S, ) { // If the room is already being displayed, then do nothing. - if self.room_id.as_ref().is_some_and(|id| id == &room_id) { return; } + if self.room_id.as_ref().is_some_and(|id| id == &room_id) { + return; + } self.hide_timeline(); // Reset the the state of the inner loading pane. @@ -2186,7 +2492,9 @@ impl RoomScreen { return; } let first_index = portal_list.first_id(); - let Some(tl_state) = self.tl_state.as_mut() else { return }; + let Some(tl_state) = self.tl_state.as_mut() else { + return; + }; if let Some(ref mut index) = tl_state.prev_first_index { // to detect change of scroll when scroll ends @@ -2197,7 +2505,7 @@ impl RoomScreen { .items .get(std::cmp::min( first_index + portal_list.visible_items(), - tl_state.items.len().saturating_sub(1) + tl_state.items.len().saturating_sub(1), )) .and_then(|f| f.as_event()) .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) @@ -2215,17 +2523,20 @@ impl RoomScreen { event_id: last_event_id.to_owned(), }); } else { - if let Some(own_user_receipt_timestamp) = &tl_state.latest_own_user_receipt.clone() - .and_then(|receipt| receipt.ts) { + if let Some(own_user_receipt_timestamp) = &tl_state + .latest_own_user_receipt + .clone() + .and_then(|receipt| receipt.ts) + { let Some((_first_event_id, first_timestamp)) = tl_state .items .get(first_index) .and_then(|f| f.as_event()) .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) - else { - *index = first_index; - return; - }; + else { + *index = first_index; + return; + }; if own_user_receipt_timestamp >= &first_timestamp && own_user_receipt_timestamp <= &last_timestamp { @@ -2235,7 +2546,6 @@ impl RoomScreen { event_id: last_event_id.to_owned(), }); } - } } } @@ -2254,14 +2564,22 @@ impl RoomScreen { actions: &ActionsBuf, portal_list: &PortalListRef, ) { - let Some(tl) = self.tl_state.as_mut() else { return }; - if tl.fully_paginated { return }; - if !portal_list.scrolled(actions) { return }; + let Some(tl) = self.tl_state.as_mut() else { + return; + }; + if tl.fully_paginated { + return; + }; + if !portal_list.scrolled(actions) { + return; + }; let first_index = portal_list.first_id(); if first_index == 0 && tl.last_scrolled_index > 0 { - log!("Scrolled up from item {} --> 0, sending back pagination request for room {}", - tl.last_scrolled_index, tl.room_id, + log!( + "Scrolled up from item {} --> 0, sending back pagination request for room {}", + tl.last_scrolled_index, + tl.room_id, ); submit_async_request(MatrixRequest::PaginateRoomTimeline { room_id: tl.room_id.clone(), @@ -2281,7 +2599,9 @@ impl RoomScreenRef { room_id: OwnedRoomId, room_name: S, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_displayed_room(cx, room_id, room_name); } } @@ -2292,11 +2612,11 @@ pub struct RoomScreenProps { pub room_screen_widget_uid: WidgetUid, pub room_id: OwnedRoomId, pub room_members: Option>>, + pub room_members_sort: Option>, pub room_display_name: Option, pub room_avatar_url: Option, } - /// Actions for the room screen's tooltip. #[derive(Clone, Debug, DefaultNone)] pub enum RoomScreenTooltipActions { @@ -2391,9 +2711,7 @@ pub enum TimelineUpdate { /// includes a complete list of room members that can be shared across components. /// This is different from RoomMembersSynced which only indicates members were fetched /// but doesn't provide the actual data. - RoomMembersListFetched { - members: Vec, - }, + RoomMembersListFetched { members: Vec }, /// A notice that one or more requested media items (images, videos, etc.) /// that should be displayed in this timeline have now been fetched and are available. MediaFetched, @@ -2444,6 +2762,8 @@ struct TimelineUiState { /// The list of room members for this room. room_members: Option>>, + /// Precomputed sort keys for room members to speed up mentions search. + room_members_sort: Option>, /// Whether this room's timeline has been fully paginated, which means /// that the oldest (first) event in the timeline is locally synced and available. @@ -2537,7 +2857,9 @@ struct TimelineUiState { #[derive(Default, Debug)] enum MessageHighlightAnimationState { - Pending { item_id: usize }, + Pending { + item_id: usize, + }, #[default] Off, } @@ -2574,9 +2896,8 @@ fn find_new_item_matching_current_item( ) -> Option<(usize, usize, f64, OwnedEventId)> { let mut curr_item_focus = curr_items.focus(); let mut idx_curr = starting_at_curr_idx; - let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = Vec::with_capacity( - portal_list.visible_items() - ); + let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = + Vec::with_capacity(portal_list.visible_items()); // Find all items with real event IDs that are currently visible in the portal list. // TODO: if this is slow, we could limit it to 3-5 events at the most. @@ -2605,7 +2926,9 @@ fn find_new_item_matching_current_item( // some may be zeroed-out, so we need to account for that possibility by only // using events that have a real non-zero area if let Some(pos_offset) = portal_list.position_of_item(cx, *idx_curr) { - log!("Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}"); + log!( + "Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}" + ); return Some((*idx_curr, idx_new, pos_offset, event_id.to_owned())); } } @@ -2671,7 +2994,8 @@ fn populate_message_view( TimelineItemContent::MsgLike(_msg_like_content) => { let prev_msg_sender = prev_event_tl_item.sender(); prev_msg_sender == event_tl_item.sender() - && ts_millis.0 + && ts_millis + .0 .checked_sub(prev_event_tl_item.timestamp().0) .is_some_and(|d| d < uint!(600000)) // 10 mins in millis } @@ -2688,8 +3012,12 @@ fn populate_message_view( let (item, used_cached_item) = match &msg_like_content.kind { MsgLikeKind::Message(msg) => { match msg.msgtype() { - MessageType::Text(TextMessageEventContent { body, formatted, .. }) => { - has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + MessageType::Text(TextMessageEventContent { + body, formatted, .. + }) => { + has_html_body = formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -2713,9 +3041,13 @@ fn populate_message_view( } // A notice message is just a message sent by an automated bot, // so we treat it just like a message but use a different font color. - MessageType::Notice(NoticeMessageEventContent{body, formatted, ..}) => { + MessageType::Notice(NoticeMessageEventContent { + body, formatted, .. + }) => { is_notice = true; - has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -2726,17 +3058,20 @@ fn populate_message_view( (item, true) } else { let html_or_plaintext_ref = item.html_or_plaintext(id!(content.message)); - html_or_plaintext_ref.apply_over(cx, live!( - html_view = { - html = { - font_color: (COLOR_MESSAGE_NOTICE_TEXT), - draw_normal: { color: (COLOR_MESSAGE_NOTICE_TEXT), } - draw_italic: { color: (COLOR_MESSAGE_NOTICE_TEXT), } - draw_bold: { color: (COLOR_MESSAGE_NOTICE_TEXT), } - draw_bold_italic: { color: (COLOR_MESSAGE_NOTICE_TEXT), } + html_or_plaintext_ref.apply_over( + cx, + live!( + html_view = { + html = { + font_color: (COLOR_MESSAGE_NOTICE_TEXT), + draw_normal: { color: (COLOR_MESSAGE_NOTICE_TEXT), } + draw_italic: { color: (COLOR_MESSAGE_NOTICE_TEXT), } + draw_bold: { color: (COLOR_MESSAGE_NOTICE_TEXT), } + draw_bold_italic: { color: (COLOR_MESSAGE_NOTICE_TEXT), } + } } - } - )); + ), + ); new_drawn_status.content_drawn = populate_text_message_content( cx, &html_or_plaintext_ref, @@ -2757,25 +3092,30 @@ fn populate_message_view( (item, true) } else { let html_or_plaintext_ref = item.html_or_plaintext(id!(content.message)); - html_or_plaintext_ref.apply_over(cx, live!( - html_view = { - html = { - font_color: (COLOR_FG_DANGER_RED), - draw_normal: { color: (COLOR_FG_DANGER_RED), } - draw_italic: { color: (COLOR_FG_DANGER_RED), } - draw_bold: { color: (COLOR_FG_DANGER_RED), } - draw_bold_italic: { color: (COLOR_FG_DANGER_RED), } + html_or_plaintext_ref.apply_over( + cx, + live!( + html_view = { + html = { + font_color: (COLOR_FG_DANGER_RED), + draw_normal: { color: (COLOR_FG_DANGER_RED), } + draw_italic: { color: (COLOR_FG_DANGER_RED), } + draw_bold: { color: (COLOR_FG_DANGER_RED), } + draw_bold_italic: { color: (COLOR_FG_DANGER_RED), } + } } - } - )); + ), + ); let formatted = format!( "Server notice: {}\n\nNotice type:: {}{}{}", sn.body, sn.server_notice_type.as_str(), - sn.limit_type.as_ref() + sn.limit_type + .as_ref() .map(|l| format!("\nLimit type: {}", l.as_str())) .unwrap_or_default(), - sn.admin_contact.as_ref() + sn.admin_contact + .as_ref() .map(|c| format!("\nAdmin contact: {}", c)) .unwrap_or_default(), ); @@ -2796,8 +3136,12 @@ fn populate_message_view( } // An emote is just like a message but is prepended with the user's name // to indicate that it's an "action" that the user is performing. - MessageType::Emote(EmoteMessageEventContent { body, formatted, .. }) => { - has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + MessageType::Emote(EmoteMessageEventContent { + body, formatted, .. + }) => { + has_html_body = formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -2808,13 +3152,15 @@ fn populate_message_view( (item, true) } else { // Draw the profile up front here because we need the username for the emote body. - let (username, profile_drawn) = item.avatar(id!(profile.avatar)).set_avatar_and_get_username( - cx, - room_id, - event_tl_item.sender(), - Some(event_tl_item.sender_profile()), - event_tl_item.event_id(), - ); + let (username, profile_drawn) = item + .avatar(id!(profile.avatar)) + .set_avatar_and_get_username( + cx, + room_id, + event_tl_item.sender(), + Some(event_tl_item.sender_profile()), + event_tl_item.event_id(), + ); // Prepend a "* " to the emote body, as suggested by the Matrix spec. let (body, formatted) = if let Some(fb) = formatted.as_ref() { @@ -2823,7 +3169,7 @@ fn populate_message_view( Some(FormattedBody { format: fb.format.clone(), body: format!("* {} {}", &username, &fb.body), - }) + }), ) } else { (Cow::from(format!("* {} {}", &username, body)), None) @@ -2843,7 +3189,9 @@ fn populate_message_view( } } MessageType::Image(image) => { - has_html_body = image.formatted.as_ref() + has_html_body = image + .formatted + .as_ref() .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedImageMessage) @@ -2888,7 +3236,10 @@ fn populate_message_view( } } MessageType::File(file_content) => { - has_html_body = file_content.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = file_content + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -2907,7 +3258,10 @@ fn populate_message_view( } } MessageType::Audio(audio) => { - has_html_body = audio.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = audio + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -2926,7 +3280,10 @@ fn populate_message_view( } } MessageType::Video(video) => { - has_html_body = video.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = video + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -2945,7 +3302,10 @@ fn populate_message_view( } } MessageType::VerificationRequest(verification) => { - has_html_body = verification.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = verification + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = live_id!(Message); let (item, existed) = list.item_with_existed(cx, item_id, template); if existed && item_drawn_status.content_drawn { @@ -2957,7 +3317,8 @@ fn populate_message_view( body: format!( "Sent a verification request to {}.
(Supported methods: {})
", verification.to, - verification.methods + verification + .methods .iter() .map(|m| m.as_str()) .collect::>() @@ -2983,10 +3344,8 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - item.label(id!(content.message)).set_text( - cx, - &format!("[Unsupported {:?}]", msg_like_content.kind), - ); + item.label(id!(content.message)) + .set_text(cx, &format!("[Unsupported {:?}]", msg_like_content.kind)); new_drawn_status.content_drawn = true; (item, false) } @@ -2996,7 +3355,9 @@ fn populate_message_view( // Handle sticker messages that are static images. MsgLikeKind::Sticker(sticker) => { has_html_body = false; - let StickerEventContent { body, info, source, .. } = sticker.content(); + let StickerEventContent { + body, info, source, .. + } = sticker.content(); let template = if use_compact_view { live_id!(CondensedImageMessage) @@ -3031,10 +3392,8 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - item.label(id!(content.message)).set_text( - cx, - &format!("[Unsupported {:?}] ", other), - ); + item.label(id!(content.message)) + .set_text(cx, &format!("[Unsupported {:?}] ", other)); new_drawn_status.content_drawn = true; (item, false) } @@ -3094,36 +3453,44 @@ fn populate_message_view( // log!("\t --> populate_message_view(): DRAWING profile draw for item_id: {item_id}"); let username_label = item.label(id!(content.username)); - if !is_server_notice { // the normal case - let (username, profile_drawn) = set_username_and_get_avatar_retval.unwrap_or_else(|| - item.avatar(id!(profile.avatar)).set_avatar_and_get_username( - cx, - room_id, - event_tl_item.sender(), - Some(event_tl_item.sender_profile()), - event_tl_item.event_id(), - ) - ); + if !is_server_notice { + // the normal case + let (username, profile_drawn) = + set_username_and_get_avatar_retval.unwrap_or_else(|| { + item.avatar(id!(profile.avatar)) + .set_avatar_and_get_username( + cx, + room_id, + event_tl_item.sender(), + Some(event_tl_item.sender_profile()), + event_tl_item.event_id(), + ) + }); if is_notice { - username_label.apply_over(cx, live!( - draw_text: { - color: (COLOR_MESSAGE_NOTICE_TEXT), - } - )); + username_label.apply_over( + cx, + live!( + draw_text: { + color: (COLOR_MESSAGE_NOTICE_TEXT), + } + ), + ); } username_label.set_text(cx, &username); new_drawn_status.profile_drawn = profile_drawn; - } - else { + } else { // Server notices are drawn with a red color avatar background and username. let avatar = item.avatar(id!(profile.avatar)); avatar.show_text(cx, Some(COLOR_FG_DANGER_RED), None, "⚠"); username_label.set_text(cx, "Server notice"); - username_label.apply_over(cx, live!( - draw_text: { - color: (COLOR_FG_DANGER_RED), - } - )); + username_label.apply_over( + cx, + live!( + draw_text: { + color: (COLOR_FG_DANGER_RED), + } + ), + ); new_drawn_status.profile_drawn = true; } } @@ -3140,28 +3507,40 @@ fn populate_message_view( // Set the "edited" indicator if this message was edited. if msg_like_content.as_message().is_some_and(|m| m.is_edited()) { - item.edited_indicator(id!(profile.edited_indicator)).set_latest_edit( - cx, - event_tl_item, - ); + item.edited_indicator(id!(profile.edited_indicator)) + .set_latest_edit(cx, event_tl_item); } - #[cfg(feature = "tsp")] { + #[cfg(feature = "tsp")] + { use matrix_sdk::ruma::serde::Base64; - use crate::tsp::{self, tsp_sign_indicator::{TspSignState, TspSignIndicatorWidgetRefExt}}; + use crate::tsp::{ + self, + tsp_sign_indicator::{TspSignState, TspSignIndicatorWidgetRefExt}, + }; - if let Some(mut tsp_sig) = event_tl_item.latest_json() + if let Some(mut tsp_sig) = event_tl_item + .latest_json() .and_then(|raw| raw.get_field::("content").ok()) .flatten() .and_then(|content_obj| content_obj.get("org.robius.tsp_signature").cloned()) .and_then(|tsp_sig_value| serde_json::from_value::(tsp_sig_value).ok()) .map(|b64| b64.into_inner()) { - log!("Found event {:?} with TSP signature.", event_tl_item.event_id()); - let tsp_sign_state = if let Some(sender_vid) = tsp::tsp_state_ref().lock().unwrap() + log!( + "Found event {:?} with TSP signature.", + event_tl_item.event_id() + ); + let tsp_sign_state = if let Some(sender_vid) = tsp::tsp_state_ref() + .lock() + .unwrap() .get_verified_vid_for(event_tl_item.sender()) { - log!("Found verified VID for sender {}: \"{}\"", event_tl_item.sender(), sender_vid.identifier()); + log!( + "Found verified VID for sender {}: \"{}\"", + event_tl_item.sender(), + sender_vid.identifier() + ); tsp_sdk::crypto::verify(&*sender_vid, &mut tsp_sig).map_or( TspSignState::WrongSignature, |(msg, msg_type)| { @@ -3173,7 +3552,11 @@ fn populate_message_view( TspSignState::Unknown }; - log!("TSP signature state for event {:?} is {:?}", event_tl_item.event_id(), tsp_sign_state); + log!( + "TSP signature state for event {:?} is {:?}", + event_tl_item.event_id(), + tsp_sign_state + ); item.tsp_sign_indicator(id!(profile.tsp_sign_indicator)) .show_with_state(cx, tsp_sign_state); } @@ -3195,17 +3578,13 @@ fn populate_text_message_content( link_preview_cache: Option<&mut LinkPreviewCache>, ) -> bool { // The message was HTML-formatted rich text. - let links = 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 (linkified_html, links) = utils::linkify_get_urls( - utils::trim_start_html_whitespace(&fb.body), - true, - ); - message_content_widget.show_html( - cx, - linkified_html - ); + let (linkified_html, links) = + utils::linkify_get_urls(utils::trim_start_html_whitespace(&fb.body), true); + message_content_widget.show_html(cx, linkified_html); links } // The message was non-HTML plaintext. @@ -3219,8 +3598,9 @@ fn populate_text_message_content( }; // 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, @@ -3246,7 +3626,8 @@ fn populate_image_message_content( ) -> bool { // We don't use thumbnails, as their resolution is too low to be visually useful. // We also don't trust the provided mimetype, as it can be incorrect. - let (mimetype, _width, _height) = image_info_source.as_ref() + let (mimetype, _width, _height) = image_info_source + .as_ref() .map(|info| (info.mimetype.as_deref(), info.width, info.height)) .unwrap_or_default(); @@ -3266,102 +3647,120 @@ fn populate_image_message_content( // A closure that fetches and shows the image from the given `mxc_uri`, // marking it as fully drawn if the image was available. - let mut fetch_and_show_image_uri = |cx: &mut Cx, mxc_uri: OwnedMxcUri, image_info: Box| { - match media_cache.try_get_media_or_fetch(mxc_uri.clone(), MEDIA_THUMBNAIL_FORMAT.into()) { - (MediaCacheEntry::Loaded(data), _media_format) => { - let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { - utils::load_png_or_jpg(&img, cx, &data) - .map(|()| img.size_in_pixels(cx).unwrap_or_default()) - }); - if let Err(e) = show_image_result { - let err_str = format!("{body}\n\nFailed to display image: {e:?}"); - error!("{err_str}"); - text_or_image_ref.show_text(cx, &err_str); - } - - // We're done drawing the image, so mark it as fully drawn. - fully_drawn = true; - } - (MediaCacheEntry::Requested, _media_format) => { - // If the image is being fetched, we try to show its blurhash. - if let (Some(ref blurhash), Some(width), Some(height)) = (image_info.blurhash.clone(), image_info.width, image_info.height) { + let mut fetch_and_show_image_uri = + |cx: &mut Cx, mxc_uri: OwnedMxcUri, image_info: Box| { + match media_cache.try_get_media_or_fetch(mxc_uri.clone(), MEDIA_THUMBNAIL_FORMAT.into()) + { + (MediaCacheEntry::Loaded(data), _media_format) => { let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { - let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) else { - return Err(image_cache::ImageError::EmptyData) - }; - let (width, height): (u32, u32) = (width, height); - if width == 0 || height == 0 { - warning!("Image had an invalid aspect ratio (width or height of 0)."); - return Err(image_cache::ImageError::EmptyData); - } - let aspect_ratio: f32 = width as f32 / height as f32; - // Cap the blurhash to a max size of 500 pixels in each dimension - // because the `blurhash::decode()` function can be rather expensive. - let (mut capped_width, mut capped_height) = (width, height); - if capped_height > BLURHASH_IMAGE_MAX_SIZE { - capped_height = BLURHASH_IMAGE_MAX_SIZE; - capped_width = (capped_height as f32 * aspect_ratio).floor() as u32; - } - if capped_width > BLURHASH_IMAGE_MAX_SIZE { - capped_width = BLURHASH_IMAGE_MAX_SIZE; - capped_height = (capped_width as f32 / aspect_ratio).floor() as u32; - } + utils::load_png_or_jpg(&img, cx, &data) + .map(|()| img.size_in_pixels(cx).unwrap_or_default()) + }); + if let Err(e) = show_image_result { + let err_str = format!("{body}\n\nFailed to display image: {e:?}"); + error!("{err_str}"); + text_or_image_ref.show_text(cx, &err_str); + } - match blurhash::decode(blurhash, capped_width, capped_height, 1.0) { - Ok(data) => { - ImageBuffer::new(&data, capped_width as usize, capped_height as usize).map(|img_buff| { + // We're done drawing the image, so mark it as fully drawn. + fully_drawn = true; + } + (MediaCacheEntry::Requested, _media_format) => { + // If the image is being fetched, we try to show its blurhash. + if let (Some(ref blurhash), Some(width), Some(height)) = ( + image_info.blurhash.clone(), + image_info.width, + image_info.height, + ) { + let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { + let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) + else { + return Err(image_cache::ImageError::EmptyData); + }; + let (width, height): (u32, u32) = (width, height); + if width == 0 || height == 0 { + warning!( + "Image had an invalid aspect ratio (width or height of 0)." + ); + return Err(image_cache::ImageError::EmptyData); + } + let aspect_ratio: f32 = width as f32 / height as f32; + // Cap the blurhash to a max size of 500 pixels in each dimension + // because the `blurhash::decode()` function can be rather expensive. + let (mut capped_width, mut capped_height) = (width, height); + if capped_height > BLURHASH_IMAGE_MAX_SIZE { + capped_height = BLURHASH_IMAGE_MAX_SIZE; + capped_width = (capped_height as f32 * aspect_ratio).floor() as u32; + } + if capped_width > BLURHASH_IMAGE_MAX_SIZE { + capped_width = BLURHASH_IMAGE_MAX_SIZE; + capped_height = (capped_width as f32 / aspect_ratio).floor() as u32; + } + + match blurhash::decode(blurhash, capped_width, capped_height, 1.0) { + Ok(data) => ImageBuffer::new( + &data, + capped_width as usize, + capped_height as usize, + ) + .map(|img_buff| { let texture = Some(img_buff.into_new_texture(cx)); img.set_texture(cx, texture); img.size_in_pixels(cx).unwrap_or_default() - }) + }), + Err(e) => { + error!("Failed to decode blurhash {e:?}"); + Err(image_cache::ImageError::EmptyData) + } } - Err(e) => { - error!("Failed to decode blurhash {e:?}"); - Err(image_cache::ImageError::EmptyData) - } + }); + if let Err(e) = show_image_result { + let err_str = format!("{body}\n\nFailed to display image: {e:?}"); + error!("{err_str}"); + text_or_image_ref.show_text(cx, &err_str); } - }); - if let Err(e) = show_image_result { - let err_str = format!("{body}\n\nFailed to display image: {e:?}"); - error!("{err_str}"); - text_or_image_ref.show_text(cx, &err_str); } + fully_drawn = false; } - fully_drawn = false; - } - (MediaCacheEntry::Failed, _media_format) => { - if text_or_image_ref.view(id!(default_image_view)).visible() { + (MediaCacheEntry::Failed, _media_format) => { + if text_or_image_ref.view(id!(default_image_view)).visible() { + fully_drawn = true; + return; + } + text_or_image_ref.show_text( + cx, + format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri), + ); + // For now, we consider this as being "complete". In the future, we could support + // retrying to fetch thumbnail of the image on a user click/tap. fully_drawn = true; - return; } - text_or_image_ref - .show_text(cx, format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri)); - // For now, we consider this as being "complete". In the future, we could support - // retrying to fetch thumbnail of the image on a user click/tap. - fully_drawn = true; } - } - }; + }; - let mut fetch_and_show_media_source = |cx: &mut Cx, media_source: MediaSource, image_info: Box| { - match media_source { - MediaSource::Encrypted(encrypted) => { - // We consider this as "fully drawn" since we don't yet support encryption. - text_or_image_ref.show_text( - cx, - format!("{body}\n\n[TODO] fetch encrypted image at {:?}", encrypted.url) - ); - }, - MediaSource::Plain(mxc_uri) => { - fetch_and_show_image_uri(cx, mxc_uri, image_info) + let mut fetch_and_show_media_source = + |cx: &mut Cx, media_source: MediaSource, image_info: Box| { + match media_source { + MediaSource::Encrypted(encrypted) => { + // We consider this as "fully drawn" since we don't yet support encryption. + text_or_image_ref.show_text( + cx, + format!( + "{body}\n\n[TODO] fetch encrypted image at {:?}", + encrypted.url + ), + ); + } + MediaSource::Plain(mxc_uri) => fetch_and_show_image_uri(cx, mxc_uri, image_info), } - } - }; + }; match image_info_source { Some(image_info) => { // Use the provided thumbnail URI if it exists; otherwise use the original URI. - let media_source = image_info.thumbnail_source.clone() + let media_source = image_info + .thumbnail_source + .clone() .unwrap_or(original_source); fetch_and_show_media_source(cx, media_source, image_info); } @@ -3374,7 +3773,6 @@ fn populate_image_message_content( fully_drawn } - /// Draws a file message's content into the given `message_content_widget`. /// /// Returns whether the file message content was fully drawn. @@ -3391,7 +3789,8 @@ fn populate_file_message_content( .and_then(|info| info.size) .map(|bytes| format!(" ({})", ByteSize::b(bytes.into()))) .unwrap_or_default(); - let caption = file_content.formatted_caption() + let caption = file_content + .formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| file_content.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -3418,20 +3817,23 @@ fn populate_audio_message_content( let (duration, mime, size) = audio .info .as_ref() - .map(|info| ( - info.duration - .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) - .unwrap_or_default(), - info.mimetype - .as_ref() - .map(|m| format!(" {m},")) - .unwrap_or_default(), - info.size - .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) - .unwrap_or_default(), - )) + .map(|info| { + ( + info.duration + .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) + .unwrap_or_default(), + info.mimetype + .as_ref() + .map(|m| format!(" {m},")) + .unwrap_or_default(), + info.size + .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) + .unwrap_or_default(), + ) + }) .unwrap_or_default(); - let caption = audio.formatted_caption() + let caption = audio + .formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| audio.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -3445,7 +3847,6 @@ fn populate_audio_message_content( true } - /// Draws a video message's content into the given `message_content_widget`. /// /// Returns whether the video message content was fully drawn. @@ -3459,23 +3860,26 @@ fn populate_video_message_content( let (duration, mime, size, dimensions) = video .info .as_ref() - .map(|info| ( - info.duration - .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) - .unwrap_or_default(), - info.mimetype - .as_ref() - .map(|m| format!(" {m},")) - .unwrap_or_default(), - info.size - .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) - .unwrap_or_default(), - info.width.and_then(|width| - info.height.map(|height| format!(" {width}x{height},")) - ).unwrap_or_default(), - )) + .map(|info| { + ( + info.duration + .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) + .unwrap_or_default(), + info.mimetype + .as_ref() + .map(|m| format!(" {m},")) + .unwrap_or_default(), + info.size + .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) + .unwrap_or_default(), + info.width + .and_then(|width| info.height.map(|height| format!(" {width}x{height},"))) + .unwrap_or_default(), + ) + }) .unwrap_or_default(); - let caption = video.formatted_caption() + let caption = video + .formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| video.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -3489,8 +3893,6 @@ fn populate_video_message_content( true } - - /// Draws the given location message's content into the `message_content_widget`. /// /// Returns whether the location message content was fully drawn. @@ -3499,8 +3901,9 @@ fn populate_location_message_content( message_content_widget: &HtmlOrPlaintextRef, location: &LocationMessageEventContent, ) -> bool { - let coords = location.geo_uri - .get(utils::GEO_URI_SCHEME.len() ..) + let coords = location + .geo_uri + .get(utils::GEO_URI_SCHEME.len()..) .and_then(|s| { let mut iter = s.split(','); if let (Some(lat), Some(long)) = (iter.next(), iter.next()) { @@ -3510,8 +3913,14 @@ fn populate_location_message_content( } }); if let Some((lat, long)) = coords { - let short_lat = lat.find('.').and_then(|dot| lat.get(..dot + 7)).unwrap_or(lat); - let short_long = long.find('.').and_then(|dot| long.get(..dot + 7)).unwrap_or(long); + let short_lat = lat + .find('.') + .and_then(|dot| lat.get(..dot + 7)) + .unwrap_or(lat); + let short_long = long + .find('.') + .and_then(|dot| long.get(..dot + 7)) + .unwrap_or(long); let html_body = format!( "Location: {short_lat},{short_long}
\
    \ @@ -3523,10 +3932,8 @@ fn populate_location_message_content( ); message_content_widget.show_html(cx, html_body); } else { - message_content_widget.show_html( - cx, - format!("[Location invalid] {}", location.body) - ); + message_content_widget + .show_html(cx, format!("[Location invalid] {}", location.body)); } // Currently we do not fetch location thumbnail previews, so we consider this as fully drawn. @@ -3535,7 +3942,6 @@ fn populate_location_message_content( true } - /// Draws a ReplyPreview above a message if it was in-reply to another message. /// /// ## Arguments @@ -3564,16 +3970,15 @@ fn draw_replied_to_message( match &in_reply_to_details.event { TimelineDetails::Ready(replied_to_event) => { - let (in_reply_to_username, is_avatar_fully_drawn) = - replied_to_message_view - .avatar(id!(replied_to_message_content.reply_preview_avatar)) - .set_avatar_and_get_username( - cx, - room_id, - &replied_to_event.sender, - Some(&replied_to_event.sender_profile), - Some(in_reply_to_details.event_id.as_ref()), - ); + let (in_reply_to_username, is_avatar_fully_drawn) = replied_to_message_view + .avatar(id!(replied_to_message_content.reply_preview_avatar)) + .set_avatar_and_get_username( + cx, + room_id, + &replied_to_event.sender, + Some(&replied_to_event.sender_profile), + Some(in_reply_to_details.event_id.as_ref()), + ); fully_drawn = is_avatar_fully_drawn; @@ -3647,23 +4052,32 @@ pub fn populate_preview_of_timeline_item( ) { if let Some(m) = timeline_item_content.as_message() { match m.msgtype() { - MessageType::Text(TextMessageEventContent { body, formatted, .. }) - | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. }) => { - let _ = populate_text_message_content(cx, widget_out, body, formatted.as_ref(), None, None, None); + MessageType::Text(TextMessageEventContent { + body, formatted, .. + }) + | MessageType::Notice(NoticeMessageEventContent { + body, formatted, .. + }) => { + let _ = populate_text_message_content( + cx, + widget_out, + body, + formatted.as_ref(), + None, + None, + None, + ); return; } - _ => { } // fall through to the general case for all timeline items below. + _ => {} // fall through to the general case for all timeline items below. } } - let html = text_preview_of_timeline_item( - timeline_item_content, - sender_user_id, - sender_username, - ).format_with(sender_username, true); + let html = + text_preview_of_timeline_item(timeline_item_content, sender_user_id, sender_username) + .format_with(sender_username, true); widget_out.show_html(cx, html); } - /// A trait for abstracting over the different types of timeline events /// that can be displayed in a `SmallStateEvent` widget. trait SmallStateEventContent { @@ -3715,7 +4129,8 @@ impl SmallStateEventContent for RedactedMessageEventMarker { event_tl_item.latest_json(), event_tl_item.sender(), original_sender, - ).format_with(original_sender, false), + ) + .format_with(original_sender, false), ); new_drawn_status.content_drawn = true; (item, new_drawn_status) @@ -3782,7 +4197,9 @@ impl SmallStateEventContent for PollState { ) -> (WidgetRef, ItemDrawnStatus) { item.label(id!(content)).set_text( cx, - self.fallback_text().unwrap_or_else(|| self.results().question).as_str(), + self.fallback_text() + .unwrap_or_else(|| self.results().question) + .as_str(), ); new_drawn_status.content_drawn = true; (item, new_drawn_status) @@ -3908,7 +4325,8 @@ fn populate_small_state_event( ); // Draw the timestamp as part of the profile. if let Some(dt) = unix_time_millis_to_datetime(event_tl_item.timestamp()) { - item.timestamp(id!(left_container.timestamp)).set_date_time(cx, dt); + item.timestamp(id!(left_container.timestamp)) + .set_date_time(cx, dt); } new_drawn_status.profile_drawn = profile_drawn; username @@ -3927,7 +4345,6 @@ fn populate_small_state_event( ) } - /// Returns the display name of the sender of the given `event_tl_item`, if available. fn get_profile_display_name(event_tl_item: &EventTimelineItem) -> Option { if let TimelineDetails::Ready(profile) = event_tl_item.sender_profile() { @@ -3937,7 +4354,6 @@ fn get_profile_display_name(event_tl_item: &EventTimelineItem) -> Option } } - /// Actions related to a specific message within a room timeline. #[derive(Clone, DefaultNone, Debug)] pub enum MessageAction { @@ -3980,7 +4396,6 @@ pub enum MessageAction { // /// The user clicked the "report" button on a message. // Report(MessageDetails), - /// The message at the given item index in the timeline should be highlighted. HighlightMessage(usize), /// The user requested that we show a context menu with actions @@ -4006,10 +4421,13 @@ pub enum MessageAction { /// A widget representing a single message of any kind within a room timeline. #[derive(Live, LiveHook, Widget)] pub struct Message { - #[deref] view: View, - #[animator] animator: Animator, + #[deref] + view: View, + #[animator] + animator: Animator, - #[rust] details: Option, + #[rust] + details: Option, } impl Widget for Message { @@ -4024,7 +4442,9 @@ impl Widget for Message { self.animator_play(cx, id!(highlight.off)); } - let Some(details) = self.details.clone() else { return }; + let Some(details) = self.details.clone() else { + return; + }; // We first handle a click on the replied-to message preview, if present, // because we don't want any widgets within the replied-to message to be @@ -4038,7 +4458,7 @@ impl Widget for Message { MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - } + }, ); } } @@ -4049,7 +4469,7 @@ impl Widget for Message { MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - } + }, ); } // If the hit occurred on the replied-to message preview, jump to it. @@ -4060,7 +4480,7 @@ impl Widget for Message { MessageAction::JumpToRelated(details.clone()), ); } - _ => { } + _ => {} } // Next, we forward the event to the child view such that it has the chance @@ -4083,7 +4503,7 @@ impl Widget for Message { MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - } + }, ); } } @@ -4094,7 +4514,7 @@ impl Widget for Message { MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - } + }, ); } Hit::FingerHoverIn(..) => { @@ -4105,7 +4525,7 @@ impl Widget for Message { self.animator_play(cx, id!(hover.off)); // TODO: here, hide the "action bar" buttons upon hover-out } - _ => { } + _ => {} } if let Event::Actions(actions) = event { @@ -4122,14 +4542,19 @@ impl Widget for Message { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - if self.details.as_ref().is_some_and(|d| d.should_be_highlighted) { + if self + .details + .as_ref() + .is_some_and(|d| d.should_be_highlighted) + { self.view.apply_over( - cx, live!( + cx, + live!( draw_bg: { color: (vec4(1.0, 1.0, 0.82, 1.0)) mentions_bar_color: #ffd54f } - ) + ), ) } @@ -4145,7 +4570,9 @@ impl Message { impl MessageRef { fn set_data(&self, details: MessageDetails) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_data(details); } } @@ -4154,10 +4581,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/room/member_search.rs b/src/room/member_search.rs new file mode 100644 index 00000000..5cdb4940 --- /dev/null +++ b/src/room/member_search.rs @@ -0,0 +1,1108 @@ +//! 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::sync::Arc; +use std::sync::mpsc::Sender; +use std::collections::BinaryHeap; +use matrix_sdk::room::{RoomMember, RoomMemberRole}; +use unicode_segmentation::UnicodeSegmentation; +use crate::shared::mentionable_text_input::SearchResult; +use crate::sliding_sync::current_user_id; +use makepad_widgets::log; + +/// 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, + } +} + +/// 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_room_members_streaming_with_sort(members, search_text, max_results, sender, 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, + precomputed_sort: Option>, +) { + // Get current user ID to filter out self-mentions + // Note: We capture this once at the start to avoid repeated global state access + let current_user_id = current_user_id(); + + // Constants for batching + const BATCH_SIZE: usize = 10; // Send results in batches + + // For empty search, use pre-computed sort if available + if search_text.is_empty() { + let all_results: Vec = if let Some(ref sort_data) = precomputed_sort { + // Ultra-fast path: O(K) - just take first K from pre-sorted indices + sort_data + .sorted_indices + .iter() + .take(max_results) + .copied() + .collect() + } else { + // Fallback: compute on the fly (should rarely happen) + let mut valid_members: Vec<(u8, u8, usize)> = Vec::with_capacity(members.len()); + + for (index, member) in members.iter().enumerate() { + // Skip the current user + if let Some(ref current_id) = current_user_id { + if member.user_id() == current_id { + continue; + } + } + + // Get power level rank (0=highest priority) + 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()); + + // Determine name category based on stripped name for consistency + 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, // Letters + Some(c) if c.is_numeric() => 1, // Numbers + _ => 2, // Symbols + } + } else { + 2 // All symbols + }; + + valid_members.push((power_rank, name_category, index)); + } + + // Sort all members by (power_rank, name_category, then by actual name) + 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 => { + // Only compute display names when needed for comparison + 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()); + + // Simple case-insensitive comparison without creating new strings + name_a + .chars() + .map(|c| c.to_ascii_lowercase()) + .cmp(name_b.chars().map(|c| c.to_ascii_lowercase())) + } + other => other, + }, + other => other, + } + }); + + // Take only the first max_results + valid_members.truncate(max_results); + + // Extract just the indices + valid_members.into_iter().map(|(_, _, idx)| idx).collect() + }; + + let mut sent_count = 0; + + // Send in batches + while sent_count < all_results.len() { + let batch_end = (sent_count + BATCH_SIZE).min(all_results.len()); + let batch: Vec<_> = all_results[sent_count..batch_end].to_vec(); + sent_count = batch_end; + + let is_last = sent_count >= all_results.len(); + let search_result = SearchResult { + results: batch, + is_complete: is_last, + search_text: search_text.clone(), + }; + + if sender.send(search_result).is_err() { + return; + } + } + + // If no results were sent, send completion signal + if all_results.is_empty() { + let completion_result = SearchResult { + results: Vec::new(), + is_complete: true, + search_text, + }; + let _ = sender.send(completion_result); + } + return; + } + + // Use a max-heap to keep only the top max_results (with best/smallest priorities) + // Max-heap keeps the worst element (highest priority value) at the top for easy replacement + let mut top_matches: BinaryHeap<(u8, usize)> = BinaryHeap::with_capacity(max_results); + + // Track if we have enough high-priority matches to stop early + let mut high_priority_count = 0; + let mut best_priority_seen = u8::MAX; + + for (index, member) in members.iter().enumerate() { + // 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 and get priority + if let Some(priority) = match_member_with_priority(member, &search_text) { + // Count high-priority matches (0-3 are exact or starts-with matches) + if priority <= 3 { + high_priority_count += 1; + } + best_priority_seen = best_priority_seen.min(priority); + + // Add to heap - maintain top K elements with smallest priorities + if top_matches.len() < max_results { + top_matches.push((priority, index)); + } else if let Some(&(worst_priority, _)) = top_matches.peek() { + // Only add if this match is better (smaller priority) than the worst in heap + if priority < worst_priority { + top_matches.pop(); // Remove worst element + top_matches.push((priority, index)); + } + } + + // Soft early exit: continue searching a bit more even after finding enough + // high-priority matches to ensure we don't miss better matches + // Only exit if we have significantly more high-priority matches than needed + if max_results > 0 + && high_priority_count >= max_results * 2 + && top_matches.len() == max_results + && best_priority_seen == 0 + { + break; + } + } + } + + // Extract results from heap and sort them with stable secondary sorting + let mut all_matches: Vec<(u8, usize)> = top_matches.into_iter().collect(); + + // Sort by priority first, then by power level, then by name category, then by sort_key + // This ensures consistency with empty search sorting + all_matches.sort_by(|(priority_a, idx_a), (priority_b, idx_b)| { + match priority_a.cmp(priority_b) { + std::cmp::Ordering::Equal => { + // Same priority - use precomputed sort keys if available + if let Some(ref sort_data) = precomputed_sort { + // Get precomputed keys for efficient comparison + let key_a = &sort_data.member_keys[*idx_a]; + let key_b = &sort_data.member_keys[*idx_b]; + + // Sort by: power_rank → name_category → sort_key + 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 { + // Fallback: compute on the fly (should rarely happen) + let member_a = &members[*idx_a]; + let member_b = &members[*idx_b]; + + // Get power level ranks + let power_a = match member_a.suggested_role_for_power_level() { + RoomMemberRole::Administrator | RoomMemberRole::Creator => 0, + RoomMemberRole::Moderator => 1, + RoomMemberRole::User => 2, + }; + let power_b = match member_b.suggested_role_for_power_level() { + RoomMemberRole::Administrator | RoomMemberRole::Creator => 0, + RoomMemberRole::Moderator => 1, + RoomMemberRole::User => 2, + }; + + match power_a.cmp(&power_b) { + std::cmp::Ordering::Equal => { + // Same power level - sort by display name + 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()); + + // Use efficient ASCII lowercase for ASCII strings + 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, + } + }); + + // Send results in sorted batches + let mut sent_count = 0; + let total_results = all_matches.len(); + + while sent_count < total_results { + let batch_end = (sent_count + BATCH_SIZE).min(total_results); + + let batch: Vec = all_matches + .get(sent_count..batch_end) + .map(|slice| slice.iter().map(|(_, idx)| *idx).collect()) + .unwrap_or_else(Vec::new); + + if batch.is_empty() { + break; // Safety: prevent infinite loop + } + + sent_count = batch_end; + let is_last_batch = sent_count >= total_results; + + let search_result = SearchResult { + results: batch, + is_complete: is_last_batch, + search_text: search_text.clone(), + }; + + // Sending search results + if sender.send(search_result).is_err() { + log!("Failed to send search results - receiver dropped"); + return; + } + } + + // If we didn't send any results, send completion signal + if total_results == 0 { + // No search results found, sending completion signal + let completion_result = SearchResult { + results: Vec::new(), + is_complete: true, + search_text, + }; + if sender.send(completion_result).is_err() { + // Failed to send completion signal - receiver dropped + } + } +} + +/// 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 { + // Early return for empty search - all members match with lowest priority + 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(); + + // Determine if we should do case-insensitive search (only for pure ASCII text) + 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(); + + // Priority 0: Exact display name match (case-sensitive) + if display_name == Some(search_text) { + return Some(0); + } + + // Priority 1: Exact display name match (case-insensitive for ASCII) + if case_insensitive { + if let Some(display) = display_name { + if display.eq_ignore_ascii_case(search_text) { + return Some(1); + } + } + } + + // Priority 2: Exact user ID match (with or without @) + if user_id == search_text + || (!search_has_at + && user_id.starts_with('@') + && user_id.strip_prefix('@') == Some(search_text)) + { + return Some(2); + } + + // Priority 3: Exact user ID match (case-insensitive for ASCII) + if case_insensitive { + if 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))) + { + return Some(3); + } + } + + // Priority 4: Display name starts with search text (case-sensitive) + if display_name.is_some_and(|d| d.starts_with(search_text)) { + return Some(4); + } + + // Priority 5: Display name starts with search text (case-insensitive for ASCII) + if case_insensitive { + if let Some(display) = display_name { + if starts_with_ignore_ascii_case(display, search_text) { + return Some(5); + } + } + } + + // Priority 6: User ID/localpart starts with search text (case-sensitive) + if 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) + { + return Some(6); + } + + // Priority 7: User ID/localpart starts with search text (case-insensitive) + if case_insensitive { + if starts_with_ignore_ascii_case(user_id, 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))) + { + return Some(7); + } + } + + // Priority 8: Display name contains search text (at word boundary or anywhere) + if let Some(display) = display_name { + if check_word_boundary_match(display, search_text, case_insensitive) { + return Some(8); + } + + if display.contains(search_text) { + return Some(8); + } + + if case_insensitive && contains_ignore_ascii_case(display, search_text) { + return Some(8); + } + } + + // Priority 9: User ID contains search text anywhere + if case_insensitive { + if contains_ignore_ascii_case(user_id, search_text) + || contains_ignore_ascii_case(localpart, search_text) + { + return Some(9); + } + } else if user_id.contains(search_text) || localpart.contains(search_text) { + return Some(9); + } + + // For non-ASCII text with complex graphemes, check grapheme-based matching + 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); // Treat as display name contains + } + } + } + + // No match found + 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)) +} + +#[cfg(test)] +mod tests { + use super::*; + use matrix_sdk::room::RoomMemberRole; + + #[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 71e860f2..843ff5c6 100644 --- a/src/room/mod.rs +++ b/src/room/mod.rs @@ -6,6 +6,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); @@ -21,7 +22,6 @@ pub struct BasicRoomDetails { pub room_avatar: RoomPreviewAvatar, } - #[derive(Clone)] pub enum RoomPreviewAvatar { Text(String), diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 2d2a91f8..a542f7f9 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -6,20 +6,65 @@ use crate::shared::avatar::AvatarWidgetRefExt; use crate::shared::bouncing_dots::BouncingDotsWidgetRefExt; use crate::shared::styles::COLOR_UNKNOWN_ROOM_AVATAR; use crate::utils; - +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 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; + +/// Result type for member search channel communication +#[derive(Debug, Clone)] +pub struct SearchResult { + pub results: Vec, // indices in members vec + pub is_complete: bool, + pub search_text: String, +} + +/// 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 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,66 +331,101 @@ 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 }, } /// Widget that extends CommandTextInput with @mention capabilities #[derive(Live, LiveHook, Widget)] pub struct MentionableTextInput { /// Base command text input - #[deref] cmd_text_input: CommandTextInput, + #[deref] + cmd_text_input: CommandTextInput, /// Template for user list items - #[live] user_list_item: Option, + #[live] + user_list_item: Option, /// Template for the @room mention list item - #[live] room_mention_list_item: Option, + #[live] + room_mention_list_item: Option, /// Template for loading indicator - #[live] loading_indicator: Option, + #[live] + loading_indicator: Option, /// Template for no matches indicator - #[live] no_matches_indicator: Option, - /// Position where the @ mention starts - #[rust] current_mention_start_index: Option, + #[live] + no_matches_indicator: Option, /// The set of users that were mentioned (at one point) in this text input. /// Due to characters being deleted/removed, this list is a *superset* /// of possible users who may have been mentioned. /// All of these mentions may not exist in the final text input content; /// this is just a list of users to search the final sent message for /// when adding in new mentions. - #[rust] possible_mentions: BTreeMap, + #[rust] + possible_mentions: BTreeMap, /// Indicates if the `@room` option was explicitly selected. - #[rust] possible_room_mention: bool, - /// Indicates if currently in mention search mode - #[rust] is_searching: bool, + #[rust] + possible_room_mention: bool, /// Whether the current user can notify everyone in the room (@room mention) - #[rust] can_notify_room: bool, - /// Whether the room members are currently being loaded - #[rust] members_loading: bool, + #[rust] + can_notify_room: bool, + /// Current state of the mention search functionality + #[rust] + search_state: MentionSearchState, + /// Last search text to avoid duplicate searches + #[rust] + last_search_text: Option, } - 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.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") + let scope_room_id = scope + .props + .get::() + .expect("RoomScreenProps should be available in scope for MentionableTextInput") .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(); let text_input_uid = text_input_ref.widget_uid(); 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 +434,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,31 +463,55 @@ 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 } => { + if &scope_room_id != room_id { + continue; + } + + if 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); + } } } } } - // 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::() @@ -408,14 +519,12 @@ impl Widget for MentionableTextInput { if let Some(room_members) = &room_props.room_members { if !room_members.is_empty() { - // Members are now available, update the list - self.members_loading = false; let text_input = self.cmd_text_input.text_input(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 +537,31 @@ 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(); - - 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 +578,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 +603,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 +762,41 @@ 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() { + // Search completed with no results + self.show_no_matches_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 +810,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 +827,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 +850,351 @@ 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.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 = String::new(); + 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, + .. + } = &mut self.search_state + { + while let Ok(result) = receiver.try_recv() { + any_results = true; + search_text = 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; + } + } - // 2. Check if members are loading and handle loading state - if self.handle_members_loading_state(cx, &room_props.room_members) { - return; + 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() + }; + + if !results_for_ui.is_empty() { + // Results are already sorted in member_search.rs and indices are unique + self.update_ui_with_results(cx, scope, &search_text); + } + } - // Clear old list items, prepare to populate new list - self.cmd_text_input.clear_items(); + // Handle completion + if is_complete { + // 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 !self.is_searching { - return; + if final_results.is_empty() { + // No user results, but still update UI (may show @room) + self.update_ui_with_results(cx, scope, &search_text); + } + + // 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.handle_search_channel_closed(cx, scope); + } } + // 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) { + // Clear old list items + self.cmd_text_input.clear_items(); + + 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 { 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 + }; + + // Check if we have cached members + let has_members = matches!(&room_props.room_members, Some(members) if !members.is_empty()); + + if has_members { + // We have cached members, transition to Searching state + 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); + } else { + // No cached members yet, transition to WaitingForMembers state + 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 + } + + // Only submit search request if we have cached members + if let Some(cached_members) = &room_props.room_members { + // Create a new channel for this search + let (sender, receiver) = std::sync::mpsc::channel(); + + // Submit search request to background worker + let search_text_clone = search_text.to_string(); + let max_results = max_visible_items * SEARCH_BUFFER_MULTIPLIER; + + // Transition to Searching state with new receiver + self.search_state = MentionSearchState::Searching { + trigger_position: trigger_pos, + _search_text: search_text.to_string(), + receiver, + accumulated_results: Vec::new(), + }; + + submit_async_request(MatrixRequest::SearchRoomMembers { + room_id: room_props.room_id.clone(), + search_text: search_text_clone, + sender, + max_results, + cached_members: cached_members.clone(), + precomputed_sort: room_props.room_members_sort.clone(), + }); + + // 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 +1213,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 +1227,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,113 +1267,21 @@ 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; - } - - // Should not reach here if user_matches_search returned true - u8::MAX - } - - /// Shows the loading indicator when members are being fetched + /// Shows the loading indicator when waiting for initial members to be loaded fn show_loading_indicator(&mut self, cx: &mut Cx) { // Clear any existing items self.cmd_text_input.clear_items(); // Create loading indicator widget - let Some(ptr) = self.loading_indicator else { return }; + 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); + loading_item + .bouncing_dots(id!(loading_animation)) + .start_animation(cx); // Add the loading indicator to the popup self.cmd_text_input.add_item(loading_item); @@ -1021,7 +1298,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,7 +1309,9 @@ 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 @@ -1049,19 +1328,59 @@ 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 + } + + /// Reset all search-related state + fn reset_search_state(&mut self) { + // Reset to idle state + self.search_state = MentionSearchState::Idle; - // Clear list items to avoid keeping old content when popup is shown again + // Reset last search text to allow new searches + self.last_search_text = None; + + // 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 +1400,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 +1411,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 +1423,6 @@ impl MentionableTextInput { pub fn can_notify_room(&self) -> bool { self.can_notify_room } - - } impl MentionableTextInputRef { @@ -1123,13 +1437,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 +1466,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 +1531,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 e5d5a284..bb0c8edb 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -7,24 +7,59 @@ use futures_util::{pin_mut, StreamExt}; use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk::{ - config::RequestConfig, crypto::{DecryptionSettings, TrustRequirement}, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, RoomMember}, ruma::{ - api::client::{profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType}, events::{ - room::{ - message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource - }, FullStateEventContent, MessageLikeEventType, StateEventType - }, matrix_uri::MatrixId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId - }, sliding_sync::VersionBuilder, Client, ClientBuildError, Error, OwnedServerName, Room, RoomMemberships, RoomState, SuccessorRoom + config::RequestConfig, + crypto::{DecryptionSettings, TrustRequirement}, + encryption::EncryptionSettings, + event_handler::EventHandlerDropGuard, + media::MediaRequestParameters, + room::{edit::EditedContent, reply::Reply, RoomMember}, + ruma::{ + api::client::{ + profile::{AvatarUrl, DisplayName}, + receipt::create_receipt::v3::ReceiptType, + }, + events::{ + room::{message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource}, + FullStateEventContent, MessageLikeEventType, StateEventType, + }, + matrix_uri::MatrixId, + MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, + OwnedUserId, RoomOrAliasId, UserId, + }, + sliding_sync::VersionBuilder, + Client, ClientBuildError, Error, OwnedServerName, Room, RoomMemberships, RoomState, + SuccessorRoom, }; use matrix_sdk_ui::{ - room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator}, sync_service::{self, SyncService}, timeline::{AnyOtherFullStateEventContent, EventTimelineItem, MembershipChange, RoomExt, TimelineEventItemId, TimelineItem, TimelineItemContent}, RoomListService, Timeline + room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator}, + sync_service::{self, SyncService}, + timeline::{ + AnyOtherFullStateEventContent, EventTimelineItem, MembershipChange, RoomExt, + TimelineEventItemId, TimelineItem, TimelineItemContent, + }, + RoomListService, Timeline, }; use robius_open::Uri; use tokio::{ runtime::Handle, - sync::{mpsc::{Receiver, Sender, UnboundedReceiver, UnboundedSender}, watch, Notify}, task::JoinHandle, time::error::Elapsed, + sync::{ + mpsc::{Receiver, Sender, UnboundedReceiver, UnboundedSender}, + watch, Notify, + }, + task::JoinHandle, + time::error::Elapsed, }; use url::Url; -use std::{cmp::{max, min}, collections::{BTreeMap, BTreeSet}, future::Future, iter::Peekable, ops::{Deref, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; +use std::{ + cmp::{max, min}, + collections::{BTreeMap, BTreeSet}, + future::Future, + iter::Peekable, + ops::{Deref, Not}, + path::Path, + sync::{Arc, LazyLock, Mutex}, + time::Duration, +}; use std::io; use crate::{ app::AppStateAction, @@ -32,23 +67,37 @@ use crate::{ avatar_cache::AvatarUpdate, event_preview::text_preview_of_timeline_item, home::{ - invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewRateLimitResponse, LinkPreviewDataNonNumeric}, room_screen::TimelineUpdate, rooms_list::{self, enqueue_rooms_list_update, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate}, rooms_list_header::RoomsListHeaderAction + invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, + link_preview::{LinkPreviewData, LinkPreviewRateLimitResponse, LinkPreviewDataNonNumeric}, + room_screen::TimelineUpdate, + rooms_list::{ + self, enqueue_rooms_list_update, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, + RoomsListUpdate, + }, + rooms_list_header::RoomsListHeaderAction, }, login::login_screen::LoginAction, - logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{is_logout_in_progress, logout_with_state_machine, LogoutConfig}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, + logout::{ + logout_confirm_modal::LogoutAction, + logout_state_machine::{is_logout_in_progress, logout_with_state_machine, LogoutConfig}, + }, + media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, load_app_state, ClientSessionPersisted}, profile::{ user_profile::{AvatarState, UserProfile}, user_profile_cache::{enqueue_user_profile_update, UserProfileUpdate}, }, - room::RoomPreviewAvatar, + room::{ + RoomPreviewAvatar, + member_search::{search_room_members_streaming_with_sort, PrecomputedMemberSort}, + }, shared::{ html_or_plaintext::MatrixLinkPillState, jump_to_bottom_button::UnreadMessageCount, - popup_list::{enqueue_popup_notification, PopupItem, PopupKind} + popup_list::{enqueue_popup_notification, PopupItem, PopupKind}, }, utils::{self, avatar_from_room_name, AVATAR_THUMBNAIL_FORMAT}, - verification::add_verification_event_handlers_and_sync_client + verification::add_verification_event_handlers_and_sync_client, }; #[derive(Parser, Debug, Default)] @@ -90,7 +139,6 @@ impl From for Cli { } } - /// Build a new client. async fn build_client( cli: &Cli, @@ -112,9 +160,11 @@ async fn build_client( .collect() }; - let homeserver_url = cli.homeserver.as_deref() + let homeserver_url = cli + .homeserver + .as_deref() .unwrap_or("https://matrix-client.matrix.org/"); - // .unwrap_or("https://matrix.org/"); + // .unwrap_or("https://matrix.org/"); let mut builder = Client::builder() .server_name_or_homeserver_url(homeserver_url) @@ -139,13 +189,11 @@ async fn build_client( // Use a 60 second timeout for all requests to the homeserver. // Yes, this is a long timeout, but the standard matrix homeserver is often very slow. - builder = builder.request_config( - RequestConfig::new() - .timeout(std::time::Duration::from_secs(60)) - ); + builder = + builder.request_config(RequestConfig::new().timeout(std::time::Duration::from_secs(60))); let client = builder.build().await?; - let homeserver_url = client.homeserver().to_string(); + let homeserver_url = client.homeserver().to_string(); Ok(( client, ClientSessionPersisted { @@ -161,10 +209,7 @@ async fn build_client( /// This function is used by the login screen to log in to the Matrix server. /// /// Upon success, this function returns the logged-in client and an optional sync token. -async fn login( - cli: &Cli, - login_request: LoginRequest, -) -> Result<(Client, Option)> { +async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option)> { match login_request { LoginRequest::LoginByCli | LoginRequest::LoginByPassword(_) => { let cli = if let LoginRequest::LoginByPassword(login_by_password) = login_request { @@ -188,13 +233,23 @@ async fn login( if let Err(e) = persistence::save_session(&client, client_session).await { let err_msg = format!("Failed to save session state to storage: {e}"); error!("{err_msg}"); - enqueue_popup_notification(PopupItem { message: err_msg, kind: PopupKind::Error, auto_dismissal_duration: None }); + enqueue_popup_notification(PopupItem { + message: err_msg, + kind: PopupKind::Error, + auto_dismissal_duration: None, + }); } Ok((client, None)) } else { let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); - enqueue_popup_notification(PopupItem { message: err_msg.clone(), kind: PopupKind::Error, auto_dismissal_duration: None }); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + enqueue_popup_notification(PopupItem { + message: err_msg.clone(), + kind: PopupKind::Error, + auto_dismissal_duration: None, + }); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.clone(), + }); bail!(err_msg); } } @@ -211,7 +266,6 @@ async fn login( } } - /// Which direction to paginate in. /// /// * `Forwards` will retrieve later events (towards the end of the timeline), @@ -286,9 +340,7 @@ pub enum MatrixRequest { /// Request from the login screen to log in with the given credentials. Login(LoginRequest), /// Request to logout. - Logout{ - is_desktop: bool, - }, + Logout { is_desktop: bool }, /// Request to paginate the older (or newer) events of a room's timeline. PaginateRoomTimeline { room_id: OwnedRoomId, @@ -309,17 +361,11 @@ pub enum MatrixRequest { }, /// Request to fetch profile information for all members of a room. /// This can be *very* slow depending on the number of members in the room. - SyncRoomMemberList { - room_id: OwnedRoomId, - }, + SyncRoomMemberList { room_id: OwnedRoomId }, /// Request to join the given room. - JoinRoom { - room_id: OwnedRoomId, - }, + JoinRoom { room_id: OwnedRoomId }, /// Request to leave the given room. - LeaveRoom { - room_id: OwnedRoomId, - }, + LeaveRoom { room_id: OwnedRoomId }, /// Request to get the actual list of members in a room. /// This returns the list of members that can be displayed in the UI. GetRoomMembers { @@ -329,6 +375,15 @@ pub enum MatrixRequest { /// * If `false` (recommended), details will be fetched from the server. local_only: bool, }, + /// Request to search room members in background thread + SearchRoomMembers { + room_id: OwnedRoomId, + search_text: String, + sender: std::sync::mpsc::Sender, + max_results: usize, + cached_members: Arc>, + precomputed_sort: Option>, + }, /// Request to fetch profile information for the given user ID. GetUserProfile { user_id: OwnedUserId, @@ -342,9 +397,7 @@ pub enum MatrixRequest { local_only: bool, }, /// Request to fetch the number of unread messages in the given room. - GetNumberUnreadMessages { - room_id: OwnedRoomId, - }, + GetNumberUnreadMessages { room_id: OwnedRoomId }, /// Request to ignore/block or unignore/unblock a user. IgnoreUser { /// Whether to ignore (`true`) or unignore (`false`) the user. @@ -387,15 +440,12 @@ pub enum MatrixRequest { /// This request does not return a response or notify the UI thread, and /// furthermore, there is no need to send a follow-up request to stop typing /// (though you certainly can do so). - SendTypingNotice { - room_id: OwnedRoomId, - typing: bool, - }, + SendTypingNotice { room_id: OwnedRoomId, typing: bool }, /// Spawn an async task to login to the given Matrix homeserver using the given SSO identity provider ID. /// /// While an SSO request is in flight, the login screen will temporarily prevent the user /// from submitting another redundant request, until this request has succeeded or failed. - SpawnSSOServer{ + SpawnSSOServer { brand: String, homeserver_url: String, identity_provider_id: String, @@ -435,9 +485,7 @@ pub enum MatrixRequest { /// Sends a request to obtain the power levels for this room. /// /// The response is delivered back to the main UI thread via [`TimelineUpdate::UserPowerLevels`]. - GetRoomPowerLevels { - room_id: OwnedRoomId, - }, + GetRoomPowerLevels { room_id: OwnedRoomId }, /// Toggles the given reaction to the given event in the given room. ToggleReaction { room_id: OwnedRoomId, @@ -463,7 +511,7 @@ pub enum MatrixRequest { /// The MatrixLinkPillInfo::Loaded variant is sent back to the main UI thread via. GetMatrixRoomLinkPillInfo { matrix_id: MatrixId, - via: Vec + via: Vec, }, /// Request to fetch URL preview from the Matrix homeserver. GetUrlPreview { @@ -477,18 +525,18 @@ pub enum MatrixRequest { /// Submits a request to the worker thread to be executed asynchronously. pub fn submit_async_request(req: MatrixRequest) { if let Some(sender) = REQUEST_SENDER.lock().unwrap().as_ref() { - sender.send(req) + sender + .send(req) .expect("BUG: async worker task receiver has died!"); } } /// Details of a login request that get submitted within [`MatrixRequest::Login`]. -pub enum LoginRequest{ +pub enum LoginRequest { LoginByPassword(LoginByPassword), LoginBySSOSuccess(Client, ClientSessionPersisted), LoginByCli, HomeserverLoginTypesQuery(String), - } /// Information needed to log in to a Matrix homeserver. pub struct LoginByPassword { @@ -497,7 +545,6 @@ pub struct LoginByPassword { pub homeserver: Option, } - /// The entry point for an async worker thread that can run async tasks. /// /// All this thread does is wait for [`MatrixRequests`] from the main UI-driven non-async thread(s) @@ -507,7 +554,8 @@ async fn async_worker( login_sender: Sender, ) -> Result<()> { log!("Started async_worker task."); - let mut subscribers_own_user_read_receipts: BTreeMap> = BTreeMap::new(); + let mut subscribers_own_user_read_receipts: BTreeMap> = + BTreeMap::new(); let mut subscribers_pinned_events: BTreeMap> = BTreeMap::new(); while let Some(request) = request_receiver.recv().await { @@ -516,7 +564,7 @@ async fn async_worker( if let Err(e) = login_sender.send(login_request).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to async worker thread." + "BUG: failed to send login request to async worker thread.", ))); } } @@ -529,7 +577,7 @@ async fn async_worker( match logout_with_state_machine(is_desktop).await { Ok(()) => { log!("Logout completed successfully via state machine"); - }, + } Err(e) => { error!("Logout failed: {e:?}"); } @@ -537,7 +585,11 @@ async fn async_worker( }); } - MatrixRequest::PaginateRoomTimeline { room_id, num_events, direction } => { + MatrixRequest::PaginateRoomTimeline { + room_id, + num_events, + direction, + } => { let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { @@ -586,28 +638,43 @@ async fn async_worker( }); } - MatrixRequest::EditMessage { room_id, timeline_event_item_id: timeline_event_id, edited_content } => { + MatrixRequest::EditMessage { + room_id, + timeline_event_item_id: timeline_event_id, + edited_content, + } => { let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { error!("BUG: room info not found for edit request, room {room_id}"); continue; }; - (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) + ( + room_info.timeline.clone(), + room_info.timeline_update_sender.clone(), + ) }; // Spawn a new async task that will make the actual edit request. let _edit_task = Handle::current().spawn(async move { - log!("Sending request to edit message {timeline_event_id:?} in room {room_id}..."); + log!( + "Sending request to edit message {timeline_event_id:?} in room {room_id}..." + ); let result = timeline.edit(&timeline_event_id, edited_content).await; match result { - Ok(_) => log!("Successfully edited message {timeline_event_id:?} in room {room_id}."), - Err(ref e) => error!("Error editing message {timeline_event_id:?} in room {room_id}: {e:?}"), + Ok(_) => log!( + "Successfully edited message {timeline_event_id:?} in room {room_id}." + ), + Err(ref e) => error!( + "Error editing message {timeline_event_id:?} in room {room_id}: {e:?}" + ), } - sender.send(TimelineUpdate::MessageEdited { - timeline_event_id, - result, - }).unwrap(); + sender + .send(TimelineUpdate::MessageEdited { + timeline_event_id, + result, + }) + .unwrap(); SignalToUI::set_ui_signal(); }); } @@ -616,11 +683,16 @@ async fn async_worker( let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - error!("BUG: room info not found for fetch details for event request {room_id}"); + error!( + "BUG: room info not found for fetch details for event request {room_id}" + ); continue; }; - (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) + ( + room_info.timeline.clone(), + room_info.timeline_update_sender.clone(), + ) }; // Spawn a new async task that will make the actual fetch request. @@ -635,10 +707,9 @@ async fn async_worker( // error!("Error fetching details for event {event_id} in room {room_id}: {e:?}"); } } - sender.send(TimelineUpdate::EventDetailsFetched { - event_id, - result, - }).unwrap(); + sender + .send(TimelineUpdate::EventDetailsFetched { event_id, result }) + .unwrap(); SignalToUI::set_ui_signal(); }); } @@ -651,7 +722,10 @@ async fn async_worker( continue; }; - (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) + ( + room_info.timeline.clone(), + room_info.timeline_update_sender.clone(), + ) }; // Spawn a new async task that will make the actual fetch request. @@ -679,8 +753,7 @@ async fn async_worker( JoinRoomResultAction::Failed { room_id, error: e } } } - } - else { + } else { match client.join_room_by_id(&room_id).await { Ok(_room) => { log!("Successfully joined new unknown room {room_id}."); @@ -716,7 +789,7 @@ async fn async_worker( LeaveRoomResultAction::Failed { room_id, error: matrix_sdk::Error::UnknownError( - String::from("Client couldn't locate room to leave it.").into() + String::from("Client couldn't locate room to leave it.").into(), ), } }; @@ -724,14 +797,21 @@ async fn async_worker( }); } - MatrixRequest::GetRoomMembers { room_id, memberships, local_only } => { + MatrixRequest::GetRoomMembers { + room_id, + memberships, + local_only, + } => { let (timeline, sender) = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { log!("BUG: room info not found for get room members request {room_id}"); continue; }; - (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) + ( + room_info.timeline.clone(), + room_info.timeline_update_sender.clone(), + ) }; let _get_members_task = Handle::current().spawn(async move { @@ -739,9 +819,9 @@ async fn async_worker( let send_update = |members: Vec, source: &str| { log!("{} {} members for room {}", source, members.len(), room_id); - sender.send(TimelineUpdate::RoomMembersListFetched { - members - }).unwrap(); + sender + .send(TimelineUpdate::RoomMembersListFetched { members }) + .unwrap(); SignalToUI::set_ui_signal(); }; @@ -757,7 +837,32 @@ async fn async_worker( }); } - MatrixRequest::GetUserProfile { user_id, room_id, local_only } => { + MatrixRequest::SearchRoomMembers { + room_id: _, + search_text, + sender, + max_results, + cached_members, + precomputed_sort, + } => { + // Directly spawn blocking task for search + let _search_task = tokio::task::spawn_blocking(move || { + // Perform streaming search with precomputed sort data + search_room_members_streaming_with_sort( + cached_members, + search_text, + max_results, + sender, + precomputed_sort, + ); + }); + } + + MatrixRequest::GetUserProfile { + user_id, + room_id, + local_only, + } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { // log!("Sending get user profile request: user: {user_id}, \ @@ -830,11 +935,16 @@ async fn async_worker( let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - log!("Skipping get number of unread messages request for not-yet-known room {room_id}"); + log!( + "Skipping get number of unread messages request for not-yet-known room {room_id}" + ); continue; }; - (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) + ( + room_info.timeline.clone(), + room_info.timeline_update_sender.clone(), + ) }; let _get_unreads_task = Handle::current().spawn(async move { match sender.send(TimelineUpdate::NewUnreadMessagesCount( @@ -850,7 +960,11 @@ async fn async_worker( }); }); } - MatrixRequest::IgnoreUser { ignore, room_member, room_id } => { + MatrixRequest::IgnoreUser { + ignore, + room_member, + room_id, + } => { let Some(client) = get_client() else { continue }; let _ignore_task = Handle::current().spawn(async move { let user_id = room_member.user_id(); @@ -919,16 +1033,22 @@ async fn async_worker( let (room, timeline_update_sender, mut typing_notice_receiver) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - log!("BUG: room info not found for subscribe to typing notices request, room {room_id}"); + log!( + "BUG: room info not found for subscribe to typing notices request, room {room_id}" + ); continue; }; let (room, recv) = if subscribe { if room_info.typing_notice_subscriber.is_some() { - warning!("Note: room {room_id} is already subscribed to typing notices."); + warning!( + "Note: room {room_id} is already subscribed to typing notices." + ); continue; } else { let Some(room) = get_client().and_then(|c| c.get_room(&room_id)) else { - error!("BUG: client/room not found when subscribing to typing notices request, room: {room_id}"); + error!( + "BUG: client/room not found when subscribing to typing notices request, room: {room_id}" + ); continue; }; let (drop_guard, recv) = room.subscribe_to_typing_notifications(); @@ -967,7 +1087,8 @@ async fn async_worker( } MatrixRequest::SubscribeToOwnUserReadReceiptsChanged { room_id, subscribe } => { if !subscribe { - if let Some(task_handler) = subscribers_own_user_read_receipts.remove(&room_id) { + if let Some(task_handler) = subscribers_own_user_read_receipts.remove(&room_id) + { task_handler.abort(); } continue; @@ -975,10 +1096,15 @@ async fn async_worker( let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - log!("BUG: room info not found for subscribe to own user read receipts changed request, room {room_id}"); + log!( + "BUG: room info not found for subscribe to own user read receipts changed request, room {room_id}" + ); continue; }; - (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) + ( + room_info.timeline.clone(), + room_info.timeline_update_sender.clone(), + ) }; let room_id_clone = room_id.clone(); let subscribe_own_read_receipt_task = Handle::current().spawn(async move { @@ -1028,10 +1154,15 @@ async fn async_worker( let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - log!("BUG: room info not found for subscribe to pinned events request, room {room_id}"); + log!( + "BUG: room info not found for subscribe to pinned events request, room {room_id}" + ); continue; }; - (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) + ( + room_info.timeline.clone(), + room_info.timeline_update_sender.clone(), + ) }; let room_id2 = room_id.clone(); let subscribe_pinned_events_task = Handle::current().spawn(async move { @@ -1053,8 +1184,18 @@ async fn async_worker( }); subscribers_pinned_events.insert(room_id, subscribe_pinned_events_task); } - MatrixRequest::SpawnSSOServer { brand, homeserver_url, identity_provider_id} => { - spawn_sso_server(brand, homeserver_url, identity_provider_id, login_sender.clone()).await; + MatrixRequest::SpawnSSOServer { + brand, + homeserver_url, + identity_provider_id, + } => { + spawn_sso_server( + brand, + homeserver_url, + identity_provider_id, + login_sender.clone(), + ) + .await; } MatrixRequest::ResolveRoomAlias(room_alias) => { let Some(client) = get_client() else { continue }; @@ -1065,7 +1206,10 @@ async fn async_worker( todo!("Send the resolved room alias back to the UI thread somehow."); }); } - MatrixRequest::FetchAvatar { mxc_uri, on_fetched } => { + MatrixRequest::FetchAvatar { + mxc_uri, + on_fetched, + } => { let Some(client) = get_client() else { continue }; Handle::current().spawn(async move { // log!("Sending fetch avatar request for {mxc_uri:?}..."); @@ -1075,11 +1219,19 @@ async fn async_worker( }; let res = client.media().get_media_content(&media_request, true).await; // log!("Fetched avatar for {mxc_uri:?}, succeeded? {}", res.is_ok()); - on_fetched(AvatarUpdate { mxc_uri, avatar_data: res.map(|v| v.into()) }); + on_fetched(AvatarUpdate { + mxc_uri, + avatar_data: res.map(|v| v.into()), + }); }); } - MatrixRequest::FetchMedia { media_request, on_fetched, destination, update_sender } => { + MatrixRequest::FetchMedia { + media_request, + on_fetched, + destination, + update_sender, + } => { let Some(client) = get_client() else { continue }; let media = client.media(); @@ -1178,7 +1330,9 @@ async fn async_worker( let timeline = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { - log!("BUG: room info not found when sending read receipt, room {room_id}, {event_id}"); + log!( + "BUG: room info not found when sending read receipt, room {room_id}, {event_id}" + ); continue; }; room_info.timeline.clone() @@ -1195,13 +1349,17 @@ async fn async_worker( unread_mentions: timeline.room().num_unread_mentions() }); }); - }, + } - MatrixRequest::FullyReadReceipt { room_id, event_id, .. } => { + MatrixRequest::FullyReadReceipt { + room_id, event_id, .. + } => { let timeline = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { - log!("BUG: room info not found when sending fully read receipt, room {room_id}, {event_id}"); + log!( + "BUG: room info not found when sending fully read receipt, room {room_id}, {event_id}" + ); continue; }; room_info.timeline.clone() @@ -1220,7 +1378,7 @@ async fn async_worker( unread_mentions: timeline.room().num_unread_mentions() }); }); - }, + } MatrixRequest::GetRoomPowerLevels { room_id } => { let (timeline, sender) = { @@ -1230,10 +1388,15 @@ async fn async_worker( continue; }; - (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) + ( + room_info.timeline.clone(), + room_info.timeline_update_sender.clone(), + ) }; - let Some(user_id) = current_user_id() else { continue }; + let Some(user_id) = current_user_id() else { + continue; + }; let _power_levels_task = Handle::current().spawn(async move { match timeline.room().power_levels().await { @@ -1251,8 +1414,12 @@ async fn async_worker( } } }); - }, - MatrixRequest::ToggleReaction { room_id, timeline_event_id, reaction } => { + } + MatrixRequest::ToggleReaction { + room_id, + timeline_event_id, + reaction, + } => { let timeline = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { @@ -1272,9 +1439,12 @@ async fn async_worker( Err(_e) => error!("Failed to send toggle reaction to room {room_id} {reaction}; error: {_e:?}"), } }); - - }, - MatrixRequest::RedactMessage { room_id, timeline_event_id, reason } => { + } + MatrixRequest::RedactMessage { + room_id, + timeline_event_id, + reason, + } => { let timeline = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { @@ -1289,19 +1459,30 @@ async fn async_worker( Ok(()) => log!("Successfully redacted message in room {room_id}."), Err(e) => { error!("Failed to redact message in {room_id}; error: {e:?}"); - enqueue_popup_notification(PopupItem { message: format!("Failed to redact message. Error: {e}"), kind: PopupKind::Error, auto_dismissal_duration: None }); + enqueue_popup_notification(PopupItem { + message: format!("Failed to redact message. Error: {e}"), + kind: PopupKind::Error, + auto_dismissal_duration: None, + }); } } }); - }, - MatrixRequest::PinEvent { room_id, event_id, pin } => { + } + MatrixRequest::PinEvent { + room_id, + event_id, + pin, + } => { let (timeline, sender) = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { log!("BUG: room info not found for pin message {room_id}"); continue; }; - (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) + ( + room_info.timeline.clone(), + room_info.timeline_update_sender.clone(), + ) }; let _pin_task = Handle::current().spawn(async move { @@ -1310,7 +1491,11 @@ async fn async_worker( } else { timeline.unpin_event(&event_id).await }; - match sender.send(TimelineUpdate::PinResult { event_id, pin, result }) { + match sender.send(TimelineUpdate::PinResult { + event_id, + pin, + result, + }) { Ok(_) => SignalToUI::set_ui_signal(), Err(e) => log!("Failed to send timeline update for pin event: {e:?}"), } @@ -1342,7 +1527,12 @@ async fn async_worker( } }); } - MatrixRequest::GetUrlPreview { url, on_fetched, destination, update_sender,} => { + MatrixRequest::GetUrlPreview { + url, + on_fetched, + destination, + update_sender, + } => { const MAX_LOG_RESPONSE_BODY_LENGTH: usize = 1000; log!("Starting URL preview fetch for: {}", url); @@ -1353,7 +1543,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 @@ -1363,7 +1553,7 @@ 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()) @@ -1376,20 +1566,20 @@ 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]); @@ -1413,7 +1603,7 @@ async fn async_worker( destination: destination.clone(), update_sender: update_sender.clone(), }); - + } } Err(e) => { @@ -1437,7 +1627,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) => { @@ -1456,7 +1646,6 @@ async fn async_worker( bail!("async_worker task ended unexpectedly") } - /// The single global Tokio runtime that is used by all async tasks. static TOKIO_RUNTIME: Mutex> = Mutex::new(None); @@ -1468,9 +1657,8 @@ static REQUEST_SENDER: Mutex>> = Mutex::ne /// in order to speed up the client-building process when the user logs in. static DEFAULT_SSO_CLIENT: Mutex> = Mutex::new(None); /// Used to notify the SSO login task that the async creation of the `DEFAULT_SSO_CLIENT` has finished. -static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = LazyLock::new( - || Arc::new(Notify::new()) -); +static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = + LazyLock::new(|| Arc::new(Notify::new())); /// Blocks the current thread until the given future completes. /// @@ -1481,29 +1669,36 @@ pub fn block_on_async_with_timeout( timeout: Option, async_future: impl Future, ) -> Result { - let rt = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - ).handle().clone(); + let rt = TOKIO_RUNTIME + .lock() + .unwrap() + .get_or_insert_with(|| { + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + }) + .handle() + .clone(); if let Some(timeout) = timeout { - rt.block_on(async { - tokio::time::timeout(timeout, async_future).await - }) + rt.block_on(async { tokio::time::timeout(timeout, async_future).await }) } else { Ok(rt.block_on(async_future)) } } - /// The primary initialization routine for starting the Matrix client sync /// and the async tokio runtime. /// /// Returns a handle to the Tokio runtime that is used to run async background tasks. pub fn start_matrix_tokio() -> Result { // Create a Tokio runtime, and save it in a static variable to ensure it isn't dropped. - let rt_handle = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| { - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - }).handle().clone(); + let rt_handle = TOKIO_RUNTIME + .lock() + .unwrap() + .get_or_insert_with(|| { + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + }) + .handle() + .clone(); // Create a channel to be used between UI thread(s) and the async worker thread. let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); @@ -1588,7 +1783,6 @@ pub fn start_matrix_tokio() -> Result { Ok(rt_handle) } - /// A tokio::watch channel sender for sending requests from the RoomScreen UI widget /// to the corresponding background async task for that room (its `timeline_subscriber_handler`). pub type TimelineRequestSender = watch::Sender>; @@ -1639,9 +1833,9 @@ impl Drop for JoinedRoomDetails { } } - /// Information about all joined rooms that our client currently know about. -static ALL_JOINED_ROOMS: Mutex> = Mutex::new(BTreeMap::new()); +static ALL_JOINED_ROOMS: Mutex> = + Mutex::new(BTreeMap::new()); /// The logged-in Matrix client, which can be freely and cheaply cloned. static CLIENT: Mutex> = Mutex::new(None); @@ -1652,15 +1846,16 @@ pub fn get_client() -> Option { /// Returns the user ID of the currently logged-in user, if any. pub fn current_user_id() -> Option { - CLIENT.lock().unwrap().as_ref().and_then(|c| - c.session_meta().map(|m| m.user_id.clone()) - ) + CLIENT + .lock() + .unwrap() + .as_ref() + .and_then(|c| c.session_meta().map(|m| m.user_id.clone())) } /// The singleton sync service. static SYNC_SERVICE: Mutex>> = Mutex::new(None); - /// Get a reference to the current sync service, if available. pub fn get_sync_service() -> Option> { SYNC_SERVICE.lock().ok()?.as_ref().cloned() @@ -1681,7 +1876,6 @@ pub fn is_user_ignored(user_id: &UserId) -> bool { IGNORED_USERS.lock().unwrap().contains(user_id) } - /// Returns three channel endpoints related to the timeline for the given joined room. /// /// 1. A timeline update sender. @@ -1689,49 +1883,46 @@ pub fn is_user_ignored(user_id: &UserId) -> bool { /// 3. A `tokio::watch` sender that can be used to send requests to the timeline subscriber handler. /// /// This will only succeed once per room, as only a single channel receiver can exist. -pub fn take_timeline_endpoints( - room_id: &OwnedRoomId, -) -> Option -{ +pub fn take_timeline_endpoints(room_id: &OwnedRoomId) -> Option { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); all_joined_rooms .get_mut(room_id) - .and_then(|jrd| jrd.timeline_singleton_endpoints.take() - .map(|(update_receiver, request_sender)| - (jrd.timeline_update_sender.clone(), update_receiver, request_sender, jrd.timeline.room().successor_room()) - ) - ) - .map(|(update_sender, update_receiver, request_sender, successor_room)| { - TimelineEndpoints { + .and_then(|jrd| { + jrd.timeline_singleton_endpoints + .take() + .map(|(update_receiver, request_sender)| { + ( + jrd.timeline_update_sender.clone(), + update_receiver, + request_sender, + jrd.timeline.room().successor_room(), + ) + }) + }) + .map( + |(update_sender, update_receiver, request_sender, successor_room)| TimelineEndpoints { update_sender, update_receiver, request_sender, successor_room, - } - }) + }, + ) } const DEFAULT_HOMESERVER: &str = "matrix.org"; -fn username_to_full_user_id( - username: &str, - homeserver: Option<&str>, -) -> Option { - username - .try_into() - .ok() - .or_else(|| { - let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); - let user_id_str = if username.starts_with("@") { - format!("{}:{}", username, homeserver_url) - } else { - format!("@{}:{}", username, homeserver_url) - }; - user_id_str.as_str().try_into().ok() - }) +fn username_to_full_user_id(username: &str, homeserver: Option<&str>) -> Option { + username.try_into().ok().or_else(|| { + let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); + let user_id_str = if username.starts_with("@") { + format!("{}:{}", username, homeserver_url) + } else { + format!("@{}:{}", username, homeserver_url) + }; + user_id_str.as_str().try_into().ok() + }) } - /// Info we store about a room received by the room list service. /// /// This struct is necessary in order for us to track the previous state @@ -1761,35 +1952,33 @@ impl From for RoomListServiceRoomInfo { } } -async fn async_main_loop( - mut login_receiver: Receiver, -) -> Result<()> { +async fn async_main_loop(mut login_receiver: Receiver) -> Result<()> { // only init subscribe once let _ = tracing_subscriber::fmt::try_init(); let most_recent_user_id = persistence::most_recent_user_id(); log!("Most recent user ID: {most_recent_user_id:?}"); let cli_parse_result = Cli::try_parse(); - let cli_has_valid_username_password = cli_parse_result.as_ref() + let cli_has_valid_username_password = cli_parse_result + .as_ref() .is_ok_and(|cli| !cli.user_id.is_empty() && !cli.password.is_empty()); - log!("CLI parsing succeeded? {}. CLI has valid UN+PW? {}", + log!( + "CLI parsing succeeded? {}. CLI has valid UN+PW? {}", cli_parse_result.as_ref().is_ok(), cli_has_valid_username_password, ); - let wait_for_login = !cli_has_valid_username_password && ( - most_recent_user_id.is_none() - || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login") - ); + let wait_for_login = !cli_has_valid_username_password + && (most_recent_user_id.is_none() + || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login")); log!("Waiting for login? {}", wait_for_login); let new_login_opt = if !wait_for_login { - let specified_username = cli_parse_result.as_ref().ok().and_then(|cli| - username_to_full_user_id( - &cli.user_id, - cli.homeserver.as_deref(), - ) - ); - log!("Trying to restore session for user: {:?}", + let specified_username = cli_parse_result + .as_ref() + .ok() + .and_then(|cli| username_to_full_user_id(&cli.user_id, cli.homeserver.as_deref())); + log!( + "Trying to restore session for user: {:?}", specified_username.as_ref().or(most_recent_user_id.as_ref()) ); match persistence::restore_session(specified_username).await { @@ -1800,7 +1989,10 @@ async fn async_main_loop( Cx::post_action(LoginAction::LoginFailure(status_err.to_string())); if let Ok(cli) = &cli_parse_result { - log!("Attempting auto-login from CLI arguments as user '{}'...", cli.user_id); + log!( + "Attempting auto-login from CLI arguments as user '{}'...", + cli.user_id + ); Cx::post_action(LoginAction::CliAutoLogin { user_id: cli.user_id.clone(), homeserver: cli.homeserver.clone(), @@ -1809,9 +2001,9 @@ async fn async_main_loop( Ok(new_login) => Some(new_login), Err(e) => { error!("CLI-based login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure( - format!("Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}") - )); + Cx::post_action(LoginAction::LoginFailure(format!( + "Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}" + ))); enqueue_rooms_list_update(RoomsListUpdate::Status { status: format!("Login failed: {e:?}"), }); @@ -1829,31 +2021,27 @@ async fn async_main_loop( let cli: Cli = cli_parse_result.unwrap_or(Cli::default()); let (client, _sync_token) = match new_login_opt { Some(new_login) => new_login, - None => { - loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => { - match login(&cli, login_request).await { - Ok((client, sync_token)) => { - break (client, sync_token); - } - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), - }); - } - } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - return Err(anyhow::anyhow!("BUG: login_receiver hung up unexpectedly")); + None => loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => match login(&cli, login_request).await { + Ok((client, sync_token)) => { + break (client, sync_token); + } + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: format!("Login failed: {e}"), + }); } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + return Err(anyhow::anyhow!("BUG: login_receiver hung up unexpectedly")); } } - } + }, }; Cx::post_action(LoginAction::LoginSuccess); @@ -1863,16 +2051,22 @@ async fn async_main_loop( let _ = client_opt.take(); } - let logged_in_user_id = client.user_id() + let logged_in_user_id = client + .user_id() .expect("BUG: client.user_id() returned None after successful login!"); let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); // enqueue_popup_notification(status.clone()); enqueue_rooms_list_update(RoomsListUpdate::Status { status }); - client.event_cache().subscribe().expect("BUG: CLIENT's event cache unable to subscribe"); + client + .event_cache() + .subscribe() + .expect("BUG: CLIENT's event cache unable to subscribe"); if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { - error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); + error!( + "BUG: unexpectedly replaced an existing client when initializing the matrix client." + ); } add_verification_event_handlers_and_sync_client(client.clone()); @@ -1894,7 +2088,9 @@ async fn async_main_loop( let room_list_service = sync_service.room_list_service(); if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { - error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); + error!( + "BUG: unexpectedly replaced an existing sync service when initializing the matrix client." + ); } let all_rooms_list = room_list_service.all_rooms().await?; @@ -1904,9 +2100,7 @@ async fn async_main_loop( // TODO: paginate room list to avoid loading all rooms at once all_rooms_list.entries_with_dynamic_adapters(usize::MAX); - room_list_dynamic_entries_controller.set_filter( - Box::new(|_room| true), - ); + room_list_dynamic_entries_controller.set_filter(Box::new(|_room| true)); let mut all_known_rooms: Vector = Vector::new(); @@ -1917,30 +2111,40 @@ async fn async_main_loop( match diff { VectorDiff::Append { values: new_rooms } => { let _num_new_rooms = new_rooms.len(); - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Append {_num_new_rooms}"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Append {_num_new_rooms}"); + } for new_room in new_rooms { add_new_room(&new_room, &room_list_service).await?; all_known_rooms.push_back(new_room.into_inner().into()); } } VectorDiff::Clear => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Clear"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Clear"); + } all_known_rooms.clear(); ALL_JOINED_ROOMS.lock().unwrap().clear(); enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); } VectorDiff::PushFront { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushFront"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PushFront"); + } add_new_room(&new_room, &room_list_service).await?; all_known_rooms.push_front(new_room.into_inner().into()); } VectorDiff::PushBack { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushBack"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PushBack"); + } add_new_room(&new_room, &room_list_service).await?; all_known_rooms.push_back(new_room.into_inner().into()); } remove_diff @ VectorDiff::PopFront => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopFront"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PopFront"); + } if let Some(room) = all_known_rooms.pop_front() { optimize_remove_then_add_into_update( remove_diff, @@ -1948,11 +2152,14 @@ async fn async_main_loop( &mut peekable_diffs, &mut all_known_rooms, &room_list_service, - ).await?; + ) + .await?; } } remove_diff @ VectorDiff::PopBack => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopBack"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PopBack"); + } if let Some(room) = all_known_rooms.pop_back() { optimize_remove_then_add_into_update( remove_diff, @@ -1960,16 +2167,27 @@ async fn async_main_loop( &mut peekable_diffs, &mut all_known_rooms, &room_list_service, - ).await?; + ) + .await?; } } - VectorDiff::Insert { index, value: new_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Insert at {index}"); } + VectorDiff::Insert { + index, + value: new_room, + } => { + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Insert at {index}"); + } add_new_room(&new_room, &room_list_service).await?; all_known_rooms.insert(index, new_room.into_inner().into()); } - VectorDiff::Set { index, value: changed_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Set at {index}"); } + VectorDiff::Set { + index, + value: changed_room, + } => { + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Set at {index}"); + } if let Some(old_room) = all_known_rooms.get(index) { update_room(old_room, &changed_room, &room_list_service).await?; } else { @@ -1977,8 +2195,12 @@ async fn async_main_loop( } all_known_rooms.set(index, changed_room.into_inner().into()); } - remove_diff @ VectorDiff::Remove { index: remove_index } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Remove at {remove_index}"); } + remove_diff @ VectorDiff::Remove { + index: remove_index, + } => { + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Remove at {remove_index}"); + } if remove_index < all_known_rooms.len() { let room = all_known_rooms.remove(remove_index); optimize_remove_then_add_into_update( @@ -1987,13 +2209,19 @@ async fn async_main_loop( &mut peekable_diffs, &mut all_known_rooms, &room_list_service, - ).await?; + ) + .await?; } else { - error!("BUG: room_list: diff Remove index {remove_index} out of bounds, len {}", all_known_rooms.len()); + error!( + "BUG: room_list: diff Remove index {remove_index} out of bounds, len {}", + all_known_rooms.len() + ); } } VectorDiff::Truncate { length } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Truncate to {length}"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Truncate to {length}"); + } // Iterate manually so we can know which rooms are being removed. while all_known_rooms.len() > length { if let Some(room) = all_known_rooms.pop_back() { @@ -2004,7 +2232,13 @@ async fn async_main_loop( } VectorDiff::Reset { values: new_rooms } => { // We implement this by clearing all rooms and then adding back the new values. - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Reset, old length {}, new length {}", all_known_rooms.len(), new_rooms.len()); } + if LOG_ROOM_LIST_DIFFS { + log!( + "room_list: diff Reset, old length {}, new length {}", + all_known_rooms.len(), + new_rooms.len() + ); + } // Iterate manually so we can know which rooms are being removed. while let Some(room) = all_known_rooms.pop_back() { remove_room(&room); @@ -2028,7 +2262,6 @@ async fn async_main_loop( bail!("room list service sync loop ended unexpectedly") } - /// Attempts to optimize a common RoomListService operation of remove + add. /// /// If a `Remove` diff (or `PopBack` or `PopFront`) is immediately followed by @@ -2047,31 +2280,37 @@ async fn optimize_remove_then_add_into_update( ) -> Result<()> { let next_diff_was_handled: bool; match peekable_diffs.peek() { - Some(VectorDiff::Insert { index: insert_index, value: new_room }) - if room.room_id == new_room.room_id() => - { + Some(VectorDiff::Insert { + index: insert_index, + value: new_room, + }) if room.room_id == new_room.room_id() => { if LOG_ROOM_LIST_DIFFS { - log!("Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", room.room_id); + log!( + "Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", + room.room_id + ); } update_room(room, new_room, room_list_service).await?; all_known_rooms.insert(*insert_index, new_room.deref().clone().into()); next_diff_was_handled = true; } - Some(VectorDiff::PushFront { value: new_room }) - if room.room_id == new_room.room_id() => - { + Some(VectorDiff::PushFront { value: new_room }) if room.room_id == new_room.room_id() => { if LOG_ROOM_LIST_DIFFS { - log!("Optimizing {remove_diff:?} + PushFront into Update for room {}", room.room_id); + log!( + "Optimizing {remove_diff:?} + PushFront into Update for room {}", + room.room_id + ); } update_room(room, new_room, room_list_service).await?; all_known_rooms.push_front(new_room.deref().clone().into()); next_diff_was_handled = true; } - Some(VectorDiff::PushBack { value: new_room }) - if room.room_id == new_room.room_id() => - { + Some(VectorDiff::PushBack { value: new_room }) if room.room_id == new_room.room_id() => { if LOG_ROOM_LIST_DIFFS { - log!("Optimizing {remove_diff:?} + PushBack into Update for room {}", room.room_id); + log!( + "Optimizing {remove_diff:?} + PushBack into Update for room {}", + room.room_id + ); } update_room(room, new_room, room_list_service).await?; all_known_rooms.push_back(new_room.deref().clone().into()); @@ -2087,7 +2326,6 @@ async fn optimize_remove_then_add_into_update( Ok(()) } - /// Invoked when the room list service has received an update that changes an existing room. async fn update_room( old_room: &RoomListServiceRoomInfo, @@ -2103,7 +2341,9 @@ async fn update_room( let old_room_state = old_room.room_state; let new_room_state = new_room.state(); if LOG_ROOM_LIST_DIFFS { - log!("Room {new_room_name:?} ({new_room_id}) state went from {old_room_state:?} --> {new_room_state:?}"); + log!( + "Room {new_room_name:?} ({new_room_id}) state went from {old_room_state:?} --> {new_room_state:?}" + ); } if old_room_state != new_room_state { match new_room_state { @@ -2124,11 +2364,15 @@ async fn update_room( return Ok(()); } RoomState::Joined => { - log!("update_room(): adding new Joined room: {new_room_name:?} ({new_room_id})"); + log!( + "update_room(): adding new Joined room: {new_room_name:?} ({new_room_id})" + ); return add_new_room(new_room, room_list_service).await; } RoomState::Invited => { - log!("update_room(): adding new Invited room: {new_room_name:?} ({new_room_id})"); + log!( + "update_room(): adding new Invited room: {new_room_name:?} ({new_room_id})" + ); return add_new_room(new_room, room_list_service).await; } RoomState::Knocked => { @@ -2138,7 +2382,6 @@ async fn update_room( } } - let Some(client) = get_client() else { return Ok(()); }; @@ -2171,8 +2414,18 @@ async fn update_room( } if let Some(new_room_name) = new_room_name { - if old_room.room.cached_display_name().map(|room_name| room_name.to_string()).as_ref() != Some(&new_room_name) { - log!("Updating room name for room {} to {}", new_room_id, new_room_name); + if old_room + .room + .cached_display_name() + .map(|room_name| room_name.to_string()) + .as_ref() + != Some(&new_room_name) + { + log!( + "Updating room name for room {} to {}", + new_room_id, + new_room_name + ); enqueue_rooms_list_update(RoomsListUpdate::UpdateRoomName { room_id: new_room_id.clone(), new_room_name, @@ -2193,34 +2446,31 @@ async fn update_room( enqueue_rooms_list_update(RoomsListUpdate::UpdateNumUnreadMessages { room_id: new_room_id.clone(), count: UnreadMessageCount::Known(new_room.num_unread_messages()), - unread_mentions: new_room.num_unread_mentions() + unread_mentions: new_room.num_unread_mentions(), }); } Ok(()) - } - else { - warning!("UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", - old_room.room_id, new_room_id, + } else { + warning!( + "UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", + old_room.room_id, + new_room_id, ); remove_room(old_room); add_new_room(new_room, room_list_service).await } } - /// Invoked when the room list service has received an update to remove an existing room. fn remove_room(room: &RoomListServiceRoomInfo) { ALL_JOINED_ROOMS.lock().unwrap().remove(&room.room_id); - enqueue_rooms_list_update( - RoomsListUpdate::RemoveRoom { - room_id: room.room_id.clone(), - new_state: room.room_state, - } - ); + enqueue_rooms_list_update(RoomsListUpdate::RemoveRoom { + room_id: room.room_id.clone(), + new_state: room.room_state, + }); } - /// Invoked when the room list service has received an update with a brand new room. async fn add_new_room(room: &matrix_sdk::Room, room_list_service: &RoomListService) -> Result<()> { let room_id = room.room_id().to_owned(); @@ -2260,9 +2510,9 @@ async fn add_new_room(room: &matrix_sdk::Room, room_list_service: &RoomListServi } else { None }; - let latest = latest_event.as_ref().map( - |ev| get_latest_event_details(ev, &room_id) - ); + let latest = latest_event + .as_ref() + .map(|ev| get_latest_event_details(ev, &room_id)); let room_avatar = room_avatar(room, room_name.as_deref()).await; let inviter_info = if let Some(inviter) = invite_details.and_then(|d| d.inviter) { @@ -2279,34 +2529,37 @@ async fn add_new_room(room: &matrix_sdk::Room, room_list_service: &RoomListServi } else { None }; - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom(InvitedRoomInfo { - room_id: room_id.clone(), - room_name, - inviter_info, - room_avatar, - canonical_alias: room.canonical_alias(), - alt_aliases: room.alt_aliases(), - latest, - invite_state: Default::default(), - is_selected: false, - is_direct, - })); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom( + InvitedRoomInfo { + room_id: room_id.clone(), + room_name, + inviter_info, + room_avatar, + canonical_alias: room.canonical_alias(), + alt_aliases: room.alt_aliases(), + latest, + invite_state: Default::default(), + is_selected: false, + is_direct, + }, + )); Cx::post_action(AppStateAction::RoomLoadedSuccessfully(room_id)); return Ok(()); } - RoomState::Joined => { } // Fall through to adding the joined room below. + RoomState::Joined => {} // Fall through to adding the joined room below. } // Subscribe to all updates for this room in order to properly receive all of its states. room_list_service.subscribe_to_rooms(&[&room_id]).await; - let timeline = Arc::new( room.timeline_builder() .track_read_marker_and_receipts() .build() .await - .map_err(|e| anyhow::anyhow!("BUG: Failed to build timeline for room {room_id}: {e}"))?, + .map_err(|e| { + anyhow::anyhow!("BUG: Failed to build timeline for room {room_id}: {e}") + })?, ); let latest_event = timeline.latest_event().await; let (timeline_update_sender, timeline_update_receiver) = crossbeam_channel::unbounded(); @@ -2319,9 +2572,9 @@ async fn add_new_room(room: &matrix_sdk::Room, room_list_service: &RoomListServi request_receiver, )); - let latest = latest_event.as_ref().map( - |ev| get_latest_event_details(ev, &room_id) - ); + let latest = latest_event + .as_ref() + .map(|ev| get_latest_event_details(ev, &room_id)); log!("Adding new joined room {room_id}."); ALL_JOINED_ROOMS.lock().unwrap().insert( @@ -2365,7 +2618,8 @@ async fn add_new_room(room: &matrix_sdk::Room, room_list_service: &RoomListServi #[allow(unused)] async fn current_ignore_user_list(client: &Client) -> Option> { use matrix_sdk::ruma::events::ignored_user_list::IgnoredUserListEventContent; - let ignored_users = client.account() + let ignored_users = client + .account() .account_data::() .await .ok()?? @@ -2427,7 +2681,9 @@ fn handle_load_app_state(user_id: OwnedUserId) { && !app_state.saved_dock_state.dock_items.is_empty() { log!("Loaded room panel state from app data directory. Restoring now..."); - Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState(app_state)); + Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState( + app_state, + )); } } Err(_e) => { @@ -2435,7 +2691,7 @@ fn handle_load_app_state(user_id: OwnedUserId) { enqueue_popup_notification(PopupItem { message: String::from("Could not restore the previous dock layout."), kind: PopupKind::Error, - auto_dismissal_duration: None + auto_dismissal_duration: None, }); } } @@ -2471,14 +2727,12 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { const SYNC_INDICATOR_DELAY: Duration = Duration::from_millis(100); /// Duration for sync indicator delay before hiding const SYNC_INDICATOR_HIDE_DELAY: Duration = Duration::from_millis(200); - let sync_indicator_stream = sync_service.room_list_service() - .sync_indicator( - SYNC_INDICATOR_DELAY, - SYNC_INDICATOR_HIDE_DELAY - ); - + let sync_indicator_stream = sync_service + .room_list_service() + .sync_indicator(SYNC_INDICATOR_DELAY, SYNC_INDICATOR_HIDE_DELAY); + Handle::current().spawn(async move { - let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); + let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); while let Some(indicator) = sync_indicator_stream.next().await { let is_syncing = match indicator { @@ -2491,7 +2745,10 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { } fn handle_room_list_service_loading_state(mut loading_state: Subscriber) { - log!("Initial room list loading state is {:?}", loading_state.get()); + log!( + "Initial room list loading state is {:?}", + loading_state.get() + ); Handle::current().spawn(async move { while let Some(state) = loading_state.next().await { log!("Received a room list loading state update: {state:?}"); @@ -2499,8 +2756,12 @@ fn handle_room_list_service_loading_state(mut loading_state: Subscriber { enqueue_rooms_list_update(RoomsListUpdate::NotLoaded); } - RoomListLoadingState::Loaded { maximum_number_of_rooms } => { - enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { max_rooms: maximum_number_of_rooms }); + RoomListLoadingState::Loaded { + maximum_number_of_rooms, + } => { + enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { + max_rooms: maximum_number_of_rooms, + }); } } } @@ -2523,11 +2784,11 @@ fn get_latest_event_details( latest_event.content(), latest_event.sender(), sender_username, - ).format_with(sender_username, true), + ) + .format_with(sender_username, true), ) } - /// A request to search backwards for a specific event in a room's timeline. pub struct BackwardsPaginateUntilEventRequest { pub room_id: OwnedRoomId, @@ -2554,7 +2815,6 @@ async fn timeline_subscriber_handler( timeline_update_sender: crossbeam_channel::Sender, mut request_receiver: watch::Receiver>, ) { - /// An inner function that searches the given new timeline items for a target event. /// /// If the target event is found, it is removed from the `target_event_id_opt` and returned, @@ -2563,14 +2823,13 @@ async fn timeline_subscriber_handler( target_event_id_opt: &mut Option, mut new_items_iter: impl Iterator>, ) -> Option<(usize, OwnedEventId)> { - let found_index = target_event_id_opt - .as_ref() - .and_then(|target_event_id| new_items_iter - .position(|new_item| new_item + let found_index = target_event_id_opt.as_ref().and_then(|target_event_id| { + new_items_iter.position(|new_item| { + new_item .as_event() .is_some_and(|new_ev| new_ev.event_id() == Some(target_event_id)) - ) - ); + }) + }); if let Some(index) = found_index { target_event_id_opt.take().map(|ev| (index, ev)) @@ -2579,11 +2838,13 @@ async fn timeline_subscriber_handler( } } - let room_id = room.room_id().to_owned(); log!("Starting timeline subscriber for room {room_id}..."); let (mut timeline_items, mut subscriber) = timeline.subscribe().await; - log!("Received initial timeline update of {} items for room {room_id}.", timeline_items.len()); + log!( + "Received initial timeline update of {} items for room {room_id}.", + timeline_items.len() + ); timeline_update_sender.send(TimelineUpdate::FirstUpdate { initial_items: timeline_items.clone(), @@ -2598,279 +2859,281 @@ async fn timeline_subscriber_handler( // the timeline index and event ID of the target event, if it has been found. let mut found_target_event_id: Option<(usize, OwnedEventId)> = None; - loop { tokio::select! { - // we should check for new requests before handling new timeline updates, - // because the request might influence how we handle a timeline update. - biased; - - // Handle updates to the current backwards pagination requests. - Ok(()) = request_receiver.changed() => { - let prev_target_event_id = target_event_id.clone(); - let new_request_details = request_receiver - .borrow_and_update() - .iter() - .find_map(|req| req.room_id - .eq(&room_id) - .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) - ); + loop { + tokio::select! { + // we should check for new requests before handling new timeline updates, + // because the request might influence how we handle a timeline update. + biased; + + // Handle updates to the current backwards pagination requests. + Ok(()) = request_receiver.changed() => { + let prev_target_event_id = target_event_id.clone(); + let new_request_details = request_receiver + .borrow_and_update() + .iter() + .find_map(|req| req.room_id + .eq(&room_id) + .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) + ); - target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); + target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); - // If we received a new request, start searching backwards for the target event. - if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { - if prev_target_event_id.as_ref() != Some(&new_target_event_id) { - let starting_index = if current_tl_len == timeline_items.len() { - starting_index - } else { - // The timeline has changed since the request was made, so we can't rely on the `starting_index`. - // Instead, we have no choice but to start from the end of the timeline. - timeline_items.len() - }; - // log!("Received new request to search for event {new_target_event_id} in room {room_id} starting from index {starting_index} (tl len {}).", timeline_items.len()); - // Search backwards for the target event in the timeline, starting from the given index. - if let Some(target_event_tl_index) = timeline_items - .focus() - .narrow(..starting_index) - .into_iter() - .rev() - .position(|i| i.as_event() - .and_then(|e| e.event_id()) - .is_some_and(|ev_id| ev_id == new_target_event_id) - ) - .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) - { - // log!("Found existing target event {new_target_event_id} in room {room_id} at index {target_event_tl_index}."); - - // Nice! We found the target event in the current timeline items, - // so there's no need to actually proceed with backwards pagination; - // thus, we can clear the locally-tracked target event ID. - target_event_id = None; - found_target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: new_target_event_id.clone(), - index: target_event_tl_index, - } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}!") - ); - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); - } - else { - log!("Target event not in timeline. Starting backwards pagination \ - in room {room_id} to find target event {new_target_event_id} \ - starting from index {starting_index}.", - ); - // If we didn't find the target event in the current timeline items, - // we need to start loading previous items into the timeline. - submit_async_request(MatrixRequest::PaginateRoomTimeline { - room_id: room_id.clone(), - num_events: 50, - direction: PaginationDirection::Backwards, - }); + // If we received a new request, start searching backwards for the target event. + if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { + if prev_target_event_id.as_ref() != Some(&new_target_event_id) { + let starting_index = if current_tl_len == timeline_items.len() { + starting_index + } else { + // The timeline has changed since the request was made, so we can't rely on the `starting_index`. + // Instead, we have no choice but to start from the end of the timeline. + timeline_items.len() + }; + // log!("Received new request to search for event {new_target_event_id} in room {room_id} starting from index {starting_index} (tl len {}).", timeline_items.len()); + // Search backwards for the target event in the timeline, starting from the given index. + if let Some(target_event_tl_index) = timeline_items + .focus() + .narrow(..starting_index) + .into_iter() + .rev() + .position(|i| i.as_event() + .and_then(|e| e.event_id()) + .is_some_and(|ev_id| ev_id == new_target_event_id) + ) + .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) + { + // log!("Found existing target event {new_target_event_id} in room {room_id} at index {target_event_tl_index}."); + + // Nice! We found the target event in the current timeline items, + // so there's no need to actually proceed with backwards pagination; + // thus, we can clear the locally-tracked target event ID. + target_event_id = None; + found_target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: new_target_event_id.clone(), + index: target_event_tl_index, + } + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}!") + ); + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); + } + else { + log!("Target event not in timeline. Starting backwards pagination \ + in room {room_id} to find target event {new_target_event_id} \ + starting from index {starting_index}.", + ); + // If we didn't find the target event in the current timeline items, + // we need to start loading previous items into the timeline. + submit_async_request(MatrixRequest::PaginateRoomTimeline { + room_id: room_id.clone(), + num_events: 50, + direction: PaginationDirection::Backwards, + }); + } } } } - } - // Handle updates to the actual timeline content. - batch_opt = subscriber.next() => { - let Some(batch) = batch_opt else { break }; - let mut num_updates = 0; - // For now we always requery the latest event, but this can be better optimized. - let mut reobtain_latest_event = true; - let mut index_of_first_change = usize::MAX; - let mut index_of_last_change = usize::MIN; - // whether to clear the entire cache of drawn items - let mut clear_cache = false; - // whether the changes include items being appended to the end of the timeline - let mut is_append = false; - for diff in batch { - num_updates += 1; - match diff { - VectorDiff::Append { values } => { - let _values_len = values.len(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.extend(values); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; - is_append = true; - } - VectorDiff::Clear => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Clear"); } - clear_cache = true; - timeline_items.clear(); - reobtain_latest_event = true; - } - VectorDiff::PushFront { value } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PushFront"); } - if let Some((index, _ev)) = found_target_event_id.as_mut() { - *index += 1; // account for this new `value` being prepended. - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); + // Handle updates to the actual timeline content. + batch_opt = subscriber.next() => { + let Some(batch) = batch_opt else { break }; + let mut num_updates = 0; + // For now we always requery the latest event, but this can be better optimized. + let mut reobtain_latest_event = true; + let mut index_of_first_change = usize::MAX; + let mut index_of_last_change = usize::MIN; + // whether to clear the entire cache of drawn items + let mut clear_cache = false; + // whether the changes include items being appended to the end of the timeline + let mut is_append = false; + for diff in batch { + num_updates += 1; + match diff { + VectorDiff::Append { values } => { + let _values_len = values.len(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.extend(values); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; + is_append = true; } - - clear_cache = true; - timeline_items.push_front(value); - reobtain_latest_event |= latest_event.is_none(); - } - VectorDiff::PushBack { value } => { - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.push_back(value); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; - is_append = true; - } - VectorDiff::PopFront => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PopFront"); } - clear_cache = true; - timeline_items.pop_front(); - if let Some((i, _ev)) = found_target_event_id.as_mut() { - *i = i.saturating_sub(1); // account for the first item being removed. + VectorDiff::Clear => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Clear"); } + clear_cache = true; + timeline_items.clear(); + reobtain_latest_event = true; } - // This doesn't affect whether we should reobtain the latest event. - } - VectorDiff::PopBack => { - timeline_items.pop_back(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); - index_of_last_change = usize::MAX; - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; - } - VectorDiff::Insert { index, value } => { - if index == 0 { + VectorDiff::PushFront { value } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PushFront"); } + if let Some((index, _ev)) = found_target_event_id.as_mut() { + *index += 1; // account for this new `value` being prepended. + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); + } + clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = usize::MAX; + timeline_items.push_front(value); + reobtain_latest_event |= latest_event.is_none(); } - if index >= timeline_items.len() { + VectorDiff::PushBack { value } => { + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.push_back(value); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; is_append = true; } - - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for this new `value` being inserted before the previously-found target event's index. - if index <= *i { - *i += 1; + VectorDiff::PopFront => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PopFront"); } + clear_cache = true; + timeline_items.pop_front(); + if let Some((i, _ev)) = found_target_event_id.as_mut() { + *i = i.saturating_sub(1); // account for the first item being removed. } - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) - .map(|(i, ev)| (i + index, ev)); + // This doesn't affect whether we should reobtain the latest event. } - - timeline_items.insert(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; - } - VectorDiff::Set { index, value } => { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = max(index_of_last_change, index.saturating_add(1)); - timeline_items.set(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; - } - VectorDiff::Remove { index } => { - if index == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); + VectorDiff::PopBack => { + timeline_items.pop_back(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); index_of_last_change = usize::MAX; + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; } - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for an item being removed before the previously-found target event's index. - if index <= *i { - *i = i.saturating_sub(1); + VectorDiff::Insert { index, value } => { + if index == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, index); + index_of_last_change = usize::MAX; + } + if index >= timeline_items.len() { + is_append = true; } + + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for this new `value` being inserted before the previously-found target event's index. + if index <= *i { + *i += 1; + } + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) + .map(|(i, ev)| (i + index, ev)); + } + + timeline_items.insert(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; } - timeline_items.remove(index); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; - } - VectorDiff::Truncate { length } => { - if length == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); - index_of_last_change = usize::MAX; + VectorDiff::Set { index, value } => { + index_of_first_change = min(index_of_first_change, index); + index_of_last_change = max(index_of_last_change, index.saturating_add(1)); + timeline_items.set(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; + } + VectorDiff::Remove { index } => { + if index == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); + index_of_last_change = usize::MAX; + } + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for an item being removed before the previously-found target event's index. + if index <= *i { + *i = i.saturating_sub(1); + } + } + timeline_items.remove(index); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; + } + VectorDiff::Truncate { length } => { + if length == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); + index_of_last_change = usize::MAX; + } + timeline_items.truncate(length); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; + } + VectorDiff::Reset { values } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Reset, new length {}", values.len()); } + clear_cache = true; // we must assume all items have changed. + timeline_items = values; + reobtain_latest_event = true; } - timeline_items.truncate(length); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; - } - VectorDiff::Reset { values } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Reset, new length {}", values.len()); } - clear_cache = true; // we must assume all items have changed. - timeline_items = values; - reobtain_latest_event = true; } } - } - if num_updates > 0 { - let new_latest_event = if reobtain_latest_event { - timeline.latest_event().await - } else { - None - }; + if num_updates > 0 { + let new_latest_event = if reobtain_latest_event { + timeline.latest_event().await + } else { + None + }; - // Handle the case where back pagination inserts items at the beginning of the timeline - // (meaning the entire timeline needs to be re-drawn), - // but there is a virtual event at index 0 (e.g., a day divider). - // When that happens, we want the RoomScreen to treat this as if *all* events changed. - if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { - index_of_first_change = 0; - clear_cache = true; - } + // Handle the case where back pagination inserts items at the beginning of the timeline + // (meaning the entire timeline needs to be re-drawn), + // but there is a virtual event at index 0 (e.g., a day divider). + // When that happens, we want the RoomScreen to treat this as if *all* events changed. + if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { + index_of_first_change = 0; + clear_cache = true; + } - let changed_indices = index_of_first_change..index_of_last_change; + let changed_indices = index_of_first_change..index_of_last_change; - if LOG_TIMELINE_DIFFS { - log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); - } - timeline_update_sender.send(TimelineUpdate::NewItems { - new_items: timeline_items.clone(), - changed_indices, - clear_cache, - is_append, - }).expect("Error: timeline update sender couldn't send update with new items!"); - - // We must send this update *after* the actual NewItems update, - // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. - if let Some((index, found_event_id)) = found_target_event_id.take() { - target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: found_event_id.clone(), - index, - } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}!") - ); - } + if LOG_TIMELINE_DIFFS { + log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); + } + timeline_update_sender.send(TimelineUpdate::NewItems { + new_items: timeline_items.clone(), + changed_indices, + clear_cache, + is_append, + }).expect("Error: timeline update sender couldn't send update with new items!"); + + // We must send this update *after* the actual NewItems update, + // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. + if let Some((index, found_event_id)) = found_target_event_id.take() { + target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: found_event_id.clone(), + index, + } + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}!") + ); + } - // Update the latest event for this room. - // We always do this in case a redaction or other event has changed the latest event. - if let Some(new_latest) = new_latest_event { - let room_avatar_changed = update_latest_event(&room, &new_latest, Some(&timeline_update_sender)); - if room_avatar_changed { - spawn_fetch_room_avatar(room.clone()); + // Update the latest event for this room. + // We always do this in case a redaction or other event has changed the latest event. + if let Some(new_latest) = new_latest_event { + let room_avatar_changed = update_latest_event(&room, &new_latest, Some(&timeline_update_sender)); + if room_avatar_changed { + spawn_fetch_room_avatar(room.clone()); + } + latest_event = Some(new_latest); } - latest_event = Some(new_latest); - } - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); + } } - } - else => { - break; + else => { + break; + } } - } } + } error!("Error: unexpectedly ended timeline subscriber for room {room_id}."); } @@ -2892,7 +3155,7 @@ async fn timeline_subscriber_handler( fn update_latest_event( room: &Room, event_tl_item: &EventTimelineItem, - timeline_update_sender: Option<&crossbeam_channel::Sender> + timeline_update_sender: Option<&crossbeam_channel::Sender>, ) -> bool { let mut room_avatar_changed = false; @@ -2903,7 +3166,10 @@ fn update_latest_event( TimelineItemContent::OtherState(other) => { match other.content() { // Check for room name changes. - AnyOtherFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => { + AnyOtherFullStateEventContent::RoomName(FullStateEventContent::Original { + content, + .. + }) => { rooms_list::enqueue_rooms_list_update(RoomsListUpdate::UpdateRoomName { room_id: room_id.clone(), new_room_name: content.name.clone(), @@ -2914,9 +3180,19 @@ fn update_latest_event( room_avatar_changed = true; } // Check for an update to the current user's power levels in this room. - AnyOtherFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { content, prev_content: _ }) => { - if let (Some(sender), Some(user_id)) = (timeline_update_sender, current_user_id()) { - if let Some(authorization_rules) = room.version().and_then(|v| v.rules().map(|r| r.authorization)) { + AnyOtherFullStateEventContent::RoomPowerLevels( + FullStateEventContent::Original { + content, + prev_content: _, + }, + ) => { + if let (Some(sender), Some(user_id)) = + (timeline_update_sender, current_user_id()) + { + if let Some(authorization_rules) = room + .version() + .and_then(|v| v.rules().map(|r| r.authorization)) + { let user_power_levels = UserPowerLevels::from( &RoomPowerLevels::new( content.clone().into(), @@ -2927,14 +3203,21 @@ fn update_latest_event( ); match sender.send(TimelineUpdate::UserPowerLevels(user_power_levels)) { Ok(_) => SignalToUI::set_ui_signal(), - Err(e) => error!("Failed to send the new RoomPowerLevels from an updated latest event: {e}"), + Err(e) => error!( + "Failed to send the new RoomPowerLevels from an updated latest event: {e}" + ), } } } } // Check for room tombstone status changes. - AnyOtherFullStateEventContent::RoomTombstone(FullStateEventContent::Original { content: _, prev_content: _ }) => { - enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { room_id: room_id.clone()}); + AnyOtherFullStateEventContent::RoomTombstone(FullStateEventContent::Original { + content: _, + prev_content: _, + }) => { + enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { + room_id: room_id.clone(), + }); if let (Some(sender), Some(room)) = ( timeline_update_sender, get_client() @@ -2952,7 +3235,7 @@ fn update_latest_event( } } } - _ => { } + _ => {} } } TimelineItemContent::MembershipChange(room_membership_change) => { @@ -2961,11 +3244,13 @@ fn update_latest_event( Some(MembershipChange::InvitationAccepted | MembershipChange::Joined) ) { if current_user_id().as_deref() == Some(room_membership_change.user_id()) { - submit_async_request(MatrixRequest::GetRoomPowerLevels { room_id: room_id.clone() }); + submit_async_request(MatrixRequest::GetRoomPowerLevels { + room_id: room_id.clone(), + }); } } } - _ => { } + _ => {} } enqueue_rooms_list_update(RoomsListUpdate::UpdateLatestEvent { @@ -2997,8 +3282,13 @@ async fn room_avatar(room: &Room, room_name: Option<&str>) -> RoomPreviewAvatar _ => { if let Ok(room_members) = room.members(RoomMemberships::ACTIVE).await { if room_members.len() == 2 { - if let Some(non_account_member) = room_members.iter().find(|m| !m.is_account_user()) { - if let Ok(Some(avatar)) = non_account_member.avatar(AVATAR_THUMBNAIL_FORMAT.into()).await { + if let Some(non_account_member) = + room_members.iter().find(|m| !m.is_account_user()) + { + if let Ok(Some(avatar)) = non_account_member + .avatar(AVATAR_THUMBNAIL_FORMAT.into()) + .await + { return RoomPreviewAvatar::Image(avatar.into()); } } @@ -3027,7 +3317,8 @@ async fn spawn_sso_server( // Post a status update to inform the user that we're waiting for the client to be built. Cx::post_action(LoginAction::Status { title: "Initializing client...".into(), - status: "Please wait while Matrix builds and configures the client object for login.".into(), + status: "Please wait while Matrix builds and configures the client object for login." + .into(), }); // Wait for the notification that the client has been built @@ -3048,19 +3339,21 @@ async fn spawn_sso_server( // or if the homeserver_url is *not* empty and isn't the default, // we cannot use the DEFAULT_SSO_CLIENT, so we must build a new one. let mut build_client_error = None; - if client_and_session.is_none() || ( - !homeserver_url.is_empty() + if client_and_session.is_none() + || (!homeserver_url.is_empty() && homeserver_url != "matrix.org" && Url::parse(&homeserver_url) != Url::parse("https://matrix-client.matrix.org/") - && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/") - ) { + && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/")) + { match build_client( &Cli { homeserver: homeserver_url.is_empty().not().then_some(homeserver_url), ..Default::default() }, app_data_dir(), - ).await { + ) + .await + { Ok(success) => client_and_session = Some(success), Err(e) => build_client_error = Some(e), } @@ -3069,10 +3362,12 @@ async fn spawn_sso_server( let Some((client, client_session)) = client_and_session else { Cx::post_action(LoginAction::LoginFailure( if let Some(err) = build_client_error { - format!("Could not create client object. Please try to login again.\n\nError: {err}") + format!( + "Could not create client object. Please try to login again.\n\nError: {err}" + ) } else { String::from("Could not create client object. Please try to login again.") - } + }, )); // This ensures that the called to `DEFAULT_SSO_CLIENT_NOTIFIER.notified()` // at the top of this function will not block upon the next login attempt. @@ -3084,7 +3379,8 @@ async fn spawn_sso_server( let mut is_logged_in = false; Cx::post_action(LoginAction::Status { title: "Opening your browser...".into(), - status: "Please finish logging in using your browser, and then come back to Robrix.".into(), + status: "Please finish logging in using your browser, and then come back to Robrix." + .into(), }); match client .matrix_auth() @@ -3094,14 +3390,15 @@ async fn spawn_sso_server( if key == "redirectUrl" { let redirect_url = Url::parse(&value)?; Cx::post_action(LoginAction::SsoSetRedirectUrl(redirect_url)); - break + break; } } Uri::new(&sso_url).open().map_err(|err| { Error::UnknownError( - Box::new(io::Error::other( - format!("Unable to open SSO login url. Error: {:?}", err), - )) + Box::new(io::Error::other(format!( + "Unable to open SSO login url. Error: {:?}", + err + ))) .into(), ) }) @@ -3119,10 +3416,13 @@ async fn spawn_sso_server( }) { Ok(identity_provider_res) => { if !is_logged_in { - if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session)).await { + if let Err(e) = login_sender + .send(LoginRequest::LoginBySSOSuccess(client, client_session)) + .await + { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to async worker thread." + "BUG: failed to send login request to async worker thread.", ))); } enqueue_rooms_list_update(RoomsListUpdate::Status { @@ -3148,7 +3448,6 @@ async fn spawn_sso_server( }); } - bitflags! { /// The powers that a user has in a given room. #[derive(Copy, Clone, PartialEq, Eq)] @@ -3226,14 +3525,38 @@ impl UserPowerLevels { retval.set(UserPowerLevels::Invite, user_power >= power_levels.invite); retval.set(UserPowerLevels::Kick, user_power >= power_levels.kick); retval.set(UserPowerLevels::Redact, user_power >= power_levels.redact); - retval.set(UserPowerLevels::NotifyRoom, user_power >= power_levels.notifications.room); - retval.set(UserPowerLevels::Location, user_power >= power_levels.for_message(MessageLikeEventType::Location)); - retval.set(UserPowerLevels::Message, user_power >= power_levels.for_message(MessageLikeEventType::Message)); - retval.set(UserPowerLevels::Reaction, user_power >= power_levels.for_message(MessageLikeEventType::Reaction)); - retval.set(UserPowerLevels::RoomMessage, user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage)); - retval.set(UserPowerLevels::RoomRedaction, user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction)); - retval.set(UserPowerLevels::Sticker, user_power >= power_levels.for_message(MessageLikeEventType::Sticker)); - retval.set(UserPowerLevels::RoomPinnedEvents, user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents)); + retval.set( + UserPowerLevels::NotifyRoom, + user_power >= power_levels.notifications.room, + ); + retval.set( + UserPowerLevels::Location, + user_power >= power_levels.for_message(MessageLikeEventType::Location), + ); + retval.set( + UserPowerLevels::Message, + user_power >= power_levels.for_message(MessageLikeEventType::Message), + ); + retval.set( + UserPowerLevels::Reaction, + user_power >= power_levels.for_message(MessageLikeEventType::Reaction), + ); + retval.set( + UserPowerLevels::RoomMessage, + user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage), + ); + retval.set( + UserPowerLevels::RoomRedaction, + user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction), + ); + retval.set( + UserPowerLevels::Sticker, + user_power >= power_levels.for_message(MessageLikeEventType::Sticker), + ); + retval.set( + UserPowerLevels::RoomPinnedEvents, + user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents), + ); retval } @@ -3274,8 +3597,7 @@ impl UserPowerLevels { } pub fn can_send_message(self) -> bool { - self.contains(UserPowerLevels::RoomMessage) - || self.contains(UserPowerLevels::Message) + self.contains(UserPowerLevels::RoomMessage) || self.contains(UserPowerLevels::Message) } pub fn can_send_reaction(self) -> bool { @@ -3292,7 +3614,6 @@ impl UserPowerLevels { } } - /// Shuts down the current Tokio runtime completely and takes ownership to ensure proper cleanup. pub fn shutdown_background_tasks() { if let Some(runtime) = TOKIO_RUNTIME.lock().unwrap().take() { @@ -3305,20 +3626,27 @@ 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 { + 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"); Ok(()) From 3c9ec1a7ce3f4c1669f68f8f108c068febbd2b86 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Wed, 22 Oct 2025 15:11:52 +0800 Subject: [PATCH 02/20] fixed fmt --- .typos.toml | 14 + src/home/room_screen.rs | 1525 +++++++++++++---------------------- src/room/member_search.rs | 3 +- src/sliding_sync.rs | 1606 +++++++++++++++---------------------- 4 files changed, 1216 insertions(+), 1932 deletions(-) create mode 100644 .typos.toml 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/home/room_screen.rs b/src/home/room_screen.rs index 05863fb0..b06e8850 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1,94 +1,40 @@ //! A room screen is the UI view that displays a single Room's timeline of events/messages //! along with a message input bar at the bottom. -use std::{ - borrow::Cow, - cell::RefCell, - collections::BTreeMap, - ops::{DerefMut, Range}, - sync::Arc, -}; +use std::{borrow::Cow, cell::RefCell, collections::BTreeMap, ops::{DerefMut, Range}, sync::Arc}; use bytesize::ByteSize; use imbl::Vector; use makepad_widgets::{image_cache::ImageBuffer, *}; use matrix_sdk::{ - room::RoomMember, - ruma::{ + room::RoomMember, ruma::{ events::{ receipt::Receipt, room::{ message::{ - AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, - FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, - LocationMessageEventContent, MessageFormat, MessageType, - NoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent, + AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent }, - ImageInfo, MediaSource, + ImageInfo, MediaSource }, sticker::{StickerEventContent, StickerMediaSource}, }, - matrix_uri::MatrixId, - uint, EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, - }, - OwnedServerName, SuccessorRoom, + matrix_uri::MatrixId, uint, EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId + }, OwnedServerName, SuccessorRoom }; use matrix_sdk_ui::timeline::{ - self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, - MemberProfileChange, MsgLikeContent, MsgLikeKind, OtherMessageLike, PollState, - RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, TimelineItemContent, - TimelineItemKind, VirtualTimelineItem, + self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, MemberProfileChange, MsgLikeContent, MsgLikeKind, OtherMessageLike, PollState, RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem }; use crate::{ - app::AppStateAction, - avatar_cache, - event_preview::{ - plaintext_body_of_timeline_item, text_preview_of_encrypted_message, - text_preview_of_member_profile_change, text_preview_of_other_message_like, - text_preview_of_other_state, text_preview_of_redacted_message, - text_preview_of_room_membership_change, text_preview_of_timeline_item, - }, - home::{ - edited_indicator::EditedIndicatorWidgetRefExt, - link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, - loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, - rooms_list::RoomsListRef, - }, - media_cache::{MediaCache, MediaCacheEntry}, - profile::{ - user_profile::{ - AvatarState, ShowUserProfileAction, UserProfile, UserProfileAndRoomId, - UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt, - }, + app::AppStateAction, avatar_cache, event_preview::{plaintext_body_of_timeline_item, text_preview_of_encrypted_message, text_preview_of_member_profile_change, text_preview_of_other_message_like, text_preview_of_other_state, text_preview_of_redacted_message, text_preview_of_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, rooms_list::RoomsListRef}, media_cache::{MediaCache, MediaCacheEntry}, profile::{ + user_profile::{AvatarState, ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache, }, - room::{ - member_search::{precompute_member_sort, PrecomputedMemberSort}, - 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, - }, - sliding_sync::{ - get_client, submit_async_request, take_timeline_endpoints, - BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, - TimelineRequestSender, UserPowerLevels, - }, - utils::{ - self, room_name_or_id, unix_time_millis_to_datetime, ImageFormat, MEDIA_THUMBNAIL_FORMAT, + 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 }, + sliding_sync::{get_client, submit_async_request, take_timeline_endpoints, BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineRequestSender, UserPowerLevels}, utils::{self, room_name_or_id, unix_time_millis_to_datetime, ImageFormat, MEDIA_THUMBNAIL_FORMAT} }; use crate::home::event_reaction_list::ReactionListWidgetRefExt; use crate::home::room_read_receipt::AvatarRowWidgetRefExt; @@ -97,12 +43,7 @@ use crate::shared::mentionable_text_input::MentionableTextInputAction; use rangemap::RangeSet; -use super::{ - event_reaction_list::ReactionData, - loading_pane::LoadingPaneRef, - new_message_context_menu::{MessageAbilities, MessageDetails}, - room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}, -}; +use super::{event_reaction_list::ReactionData, loading_pane::LoadingPaneRef, new_message_context_menu::{MessageAbilities, MessageDetails}, room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}}; /// The maximum number of timeline items to search through /// when looking for a particular event. @@ -114,6 +55,7 @@ const MAX_ITEMS_TO_SEARCH_THROUGH: usize = 100; /// The max size (width or height) of a blurhash image to decode. const BLURHASH_IMAGE_MAX_SIZE: u32 = 500; + live_design! { use link::theme::*; use link::shaders::*; @@ -549,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. @@ -606,27 +548,20 @@ live_design! { /// The main widget that displays a single Matrix room. #[derive(Live, Widget)] pub struct RoomScreen { - #[deref] - view: View, + #[deref] view: View, /// The room ID of the currently-shown room. - #[rust] - room_id: Option, + #[rust] room_id: Option, /// The display name of the currently-shown room. - #[rust] - room_name: String, + #[rust] room_name: String, /// The persistent UI-relevant states for the room that this widget is currently displaying. - #[rust] - tl_state: Option, + #[rust] tl_state: Option, /// The set of pinned events in this room. - #[rust] - pinned_events: Vec, + #[rust] pinned_events: Vec, /// Whether this room has been successfully loaded (received from the homeserver). - #[rust] - is_loaded: bool, + #[rust] is_loaded: bool, /// Whether or not all rooms have been loaded (received from the homeserver). - #[rust] - all_rooms_loaded: bool, + #[rust] all_rooms_loaded: bool, } impl Drop for RoomScreen { fn drop(&mut self) { @@ -654,8 +589,7 @@ impl Widget for RoomScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { let room_screen_widget_uid = self.widget_uid(); let portal_list = self.portal_list(id!(timeline.list)); - let user_profile_sliding_pane = - self.user_profile_sliding_pane(id!(user_profile_sliding_pane)); + let user_profile_sliding_pane = self.user_profile_sliding_pane(id!(user_profile_sliding_pane)); let loading_pane = self.loading_pane(id!(loading_pane)); // Handle actions here before processing timeline updates. @@ -670,30 +604,14 @@ impl Widget for RoomScreen { widget_rect, bg_color, reaction_data, - } = reaction_list.hover_in(actions) - { - let Some(_tl_state) = self.tl_state.as_ref() else { - continue; - }; - let tooltip_text_arr: Vec = reaction_data - .reaction_senders - .iter() - .map(|(sender, _react_info)| { - user_profile_cache::get_user_profile_and_room_member( - cx, - sender.clone(), - &reaction_data.room_id, - true, - ) - .0 + } = reaction_list.hover_in(actions) { + let Some(_tl_state) = self.tl_state.as_ref() else { continue }; + let tooltip_text_arr: Vec = reaction_data.reaction_senders.iter().map(|(sender, _react_info)| { + user_profile_cache::get_user_profile_and_room_member(cx, sender.clone(), &reaction_data.room_id, true).0 .map(|user_profile| user_profile.displayable_name().to_string()) .unwrap_or_else(|| sender.to_string()) - }) - .collect(); - let mut tooltip_text = utils::human_readable_list( - &tooltip_text_arr, - MAX_VISIBLE_AVATARS_IN_READ_RECEIPT, - ); + }).collect(); + let mut tooltip_text = utils::human_readable_list(&tooltip_text_arr, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT); tooltip_text.push_str(&format!(" reacted with: {}", reaction_data.reaction)); cx.widget_action( room_screen_widget_uid, @@ -703,24 +621,24 @@ impl Widget for RoomScreen { text: tooltip_text, text_color: None, bg_color, - }, + } ); } if reaction_list.hover_out(actions) { - cx.widget_action(room_screen_widget_uid, &scope.path, TooltipAction::HoverOut); + cx.widget_action( + room_screen_widget_uid, + &scope.path, + TooltipAction::HoverOut + ); } let avatar_row_ref = wr.avatar_row(id!(avatar_row)); if let RoomScreenTooltipActions::HoverInReadReceipt { widget_rect, bg_color, - read_receipts, - } = avatar_row_ref.hover_in(actions) - { - let Some(room_id) = &self.room_id else { - return; - }; - let tooltip_text = - room_read_receipt::populate_tooltip(cx, read_receipts, room_id); + read_receipts + } = avatar_row_ref.hover_in(actions) { + let Some(room_id) = &self.room_id else { return; }; + let tooltip_text= room_read_receipt::populate_tooltip(cx, read_receipts, room_id); cx.widget_action( room_screen_widget_uid, &scope.path, @@ -729,11 +647,15 @@ impl Widget for RoomScreen { text: tooltip_text, bg_color, text_color: None, - }, + } ); } if avatar_row_ref.hover_out(actions) { - cx.widget_action(room_screen_widget_uid, &scope.path, TooltipAction::HoverOut); + cx.widget_action( + room_screen_widget_uid, + &scope.path, + TooltipAction::HoverOut + ); } } @@ -741,8 +663,7 @@ impl Widget for RoomScreen { for action in actions { // Handle actions related to restoring the previously-saved state of rooms. - if let Some(AppStateAction::RoomLoadedSuccessfully(room_id)) = action.downcast_ref() - { + if let Some(AppStateAction::RoomLoadedSuccessfully(room_id)) = action.downcast_ref() { if self.room_id.as_ref().is_some_and(|r| r == room_id) { // `set_displayed_room()` does nothing if the room_id is unchanged, so we clear it first. self.room_id = None; @@ -751,12 +672,8 @@ impl Widget for RoomScreen { } } // Handle the highlight animation. - let Some(tl) = self.tl_state.as_mut() else { - continue; - }; - if let MessageHighlightAnimationState::Pending { item_id } = - tl.message_highlight_animation_state - { + let Some(tl) = self.tl_state.as_mut() else { continue }; + if let MessageHighlightAnimationState::Pending { item_id } = tl.message_highlight_animation_state { if portal_list.smooth_scroll_reached(actions) { cx.widget_action( room_screen_widget_uid, @@ -770,15 +687,9 @@ impl Widget for RoomScreen { } // Handle the action that requests to show the user profile sliding pane. - if let ShowUserProfileAction::ShowUserProfile(profile_and_room_id) = - action.as_widget_action().cast() - { + if let ShowUserProfileAction::ShowUserProfile(profile_and_room_id) = action.as_widget_action().cast() { // Only show the user profile in room that this avatar belongs to - if self - .room_id - .as_ref() - .is_some_and(|r| r == &profile_and_room_id.room_id) - { + if self.room_id.as_ref().is_some_and(|r| r == &profile_and_room_id.room_id) { self.show_user_profile( cx, &user_profile_sliding_pane, @@ -806,19 +717,18 @@ impl Widget for RoomScreen { self.send_user_read_receipts_based_on_scroll_pos(cx, actions, &portal_list); // Handle the jump to bottom button: update its visibility, and handle clicks. - self.jump_to_bottom_button(id!(jump_to_bottom)) - .update_from_actions(cx, &portal_list, actions); + self.jump_to_bottom_button(id!(jump_to_bottom)).update_from_actions( + cx, + &portal_list, + actions, + ); } // Currently, a Signal event is only used to tell this widget: // 1. to check if the room has been loaded from the homeserver yet, or // 2. that its timeline events have been updated in the background. if let Event::Signal = event { - if let (false, Some(room_id), true) = ( - self.is_loaded, - &self.room_id, - cx.has_global::(), - ) { + if let (false, Some(room_id), true) = (self.is_loaded, &self.room_id, cx.has_global::()) { let rooms_list_ref = cx.get_global::(); if rooms_list_ref.is_room_loaded(room_id) { let same_room_id = room_id.clone(); @@ -856,12 +766,14 @@ impl Widget for RoomScreen { if is_interactive_hit { loading_pane.handle_event(cx, event, scope); } - } else if user_profile_sliding_pane.is_currently_shown(cx) { + } + else if user_profile_sliding_pane.is_currently_shown(cx) { is_pane_shown = true; if is_interactive_hit { user_profile_sliding_pane.handle_event(cx, event, scope); } - } else { + } + else { is_pane_shown = false; } @@ -881,12 +793,10 @@ impl Widget for RoomScreen { // Fetch room data once to avoid duplicate expensive lookups let (room_display_name, room_avatar_url) = get_client() .and_then(|client| client.get_room(&room_id)) - .map(|room| { - ( - room.cached_display_name().map(|name| name.to_string()), - room.avatar_url(), - ) - }) + .map(|room| ( + room.cached_display_name().map(|name| name.to_string()), + room.avatar_url() + )) .unwrap_or((None, None)); RoomScreenProps { @@ -909,9 +819,7 @@ impl Widget for RoomScreen { } } else { // No room selected yet, skip event handling that requires room context - log!( - "RoomScreen handling event with no room_id and no tl_state, skipping room-dependent event handling" - ); + log!("RoomScreen handling event with no room_id and no tl_state, skipping room-dependent event handling"); if !is_pane_shown || !is_interactive_hit { return; } @@ -927,11 +835,13 @@ impl Widget for RoomScreen { }; let mut room_scope = Scope::with_props(&room_props); + // Forward the event to the inner timeline view, but capture any actions it produces // such that we can handle the ones relevant to only THIS RoomScreen widget right here and now, // ensuring they are not mistakenly handled by other RoomScreen widget instances. - let mut actions_generated_within_this_room_screen = - cx.capture_actions(|cx| self.view.handle_event(cx, event, &mut room_scope)); + let mut actions_generated_within_this_room_screen = cx.capture_actions(|cx| + self.view.handle_event(cx, event, &mut room_scope) + ); // Here, we handle and remove any general actions that are relevant to only this RoomScreen. // Removing the handled actions ensures they are not mistakenly handled by other RoomScreen widget instances. actions_generated_within_this_room_screen.retain(|action| { @@ -987,6 +897,7 @@ impl Widget for RoomScreen { } } + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // If the room isn't loaded yet, we show the restore status label only. if !self.is_loaded { @@ -1000,15 +911,14 @@ impl Widget for RoomScreen { return DrawStep::done(); } + let room_screen_widget_uid = self.widget_uid(); while let Some(subview) = self.view.draw_walk(cx, scope, walk).step() { // Here, we only need to handle drawing the portal list. let portal_list_ref = subview.as_portal_list(); let Some(mut list_ref) = portal_list_ref.borrow_mut() else { - error!( - "!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else" - ); + error!("!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else"); continue; }; let Some(tl_state) = self.tl_state.as_mut() else { @@ -1042,90 +952,81 @@ impl Widget for RoomScreen { }; let (item, item_new_draw_status) = match timeline_item.kind() { TimelineItemKind::Event(event_tl_item) => match event_tl_item.content() { - TimelineItemContent::MsgLike(msg_like_content) => { - match &msg_like_content.kind { - MsgLikeKind::Message(_) | MsgLikeKind::Sticker(_) => { - let prev_event = - tl_idx.checked_sub(1).and_then(|i| tl_items.get(i)); - populate_message_view( - cx, - list, - item_id, - room_id, - event_tl_item, - msg_like_content, - prev_event, - &mut tl_state.media_cache, - &mut tl_state.link_preview_cache, - &tl_state.user_power, - &self.pinned_events, - item_drawn_status, - room_screen_widget_uid, - ) - } - // TODO: properly implement `Poll` as a regular Message-like timeline item. - MsgLikeKind::Poll(poll_state) => populate_small_state_event( + TimelineItemContent::MsgLike(msg_like_content) => match &msg_like_content.kind { + MsgLikeKind::Message(_) | MsgLikeKind::Sticker(_) => { + let prev_event = tl_idx.checked_sub(1).and_then(|i| tl_items.get(i)); + populate_message_view( cx, list, item_id, room_id, event_tl_item, - poll_state, + msg_like_content, + prev_event, + &mut tl_state.media_cache, + &mut tl_state.link_preview_cache, + &tl_state.user_power, + &self.pinned_events, item_drawn_status, - ), - MsgLikeKind::Redacted => populate_small_state_event( - cx, - list, - item_id, - room_id, - event_tl_item, - &RedactedMessageEventMarker, - item_drawn_status, - ), - MsgLikeKind::UnableToDecrypt(utd) => { - populate_small_state_event( - cx, - list, - item_id, - room_id, - event_tl_item, - utd, - item_drawn_status, - ) - } - MsgLikeKind::Other(other) => populate_small_state_event( - cx, - list, - item_id, - room_id, - event_tl_item, - other, - item_drawn_status, - ), - } - } - TimelineItemContent::MembershipChange(membership_change) => { - populate_small_state_event( + room_screen_widget_uid, + ) + }, + // TODO: properly implement `Poll` as a regular Message-like timeline item. + MsgLikeKind::Poll(poll_state) => populate_small_state_event( cx, list, item_id, room_id, event_tl_item, - membership_change, + poll_state, item_drawn_status, - ) - } - TimelineItemContent::ProfileChange(profile_change) => { - populate_small_state_event( + ), + MsgLikeKind::Redacted => populate_small_state_event( cx, list, item_id, room_id, event_tl_item, - profile_change, + &RedactedMessageEventMarker, item_drawn_status, - ) - } + ), + MsgLikeKind::UnableToDecrypt(utd) => populate_small_state_event( + cx, + list, + item_id, + room_id, + event_tl_item, + utd, + item_drawn_status, + ), + MsgLikeKind::Other(other) => populate_small_state_event( + cx, + list, + item_id, + room_id, + event_tl_item, + other, + item_drawn_status, + ), + }, + TimelineItemContent::MembershipChange(membership_change) => populate_small_state_event( + cx, + list, + item_id, + room_id, + event_tl_item, + membership_change, + item_drawn_status, + ), + TimelineItemContent::ProfileChange(profile_change) => populate_small_state_event( + cx, + list, + item_id, + room_id, + event_tl_item, + profile_change, + item_drawn_status, + ), TimelineItemContent::OtherState(other) => populate_small_state_event( cx, list, @@ -1137,11 +1038,10 @@ impl Widget for RoomScreen { ), unhandled => { let item = list.item(cx, item_id, live_id!(SmallStateEvent)); - item.label(id!(content)) - .set_text(cx, &format!("[Unsupported] {:?}", unhandled)); + item.label(id!(content)).set_text(cx, &format!("[Unsupported] {:?}", unhandled)); (item, ItemDrawnStatus::both_drawn()) } - }, + } TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(millis)) => { let item = list.item(cx, item_id, live_id!(DateDivider)); let text = unix_time_millis_to_datetime(*millis) @@ -1163,14 +1063,10 @@ impl Widget for RoomScreen { // Now that we've drawn the item, add its index to the set of drawn items. if item_new_draw_status.content_drawn { - tl_state - .content_drawn_since_last_update - .insert(tl_idx..tl_idx + 1); + tl_state.content_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); } if item_new_draw_status.profile_drawn { - tl_state - .profile_drawn_since_last_update - .insert(tl_idx..tl_idx + 1); + tl_state.profile_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); } item }; @@ -1180,11 +1076,7 @@ impl Widget for RoomScreen { // If the list is not filling the viewport, we need to back paginate the timeline // until we have enough events items to fill the viewport. if !tl_state.fully_paginated && !list.is_filling_viewport() { - log!( - "Automatically paginating timeline to fill viewport for room \"{}\" ({})", - self.room_name, - room_id - ); + log!("Automatically paginating timeline to fill viewport for room \"{}\" ({})", self.room_name, room_id); submit_async_request(MatrixRequest::PaginateRoomTimeline { room_id: room_id.clone(), num_events: 50, @@ -1205,9 +1097,7 @@ impl RoomScreen { let jump_to_bottom = self.jump_to_bottom_button(id!(jump_to_bottom)); let curr_first_id = portal_list.first_id(); let ui = self.widget_uid(); - let Some(tl) = self.tl_state.as_mut() else { - return; - }; + let Some(tl) = self.tl_state.as_mut() else { return }; let mut done_loading = false; let mut should_continue_backwards_pagination = false; @@ -1228,19 +1118,10 @@ impl RoomScreen { tl.items = initial_items; done_loading = true; } - TimelineUpdate::NewItems { - new_items, - changed_indices, - is_append, - clear_cache, - } => { + TimelineUpdate::NewItems { new_items, changed_indices, is_append, clear_cache } => { if new_items.is_empty() { if !tl.items.is_empty() { - log!( - "process_timeline_updates(): timeline (had {} items) was cleared for room {}", - tl.items.len(), - tl.room_id - ); + log!("process_timeline_updates(): timeline (had {} items) was cleared for room {}", tl.items.len(), tl.room_id); // For now, we paginate a cleared timeline in order to be able to show something at least. // A proper solution would be what's described below, which would be to save a few event IDs // and then either focus on them (if we're not close to the end of the timeline) @@ -1274,12 +1155,9 @@ impl RoomScreen { if new_items.len() == tl.items.len() { // log!("process_timeline_updates(): no jump necessary for updated timeline of same length: {}", items.len()); - } else if curr_first_id > new_items.len() { - log!( - "process_timeline_updates(): jumping to bottom: curr_first_id {} is out of bounds for {} new items", - curr_first_id, - new_items.len() - ); + } + else if curr_first_id > new_items.len() { + log!("process_timeline_updates(): jumping to bottom: curr_first_id {} is out of bounds for {} new items", curr_first_id, new_items.len()); portal_list.set_first_id_and_scroll(new_items.len().saturating_sub(1), 0.0); portal_list.set_tail_range(true); jump_to_bottom.update_visibility(cx, true); @@ -1288,32 +1166,19 @@ impl RoomScreen { // in the timeline viewport so that we can maintain the scroll position of that item, // which ensures that the timeline doesn't jump around unexpectedly and ruin the user's experience. else if let Some((curr_item_idx, new_item_idx, new_item_scroll, _event_id)) = - prior_items_changed - .then(|| { - find_new_item_matching_current_item( - cx, - portal_list, - curr_first_id, - &tl.items, - &new_items, - ) - }) - .flatten() + prior_items_changed.then(|| + find_new_item_matching_current_item(cx, portal_list, curr_first_id, &tl.items, &new_items) + ) + .flatten() { if curr_item_idx != new_item_idx { - log!( - "process_timeline_updates(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}" - ); + log!("process_timeline_updates(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}"); portal_list.set_first_id_and_scroll(new_item_idx, new_item_scroll); tl.prev_first_index = Some(new_item_idx); // Set scrolled_past_read_marker false when we jump to a new event tl.scrolled_past_read_marker = false; // Hide the tooltip when the timeline jumps, as a hover-out event won't occur. - cx.widget_action( - ui, - &HeapLiveIdPath::default(), - RoomScreenTooltipActions::HoverOut, - ); + cx.widget_action(ui, &HeapLiveIdPath::default(), RoomScreenTooltipActions::HoverOut); } } // @@ -1327,9 +1192,7 @@ impl RoomScreen { if is_append && !portal_list.is_at_end() { // Immediately show the unread badge with no count while we fetch the actual count in the background. jump_to_bottom.show_unread_message_badge(cx, UnreadMessageCount::Unknown); - submit_async_request(MatrixRequest::GetNumberUnreadMessages { - room_id: tl.room_id.clone(), - }); + submit_async_request(MatrixRequest::GetNumberUnreadMessages{ room_id: tl.room_id.clone() }); } if prior_items_changed { @@ -1340,15 +1203,10 @@ impl RoomScreen { let loading_pane = self.view.loading_pane(id!(loading_pane)); let mut loading_pane_state = loading_pane.take_state(); if let LoadingPaneState::BackwardsPaginateUntilEvent { - events_paginated, - target_event_id, - .. - } = &mut loading_pane_state - { + events_paginated, target_event_id, .. + } = &mut loading_pane_state { *events_paginated += new_items.len().saturating_sub(tl.items.len()); - log!( - "While finding target event {target_event_id}, we have now loaded {events_paginated} messages..." - ); + log!("While finding target event {target_event_id}, we have now loaded {events_paginated} messages..."); // Here, we assume that we have not yet found the target event, // so we need to continue paginating backwards. // If the target event has already been found, it will be handled @@ -1365,10 +1223,8 @@ impl RoomScreen { tl.profile_drawn_since_last_update.clear(); tl.fully_paginated = false; } else { - tl.content_drawn_since_last_update - .remove(changed_indices.clone()); - tl.profile_drawn_since_last_update - .remove(changed_indices.clone()); + tl.content_drawn_since_last_update.remove(changed_indices.clone()); + tl.profile_drawn_since_last_update.remove(changed_indices.clone()); // log!("process_timeline_updates(): changed_indices: {changed_indices:?}, items len: {}\ncontent drawn: {:#?}\nprofile drawn: {:#?}", items.len(), tl.content_drawn_since_last_update, tl.profile_drawn_since_last_update); } tl.items = new_items; @@ -1377,10 +1233,7 @@ impl RoomScreen { TimelineUpdate::NewUnreadMessagesCount(unread_messages_count) => { jump_to_bottom.show_unread_message_badge(cx, unread_messages_count); } - TimelineUpdate::TargetEventFound { - target_event_id, - index, - } => { + TimelineUpdate::TargetEventFound { target_event_id, index } => { // log!("Target event found in room {}: {target_event_id}, index: {index}", tl.room_id); tl.request_sender.send_if_modified(|requests| { requests.retain(|r| r.room_id != tl.room_id); @@ -1390,10 +1243,10 @@ impl RoomScreen { // sanity check: ensure the target event is in the timeline at the given `index`. let item = tl.items.get(index); - let is_valid = item.is_some_and(|item| { + let is_valid = item.is_some_and(|item| item.as_event() .is_some_and(|ev| ev.event_id() == Some(&target_event_id)) - }); + ); let loading_pane = self.view.loading_pane(id!(loading_pane)); // log!("TargetEventFound: is_valid? {is_valid}. room {}, event {target_event_id}, index {index} of {}\n --> item: {item:?}", tl.room_id, tl.items.len()); @@ -1412,24 +1265,19 @@ impl RoomScreen { // appear beneath the top of the viewport. portal_list.smooth_scroll_to(cx, index.saturating_sub(1), speed, None); // start highlight animation. - tl.message_highlight_animation_state = - MessageHighlightAnimationState::Pending { item_id: index }; - } else { + tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { + item_id: index + }; + } + else { // Here, the target event was not found in the current timeline, // or we found it previously but it is no longer in the timeline (or has moved), // which means we encountered an error and are unable to jump to the target event. - error!( - "Target event index {index} of {} is out of bounds for room {}", - tl.items.len(), - tl.room_id - ); + error!("Target event index {index} of {} is out of bounds for room {}", tl.items.len(), tl.room_id); // Show this error in the loading pane, which should already be open. - loading_pane.set_state( - cx, - LoadingPaneState::Error(String::from( - "Unable to find related message; it may have been deleted.", - )), - ); + loading_pane.set_state(cx, LoadingPaneState::Error( + String::from("Unable to find related message; it may have been deleted.") + )); } should_continue_backwards_pagination = false; @@ -1446,10 +1294,7 @@ impl RoomScreen { } } TimelineUpdate::PaginationError { error, direction } => { - error!( - "Pagination error ({direction}) in room \"{}\", {}: {error:?}", - self.room_name, tl.room_id - ); + error!("Pagination error ({direction}) in room \"{}\", {}: {error:?}", self.room_name, tl.room_id); enqueue_popup_notification(PopupItem { message: utils::stringify_pagination_error(&error, &self.room_name), auto_dismissal_duration: None, @@ -1457,10 +1302,7 @@ impl RoomScreen { }); done_loading = true; } - TimelineUpdate::PaginationIdle { - fully_paginated, - direction, - } => { + TimelineUpdate::PaginationIdle { fully_paginated, direction } => { if direction == PaginationDirection::Backwards { // Don't set `done_loading` to `true` here, because we want to keep the top space visible // (with the "loading" message) until the corresponding `NewItems` update is received. @@ -1472,12 +1314,9 @@ impl RoomScreen { error!("Unexpected PaginationIdle update in the Forwards direction"); } } - TimelineUpdate::EventDetailsFetched { event_id, result } => { + TimelineUpdate::EventDetailsFetched {event_id, result } => { if let Err(_e) = result { - error!( - "Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}", - tl.room_id - ); + error!("Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}", tl.room_id); } // Here, to be most efficient, we could redraw only the updated event, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. @@ -1488,64 +1327,42 @@ impl RoomScreen { // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. } TimelineUpdate::RoomMembersListFetched { members } => { - // Store room members and precomputed sort data for fast @mention search + // RoomMembersListFetched: Received members for room let sort_data = precompute_member_sort(&members); tl.room_members = Some(Arc::new(members)); tl.room_members_sort = Some(Arc::new(sort_data)); - - // Notify mentionable text inputs that members are ready cx.action(MentionableTextInputAction::RoomMembersLoaded { room_id: tl.room_id.clone(), }); } TimelineUpdate::MediaFetched => { - log!( - "process_timeline_updates(): media fetched for room {}", - tl.room_id - ); + 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, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. } - TimelineUpdate::MessageEdited { - timeline_event_id, - result, - } => { - self.view - .room_input_bar(id!(room_input_bar)) + TimelineUpdate::MessageEdited { timeline_event_id, result } => { + self.view.room_input_bar(id!(room_input_bar)) .handle_edit_result(cx, timeline_event_id, result); } TimelineUpdate::PinResult { result, pin, .. } => { let (message, auto_dismissal_duration, kind) = match &result { Ok(true) => ( - format!( - "Successfully {} event.", - if pin { "pinned" } else { "unpinned" } - ), + format!("Successfully {} event.", if pin { "pinned" } else { "unpinned" }), Some(4.0), - PopupKind::Success, + PopupKind::Success ), Ok(false) => ( - format!( - "Message was already {}.", - if pin { "pinned" } else { "unpinned" } - ), + format!("Message was already {}.", if pin { "pinned" } else { "unpinned" }), Some(4.0), - PopupKind::Info, + PopupKind::Info ), Err(e) => ( - format!( - "Failed to {} event. Error: {e}", - if pin { "pin" } else { "unpin" } - ), + format!("Failed to {} event. Error: {e}", if pin { "pin" } else { "unpin" }), None, - PopupKind::Error, + PopupKind::Error ), }; - enqueue_popup_notification(PopupItem { - message, - auto_dismissal_duration, - kind, - }); + enqueue_popup_notification(PopupItem { message, auto_dismissal_duration, kind, }); } TimelineUpdate::TypingUsers { users } => { // This update loop should be kept tight & fast, so all we do here is @@ -1566,8 +1383,7 @@ impl RoomScreen { } TimelineUpdate::UserPowerLevels(user_power_levels) => { tl.user_power = user_power_levels; - self.view - .room_input_bar(id!(room_input_bar)) + self.view.room_input_bar(id!(room_input_bar)) .update_user_power_levels(cx, user_power_levels); // Update the @room mention capability based on the user's power level cx.action(MentionableTextInputAction::PowerLevelsUpdated { @@ -1583,8 +1399,7 @@ impl RoomScreen { tl.latest_own_user_receipt = Some(receipt); } TimelineUpdate::Tombstoned(successor_room) => { - self.view - .room_input_bar(id!(room_input_bar)) + self.view.room_input_bar(id!(room_input_bar)) .update_tombstone_footer(cx, &tl.room_id, successor_room.as_ref()); tl.tombstone_info = successor_room; } @@ -1615,6 +1430,7 @@ impl RoomScreen { } } + /// Handles a link being clicked in any child widgets of this RoomScreen. /// /// Returns `true` if the given `action` was handled as a link click. @@ -1659,7 +1475,7 @@ impl RoomScreen { enqueue_popup_notification(PopupItem { message: "You are already viewing that room.".into(), kind: PopupKind::Error, - auto_dismissal_duration: None, + auto_dismissal_duration: None }); return true; } @@ -1693,7 +1509,8 @@ impl RoomScreen { let mut link_was_handled = false; if let Ok(matrix_to_uri) = MatrixToUri::parse(&url) { link_was_handled |= handle_matrix_link(matrix_to_uri.id(), matrix_to_uri.via()); - } else if let Ok(matrix_uri) = MatrixUri::parse(&url) { + } + else if let Ok(matrix_uri) = MatrixUri::parse(&url) { link_was_handled |= handle_matrix_link(matrix_uri.id(), matrix_uri.via()); } @@ -1704,18 +1521,13 @@ impl RoomScreen { enqueue_popup_notification(PopupItem { message: format!("Could not open URL: {url}"), kind: PopupKind::Error, - auto_dismissal_duration: None, + auto_dismissal_duration: None }); } } true - } else if let RobrixHtmlLinkAction::ClickedMatrixLink { - url, - matrix_id, - via, - .. - } = action.as_widget_action().cast() - { + } + else if let RobrixHtmlLinkAction::ClickedMatrixLink { url, matrix_id, via, .. } = action.as_widget_action().cast() { let link_was_handled = handle_matrix_link(&matrix_id, &via); if !link_was_handled { log!("Opening URL \"{}\"", url); @@ -1724,12 +1536,13 @@ impl RoomScreen { enqueue_popup_notification(PopupItem { message: format!("Could not open URL: {url}"), kind: PopupKind::Error, - auto_dismissal_duration: None, + auto_dismissal_duration: None }); } } true - } else { + } + else { false } } @@ -1744,15 +1557,9 @@ impl RoomScreen { ) { let room_screen_widget_uid = self.widget_uid(); for action in actions { - match action - .as_widget_action() - .widget_uid_eq(room_screen_widget_uid) - .cast() - { + match action.as_widget_action().widget_uid_eq(room_screen_widget_uid).cast() { MessageAction::React { details, reaction } => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; let mut success = false; if let Some(timeline_item) = tl.items.get(details.item_id) { if let Some(event_tl_item) = timeline_item.as_event() { @@ -1771,10 +1578,9 @@ impl RoomScreen { enqueue_popup_notification(PopupItem { message: "Couldn't find message in timeline to react to.".to_string(), kind: PopupKind::Error, - auto_dismissal_duration: None, + auto_dismissal_duration: None }); - error!( - "MessageAction::React: couldn't find event [{}] {:?} to react to in room {}", + error!("MessageAction::React: couldn't find event [{}] {:?} to react to in room {}", details.item_id, details.event_id.as_deref(), tl.room_id, @@ -1782,29 +1588,18 @@ impl RoomScreen { } } MessageAction::Reply(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; - if let Some(event_tl_item) = tl - .items - .get(details.item_id) + let Some(tl) = self.tl_state.as_ref() else { return }; + if let Some(event_tl_item) = tl.items.get(details.item_id) .and_then(|tl_item| tl_item.as_event().cloned()) .filter(|ev| ev.event_id() == details.event_id.as_deref()) { let replied_to_info = EmbeddedEvent::from_timeline_item(&event_tl_item); - self.view - .room_input_bar(id!(room_input_bar)) + self.view.room_input_bar(id!(room_input_bar)) .show_replying_to(cx, (event_tl_item, replied_to_info), &tl.room_id); - } else { - enqueue_popup_notification(PopupItem { - message: - "Could not find message in timeline to reply to. Please try again!" - .to_string(), - kind: PopupKind::Error, - auto_dismissal_duration: None, - }); - error!( - "MessageAction::Reply: couldn't find event [{}] {:?} to reply to in room {:?}", + } + else { + enqueue_popup_notification(PopupItem { message: "Could not find message in timeline to reply to. Please try again!".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); + error!("MessageAction::Reply: couldn't find event [{}] {:?} to reply to in room {:?}", details.item_id, details.event_id.as_deref(), self.room_id, @@ -1812,28 +1607,17 @@ impl RoomScreen { } } MessageAction::Edit(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; - if let Some(event_tl_item) = tl - .items - .get(details.item_id) + let Some(tl) = self.tl_state.as_ref() else { return }; + if let Some(event_tl_item) = tl.items.get(details.item_id) .and_then(|tl_item| tl_item.as_event().cloned()) .filter(|ev| ev.event_id() == details.event_id.as_deref()) { - self.view - .room_input_bar(id!(room_input_bar)) + self.view.room_input_bar(id!(room_input_bar)) .show_editing_pane(cx, event_tl_item, tl.room_id.clone()); - } else { - enqueue_popup_notification(PopupItem { - message: - "Could not find message in timeline to edit. Please try again!" - .to_string(), - kind: PopupKind::Error, - auto_dismissal_duration: None, - }); - error!( - "MessageAction::Edit: couldn't find event [{}] {:?} to edit in room {:?}", + } + else { + enqueue_popup_notification(PopupItem { message: "Could not find message in timeline to edit. Please try again!".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); + error!("MessageAction::Edit: couldn't find event [{}] {:?} to edit in room {:?}", details.item_id, details.event_id.as_deref(), self.room_id, @@ -1841,20 +1625,17 @@ impl RoomScreen { } } MessageAction::EditLatest => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; - if let Some(latest_sent_msg) = tl - .items + let Some(tl) = self.tl_state.as_ref() else { return }; + if let Some(latest_sent_msg) = tl.items .iter() .rev() .take(MAX_ITEMS_TO_SEARCH_THROUGH) .find_map(|item| item.as_event().filter(|ev| ev.is_editable()).cloned()) { - self.view - .room_input_bar(id!(room_input_bar)) + self.view.room_input_bar(id!(room_input_bar)) .show_editing_pane(cx, latest_sent_msg, tl.room_id.clone()); - } else { + } + else { enqueue_popup_notification(PopupItem { message: "No recent message available to edit.".to_string(), kind: PopupKind::Warning, @@ -1863,9 +1644,7 @@ impl RoomScreen { } } MessageAction::Pin(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; if let Some(event_id) = details.event_id { submit_async_request(MatrixRequest::PinEvent { event_id, @@ -1881,9 +1660,7 @@ impl RoomScreen { } } MessageAction::Unpin(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; if let Some(event_id) = details.event_id { submit_async_request(MatrixRequest::PinEvent { event_id, @@ -1899,19 +1676,16 @@ impl RoomScreen { } } MessageAction::CopyText(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; - if let Some(text) = tl - .items + let Some(tl) = self.tl_state.as_ref() else { return }; + if let Some(text) = tl.items .get(details.item_id) .and_then(|tl_item| tl_item.as_event().map(plaintext_body_of_timeline_item)) { cx.copy_to_clipboard(&text); - } else { + } + else { enqueue_popup_notification(PopupItem { message: "Could not find message in timeline to copy text from. Please try again!".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None}); - error!( - "MessageAction::CopyText: couldn't find event [{}] {:?} to copy text from in room {}", + error!("MessageAction::CopyText: couldn't find event [{}] {:?} to copy text from in room {}", details.item_id, details.event_id.as_deref(), tl.room_id, @@ -1919,54 +1693,26 @@ impl RoomScreen { } } MessageAction::CopyHtml(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; // The logic for getting the formatted body of a message is the same // as the logic used in `populate_message_view()`. let mut success = false; - if let Some(event_tl_item) = tl - .items + if let Some(event_tl_item) = tl.items .get(details.item_id) .and_then(|tl_item| tl_item.as_event()) .filter(|ev| ev.event_id() == details.event_id.as_deref()) { if let Some(message) = event_tl_item.content().as_message() { match message.msgtype() { - MessageType::Text(TextMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::Notice(NoticeMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::Emote(EmoteMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::Image(ImageMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::File(FileMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::Audio(AudioMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::Video(VideoMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::VerificationRequest( - KeyVerificationRequestEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }, - ) => { + MessageType::Text(TextMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::Notice(NoticeMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::Emote(EmoteMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::Image(ImageMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::File(FileMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::Audio(AudioMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::Video(VideoMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::VerificationRequest(KeyVerificationRequestEventContent { formatted: Some(FormattedBody { body, .. }), .. }) => + { cx.copy_to_clipboard(body); success = true; } @@ -1976,8 +1722,7 @@ impl RoomScreen { } if !success { enqueue_popup_notification(PopupItem { message: "Could not find message in timeline to copy HTML from. Please try again!".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); - error!( - "MessageAction::CopyHtml: couldn't find event [{}] {:?} to copy HTML from in room {}", + error!("MessageAction::CopyHtml: couldn't find event [{}] {:?} to copy HTML from in room {}", details.item_id, details.event_id.as_deref(), tl.room_id, @@ -1985,20 +1730,13 @@ impl RoomScreen { } } MessageAction::CopyLink(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; if let Some(event_id) = details.event_id { let matrix_to_uri = tl.room_id.matrix_to_event_uri(event_id); cx.copy_to_clipboard(&matrix_to_uri.to_string()); } else { - enqueue_popup_notification(PopupItem { - message: "Couldn't create permalink to message.".to_string(), - kind: PopupKind::Error, - auto_dismissal_duration: None, - }); - error!( - "MessageAction::CopyLink: no `event_id`: [{}] {:?} in room {}", + enqueue_popup_notification(PopupItem { message: "Couldn't create permalink to message.".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); + error!("MessageAction::CopyLink: no `event_id`: [{}] {:?} in room {}", details.item_id, details.event_id.as_deref(), tl.room_id, @@ -2006,11 +1744,7 @@ impl RoomScreen { } } MessageAction::ViewSource(_details) => { - enqueue_popup_notification(PopupItem { - message: "Viewing an event's source is not yet implemented.".to_string(), - kind: PopupKind::Error, - auto_dismissal_duration: None, - }); + enqueue_popup_notification(PopupItem { message: "Viewing an event's source is not yet implemented.".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); // TODO: re-use Franco's implementation below: // let Some(tl) = self.tl_state.as_mut() else { continue }; @@ -2040,9 +1774,7 @@ impl RoomScreen { } MessageAction::JumpToRelated(details) => { let Some(related_event_id) = details.related_event_id.as_ref() else { - error!( - "BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}" - ); + error!("BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}"); continue; }; self.jump_to_event( @@ -2050,16 +1782,20 @@ impl RoomScreen { related_event_id, Some(details.item_id), portal_list, - loading_pane, + loading_pane ); } MessageAction::JumpToEvent(event_id) => { - self.jump_to_event(cx, &event_id, None, portal_list, loading_pane); + self.jump_to_event( + cx, + &event_id, + None, + portal_list, + loading_pane + ); } MessageAction::Redact { details, reason } => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; let mut success = false; if let Some(timeline_item) = tl.items.get(details.item_id) { if let Some(event_tl_item) = timeline_item.as_event() { @@ -2075,13 +1811,8 @@ impl RoomScreen { } } if !success { - enqueue_popup_notification(PopupItem { - message: "Couldn't find message in timeline to delete.".to_string(), - kind: PopupKind::Error, - auto_dismissal_duration: None, - }); - error!( - "MessageAction::Redact: couldn't find event [{}] {:?} to react to in room {}", + enqueue_popup_notification(PopupItem { message: "Couldn't find message in timeline to delete.".to_string(), kind: PopupKind::Error, auto_dismissal_duration: None }); + error!("MessageAction::Redact: couldn't find event [{}] {:?} to react to in room {}", details.item_id, details.event_id.as_deref(), tl.room_id, @@ -2094,14 +1825,14 @@ impl RoomScreen { // } // This is handled within the Message widget itself. - MessageAction::HighlightMessage(..) => {} + MessageAction::HighlightMessage(..) => { } // This is handled by the top-level App itself. - MessageAction::OpenMessageContextMenu { .. } => {} + MessageAction::OpenMessageContextMenu { .. } => { } // This isn't yet handled, as we need to completely redesign it. - MessageAction::ActionBarOpen { .. } => {} + MessageAction::ActionBarOpen { .. } => { } // This isn't yet handled, as we need to completely redesign it. - MessageAction::ActionBarClose => {} - MessageAction::None => {} + MessageAction::ActionBarClose => { } + MessageAction::None => { } } } } @@ -2119,17 +1850,14 @@ impl RoomScreen { portal_list: &PortalListRef, loading_pane: &LoadingPaneRef, ) { - let Some(tl) = self.tl_state.as_mut() else { - return; - }; + let Some(tl) = self.tl_state.as_mut() else { return }; let max_tl_idx = max_tl_idx.unwrap_or_else(|| tl.items.len()); // Attempt to find the index of replied-to message in the timeline. // Start from the current item's index (`tl_idx`) and search backwards, // since we know the related message must come before the current item. let mut num_items_searched = 0; - let related_msg_tl_index = tl - .items + let related_msg_tl_index = tl.items .focus() .narrow(..max_tl_idx) .into_iter() @@ -2152,13 +1880,11 @@ impl RoomScreen { // appear beneath the top of the viewport. portal_list.smooth_scroll_to(cx, index.saturating_sub(1), speed, None); // start highlight animation. - tl.message_highlight_animation_state = - MessageHighlightAnimationState::Pending { item_id: index }; + tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { + item_id: index + }; } else { - log!( - "The related event {target_event_id} wasn't immediately available in room {}, searching for it in the background...", - tl.room_id - ); + log!("The related event {target_event_id} wasn't immediately available in room {}, searching for it in the background...", tl.room_id); // Here, we set the state of the loading pane and display it to the user. // The main logic will be handled in `process_timeline_updates()`, which is the only // place where we can receive updates to the timeline from the background tasks. @@ -2194,6 +1920,7 @@ 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. @@ -2211,9 +1938,7 @@ impl RoomScreen { /// Invoke this when this timeline is being shown, /// e.g., when the user navigates to this timeline. fn show_timeline(&mut self, cx: &mut Cx) { - let room_id = self - .room_id - .clone() + let room_id = self.room_id.clone() .expect("BUG: Timeline::show_timeline(): no room_id was set."); let state_opt = TIMELINE_STATES.with_borrow_mut(|ts| ts.remove(&room_id)); @@ -2222,11 +1947,8 @@ impl RoomScreen { } else { let Some(timeline_endpoints) = take_timeline_endpoints(&room_id) else { if !self.is_loaded && self.all_rooms_loaded { - panic!( - "BUG: timeline is not loaded, but room_id {:?} \ - was not waiting for its timeline to be loaded.", - room_id - ); + panic!("BUG: timeline is not loaded, but room_id {:?} \ + was not waiting for its timeline to be loaded.", room_id); } return; }; @@ -2281,30 +2003,22 @@ impl RoomScreen { let rooms_list_ref = cx.get_global::(); let is_loaded_now = rooms_list_ref.is_room_loaded(&room_id); if is_loaded_now && !self.is_loaded { - log!( - "Detected that room \"{}\" ({}) is now loaded for the first time", - self.room_name, - room_id, + log!("Detected that room \"{}\" ({}) is now loaded for the first time", + self.room_name, room_id, ); is_first_time_being_loaded = true; } self.is_loaded = is_loaded_now; } - self.view - .restore_status_view(id!(restore_status_view)) - .set_visible(cx, !self.is_loaded); + self.view.restore_status_view(id!(restore_status_view)).set_visible(cx, !self.is_loaded); // Kick off a back pagination request if it's the first time loading this room, // because we want to show the user some messages as soon as possible // when they first open the room, and there might not be any messages yet. if is_first_time_being_loaded { if !tl_state.fully_paginated { - log!( - "Sending a first-time backwards pagination request for room \"{}\" {}", - self.room_name, - room_id - ); + log!("Sending a first-time backwards pagination request for room \"{}\" {}", self.room_name, room_id); submit_async_request(MatrixRequest::PaginateRoomTimeline { room_id: room_id.clone(), num_events: 50, @@ -2315,9 +2029,7 @@ 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. - submit_async_request(MatrixRequest::SyncRoomMemberList { - room_id: room_id.clone(), - }); + submit_async_request(MatrixRequest::SyncRoomMemberList { room_id: room_id.clone() }); } // Hide the typing notice view initially. @@ -2327,7 +2039,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 { @@ -2339,7 +2051,7 @@ impl RoomScreen { // Fetch from the local cache, as we already requested to sync // the room members from the homeserver above. local_only: true, - }); + }); submit_async_request(MatrixRequest::SubscribeToTypingNotices { room_id: room_id.clone(), subscribe: true, @@ -2370,9 +2082,7 @@ impl RoomScreen { /// Invoke this when this RoomScreen/timeline is being hidden or no longer being shown. fn hide_timeline(&mut self) { - let Some(room_id) = self.room_id.clone() else { - return; - }; + let Some(room_id) = self.room_id.clone() else { return }; self.save_state(); @@ -2401,10 +2111,7 @@ impl RoomScreen { /// Note: after calling this function, the widget's `tl_state` will be `None`. fn save_state(&mut self) { let Some(mut tl) = self.tl_state.take() else { - error!( - "Timeline::save_state(): skipping due to missing state, room {:?}", - self.room_id - ); + error!("Timeline::save_state(): skipping due to missing state, room {:?}", self.room_id); return; }; @@ -2459,9 +2166,7 @@ impl RoomScreen { room_name: S, ) { // If the room is already being displayed, then do nothing. - if self.room_id.as_ref().is_some_and(|id| id == &room_id) { - return; - } + if self.room_id.as_ref().is_some_and(|id| id == &room_id) { return; } self.hide_timeline(); // Reset the the state of the inner loading pane. @@ -2492,9 +2197,7 @@ impl RoomScreen { return; } let first_index = portal_list.first_id(); - let Some(tl_state) = self.tl_state.as_mut() else { - return; - }; + let Some(tl_state) = self.tl_state.as_mut() else { return }; if let Some(ref mut index) = tl_state.prev_first_index { // to detect change of scroll when scroll ends @@ -2505,7 +2208,7 @@ impl RoomScreen { .items .get(std::cmp::min( first_index + portal_list.visible_items(), - tl_state.items.len().saturating_sub(1), + tl_state.items.len().saturating_sub(1) )) .and_then(|f| f.as_event()) .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) @@ -2523,20 +2226,17 @@ impl RoomScreen { event_id: last_event_id.to_owned(), }); } else { - if let Some(own_user_receipt_timestamp) = &tl_state - .latest_own_user_receipt - .clone() - .and_then(|receipt| receipt.ts) - { + if let Some(own_user_receipt_timestamp) = &tl_state.latest_own_user_receipt.clone() + .and_then(|receipt| receipt.ts) { let Some((_first_event_id, first_timestamp)) = tl_state .items .get(first_index) .and_then(|f| f.as_event()) .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) - else { - *index = first_index; - return; - }; + else { + *index = first_index; + return; + }; if own_user_receipt_timestamp >= &first_timestamp && own_user_receipt_timestamp <= &last_timestamp { @@ -2546,6 +2246,7 @@ impl RoomScreen { event_id: last_event_id.to_owned(), }); } + } } } @@ -2564,22 +2265,14 @@ impl RoomScreen { actions: &ActionsBuf, portal_list: &PortalListRef, ) { - let Some(tl) = self.tl_state.as_mut() else { - return; - }; - if tl.fully_paginated { - return; - }; - if !portal_list.scrolled(actions) { - return; - }; + let Some(tl) = self.tl_state.as_mut() else { return }; + if tl.fully_paginated { return }; + if !portal_list.scrolled(actions) { return }; let first_index = portal_list.first_id(); if first_index == 0 && tl.last_scrolled_index > 0 { - log!( - "Scrolled up from item {} --> 0, sending back pagination request for room {}", - tl.last_scrolled_index, - tl.room_id, + log!("Scrolled up from item {} --> 0, sending back pagination request for room {}", + tl.last_scrolled_index, tl.room_id, ); submit_async_request(MatrixRequest::PaginateRoomTimeline { room_id: tl.room_id.clone(), @@ -2599,9 +2292,7 @@ impl RoomScreenRef { room_id: OwnedRoomId, room_name: S, ) { - let Some(mut inner) = self.borrow_mut() else { - return; - }; + let Some(mut inner) = self.borrow_mut() else { return }; inner.set_displayed_room(cx, room_id, room_name); } } @@ -2617,6 +2308,7 @@ pub struct RoomScreenProps { pub room_avatar_url: Option, } + /// Actions for the room screen's tooltip. #[derive(Clone, Debug, DefaultNone)] pub enum RoomScreenTooltipActions { @@ -2711,7 +2403,9 @@ pub enum TimelineUpdate { /// includes a complete list of room members that can be shared across components. /// This is different from RoomMembersSynced which only indicates members were fetched /// but doesn't provide the actual data. - RoomMembersListFetched { members: Vec }, + RoomMembersListFetched { + members: Vec, + }, /// A notice that one or more requested media items (images, videos, etc.) /// that should be displayed in this timeline have now been fetched and are available. MediaFetched, @@ -2762,7 +2456,7 @@ struct TimelineUiState { /// The list of room members for this room. room_members: Option>>, - /// Precomputed sort keys for room members to speed up mentions search. + /// Precomputed sort data for room members to speed up mention search. room_members_sort: Option>, /// Whether this room's timeline has been fully paginated, which means @@ -2857,9 +2551,7 @@ struct TimelineUiState { #[derive(Default, Debug)] enum MessageHighlightAnimationState { - Pending { - item_id: usize, - }, + Pending { item_id: usize }, #[default] Off, } @@ -2896,8 +2588,9 @@ fn find_new_item_matching_current_item( ) -> Option<(usize, usize, f64, OwnedEventId)> { let mut curr_item_focus = curr_items.focus(); let mut idx_curr = starting_at_curr_idx; - let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = - Vec::with_capacity(portal_list.visible_items()); + let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = Vec::with_capacity( + portal_list.visible_items() + ); // Find all items with real event IDs that are currently visible in the portal list. // TODO: if this is slow, we could limit it to 3-5 events at the most. @@ -2926,9 +2619,7 @@ fn find_new_item_matching_current_item( // some may be zeroed-out, so we need to account for that possibility by only // using events that have a real non-zero area if let Some(pos_offset) = portal_list.position_of_item(cx, *idx_curr) { - log!( - "Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}" - ); + log!("Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}"); return Some((*idx_curr, idx_new, pos_offset, event_id.to_owned())); } } @@ -2994,8 +2685,7 @@ fn populate_message_view( TimelineItemContent::MsgLike(_msg_like_content) => { let prev_msg_sender = prev_event_tl_item.sender(); prev_msg_sender == event_tl_item.sender() - && ts_millis - .0 + && ts_millis.0 .checked_sub(prev_event_tl_item.timestamp().0) .is_some_and(|d| d < uint!(600000)) // 10 mins in millis } @@ -3012,12 +2702,8 @@ fn populate_message_view( let (item, used_cached_item) = match &msg_like_content.kind { MsgLikeKind::Message(msg) => { match msg.msgtype() { - MessageType::Text(TextMessageEventContent { - body, formatted, .. - }) => { - has_html_body = formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + MessageType::Text(TextMessageEventContent { body, formatted, .. }) => { + has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -3041,13 +2727,9 @@ fn populate_message_view( } // A notice message is just a message sent by an automated bot, // so we treat it just like a message but use a different font color. - MessageType::Notice(NoticeMessageEventContent { - body, formatted, .. - }) => { + MessageType::Notice(NoticeMessageEventContent{body, formatted, ..}) => { is_notice = true; - has_html_body = formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -3058,20 +2740,17 @@ fn populate_message_view( (item, true) } else { let html_or_plaintext_ref = item.html_or_plaintext(id!(content.message)); - html_or_plaintext_ref.apply_over( - cx, - live!( - html_view = { - html = { - font_color: (COLOR_MESSAGE_NOTICE_TEXT), - draw_normal: { color: (COLOR_MESSAGE_NOTICE_TEXT), } - draw_italic: { color: (COLOR_MESSAGE_NOTICE_TEXT), } - draw_bold: { color: (COLOR_MESSAGE_NOTICE_TEXT), } - draw_bold_italic: { color: (COLOR_MESSAGE_NOTICE_TEXT), } - } + html_or_plaintext_ref.apply_over(cx, live!( + html_view = { + html = { + font_color: (COLOR_MESSAGE_NOTICE_TEXT), + draw_normal: { color: (COLOR_MESSAGE_NOTICE_TEXT), } + draw_italic: { color: (COLOR_MESSAGE_NOTICE_TEXT), } + draw_bold: { color: (COLOR_MESSAGE_NOTICE_TEXT), } + draw_bold_italic: { color: (COLOR_MESSAGE_NOTICE_TEXT), } } - ), - ); + } + )); new_drawn_status.content_drawn = populate_text_message_content( cx, &html_or_plaintext_ref, @@ -3092,30 +2771,25 @@ fn populate_message_view( (item, true) } else { let html_or_plaintext_ref = item.html_or_plaintext(id!(content.message)); - html_or_plaintext_ref.apply_over( - cx, - live!( - html_view = { - html = { - font_color: (COLOR_FG_DANGER_RED), - draw_normal: { color: (COLOR_FG_DANGER_RED), } - draw_italic: { color: (COLOR_FG_DANGER_RED), } - draw_bold: { color: (COLOR_FG_DANGER_RED), } - draw_bold_italic: { color: (COLOR_FG_DANGER_RED), } - } + html_or_plaintext_ref.apply_over(cx, live!( + html_view = { + html = { + font_color: (COLOR_FG_DANGER_RED), + draw_normal: { color: (COLOR_FG_DANGER_RED), } + draw_italic: { color: (COLOR_FG_DANGER_RED), } + draw_bold: { color: (COLOR_FG_DANGER_RED), } + draw_bold_italic: { color: (COLOR_FG_DANGER_RED), } } - ), - ); + } + )); let formatted = format!( "Server notice: {}\n\nNotice type:: {}{}{}", sn.body, sn.server_notice_type.as_str(), - sn.limit_type - .as_ref() + sn.limit_type.as_ref() .map(|l| format!("\nLimit type: {}", l.as_str())) .unwrap_or_default(), - sn.admin_contact - .as_ref() + sn.admin_contact.as_ref() .map(|c| format!("\nAdmin contact: {}", c)) .unwrap_or_default(), ); @@ -3136,12 +2810,8 @@ fn populate_message_view( } // An emote is just like a message but is prepended with the user's name // to indicate that it's an "action" that the user is performing. - MessageType::Emote(EmoteMessageEventContent { - body, formatted, .. - }) => { - has_html_body = formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + MessageType::Emote(EmoteMessageEventContent { body, formatted, .. }) => { + has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -3152,15 +2822,13 @@ fn populate_message_view( (item, true) } else { // Draw the profile up front here because we need the username for the emote body. - let (username, profile_drawn) = item - .avatar(id!(profile.avatar)) - .set_avatar_and_get_username( - cx, - room_id, - event_tl_item.sender(), - Some(event_tl_item.sender_profile()), - event_tl_item.event_id(), - ); + let (username, profile_drawn) = item.avatar(id!(profile.avatar)).set_avatar_and_get_username( + cx, + room_id, + event_tl_item.sender(), + Some(event_tl_item.sender_profile()), + event_tl_item.event_id(), + ); // Prepend a "* " to the emote body, as suggested by the Matrix spec. let (body, formatted) = if let Some(fb) = formatted.as_ref() { @@ -3169,7 +2837,7 @@ fn populate_message_view( Some(FormattedBody { format: fb.format.clone(), body: format!("* {} {}", &username, &fb.body), - }), + }) ) } else { (Cow::from(format!("* {} {}", &username, body)), None) @@ -3189,9 +2857,7 @@ fn populate_message_view( } } MessageType::Image(image) => { - has_html_body = image - .formatted - .as_ref() + has_html_body = image.formatted.as_ref() .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedImageMessage) @@ -3236,10 +2902,7 @@ fn populate_message_view( } } MessageType::File(file_content) => { - has_html_body = file_content - .formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = file_content.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -3258,10 +2921,7 @@ fn populate_message_view( } } MessageType::Audio(audio) => { - has_html_body = audio - .formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = audio.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -3280,10 +2940,7 @@ fn populate_message_view( } } MessageType::Video(video) => { - has_html_body = video - .formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = video.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { live_id!(CondensedMessage) } else { @@ -3302,10 +2959,7 @@ fn populate_message_view( } } MessageType::VerificationRequest(verification) => { - has_html_body = verification - .formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = verification.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = live_id!(Message); let (item, existed) = list.item_with_existed(cx, item_id, template); if existed && item_drawn_status.content_drawn { @@ -3317,8 +2971,7 @@ fn populate_message_view( body: format!( "Sent a verification request to {}.
    (Supported methods: {})
    ", verification.to, - verification - .methods + verification.methods .iter() .map(|m| m.as_str()) .collect::>() @@ -3344,8 +2997,10 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - item.label(id!(content.message)) - .set_text(cx, &format!("[Unsupported {:?}]", msg_like_content.kind)); + item.label(id!(content.message)).set_text( + cx, + &format!("[Unsupported {:?}]", msg_like_content.kind), + ); new_drawn_status.content_drawn = true; (item, false) } @@ -3355,9 +3010,7 @@ fn populate_message_view( // Handle sticker messages that are static images. MsgLikeKind::Sticker(sticker) => { has_html_body = false; - let StickerEventContent { - body, info, source, .. - } = sticker.content(); + let StickerEventContent { body, info, source, .. } = sticker.content(); let template = if use_compact_view { live_id!(CondensedImageMessage) @@ -3392,8 +3045,10 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - item.label(id!(content.message)) - .set_text(cx, &format!("[Unsupported {:?}] ", other)); + item.label(id!(content.message)).set_text( + cx, + &format!("[Unsupported {:?}] ", other), + ); new_drawn_status.content_drawn = true; (item, false) } @@ -3453,44 +3108,36 @@ fn populate_message_view( // log!("\t --> populate_message_view(): DRAWING profile draw for item_id: {item_id}"); let username_label = item.label(id!(content.username)); - if !is_server_notice { - // the normal case - let (username, profile_drawn) = - set_username_and_get_avatar_retval.unwrap_or_else(|| { - item.avatar(id!(profile.avatar)) - .set_avatar_and_get_username( - cx, - room_id, - event_tl_item.sender(), - Some(event_tl_item.sender_profile()), - event_tl_item.event_id(), - ) - }); - if is_notice { - username_label.apply_over( + if !is_server_notice { // the normal case + let (username, profile_drawn) = set_username_and_get_avatar_retval.unwrap_or_else(|| + item.avatar(id!(profile.avatar)).set_avatar_and_get_username( cx, - live!( - draw_text: { - color: (COLOR_MESSAGE_NOTICE_TEXT), - } - ), - ); + room_id, + event_tl_item.sender(), + Some(event_tl_item.sender_profile()), + event_tl_item.event_id(), + ) + ); + if is_notice { + username_label.apply_over(cx, live!( + draw_text: { + color: (COLOR_MESSAGE_NOTICE_TEXT), + } + )); } username_label.set_text(cx, &username); new_drawn_status.profile_drawn = profile_drawn; - } else { + } + else { // Server notices are drawn with a red color avatar background and username. let avatar = item.avatar(id!(profile.avatar)); avatar.show_text(cx, Some(COLOR_FG_DANGER_RED), None, "⚠"); username_label.set_text(cx, "Server notice"); - username_label.apply_over( - cx, - live!( - draw_text: { - color: (COLOR_FG_DANGER_RED), - } - ), - ); + username_label.apply_over(cx, live!( + draw_text: { + color: (COLOR_FG_DANGER_RED), + } + )); new_drawn_status.profile_drawn = true; } } @@ -3507,40 +3154,28 @@ fn populate_message_view( // Set the "edited" indicator if this message was edited. if msg_like_content.as_message().is_some_and(|m| m.is_edited()) { - item.edited_indicator(id!(profile.edited_indicator)) - .set_latest_edit(cx, event_tl_item); + item.edited_indicator(id!(profile.edited_indicator)).set_latest_edit( + cx, + event_tl_item, + ); } - #[cfg(feature = "tsp")] - { + #[cfg(feature = "tsp")] { use matrix_sdk::ruma::serde::Base64; - use crate::tsp::{ - self, - tsp_sign_indicator::{TspSignState, TspSignIndicatorWidgetRefExt}, - }; + use crate::tsp::{self, tsp_sign_indicator::{TspSignState, TspSignIndicatorWidgetRefExt}}; - if let Some(mut tsp_sig) = event_tl_item - .latest_json() + if let Some(mut tsp_sig) = event_tl_item.latest_json() .and_then(|raw| raw.get_field::("content").ok()) .flatten() .and_then(|content_obj| content_obj.get("org.robius.tsp_signature").cloned()) .and_then(|tsp_sig_value| serde_json::from_value::(tsp_sig_value).ok()) .map(|b64| b64.into_inner()) { - log!( - "Found event {:?} with TSP signature.", - event_tl_item.event_id() - ); - let tsp_sign_state = if let Some(sender_vid) = tsp::tsp_state_ref() - .lock() - .unwrap() + log!("Found event {:?} with TSP signature.", event_tl_item.event_id()); + let tsp_sign_state = if let Some(sender_vid) = tsp::tsp_state_ref().lock().unwrap() .get_verified_vid_for(event_tl_item.sender()) { - log!( - "Found verified VID for sender {}: \"{}\"", - event_tl_item.sender(), - sender_vid.identifier() - ); + log!("Found verified VID for sender {}: \"{}\"", event_tl_item.sender(), sender_vid.identifier()); tsp_sdk::crypto::verify(&*sender_vid, &mut tsp_sig).map_or( TspSignState::WrongSignature, |(msg, msg_type)| { @@ -3552,11 +3187,7 @@ fn populate_message_view( TspSignState::Unknown }; - log!( - "TSP signature state for event {:?} is {:?}", - event_tl_item.event_id(), - tsp_sign_state - ); + log!("TSP signature state for event {:?} is {:?}", event_tl_item.event_id(), tsp_sign_state); item.tsp_sign_indicator(id!(profile.tsp_sign_indicator)) .show_with_state(cx, tsp_sign_state); } @@ -3578,30 +3209,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) - { + (link_preview_ref, media_cache, link_preview_cache) { link_preview_ref.populate_below_message( cx, &links, @@ -3627,8 +3263,7 @@ fn populate_image_message_content( ) -> bool { // We don't use thumbnails, as their resolution is too low to be visually useful. // We also don't trust the provided mimetype, as it can be incorrect. - let (mimetype, _width, _height) = image_info_source - .as_ref() + let (mimetype, _width, _height) = image_info_source.as_ref() .map(|info| (info.mimetype.as_deref(), info.width, info.height)) .unwrap_or_default(); @@ -3648,120 +3283,102 @@ fn populate_image_message_content( // A closure that fetches and shows the image from the given `mxc_uri`, // marking it as fully drawn if the image was available. - let mut fetch_and_show_image_uri = - |cx: &mut Cx, mxc_uri: OwnedMxcUri, image_info: Box| { - match media_cache.try_get_media_or_fetch(mxc_uri.clone(), MEDIA_THUMBNAIL_FORMAT.into()) - { - (MediaCacheEntry::Loaded(data), _media_format) => { - let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { - utils::load_png_or_jpg(&img, cx, &data) - .map(|()| img.size_in_pixels(cx).unwrap_or_default()) - }); - if let Err(e) = show_image_result { - let err_str = format!("{body}\n\nFailed to display image: {e:?}"); - error!("{err_str}"); - text_or_image_ref.show_text(cx, &err_str); - } - - // We're done drawing the image, so mark it as fully drawn. - fully_drawn = true; + let mut fetch_and_show_image_uri = |cx: &mut Cx, mxc_uri: OwnedMxcUri, image_info: Box| { + match media_cache.try_get_media_or_fetch(mxc_uri.clone(), MEDIA_THUMBNAIL_FORMAT.into()) { + (MediaCacheEntry::Loaded(data), _media_format) => { + let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { + utils::load_png_or_jpg(&img, cx, &data) + .map(|()| img.size_in_pixels(cx).unwrap_or_default()) + }); + if let Err(e) = show_image_result { + let err_str = format!("{body}\n\nFailed to display image: {e:?}"); + error!("{err_str}"); + text_or_image_ref.show_text(cx, &err_str); } - (MediaCacheEntry::Requested, _media_format) => { - // If the image is being fetched, we try to show its blurhash. - if let (Some(ref blurhash), Some(width), Some(height)) = ( - image_info.blurhash.clone(), - image_info.width, - image_info.height, - ) { - let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { - let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) - else { - return Err(image_cache::ImageError::EmptyData); - }; - let (width, height): (u32, u32) = (width, height); - if width == 0 || height == 0 { - warning!( - "Image had an invalid aspect ratio (width or height of 0)." - ); - return Err(image_cache::ImageError::EmptyData); - } - let aspect_ratio: f32 = width as f32 / height as f32; - // Cap the blurhash to a max size of 500 pixels in each dimension - // because the `blurhash::decode()` function can be rather expensive. - let (mut capped_width, mut capped_height) = (width, height); - if capped_height > BLURHASH_IMAGE_MAX_SIZE { - capped_height = BLURHASH_IMAGE_MAX_SIZE; - capped_width = (capped_height as f32 * aspect_ratio).floor() as u32; - } - if capped_width > BLURHASH_IMAGE_MAX_SIZE { - capped_width = BLURHASH_IMAGE_MAX_SIZE; - capped_height = (capped_width as f32 / aspect_ratio).floor() as u32; - } - match blurhash::decode(blurhash, capped_width, capped_height, 1.0) { - Ok(data) => ImageBuffer::new( - &data, - capped_width as usize, - capped_height as usize, - ) - .map(|img_buff| { + // We're done drawing the image, so mark it as fully drawn. + fully_drawn = true; + } + (MediaCacheEntry::Requested, _media_format) => { + // If the image is being fetched, we try to show its blurhash. + if let (Some(ref blurhash), Some(width), Some(height)) = (image_info.blurhash.clone(), image_info.width, image_info.height) { + let show_image_result = text_or_image_ref.show_image(cx, |cx, img| { + let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) else { + return Err(image_cache::ImageError::EmptyData) + }; + let (width, height): (u32, u32) = (width, height); + if width == 0 || height == 0 { + warning!("Image had an invalid aspect ratio (width or height of 0)."); + return Err(image_cache::ImageError::EmptyData); + } + let aspect_ratio: f32 = width as f32 / height as f32; + // Cap the blurhash to a max size of 500 pixels in each dimension + // because the `blurhash::decode()` function can be rather expensive. + let (mut capped_width, mut capped_height) = (width, height); + if capped_height > BLURHASH_IMAGE_MAX_SIZE { + capped_height = BLURHASH_IMAGE_MAX_SIZE; + capped_width = (capped_height as f32 * aspect_ratio).floor() as u32; + } + if capped_width > BLURHASH_IMAGE_MAX_SIZE { + capped_width = BLURHASH_IMAGE_MAX_SIZE; + capped_height = (capped_width as f32 / aspect_ratio).floor() as u32; + } + + match blurhash::decode(blurhash, capped_width, capped_height, 1.0) { + Ok(data) => { + ImageBuffer::new(&data, capped_width as usize, capped_height as usize).map(|img_buff| { let texture = Some(img_buff.into_new_texture(cx)); img.set_texture(cx, texture); img.size_in_pixels(cx).unwrap_or_default() - }), - Err(e) => { - error!("Failed to decode blurhash {e:?}"); - Err(image_cache::ImageError::EmptyData) - } + }) } - }); - if let Err(e) = show_image_result { - let err_str = format!("{body}\n\nFailed to display image: {e:?}"); - error!("{err_str}"); - text_or_image_ref.show_text(cx, &err_str); + Err(e) => { + error!("Failed to decode blurhash {e:?}"); + Err(image_cache::ImageError::EmptyData) + } } + }); + if let Err(e) = show_image_result { + let err_str = format!("{body}\n\nFailed to display image: {e:?}"); + error!("{err_str}"); + text_or_image_ref.show_text(cx, &err_str); } - fully_drawn = false; } - (MediaCacheEntry::Failed, _media_format) => { - if text_or_image_ref.view(id!(default_image_view)).visible() { - fully_drawn = true; - return; - } - text_or_image_ref.show_text( - cx, - format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri), - ); - // For now, we consider this as being "complete". In the future, we could support - // retrying to fetch thumbnail of the image on a user click/tap. + fully_drawn = false; + } + (MediaCacheEntry::Failed, _media_format) => { + if text_or_image_ref.view(id!(default_image_view)).visible() { fully_drawn = true; + return; } + text_or_image_ref + .show_text(cx, format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri)); + // For now, we consider this as being "complete". In the future, we could support + // retrying to fetch thumbnail of the image on a user click/tap. + fully_drawn = true; } - }; + } + }; - let mut fetch_and_show_media_source = - |cx: &mut Cx, media_source: MediaSource, image_info: Box| { - match media_source { - MediaSource::Encrypted(encrypted) => { - // We consider this as "fully drawn" since we don't yet support encryption. - text_or_image_ref.show_text( - cx, - format!( - "{body}\n\n[TODO] fetch encrypted image at {:?}", - encrypted.url - ), - ); - } - MediaSource::Plain(mxc_uri) => fetch_and_show_image_uri(cx, mxc_uri, image_info), + let mut fetch_and_show_media_source = |cx: &mut Cx, media_source: MediaSource, image_info: Box| { + match media_source { + MediaSource::Encrypted(encrypted) => { + // We consider this as "fully drawn" since we don't yet support encryption. + text_or_image_ref.show_text( + cx, + format!("{body}\n\n[TODO] fetch encrypted image at {:?}", encrypted.url) + ); + }, + MediaSource::Plain(mxc_uri) => { + fetch_and_show_image_uri(cx, mxc_uri, image_info) } - }; + } + }; match image_info_source { Some(image_info) => { // Use the provided thumbnail URI if it exists; otherwise use the original URI. - let media_source = image_info - .thumbnail_source - .clone() + let media_source = image_info.thumbnail_source.clone() .unwrap_or(original_source); fetch_and_show_media_source(cx, media_source, image_info); } @@ -3774,6 +3391,7 @@ fn populate_image_message_content( fully_drawn } + /// Draws a file message's content into the given `message_content_widget`. /// /// Returns whether the file message content was fully drawn. @@ -3790,8 +3408,7 @@ fn populate_file_message_content( .and_then(|info| info.size) .map(|bytes| format!(" ({})", ByteSize::b(bytes.into()))) .unwrap_or_default(); - let caption = file_content - .formatted_caption() + let caption = file_content.formatted_caption() .map(|fb| format!("
    {}", fb.body)) .or_else(|| file_content.caption().map(|c| format!("
    {c}"))) .unwrap_or_default(); @@ -3818,23 +3435,20 @@ fn populate_audio_message_content( let (duration, mime, size) = audio .info .as_ref() - .map(|info| { - ( - info.duration - .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) - .unwrap_or_default(), - info.mimetype - .as_ref() - .map(|m| format!(" {m},")) - .unwrap_or_default(), - info.size - .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) - .unwrap_or_default(), - ) - }) + .map(|info| ( + info.duration + .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) + .unwrap_or_default(), + info.mimetype + .as_ref() + .map(|m| format!(" {m},")) + .unwrap_or_default(), + info.size + .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) + .unwrap_or_default(), + )) .unwrap_or_default(); - let caption = audio - .formatted_caption() + let caption = audio.formatted_caption() .map(|fb| format!("
    {}", fb.body)) .or_else(|| audio.caption().map(|c| format!("
    {c}"))) .unwrap_or_default(); @@ -3848,6 +3462,7 @@ fn populate_audio_message_content( true } + /// Draws a video message's content into the given `message_content_widget`. /// /// Returns whether the video message content was fully drawn. @@ -3861,26 +3476,23 @@ fn populate_video_message_content( let (duration, mime, size, dimensions) = video .info .as_ref() - .map(|info| { - ( - info.duration - .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) - .unwrap_or_default(), - info.mimetype - .as_ref() - .map(|m| format!(" {m},")) - .unwrap_or_default(), - info.size - .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) - .unwrap_or_default(), - info.width - .and_then(|width| info.height.map(|height| format!(" {width}x{height},"))) - .unwrap_or_default(), - ) - }) + .map(|info| ( + info.duration + .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) + .unwrap_or_default(), + info.mimetype + .as_ref() + .map(|m| format!(" {m},")) + .unwrap_or_default(), + info.size + .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) + .unwrap_or_default(), + info.width.and_then(|width| + info.height.map(|height| format!(" {width}x{height},")) + ).unwrap_or_default(), + )) .unwrap_or_default(); - let caption = video - .formatted_caption() + let caption = video.formatted_caption() .map(|fb| format!("
    {}", fb.body)) .or_else(|| video.caption().map(|c| format!("
    {c}"))) .unwrap_or_default(); @@ -3894,6 +3506,8 @@ fn populate_video_message_content( true } + + /// Draws the given location message's content into the `message_content_widget`. /// /// Returns whether the location message content was fully drawn. @@ -3902,9 +3516,8 @@ fn populate_location_message_content( message_content_widget: &HtmlOrPlaintextRef, location: &LocationMessageEventContent, ) -> bool { - let coords = location - .geo_uri - .get(utils::GEO_URI_SCHEME.len()..) + let coords = location.geo_uri + .get(utils::GEO_URI_SCHEME.len() ..) .and_then(|s| { let mut iter = s.split(','); if let (Some(lat), Some(long)) = (iter.next(), iter.next()) { @@ -3914,14 +3527,8 @@ fn populate_location_message_content( } }); if let Some((lat, long)) = coords { - let short_lat = lat - .find('.') - .and_then(|dot| lat.get(..dot + 7)) - .unwrap_or(lat); - let short_long = long - .find('.') - .and_then(|dot| long.get(..dot + 7)) - .unwrap_or(long); + let short_lat = lat.find('.').and_then(|dot| lat.get(..dot + 7)).unwrap_or(lat); + let short_long = long.find('.').and_then(|dot| long.get(..dot + 7)).unwrap_or(long); let html_body = format!( "Location: {short_lat},{short_long}
    \
      \ @@ -3933,8 +3540,10 @@ fn populate_location_message_content( ); message_content_widget.show_html(cx, html_body); } else { - message_content_widget - .show_html(cx, format!("[Location invalid] {}", location.body)); + message_content_widget.show_html( + cx, + format!("[Location invalid] {}", location.body) + ); } // Currently we do not fetch location thumbnail previews, so we consider this as fully drawn. @@ -3943,6 +3552,7 @@ fn populate_location_message_content( true } + /// Draws a ReplyPreview above a message if it was in-reply to another message. /// /// ## Arguments @@ -3971,15 +3581,16 @@ fn draw_replied_to_message( match &in_reply_to_details.event { TimelineDetails::Ready(replied_to_event) => { - let (in_reply_to_username, is_avatar_fully_drawn) = replied_to_message_view - .avatar(id!(replied_to_message_content.reply_preview_avatar)) - .set_avatar_and_get_username( - cx, - room_id, - &replied_to_event.sender, - Some(&replied_to_event.sender_profile), - Some(in_reply_to_details.event_id.as_ref()), - ); + let (in_reply_to_username, is_avatar_fully_drawn) = + replied_to_message_view + .avatar(id!(replied_to_message_content.reply_preview_avatar)) + .set_avatar_and_get_username( + cx, + room_id, + &replied_to_event.sender, + Some(&replied_to_event.sender_profile), + Some(in_reply_to_details.event_id.as_ref()), + ); fully_drawn = is_avatar_fully_drawn; @@ -4053,32 +3664,23 @@ pub fn populate_preview_of_timeline_item( ) { if let Some(m) = timeline_item_content.as_message() { match m.msgtype() { - MessageType::Text(TextMessageEventContent { - body, formatted, .. - }) - | MessageType::Notice(NoticeMessageEventContent { - body, formatted, .. - }) => { - let _ = populate_text_message_content( - cx, - widget_out, - body, - formatted.as_ref(), - None, - None, - None, - ); + MessageType::Text(TextMessageEventContent { body, formatted, .. }) + | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. }) => { + let _ = populate_text_message_content(cx, widget_out, body, formatted.as_ref(), None, None, None); return; } - _ => {} // fall through to the general case for all timeline items below. + _ => { } // fall through to the general case for all timeline items below. } } - let html = - text_preview_of_timeline_item(timeline_item_content, sender_user_id, sender_username) - .format_with(sender_username, true); + let html = text_preview_of_timeline_item( + timeline_item_content, + sender_user_id, + sender_username, + ).format_with(sender_username, true); widget_out.show_html(cx, html); } + /// A trait for abstracting over the different types of timeline events /// that can be displayed in a `SmallStateEvent` widget. trait SmallStateEventContent { @@ -4130,8 +3732,7 @@ impl SmallStateEventContent for RedactedMessageEventMarker { event_tl_item.latest_json(), event_tl_item.sender(), original_sender, - ) - .format_with(original_sender, false), + ).format_with(original_sender, false), ); new_drawn_status.content_drawn = true; (item, new_drawn_status) @@ -4198,9 +3799,7 @@ impl SmallStateEventContent for PollState { ) -> (WidgetRef, ItemDrawnStatus) { item.label(id!(content)).set_text( cx, - self.fallback_text() - .unwrap_or_else(|| self.results().question) - .as_str(), + self.fallback_text().unwrap_or_else(|| self.results().question).as_str(), ); new_drawn_status.content_drawn = true; (item, new_drawn_status) @@ -4326,8 +3925,7 @@ fn populate_small_state_event( ); // Draw the timestamp as part of the profile. if let Some(dt) = unix_time_millis_to_datetime(event_tl_item.timestamp()) { - item.timestamp(id!(left_container.timestamp)) - .set_date_time(cx, dt); + item.timestamp(id!(left_container.timestamp)).set_date_time(cx, dt); } new_drawn_status.profile_drawn = profile_drawn; username @@ -4346,6 +3944,7 @@ fn populate_small_state_event( ) } + /// Returns the display name of the sender of the given `event_tl_item`, if available. fn get_profile_display_name(event_tl_item: &EventTimelineItem) -> Option { if let TimelineDetails::Ready(profile) = event_tl_item.sender_profile() { @@ -4355,6 +3954,7 @@ fn get_profile_display_name(event_tl_item: &EventTimelineItem) -> Option } } + /// Actions related to a specific message within a room timeline. #[derive(Clone, DefaultNone, Debug)] pub enum MessageAction { @@ -4397,6 +3997,7 @@ pub enum MessageAction { // /// The user clicked the "report" button on a message. // Report(MessageDetails), + /// The message at the given item index in the timeline should be highlighted. HighlightMessage(usize), /// The user requested that we show a context menu with actions @@ -4422,13 +4023,10 @@ pub enum MessageAction { /// A widget representing a single message of any kind within a room timeline. #[derive(Live, LiveHook, Widget)] pub struct Message { - #[deref] - view: View, - #[animator] - animator: Animator, + #[deref] view: View, + #[animator] animator: Animator, - #[rust] - details: Option, + #[rust] details: Option, } impl Widget for Message { @@ -4443,9 +4041,7 @@ impl Widget for Message { self.animator_play(cx, id!(highlight.off)); } - let Some(details) = self.details.clone() else { - return; - }; + let Some(details) = self.details.clone() else { return }; // We first handle a click on the replied-to message preview, if present, // because we don't want any widgets within the replied-to message to be @@ -4459,7 +4055,7 @@ impl Widget for Message { MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - }, + } ); } } @@ -4470,7 +4066,7 @@ impl Widget for Message { MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - }, + } ); } // If the hit occurred on the replied-to message preview, jump to it. @@ -4481,7 +4077,7 @@ impl Widget for Message { MessageAction::JumpToRelated(details.clone()), ); } - _ => {} + _ => { } } // Next, we forward the event to the child view such that it has the chance @@ -4504,7 +4100,7 @@ impl Widget for Message { MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - }, + } ); } } @@ -4515,7 +4111,7 @@ impl Widget for Message { MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - }, + } ); } Hit::FingerHoverIn(..) => { @@ -4526,7 +4122,7 @@ impl Widget for Message { self.animator_play(cx, id!(hover.off)); // TODO: here, hide the "action bar" buttons upon hover-out } - _ => {} + _ => { } } if let Event::Actions(actions) = event { @@ -4543,19 +4139,14 @@ impl Widget for Message { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - if self - .details - .as_ref() - .is_some_and(|d| d.should_be_highlighted) - { + if self.details.as_ref().is_some_and(|d| d.should_be_highlighted) { self.view.apply_over( - cx, - live!( + cx, live!( draw_bg: { color: (vec4(1.0, 1.0, 0.82, 1.0)) mentions_bar_color: #ffd54f } - ), + ) ) } @@ -4571,9 +4162,7 @@ impl Message { impl MessageRef { fn set_data(&self, details: MessageDetails) { - let Some(mut inner) = self.borrow_mut() else { - return; - }; + let Some(mut inner) = self.borrow_mut() else { return }; inner.set_data(details); } } @@ -4582,7 +4171,7 @@ impl MessageRef { /// /// This function requires passing in a reference to `Cx`, /// which isn't used, but acts as a guarantee that this function -/// must only be called by the main UI thread. +/// must only be called by the main UI thread. pub fn clear_timeline_states(_cx: &mut Cx) { // Clear timeline states cache TIMELINE_STATES.with_borrow_mut(|states| { diff --git a/src/room/member_search.rs b/src/room/member_search.rs index 5cdb4940..bac2094a 100644 --- a/src/room/member_search.rs +++ b/src/room/member_search.rs @@ -549,7 +549,7 @@ fn grapheme_starts_with(haystack: &str, needle: &str, case_insensitive: bool) -> /// /// Follows Matrix official recommendations for matching order: /// 1. Exact display name match -/// 2. Exact user ID 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) @@ -716,6 +716,7 @@ fn substring_eq_ignore_ascii_case(haystack: &str, start: usize, needle: &str) -> .is_some_and(|segment| segment.eq_ignore_ascii_case(needle)) } +// typos:disable #[cfg(test)] mod tests { use super::*; diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index fa197d63..8a28a9a4 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -7,59 +7,24 @@ use futures_util::{pin_mut, StreamExt}; use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk::{ - config::RequestConfig, - crypto::{DecryptionSettings, TrustRequirement}, - encryption::EncryptionSettings, - event_handler::EventHandlerDropGuard, - media::MediaRequestParameters, - room::{edit::EditedContent, reply::Reply, RoomMember}, - ruma::{ - api::client::{ - profile::{AvatarUrl, DisplayName}, - receipt::create_receipt::v3::ReceiptType, - }, - events::{ - room::{message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource}, - FullStateEventContent, MessageLikeEventType, StateEventType, - }, - matrix_uri::MatrixId, - MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, - OwnedUserId, RoomOrAliasId, UserId, - }, - sliding_sync::VersionBuilder, - Client, ClientBuildError, Error, OwnedServerName, Room, RoomMemberships, RoomState, - SuccessorRoom, + config::RequestConfig, crypto::{DecryptionSettings, TrustRequirement}, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, RoomMember}, ruma::{ + api::client::{profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType}, events::{ + room::{ + message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource + }, FullStateEventContent, MessageLikeEventType, StateEventType + }, matrix_uri::MatrixId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId + }, sliding_sync::VersionBuilder, Client, ClientBuildError, Error, OwnedServerName, Room, RoomMemberships, RoomState, SuccessorRoom }; use matrix_sdk_ui::{ - room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator}, - sync_service::{self, SyncService}, - timeline::{ - AnyOtherFullStateEventContent, EventTimelineItem, MembershipChange, RoomExt, - TimelineEventItemId, TimelineItem, TimelineItemContent, - }, - RoomListService, Timeline, + room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator}, sync_service::{self, SyncService}, timeline::{AnyOtherFullStateEventContent, EventTimelineItem, MembershipChange, RoomExt, TimelineEventItemId, TimelineItem, TimelineItemContent}, RoomListService, Timeline }; use robius_open::Uri; use tokio::{ runtime::Handle, - sync::{ - mpsc::{Receiver, Sender, UnboundedReceiver, UnboundedSender}, - watch, Notify, - }, - task::JoinHandle, - time::error::Elapsed, + sync::{mpsc::{Receiver, Sender, UnboundedReceiver, UnboundedSender}, watch, Notify}, task::JoinHandle, time::error::Elapsed, }; use url::Url; -use std::{ - cmp::{max, min}, - collections::{BTreeMap, BTreeSet}, - future::Future, - iter::Peekable, - ops::{Deref, Not}, - path::Path, - sync::{Arc, LazyLock, Mutex}, - time::Duration, -}; +use std::{cmp::{max, min}, collections::{BTreeMap, BTreeSet}, future::Future, iter::Peekable, ops::{Deref, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; use std::io; use crate::{ app::AppStateAction, @@ -67,37 +32,23 @@ use crate::{ avatar_cache::AvatarUpdate, event_preview::text_preview_of_timeline_item, home::{ - invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, - link_preview::{LinkPreviewData, LinkPreviewRateLimitResponse, LinkPreviewDataNonNumeric}, - room_screen::TimelineUpdate, - rooms_list::{ - self, enqueue_rooms_list_update, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, - RoomsListUpdate, - }, - rooms_list_header::RoomsListHeaderAction, + invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewRateLimitResponse, LinkPreviewDataNonNumeric}, room_screen::TimelineUpdate, rooms_list::{self, enqueue_rooms_list_update, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate}, rooms_list_header::RoomsListHeaderAction }, login::login_screen::LoginAction, - logout::{ - logout_confirm_modal::LogoutAction, - logout_state_machine::{is_logout_in_progress, logout_with_state_machine, LogoutConfig}, - }, - media_cache::{MediaCacheEntry, MediaCacheEntryRef}, + logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{is_logout_in_progress, logout_with_state_machine, LogoutConfig}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, load_app_state, ClientSessionPersisted}, profile::{ user_profile::{AvatarState, UserProfile}, user_profile_cache::{enqueue_user_profile_update, UserProfileUpdate}, }, - room::{ - RoomPreviewAvatar, - member_search::{search_room_members_streaming_with_sort, PrecomputedMemberSort}, - }, + room::{member_search::{search_room_members_streaming_with_sort, PrecomputedMemberSort}, RoomPreviewAvatar}, shared::{ html_or_plaintext::MatrixLinkPillState, jump_to_bottom_button::UnreadMessageCount, - popup_list::{enqueue_popup_notification, PopupItem, PopupKind}, + popup_list::{enqueue_popup_notification, PopupItem, PopupKind} }, utils::{self, avatar_from_room_name, AVATAR_THUMBNAIL_FORMAT}, - verification::add_verification_event_handlers_and_sync_client, + verification::add_verification_event_handlers_and_sync_client }; #[derive(Parser, Debug, Default)] @@ -139,6 +90,7 @@ impl From for Cli { } } + /// Build a new client. async fn build_client( cli: &Cli, @@ -160,11 +112,9 @@ async fn build_client( .collect() }; - let homeserver_url = cli - .homeserver - .as_deref() + let homeserver_url = cli.homeserver.as_deref() .unwrap_or("https://matrix-client.matrix.org/"); - // .unwrap_or("https://matrix.org/"); + // .unwrap_or("https://matrix.org/"); let mut builder = Client::builder() .server_name_or_homeserver_url(homeserver_url) @@ -189,11 +139,13 @@ async fn build_client( // Use a 60 second timeout for all requests to the homeserver. // Yes, this is a long timeout, but the standard matrix homeserver is often very slow. - builder = - builder.request_config(RequestConfig::new().timeout(std::time::Duration::from_secs(60))); + builder = builder.request_config( + RequestConfig::new() + .timeout(std::time::Duration::from_secs(60)) + ); let client = builder.build().await?; - let homeserver_url = client.homeserver().to_string(); + let homeserver_url = client.homeserver().to_string(); Ok(( client, ClientSessionPersisted { @@ -209,7 +161,10 @@ async fn build_client( /// This function is used by the login screen to log in to the Matrix server. /// /// Upon success, this function returns the logged-in client and an optional sync token. -async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option)> { +async fn login( + cli: &Cli, + login_request: LoginRequest, +) -> Result<(Client, Option)> { match login_request { LoginRequest::LoginByCli | LoginRequest::LoginByPassword(_) => { let cli = if let LoginRequest::LoginByPassword(login_by_password) = login_request { @@ -233,23 +188,13 @@ async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option if let Err(e) = persistence::save_session(&client, client_session).await { let err_msg = format!("Failed to save session state to storage: {e}"); error!("{err_msg}"); - enqueue_popup_notification(PopupItem { - message: err_msg, - kind: PopupKind::Error, - auto_dismissal_duration: None, - }); + enqueue_popup_notification(PopupItem { message: err_msg, kind: PopupKind::Error, auto_dismissal_duration: None }); } Ok((client, None)) } else { let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); - enqueue_popup_notification(PopupItem { - message: err_msg.clone(), - kind: PopupKind::Error, - auto_dismissal_duration: None, - }); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err_msg.clone(), - }); + enqueue_popup_notification(PopupItem { message: err_msg.clone(), kind: PopupKind::Error, auto_dismissal_duration: None }); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } } @@ -266,6 +211,7 @@ async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option } } + /// Which direction to paginate in. /// /// * `Forwards` will retrieve later events (towards the end of the timeline), @@ -340,7 +286,9 @@ pub enum MatrixRequest { /// Request from the login screen to log in with the given credentials. Login(LoginRequest), /// Request to logout. - Logout { is_desktop: bool }, + Logout{ + is_desktop: bool, + }, /// Request to paginate the older (or newer) events of a room's timeline. PaginateRoomTimeline { room_id: OwnedRoomId, @@ -361,11 +309,17 @@ pub enum MatrixRequest { }, /// Request to fetch profile information for all members of a room. /// This can be *very* slow depending on the number of members in the room. - SyncRoomMemberList { room_id: OwnedRoomId }, + SyncRoomMemberList { + room_id: OwnedRoomId, + }, /// Request to join the given room. - JoinRoom { room_id: OwnedRoomId }, + JoinRoom { + room_id: OwnedRoomId, + }, /// Request to leave the given room. - LeaveRoom { room_id: OwnedRoomId }, + LeaveRoom { + room_id: OwnedRoomId, + }, /// Request to get the actual list of members in a room. /// This returns the list of members that can be displayed in the UI. GetRoomMembers { @@ -397,7 +351,9 @@ pub enum MatrixRequest { local_only: bool, }, /// Request to fetch the number of unread messages in the given room. - GetNumberUnreadMessages { room_id: OwnedRoomId }, + GetNumberUnreadMessages { + room_id: OwnedRoomId, + }, /// Request to ignore/block or unignore/unblock a user. IgnoreUser { /// Whether to ignore (`true`) or unignore (`false`) the user. @@ -440,12 +396,15 @@ pub enum MatrixRequest { /// This request does not return a response or notify the UI thread, and /// furthermore, there is no need to send a follow-up request to stop typing /// (though you certainly can do so). - SendTypingNotice { room_id: OwnedRoomId, typing: bool }, + SendTypingNotice { + room_id: OwnedRoomId, + typing: bool, + }, /// Spawn an async task to login to the given Matrix homeserver using the given SSO identity provider ID. /// /// While an SSO request is in flight, the login screen will temporarily prevent the user /// from submitting another redundant request, until this request has succeeded or failed. - SpawnSSOServer { + SpawnSSOServer{ brand: String, homeserver_url: String, identity_provider_id: String, @@ -485,7 +444,9 @@ pub enum MatrixRequest { /// Sends a request to obtain the power levels for this room. /// /// The response is delivered back to the main UI thread via [`TimelineUpdate::UserPowerLevels`]. - GetRoomPowerLevels { room_id: OwnedRoomId }, + GetRoomPowerLevels { + room_id: OwnedRoomId, + }, /// Toggles the given reaction to the given event in the given room. ToggleReaction { room_id: OwnedRoomId, @@ -511,7 +472,7 @@ pub enum MatrixRequest { /// The MatrixLinkPillInfo::Loaded variant is sent back to the main UI thread via. GetMatrixRoomLinkPillInfo { matrix_id: MatrixId, - via: Vec, + via: Vec }, /// Request to fetch URL preview from the Matrix homeserver. GetUrlPreview { @@ -525,18 +486,18 @@ pub enum MatrixRequest { /// Submits a request to the worker thread to be executed asynchronously. pub fn submit_async_request(req: MatrixRequest) { if let Some(sender) = REQUEST_SENDER.lock().unwrap().as_ref() { - sender - .send(req) + sender.send(req) .expect("BUG: async worker task receiver has died!"); } } /// Details of a login request that get submitted within [`MatrixRequest::Login`]. -pub enum LoginRequest { +pub enum LoginRequest{ LoginByPassword(LoginByPassword), LoginBySSOSuccess(Client, ClientSessionPersisted), LoginByCli, HomeserverLoginTypesQuery(String), + } /// Information needed to log in to a Matrix homeserver. pub struct LoginByPassword { @@ -545,6 +506,7 @@ pub struct LoginByPassword { pub homeserver: Option, } + /// The entry point for an async worker thread that can run async tasks. /// /// All this thread does is wait for [`MatrixRequests`] from the main UI-driven non-async thread(s) @@ -554,8 +516,7 @@ async fn async_worker( login_sender: Sender, ) -> Result<()> { log!("Started async_worker task."); - let mut subscribers_own_user_read_receipts: BTreeMap> = - BTreeMap::new(); + let mut subscribers_own_user_read_receipts: BTreeMap> = BTreeMap::new(); let mut subscribers_pinned_events: BTreeMap> = BTreeMap::new(); while let Some(request) = request_receiver.recv().await { @@ -564,7 +525,7 @@ async fn async_worker( if let Err(e) = login_sender.send(login_request).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to async worker thread.", + "BUG: failed to send login request to async worker thread." ))); } } @@ -577,7 +538,7 @@ async fn async_worker( match logout_with_state_machine(is_desktop).await { Ok(()) => { log!("Logout completed successfully via state machine"); - } + }, Err(e) => { error!("Logout failed: {e:?}"); } @@ -585,11 +546,7 @@ async fn async_worker( }); } - MatrixRequest::PaginateRoomTimeline { - room_id, - num_events, - direction, - } => { + MatrixRequest::PaginateRoomTimeline { room_id, num_events, direction } => { let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { @@ -638,43 +595,28 @@ async fn async_worker( }); } - MatrixRequest::EditMessage { - room_id, - timeline_event_item_id: timeline_event_id, - edited_content, - } => { + MatrixRequest::EditMessage { room_id, timeline_event_item_id: timeline_event_id, edited_content } => { let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { error!("BUG: room info not found for edit request, room {room_id}"); continue; }; - ( - room_info.timeline.clone(), - room_info.timeline_update_sender.clone(), - ) + (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) }; // Spawn a new async task that will make the actual edit request. let _edit_task = Handle::current().spawn(async move { - log!( - "Sending request to edit message {timeline_event_id:?} in room {room_id}..." - ); + log!("Sending request to edit message {timeline_event_id:?} in room {room_id}..."); let result = timeline.edit(&timeline_event_id, edited_content).await; match result { - Ok(_) => log!( - "Successfully edited message {timeline_event_id:?} in room {room_id}." - ), - Err(ref e) => error!( - "Error editing message {timeline_event_id:?} in room {room_id}: {e:?}" - ), + Ok(_) => log!("Successfully edited message {timeline_event_id:?} in room {room_id}."), + Err(ref e) => error!("Error editing message {timeline_event_id:?} in room {room_id}: {e:?}"), } - sender - .send(TimelineUpdate::MessageEdited { - timeline_event_id, - result, - }) - .unwrap(); + sender.send(TimelineUpdate::MessageEdited { + timeline_event_id, + result, + }).unwrap(); SignalToUI::set_ui_signal(); }); } @@ -683,16 +625,11 @@ async fn async_worker( let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - error!( - "BUG: room info not found for fetch details for event request {room_id}" - ); + error!("BUG: room info not found for fetch details for event request {room_id}"); continue; }; - ( - room_info.timeline.clone(), - room_info.timeline_update_sender.clone(), - ) + (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) }; // Spawn a new async task that will make the actual fetch request. @@ -707,9 +644,10 @@ async fn async_worker( // error!("Error fetching details for event {event_id} in room {room_id}: {e:?}"); } } - sender - .send(TimelineUpdate::EventDetailsFetched { event_id, result }) - .unwrap(); + sender.send(TimelineUpdate::EventDetailsFetched { + event_id, + result, + }).unwrap(); SignalToUI::set_ui_signal(); }); } @@ -722,10 +660,7 @@ async fn async_worker( continue; }; - ( - room_info.timeline.clone(), - room_info.timeline_update_sender.clone(), - ) + (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) }; // Spawn a new async task that will make the actual fetch request. @@ -753,7 +688,8 @@ async fn async_worker( JoinRoomResultAction::Failed { room_id, error: e } } } - } else { + } + else { match client.join_room_by_id(&room_id).await { Ok(_room) => { log!("Successfully joined new unknown room {room_id}."); @@ -789,7 +725,7 @@ async fn async_worker( LeaveRoomResultAction::Failed { room_id, error: matrix_sdk::Error::UnknownError( - String::from("Client couldn't locate room to leave it.").into(), + String::from("Client couldn't locate room to leave it.").into() ), } }; @@ -797,21 +733,14 @@ async fn async_worker( }); } - MatrixRequest::GetRoomMembers { - room_id, - memberships, - local_only, - } => { + MatrixRequest::GetRoomMembers { room_id, memberships, local_only } => { let (timeline, sender) = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { log!("BUG: room info not found for get room members request {room_id}"); continue; }; - ( - room_info.timeline.clone(), - room_info.timeline_update_sender.clone(), - ) + (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) }; let _get_members_task = Handle::current().spawn(async move { @@ -819,9 +748,9 @@ async fn async_worker( let send_update = |members: Vec, source: &str| { log!("{} {} members for room {}", source, members.len(), room_id); - sender - .send(TimelineUpdate::RoomMembersListFetched { members }) - .unwrap(); + sender.send(TimelineUpdate::RoomMembersListFetched { + members + }).unwrap(); SignalToUI::set_ui_signal(); }; @@ -837,32 +766,13 @@ async fn async_worker( }); } - MatrixRequest::SearchRoomMembers { - room_id: _, - search_text, - sender, - max_results, - cached_members, - precomputed_sort, - } => { - // Directly spawn blocking task for search + MatrixRequest::SearchRoomMembers { room_id: _, search_text, sender, max_results, cached_members, precomputed_sort } => { let _search_task = tokio::task::spawn_blocking(move || { - // Perform streaming search with precomputed sort data - search_room_members_streaming_with_sort( - cached_members, - search_text, - max_results, - sender, - precomputed_sort, - ); + search_room_members_streaming_with_sort(cached_members, search_text, max_results, sender, precomputed_sort); }); } - MatrixRequest::GetUserProfile { - user_id, - room_id, - local_only, - } => { + MatrixRequest::GetUserProfile { user_id, room_id, local_only } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { // log!("Sending get user profile request: user: {user_id}, \ @@ -935,16 +845,11 @@ async fn async_worker( let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - log!( - "Skipping get number of unread messages request for not-yet-known room {room_id}" - ); + log!("Skipping get number of unread messages request for not-yet-known room {room_id}"); continue; }; - ( - room_info.timeline.clone(), - room_info.timeline_update_sender.clone(), - ) + (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) }; let _get_unreads_task = Handle::current().spawn(async move { match sender.send(TimelineUpdate::NewUnreadMessagesCount( @@ -960,11 +865,7 @@ async fn async_worker( }); }); } - MatrixRequest::IgnoreUser { - ignore, - room_member, - room_id, - } => { + MatrixRequest::IgnoreUser { ignore, room_member, room_id } => { let Some(client) = get_client() else { continue }; let _ignore_task = Handle::current().spawn(async move { let user_id = room_member.user_id(); @@ -1033,22 +934,16 @@ async fn async_worker( let (room, timeline_update_sender, mut typing_notice_receiver) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - log!( - "BUG: room info not found for subscribe to typing notices request, room {room_id}" - ); + log!("BUG: room info not found for subscribe to typing notices request, room {room_id}"); continue; }; let (room, recv) = if subscribe { if room_info.typing_notice_subscriber.is_some() { - warning!( - "Note: room {room_id} is already subscribed to typing notices." - ); + warning!("Note: room {room_id} is already subscribed to typing notices."); continue; } else { let Some(room) = get_client().and_then(|c| c.get_room(&room_id)) else { - error!( - "BUG: client/room not found when subscribing to typing notices request, room: {room_id}" - ); + error!("BUG: client/room not found when subscribing to typing notices request, room: {room_id}"); continue; }; let (drop_guard, recv) = room.subscribe_to_typing_notifications(); @@ -1087,8 +982,7 @@ async fn async_worker( } MatrixRequest::SubscribeToOwnUserReadReceiptsChanged { room_id, subscribe } => { if !subscribe { - if let Some(task_handler) = subscribers_own_user_read_receipts.remove(&room_id) - { + if let Some(task_handler) = subscribers_own_user_read_receipts.remove(&room_id) { task_handler.abort(); } continue; @@ -1096,15 +990,10 @@ async fn async_worker( let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - log!( - "BUG: room info not found for subscribe to own user read receipts changed request, room {room_id}" - ); + log!("BUG: room info not found for subscribe to own user read receipts changed request, room {room_id}"); continue; }; - ( - room_info.timeline.clone(), - room_info.timeline_update_sender.clone(), - ) + (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) }; let room_id_clone = room_id.clone(); let subscribe_own_read_receipt_task = Handle::current().spawn(async move { @@ -1154,16 +1043,12 @@ async fn async_worker( let (timeline, sender) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - log!( - "BUG: room info not found for subscribe to pinned events request, room {room_id}" - ); + log!("BUG: room info not found for subscribe to pinned events request, room {room_id}"); continue; }; - ( - room_info.timeline.clone(), - room_info.timeline_update_sender.clone(), - ) + (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) }; + let room_id2 = room_id.clone(); let subscribe_pinned_events_task = Handle::current().spawn(async move { // Send an initial update, as the stream may not update immediately. let pinned_events = timeline.room().pinned_event_ids().unwrap_or_default(); @@ -1174,6 +1059,7 @@ async fn async_worker( let update_receiver = timeline.room().pinned_event_ids_stream(); pin_mut!(update_receiver); while let Some(pinned_events) = update_receiver.next().await { + log!("Got pinned events update for room {room_id2:?}: {pinned_events:?}"); match sender.send(TimelineUpdate::PinnedEvents(pinned_events)) { Ok(()) => SignalToUI::set_ui_signal(), Err(e) => log!("Failed to send pinned events update: {e:?}"), @@ -1182,18 +1068,8 @@ async fn async_worker( }); subscribers_pinned_events.insert(room_id, subscribe_pinned_events_task); } - MatrixRequest::SpawnSSOServer { - brand, - homeserver_url, - identity_provider_id, - } => { - spawn_sso_server( - brand, - homeserver_url, - identity_provider_id, - login_sender.clone(), - ) - .await; + MatrixRequest::SpawnSSOServer { brand, homeserver_url, identity_provider_id} => { + spawn_sso_server(brand, homeserver_url, identity_provider_id, login_sender.clone()).await; } MatrixRequest::ResolveRoomAlias(room_alias) => { let Some(client) = get_client() else { continue }; @@ -1204,10 +1080,7 @@ async fn async_worker( todo!("Send the resolved room alias back to the UI thread somehow."); }); } - MatrixRequest::FetchAvatar { - mxc_uri, - on_fetched, - } => { + MatrixRequest::FetchAvatar { mxc_uri, on_fetched } => { let Some(client) = get_client() else { continue }; Handle::current().spawn(async move { // log!("Sending fetch avatar request for {mxc_uri:?}..."); @@ -1217,19 +1090,11 @@ async fn async_worker( }; let res = client.media().get_media_content(&media_request, true).await; // log!("Fetched avatar for {mxc_uri:?}, succeeded? {}", res.is_ok()); - on_fetched(AvatarUpdate { - mxc_uri, - avatar_data: res.map(|v| v.into()), - }); + on_fetched(AvatarUpdate { mxc_uri, avatar_data: res.map(|v| v.into()) }); }); } - MatrixRequest::FetchMedia { - media_request, - on_fetched, - destination, - update_sender, - } => { + MatrixRequest::FetchMedia { media_request, on_fetched, destination, update_sender } => { let Some(client) = get_client() else { continue }; let media = client.media(); @@ -1328,9 +1193,7 @@ async fn async_worker( let timeline = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { - log!( - "BUG: room info not found when sending read receipt, room {room_id}, {event_id}" - ); + log!("BUG: room info not found when sending read receipt, room {room_id}, {event_id}"); continue; }; room_info.timeline.clone() @@ -1347,17 +1210,13 @@ async fn async_worker( unread_mentions: timeline.room().num_unread_mentions() }); }); - } + }, - MatrixRequest::FullyReadReceipt { - room_id, event_id, .. - } => { + MatrixRequest::FullyReadReceipt { room_id, event_id, .. } => { let timeline = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { - log!( - "BUG: room info not found when sending fully read receipt, room {room_id}, {event_id}" - ); + log!("BUG: room info not found when sending fully read receipt, room {room_id}, {event_id}"); continue; }; room_info.timeline.clone() @@ -1376,7 +1235,7 @@ async fn async_worker( unread_mentions: timeline.room().num_unread_mentions() }); }); - } + }, MatrixRequest::GetRoomPowerLevels { room_id } => { let (timeline, sender) = { @@ -1386,15 +1245,10 @@ async fn async_worker( continue; }; - ( - room_info.timeline.clone(), - room_info.timeline_update_sender.clone(), - ) + (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) }; - let Some(user_id) = current_user_id() else { - continue; - }; + let Some(user_id) = current_user_id() else { continue }; let _power_levels_task = Handle::current().spawn(async move { match timeline.room().power_levels().await { @@ -1412,12 +1266,8 @@ async fn async_worker( } } }); - } - MatrixRequest::ToggleReaction { - room_id, - timeline_event_id, - reaction, - } => { + }, + MatrixRequest::ToggleReaction { room_id, timeline_event_id, reaction } => { let timeline = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { @@ -1437,12 +1287,9 @@ async fn async_worker( Err(_e) => error!("Failed to send toggle reaction to room {room_id} {reaction}; error: {_e:?}"), } }); - } - MatrixRequest::RedactMessage { - room_id, - timeline_event_id, - reason, - } => { + + }, + MatrixRequest::RedactMessage { room_id, timeline_event_id, reason } => { let timeline = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { @@ -1457,30 +1304,19 @@ async fn async_worker( Ok(()) => log!("Successfully redacted message in room {room_id}."), Err(e) => { error!("Failed to redact message in {room_id}; error: {e:?}"); - enqueue_popup_notification(PopupItem { - message: format!("Failed to redact message. Error: {e}"), - kind: PopupKind::Error, - auto_dismissal_duration: None, - }); + enqueue_popup_notification(PopupItem { message: format!("Failed to redact message. Error: {e}"), kind: PopupKind::Error, auto_dismissal_duration: None }); } } }); - } - MatrixRequest::PinEvent { - room_id, - event_id, - pin, - } => { + }, + MatrixRequest::PinEvent { room_id, event_id, pin } => { let (timeline, sender) = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&room_id) else { log!("BUG: room info not found for pin message {room_id}"); continue; }; - ( - room_info.timeline.clone(), - room_info.timeline_update_sender.clone(), - ) + (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) }; let _pin_task = Handle::current().spawn(async move { @@ -1489,11 +1325,7 @@ async fn async_worker( } else { timeline.unpin_event(&event_id).await }; - match sender.send(TimelineUpdate::PinResult { - event_id, - pin, - result, - }) { + match sender.send(TimelineUpdate::PinResult { event_id, pin, result }) { Ok(_) => SignalToUI::set_ui_signal(), Err(e) => log!("Failed to send timeline update for pin event: {e:?}"), } @@ -1525,12 +1357,7 @@ async fn async_worker( } }); } - MatrixRequest::GetUrlPreview { - url, - on_fetched, - destination, - update_sender, - } => { + MatrixRequest::GetUrlPreview { url, on_fetched, destination, update_sender,} => { const MAX_LOG_RESPONSE_BODY_LENGTH: usize = 1000; log!("Starting URL preview fetch for: {}", url); @@ -1541,7 +1368,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 @@ -1551,7 +1378,7 @@ 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()) @@ -1564,20 +1391,20 @@ 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]); @@ -1601,7 +1428,7 @@ async fn async_worker( destination: destination.clone(), update_sender: update_sender.clone(), }); - + } } Err(e) => { @@ -1625,7 +1452,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) => { @@ -1644,6 +1471,7 @@ async fn async_worker( bail!("async_worker task ended unexpectedly") } + /// The single global Tokio runtime that is used by all async tasks. static TOKIO_RUNTIME: Mutex> = Mutex::new(None); @@ -1655,8 +1483,9 @@ static REQUEST_SENDER: Mutex>> = Mutex::ne /// in order to speed up the client-building process when the user logs in. static DEFAULT_SSO_CLIENT: Mutex> = Mutex::new(None); /// Used to notify the SSO login task that the async creation of the `DEFAULT_SSO_CLIENT` has finished. -static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = - LazyLock::new(|| Arc::new(Notify::new())); +static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = LazyLock::new( + || Arc::new(Notify::new()) +); /// Blocks the current thread until the given future completes. /// @@ -1667,36 +1496,29 @@ pub fn block_on_async_with_timeout( timeout: Option, async_future: impl Future, ) -> Result { - let rt = TOKIO_RUNTIME - .lock() - .unwrap() - .get_or_insert_with(|| { - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - }) - .handle() - .clone(); + let rt = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + ).handle().clone(); if let Some(timeout) = timeout { - rt.block_on(async { tokio::time::timeout(timeout, async_future).await }) + rt.block_on(async { + tokio::time::timeout(timeout, async_future).await + }) } else { Ok(rt.block_on(async_future)) } } + /// The primary initialization routine for starting the Matrix client sync /// and the async tokio runtime. /// /// Returns a handle to the Tokio runtime that is used to run async background tasks. pub fn start_matrix_tokio() -> Result { // Create a Tokio runtime, and save it in a static variable to ensure it isn't dropped. - let rt_handle = TOKIO_RUNTIME - .lock() - .unwrap() - .get_or_insert_with(|| { - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - }) - .handle() - .clone(); + let rt_handle = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| { + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + }).handle().clone(); // Create a channel to be used between UI thread(s) and the async worker thread. let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); @@ -1781,6 +1603,7 @@ pub fn start_matrix_tokio() -> Result { Ok(rt_handle) } + /// A tokio::watch channel sender for sending requests from the RoomScreen UI widget /// to the corresponding background async task for that room (its `timeline_subscriber_handler`). pub type TimelineRequestSender = watch::Sender>; @@ -1831,9 +1654,9 @@ impl Drop for JoinedRoomDetails { } } + /// Information about all joined rooms that our client currently know about. -static ALL_JOINED_ROOMS: Mutex> = - Mutex::new(BTreeMap::new()); +static ALL_JOINED_ROOMS: Mutex> = Mutex::new(BTreeMap::new()); /// The logged-in Matrix client, which can be freely and cheaply cloned. static CLIENT: Mutex> = Mutex::new(None); @@ -1844,16 +1667,15 @@ pub fn get_client() -> Option { /// Returns the user ID of the currently logged-in user, if any. pub fn current_user_id() -> Option { - CLIENT - .lock() - .unwrap() - .as_ref() - .and_then(|c| c.session_meta().map(|m| m.user_id.clone())) + CLIENT.lock().unwrap().as_ref().and_then(|c| + c.session_meta().map(|m| m.user_id.clone()) + ) } /// The singleton sync service. static SYNC_SERVICE: Mutex>> = Mutex::new(None); + /// Get a reference to the current sync service, if available. pub fn get_sync_service() -> Option> { SYNC_SERVICE.lock().ok()?.as_ref().cloned() @@ -1874,6 +1696,7 @@ pub fn is_user_ignored(user_id: &UserId) -> bool { IGNORED_USERS.lock().unwrap().contains(user_id) } + /// Returns three channel endpoints related to the timeline for the given joined room. /// /// 1. A timeline update sender. @@ -1881,46 +1704,49 @@ pub fn is_user_ignored(user_id: &UserId) -> bool { /// 3. A `tokio::watch` sender that can be used to send requests to the timeline subscriber handler. /// /// This will only succeed once per room, as only a single channel receiver can exist. -pub fn take_timeline_endpoints(room_id: &OwnedRoomId) -> Option { +pub fn take_timeline_endpoints( + room_id: &OwnedRoomId, +) -> Option +{ let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); all_joined_rooms .get_mut(room_id) - .and_then(|jrd| { - jrd.timeline_singleton_endpoints - .take() - .map(|(update_receiver, request_sender)| { - ( - jrd.timeline_update_sender.clone(), - update_receiver, - request_sender, - jrd.timeline.room().successor_room(), - ) - }) - }) - .map( - |(update_sender, update_receiver, request_sender, successor_room)| TimelineEndpoints { + .and_then(|jrd| jrd.timeline_singleton_endpoints.take() + .map(|(update_receiver, request_sender)| + (jrd.timeline_update_sender.clone(), update_receiver, request_sender, jrd.timeline.room().successor_room()) + ) + ) + .map(|(update_sender, update_receiver, request_sender, successor_room)| { + TimelineEndpoints { update_sender, update_receiver, request_sender, successor_room, - }, - ) + } + }) } const DEFAULT_HOMESERVER: &str = "matrix.org"; -fn username_to_full_user_id(username: &str, homeserver: Option<&str>) -> Option { - username.try_into().ok().or_else(|| { - let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); - let user_id_str = if username.starts_with("@") { - format!("{}:{}", username, homeserver_url) - } else { - format!("@{}:{}", username, homeserver_url) - }; - user_id_str.as_str().try_into().ok() - }) +fn username_to_full_user_id( + username: &str, + homeserver: Option<&str>, +) -> Option { + username + .try_into() + .ok() + .or_else(|| { + let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); + let user_id_str = if username.starts_with("@") { + format!("{}:{}", username, homeserver_url) + } else { + format!("@{}:{}", username, homeserver_url) + }; + user_id_str.as_str().try_into().ok() + }) } + /// Info we store about a room received by the room list service. /// /// This struct is necessary in order for us to track the previous state @@ -1928,55 +1754,57 @@ fn username_to_full_user_id(username: &str, homeserver: Option<&str>) -> Option< /// determine if the room has changed state. /// We can't just store the `matrix_sdk::Room` object itself, /// because that is a shallow reference to an inner room object within -/// the room list service. +/// the room list service #[derive(Clone)] struct RoomListServiceRoomInfo { + room: matrix_sdk::Room, room_id: OwnedRoomId, room_state: RoomState, - is_direct: bool, - room: matrix_sdk::Room, } -impl RoomListServiceRoomInfo { - async fn from_room(room: matrix_sdk::Room) -> Self { +impl From<&matrix_sdk::Room> for RoomListServiceRoomInfo { + fn from(room: &matrix_sdk::Room) -> Self { + room.clone().into() + } +} +impl From for RoomListServiceRoomInfo { + fn from(room: matrix_sdk::Room) -> Self { Self { room_id: room.room_id().to_owned(), room_state: room.state(), - is_direct: room.is_direct().await.unwrap_or(false), room, } } - async fn from_room_ref(room: &matrix_sdk::Room) -> Self { - Self::from_room(room.clone()).await - } } -async fn async_main_loop(mut login_receiver: Receiver) -> Result<()> { +async fn async_main_loop( + mut login_receiver: Receiver, +) -> Result<()> { // only init subscribe once let _ = tracing_subscriber::fmt::try_init(); let most_recent_user_id = persistence::most_recent_user_id(); log!("Most recent user ID: {most_recent_user_id:?}"); let cli_parse_result = Cli::try_parse(); - let cli_has_valid_username_password = cli_parse_result - .as_ref() + let cli_has_valid_username_password = cli_parse_result.as_ref() .is_ok_and(|cli| !cli.user_id.is_empty() && !cli.password.is_empty()); - log!( - "CLI parsing succeeded? {}. CLI has valid UN+PW? {}", + log!("CLI parsing succeeded? {}. CLI has valid UN+PW? {}", cli_parse_result.as_ref().is_ok(), cli_has_valid_username_password, ); - let wait_for_login = !cli_has_valid_username_password - && (most_recent_user_id.is_none() - || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login")); + let wait_for_login = !cli_has_valid_username_password && ( + most_recent_user_id.is_none() + || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login") + ); log!("Waiting for login? {}", wait_for_login); let new_login_opt = if !wait_for_login { - let specified_username = cli_parse_result - .as_ref() - .ok() - .and_then(|cli| username_to_full_user_id(&cli.user_id, cli.homeserver.as_deref())); - log!( - "Trying to restore session for user: {:?}", + let specified_username = cli_parse_result.as_ref().ok().and_then(|cli| + username_to_full_user_id( + &cli.user_id, + cli.homeserver.as_deref(), + ) + ); + log!("Trying to restore session for user: {:?}", specified_username.as_ref().or(most_recent_user_id.as_ref()) ); match persistence::restore_session(specified_username).await { @@ -1987,10 +1815,7 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( Cx::post_action(LoginAction::LoginFailure(status_err.to_string())); if let Ok(cli) = &cli_parse_result { - log!( - "Attempting auto-login from CLI arguments as user '{}'...", - cli.user_id - ); + log!("Attempting auto-login from CLI arguments as user '{}'...", cli.user_id); Cx::post_action(LoginAction::CliAutoLogin { user_id: cli.user_id.clone(), homeserver: cli.homeserver.clone(), @@ -1999,9 +1824,9 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( Ok(new_login) => Some(new_login), Err(e) => { error!("CLI-based login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!( - "Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}" - ))); + Cx::post_action(LoginAction::LoginFailure( + format!("Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}") + )); enqueue_rooms_list_update(RoomsListUpdate::Status { status: format!("Login failed: {e:?}"), }); @@ -2019,27 +1844,31 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( let cli: Cli = cli_parse_result.unwrap_or(Cli::default()); let (client, _sync_token) = match new_login_opt { Some(new_login) => new_login, - None => loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => match login(&cli, login_request).await { - Ok((client, sync_token)) => { - break (client, sync_token); - } - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), - }); + None => { + loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => { + match login(&cli, login_request).await { + Ok((client, sync_token)) => { + break (client, sync_token); + } + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: format!("Login failed: {e}"), + }); + } + } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + return Err(anyhow::anyhow!("BUG: login_receiver hung up unexpectedly")); } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - return Err(anyhow::anyhow!("BUG: login_receiver hung up unexpectedly")); } } - }, + } }; Cx::post_action(LoginAction::LoginSuccess); @@ -2049,22 +1878,16 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( let _ = client_opt.take(); } - let logged_in_user_id = client - .user_id() + let logged_in_user_id = client.user_id() .expect("BUG: client.user_id() returned None after successful login!"); let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); // enqueue_popup_notification(status.clone()); enqueue_rooms_list_update(RoomsListUpdate::Status { status }); - client - .event_cache() - .subscribe() - .expect("BUG: CLIENT's event cache unable to subscribe"); + client.event_cache().subscribe().expect("BUG: CLIENT's event cache unable to subscribe"); if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { - error!( - "BUG: unexpectedly replaced an existing client when initializing the matrix client." - ); + error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); } add_verification_event_handlers_and_sync_client(client.clone()); @@ -2086,9 +1909,7 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( let room_list_service = sync_service.room_list_service(); if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { - error!( - "BUG: unexpectedly replaced an existing sync service when initializing the matrix client." - ); + error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); } let all_rooms_list = room_list_service.all_rooms().await?; @@ -2098,7 +1919,9 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( // TODO: paginate room list to avoid loading all rooms at once all_rooms_list.entries_with_dynamic_adapters(usize::MAX); - room_list_dynamic_entries_controller.set_filter(Box::new(|_room| true)); + room_list_dynamic_entries_controller.set_filter( + Box::new(|_room| true), + ); let mut all_known_rooms: Vector = Vector::new(); @@ -2109,40 +1932,30 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( match diff { VectorDiff::Append { values: new_rooms } => { let _num_new_rooms = new_rooms.len(); - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Append {_num_new_rooms}"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Append {_num_new_rooms}"); } for new_room in new_rooms { add_new_room(&new_room, &room_list_service).await?; - all_known_rooms.push_back(RoomListServiceRoomInfo::from_room(new_room.into_inner()).await); + all_known_rooms.push_back(new_room.into_inner().into()); } } VectorDiff::Clear => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Clear"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Clear"); } all_known_rooms.clear(); ALL_JOINED_ROOMS.lock().unwrap().clear(); enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); } VectorDiff::PushFront { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PushFront"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushFront"); } add_new_room(&new_room, &room_list_service).await?; - all_known_rooms.push_front(RoomListServiceRoomInfo::from_room(new_room.into_inner()).await); + all_known_rooms.push_front(new_room.into_inner().into()); } VectorDiff::PushBack { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PushBack"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushBack"); } add_new_room(&new_room, &room_list_service).await?; - all_known_rooms.push_back(RoomListServiceRoomInfo::from_room(new_room.into_inner()).await); + all_known_rooms.push_back(new_room.into_inner().into()); } remove_diff @ VectorDiff::PopFront => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PopFront"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopFront"); } if let Some(room) = all_known_rooms.pop_front() { optimize_remove_then_add_into_update( remove_diff, @@ -2150,14 +1963,11 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( &mut peekable_diffs, &mut all_known_rooms, &room_list_service, - ) - .await?; + ).await?; } } remove_diff @ VectorDiff::PopBack => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PopBack"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopBack"); } if let Some(room) = all_known_rooms.pop_back() { optimize_remove_then_add_into_update( remove_diff, @@ -2165,40 +1975,25 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( &mut peekable_diffs, &mut all_known_rooms, &room_list_service, - ) - .await?; + ).await?; } } - VectorDiff::Insert { - index, - value: new_room, - } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Insert at {index}"); - } + VectorDiff::Insert { index, value: new_room } => { + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Insert at {index}"); } add_new_room(&new_room, &room_list_service).await?; - all_known_rooms.insert(index, RoomListServiceRoomInfo::from_room(new_room.into_inner()).await); + all_known_rooms.insert(index, new_room.into_inner().into()); } - VectorDiff::Set { - index, - value: changed_room, - } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Set at {index}"); - } + VectorDiff::Set { index, value: changed_room } => { + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Set at {index}"); } if let Some(old_room) = all_known_rooms.get(index) { update_room(old_room, &changed_room, &room_list_service).await?; } else { error!("BUG: room list diff: Set index {index} was out of bounds."); } - all_known_rooms.set(index, RoomListServiceRoomInfo::from_room(changed_room.into_inner()).await); + all_known_rooms.set(index, changed_room.into_inner().into()); } - remove_diff @ VectorDiff::Remove { - index: remove_index, - } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Remove at {remove_index}"); - } + remove_diff @ VectorDiff::Remove { index: remove_index } => { + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Remove at {remove_index}"); } if remove_index < all_known_rooms.len() { let room = all_known_rooms.remove(remove_index); optimize_remove_then_add_into_update( @@ -2207,19 +2002,13 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( &mut peekable_diffs, &mut all_known_rooms, &room_list_service, - ) - .await?; + ).await?; } else { - error!( - "BUG: room_list: diff Remove index {remove_index} out of bounds, len {}", - all_known_rooms.len() - ); + error!("BUG: room_list: diff Remove index {remove_index} out of bounds, len {}", all_known_rooms.len()); } } VectorDiff::Truncate { length } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Truncate to {length}"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Truncate to {length}"); } // Iterate manually so we can know which rooms are being removed. while all_known_rooms.len() > length { if let Some(room) = all_known_rooms.pop_back() { @@ -2230,13 +2019,7 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( } VectorDiff::Reset { values: new_rooms } => { // We implement this by clearing all rooms and then adding back the new values. - if LOG_ROOM_LIST_DIFFS { - log!( - "room_list: diff Reset, old length {}, new length {}", - all_known_rooms.len(), - new_rooms.len() - ); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Reset, old length {}, new length {}", all_known_rooms.len(), new_rooms.len()); } // Iterate manually so we can know which rooms are being removed. while let Some(room) = all_known_rooms.pop_back() { remove_room(&room); @@ -2248,9 +2031,10 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( for room in &new_rooms { add_new_room(room.deref(), &room_list_service).await?; } - for new_room in new_rooms.into_iter() { - all_known_rooms.push_back(RoomListServiceRoomInfo::from_room(new_room.into_inner()).await); - } + all_known_rooms = new_rooms + .into_iter() + .map(|r| r.into_inner().into()) + .collect(); } } } @@ -2259,6 +2043,7 @@ async fn async_main_loop(mut login_receiver: Receiver) -> Result<( bail!("room list service sync loop ended unexpectedly") } + /// Attempts to optimize a common RoomListService operation of remove + add. /// /// If a `Remove` diff (or `PopBack` or `PopFront`) is immediately followed by @@ -2277,40 +2062,34 @@ async fn optimize_remove_then_add_into_update( ) -> Result<()> { let next_diff_was_handled: bool; match peekable_diffs.peek() { - Some(VectorDiff::Insert { - index: insert_index, - value: new_room, - }) if room.room_id == new_room.room_id() => { + Some(VectorDiff::Insert { index: insert_index, value: new_room }) + if room.room_id == new_room.room_id() => + { if LOG_ROOM_LIST_DIFFS { - log!( - "Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", - room.room_id - ); + log!("Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", room.room_id); } update_room(room, new_room, room_list_service).await?; - all_known_rooms.insert(*insert_index, RoomListServiceRoomInfo::from_room_ref(new_room.deref()).await); + all_known_rooms.insert(*insert_index, new_room.deref().clone().into()); next_diff_was_handled = true; } - Some(VectorDiff::PushFront { value: new_room }) if room.room_id == new_room.room_id() => { + Some(VectorDiff::PushFront { value: new_room }) + if room.room_id == new_room.room_id() => + { if LOG_ROOM_LIST_DIFFS { - log!( - "Optimizing {remove_diff:?} + PushFront into Update for room {}", - room.room_id - ); + log!("Optimizing {remove_diff:?} + PushFront into Update for room {}", room.room_id); } update_room(room, new_room, room_list_service).await?; - all_known_rooms.push_front(RoomListServiceRoomInfo::from_room_ref(new_room.deref()).await); + all_known_rooms.push_front(new_room.deref().clone().into()); next_diff_was_handled = true; } - Some(VectorDiff::PushBack { value: new_room }) if room.room_id == new_room.room_id() => { + Some(VectorDiff::PushBack { value: new_room }) + if room.room_id == new_room.room_id() => + { if LOG_ROOM_LIST_DIFFS { - log!( - "Optimizing {remove_diff:?} + PushBack into Update for room {}", - room.room_id - ); + log!("Optimizing {remove_diff:?} + PushBack into Update for room {}", room.room_id); } update_room(room, new_room, room_list_service).await?; - all_known_rooms.push_back(RoomListServiceRoomInfo::from_room_ref(new_room.deref()).await); + all_known_rooms.push_back(new_room.deref().clone().into()); next_diff_was_handled = true; } _ => next_diff_was_handled = false, @@ -2323,6 +2102,7 @@ async fn optimize_remove_then_add_into_update( Ok(()) } + /// Invoked when the room list service has received an update that changes an existing room. async fn update_room( old_room: &RoomListServiceRoomInfo, @@ -2338,16 +2118,14 @@ async fn update_room( let old_room_state = old_room.room_state; let new_room_state = new_room.state(); if LOG_ROOM_LIST_DIFFS { - log!( - "Room {new_room_name:?} ({new_room_id}) state went from {old_room_state:?} --> {new_room_state:?}" - ); + log!("Room {new_room_name:?} ({new_room_id}) state went from {old_room_state:?} --> {new_room_state:?}"); } if old_room_state != new_room_state { match new_room_state { RoomState::Banned => { // TODO: handle rooms that this user has been banned from. log!("Removing Banned room: {new_room_name:?} ({new_room_id})"); - remove_room(&RoomListServiceRoomInfo::from_room_ref(new_room).await); + remove_room(&new_room.into()); return Ok(()); } RoomState::Left => { @@ -2357,19 +2135,15 @@ async fn update_room( // Upon clicking a left room, we could show a splash page // that prompts the user to rejoin the room or forget it permanently. // Currently, we just remove it and do not show left rooms at all. - remove_room(&RoomListServiceRoomInfo::from_room_ref(new_room).await); + remove_room(&new_room.into()); return Ok(()); } RoomState::Joined => { - log!( - "update_room(): adding new Joined room: {new_room_name:?} ({new_room_id})" - ); + log!("update_room(): adding new Joined room: {new_room_name:?} ({new_room_id})"); return add_new_room(new_room, room_list_service).await; } RoomState::Invited => { - log!( - "update_room(): adding new Invited room: {new_room_name:?} ({new_room_id})" - ); + log!("update_room(): adding new Invited room: {new_room_name:?} ({new_room_id})"); return add_new_room(new_room, room_list_service).await; } RoomState::Knocked => { @@ -2379,6 +2153,7 @@ async fn update_room( } } + let Some(client) = get_client() else { return Ok(()); }; @@ -2411,18 +2186,8 @@ async fn update_room( } if let Some(new_room_name) = new_room_name { - if old_room - .room - .cached_display_name() - .map(|room_name| room_name.to_string()) - .as_ref() - != Some(&new_room_name) - { - log!( - "Updating room name for room {} to {}", - new_room_id, - new_room_name - ); + if old_room.room.cached_display_name().map(|room_name| room_name.to_string()).as_ref() != Some(&new_room_name) { + log!("Updating room name for room {} to {}", new_room_id, new_room_name); enqueue_rooms_list_update(RoomsListUpdate::UpdateRoomName { room_id: new_room_id.clone(), new_room_name, @@ -2430,8 +2195,7 @@ async fn update_room( } } - // Below, we update room data that is only relevant to joined rooms: - // tags, unread count, is_direct, etc. + // We only update tags or unread count for joined rooms. // Invited or left rooms don't care about these details. if matches!(new_room_state, RoomState::Joined) { if let Ok(new_tags) = new_room.tags().await { @@ -2444,40 +2208,34 @@ async fn update_room( enqueue_rooms_list_update(RoomsListUpdate::UpdateNumUnreadMessages { room_id: new_room_id.clone(), count: UnreadMessageCount::Known(new_room.num_unread_messages()), - unread_mentions: new_room.num_unread_mentions(), + unread_mentions: new_room.num_unread_mentions() }); - - if let Ok(is_new_room_direct) = new_room.is_direct().await { - if old_room.is_direct != is_new_room_direct { - enqueue_rooms_list_update(RoomsListUpdate::UpdateIsDirect { - room_id: new_room_id.clone(), - is_direct: is_new_room_direct, - }); - } - } } Ok(()) - } else { - warning!( - "UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", - old_room.room_id, - new_room_id, + } + else { + warning!("UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", + old_room.room_id, new_room_id, ); remove_room(old_room); add_new_room(new_room, room_list_service).await } } + /// Invoked when the room list service has received an update to remove an existing room. fn remove_room(room: &RoomListServiceRoomInfo) { ALL_JOINED_ROOMS.lock().unwrap().remove(&room.room_id); - enqueue_rooms_list_update(RoomsListUpdate::RemoveRoom { - room_id: room.room_id.clone(), - new_state: room.room_state, - }); + enqueue_rooms_list_update( + RoomsListUpdate::RemoveRoom { + room_id: room.room_id.clone(), + new_state: room.room_state, + } + ); } + /// Invoked when the room list service has received an update with a brand new room. async fn add_new_room(room: &matrix_sdk::Room, room_list_service: &RoomListService) -> Result<()> { let room_id = room.room_id().to_owned(); @@ -2517,9 +2275,9 @@ async fn add_new_room(room: &matrix_sdk::Room, room_list_service: &RoomListServi } else { None }; - let latest = latest_event - .as_ref() - .map(|ev| get_latest_event_details(ev, &room_id)); + let latest = latest_event.as_ref().map( + |ev| get_latest_event_details(ev, &room_id) + ); let room_avatar = room_avatar(room, room_name.as_deref()).await; let inviter_info = if let Some(inviter) = invite_details.and_then(|d| d.inviter) { @@ -2536,37 +2294,34 @@ async fn add_new_room(room: &matrix_sdk::Room, room_list_service: &RoomListServi } else { None }; - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom( - InvitedRoomInfo { - room_id: room_id.clone(), - room_name, - inviter_info, - room_avatar, - canonical_alias: room.canonical_alias(), - alt_aliases: room.alt_aliases(), - latest, - invite_state: Default::default(), - is_selected: false, - is_direct, - }, - )); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom(InvitedRoomInfo { + room_id: room_id.clone(), + room_name, + inviter_info, + room_avatar, + canonical_alias: room.canonical_alias(), + alt_aliases: room.alt_aliases(), + latest, + invite_state: Default::default(), + is_selected: false, + is_direct, + })); Cx::post_action(AppStateAction::RoomLoadedSuccessfully(room_id)); return Ok(()); } - RoomState::Joined => {} // Fall through to adding the joined room below. + RoomState::Joined => { } // Fall through to adding the joined room below. } // Subscribe to all updates for this room in order to properly receive all of its states. room_list_service.subscribe_to_rooms(&[&room_id]).await; + let timeline = Arc::new( room.timeline_builder() .track_read_marker_and_receipts() .build() .await - .map_err(|e| { - anyhow::anyhow!("BUG: Failed to build timeline for room {room_id}: {e}") - })?, + .map_err(|e| anyhow::anyhow!("BUG: Failed to build timeline for room {room_id}: {e}"))?, ); let latest_event = timeline.latest_event().await; let (timeline_update_sender, timeline_update_receiver) = crossbeam_channel::unbounded(); @@ -2579,9 +2334,9 @@ async fn add_new_room(room: &matrix_sdk::Room, room_list_service: &RoomListServi request_receiver, )); - let latest = latest_event - .as_ref() - .map(|ev| get_latest_event_details(ev, &room_id)); + let latest = latest_event.as_ref().map( + |ev| get_latest_event_details(ev, &room_id) + ); log!("Adding new joined room {room_id}."); ALL_JOINED_ROOMS.lock().unwrap().insert( @@ -2625,8 +2380,7 @@ async fn add_new_room(room: &matrix_sdk::Room, room_list_service: &RoomListServi #[allow(unused)] async fn current_ignore_user_list(client: &Client) -> Option> { use matrix_sdk::ruma::events::ignored_user_list::IgnoredUserListEventContent; - let ignored_users = client - .account() + let ignored_users = client.account() .account_data::() .await .ok()?? @@ -2688,9 +2442,7 @@ fn handle_load_app_state(user_id: OwnedUserId) { && !app_state.saved_dock_state.dock_items.is_empty() { log!("Loaded room panel state from app data directory. Restoring now..."); - Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState( - app_state, - )); + Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState(app_state)); } } Err(_e) => { @@ -2698,7 +2450,7 @@ fn handle_load_app_state(user_id: OwnedUserId) { enqueue_popup_notification(PopupItem { message: String::from("Could not restore the previous dock layout."), kind: PopupKind::Error, - auto_dismissal_duration: None, + auto_dismissal_duration: None }); } } @@ -2734,12 +2486,14 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { const SYNC_INDICATOR_DELAY: Duration = Duration::from_millis(100); /// Duration for sync indicator delay before hiding const SYNC_INDICATOR_HIDE_DELAY: Duration = Duration::from_millis(200); - let sync_indicator_stream = sync_service - .room_list_service() - .sync_indicator(SYNC_INDICATOR_DELAY, SYNC_INDICATOR_HIDE_DELAY); - + let sync_indicator_stream = sync_service.room_list_service() + .sync_indicator( + SYNC_INDICATOR_DELAY, + SYNC_INDICATOR_HIDE_DELAY + ); + Handle::current().spawn(async move { - let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); + let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); while let Some(indicator) = sync_indicator_stream.next().await { let is_syncing = match indicator { @@ -2752,10 +2506,7 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { } fn handle_room_list_service_loading_state(mut loading_state: Subscriber) { - log!( - "Initial room list loading state is {:?}", - loading_state.get() - ); + log!("Initial room list loading state is {:?}", loading_state.get()); Handle::current().spawn(async move { while let Some(state) = loading_state.next().await { log!("Received a room list loading state update: {state:?}"); @@ -2763,12 +2514,8 @@ fn handle_room_list_service_loading_state(mut loading_state: Subscriber { enqueue_rooms_list_update(RoomsListUpdate::NotLoaded); } - RoomListLoadingState::Loaded { - maximum_number_of_rooms, - } => { - enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { - max_rooms: maximum_number_of_rooms, - }); + RoomListLoadingState::Loaded { maximum_number_of_rooms } => { + enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { max_rooms: maximum_number_of_rooms }); } } } @@ -2791,11 +2538,11 @@ fn get_latest_event_details( latest_event.content(), latest_event.sender(), sender_username, - ) - .format_with(sender_username, true), + ).format_with(sender_username, true), ) } + /// A request to search backwards for a specific event in a room's timeline. pub struct BackwardsPaginateUntilEventRequest { pub room_id: OwnedRoomId, @@ -2822,6 +2569,7 @@ async fn timeline_subscriber_handler( timeline_update_sender: crossbeam_channel::Sender, mut request_receiver: watch::Receiver>, ) { + /// An inner function that searches the given new timeline items for a target event. /// /// If the target event is found, it is removed from the `target_event_id_opt` and returned, @@ -2830,13 +2578,14 @@ async fn timeline_subscriber_handler( target_event_id_opt: &mut Option, mut new_items_iter: impl Iterator>, ) -> Option<(usize, OwnedEventId)> { - let found_index = target_event_id_opt.as_ref().and_then(|target_event_id| { - new_items_iter.position(|new_item| { - new_item + let found_index = target_event_id_opt + .as_ref() + .and_then(|target_event_id| new_items_iter + .position(|new_item| new_item .as_event() .is_some_and(|new_ev| new_ev.event_id() == Some(target_event_id)) - }) - }); + ) + ); if let Some(index) = found_index { target_event_id_opt.take().map(|ev| (index, ev)) @@ -2845,13 +2594,11 @@ async fn timeline_subscriber_handler( } } + let room_id = room.room_id().to_owned(); log!("Starting timeline subscriber for room {room_id}..."); let (mut timeline_items, mut subscriber) = timeline.subscribe().await; - log!( - "Received initial timeline update of {} items for room {room_id}.", - timeline_items.len() - ); + log!("Received initial timeline update of {} items for room {room_id}.", timeline_items.len()); timeline_update_sender.send(TimelineUpdate::FirstUpdate { initial_items: timeline_items.clone(), @@ -2866,281 +2613,279 @@ async fn timeline_subscriber_handler( // the timeline index and event ID of the target event, if it has been found. let mut found_target_event_id: Option<(usize, OwnedEventId)> = None; - loop { - tokio::select! { - // we should check for new requests before handling new timeline updates, - // because the request might influence how we handle a timeline update. - biased; - - // Handle updates to the current backwards pagination requests. - Ok(()) = request_receiver.changed() => { - let prev_target_event_id = target_event_id.clone(); - let new_request_details = request_receiver - .borrow_and_update() - .iter() - .find_map(|req| req.room_id - .eq(&room_id) - .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) - ); + loop { tokio::select! { + // we should check for new requests before handling new timeline updates, + // because the request might influence how we handle a timeline update. + biased; + + // Handle updates to the current backwards pagination requests. + Ok(()) = request_receiver.changed() => { + let prev_target_event_id = target_event_id.clone(); + let new_request_details = request_receiver + .borrow_and_update() + .iter() + .find_map(|req| req.room_id + .eq(&room_id) + .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) + ); - target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); + target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); - // If we received a new request, start searching backwards for the target event. - if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { - if prev_target_event_id.as_ref() != Some(&new_target_event_id) { - let starting_index = if current_tl_len == timeline_items.len() { - starting_index - } else { - // The timeline has changed since the request was made, so we can't rely on the `starting_index`. - // Instead, we have no choice but to start from the end of the timeline. - timeline_items.len() - }; - // log!("Received new request to search for event {new_target_event_id} in room {room_id} starting from index {starting_index} (tl len {}).", timeline_items.len()); - // Search backwards for the target event in the timeline, starting from the given index. - if let Some(target_event_tl_index) = timeline_items - .focus() - .narrow(..starting_index) - .into_iter() - .rev() - .position(|i| i.as_event() - .and_then(|e| e.event_id()) - .is_some_and(|ev_id| ev_id == new_target_event_id) - ) - .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) - { - // log!("Found existing target event {new_target_event_id} in room {room_id} at index {target_event_tl_index}."); - - // Nice! We found the target event in the current timeline items, - // so there's no need to actually proceed with backwards pagination; - // thus, we can clear the locally-tracked target event ID. - target_event_id = None; - found_target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: new_target_event_id.clone(), - index: target_event_tl_index, - } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}!") - ); - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); - } - else { - log!("Target event not in timeline. Starting backwards pagination \ - in room {room_id} to find target event {new_target_event_id} \ - starting from index {starting_index}.", - ); - // If we didn't find the target event in the current timeline items, - // we need to start loading previous items into the timeline. - submit_async_request(MatrixRequest::PaginateRoomTimeline { - room_id: room_id.clone(), - num_events: 50, - direction: PaginationDirection::Backwards, - }); - } + // If we received a new request, start searching backwards for the target event. + if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { + if prev_target_event_id.as_ref() != Some(&new_target_event_id) { + let starting_index = if current_tl_len == timeline_items.len() { + starting_index + } else { + // The timeline has changed since the request was made, so we can't rely on the `starting_index`. + // Instead, we have no choice but to start from the end of the timeline. + timeline_items.len() + }; + // log!("Received new request to search for event {new_target_event_id} in room {room_id} starting from index {starting_index} (tl len {}).", timeline_items.len()); + // Search backwards for the target event in the timeline, starting from the given index. + if let Some(target_event_tl_index) = timeline_items + .focus() + .narrow(..starting_index) + .into_iter() + .rev() + .position(|i| i.as_event() + .and_then(|e| e.event_id()) + .is_some_and(|ev_id| ev_id == new_target_event_id) + ) + .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) + { + // log!("Found existing target event {new_target_event_id} in room {room_id} at index {target_event_tl_index}."); + + // Nice! We found the target event in the current timeline items, + // so there's no need to actually proceed with backwards pagination; + // thus, we can clear the locally-tracked target event ID. + target_event_id = None; + found_target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: new_target_event_id.clone(), + index: target_event_tl_index, + } + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}!") + ); + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); + } + else { + log!("Target event not in timeline. Starting backwards pagination \ + in room {room_id} to find target event {new_target_event_id} \ + starting from index {starting_index}.", + ); + // If we didn't find the target event in the current timeline items, + // we need to start loading previous items into the timeline. + submit_async_request(MatrixRequest::PaginateRoomTimeline { + room_id: room_id.clone(), + num_events: 50, + direction: PaginationDirection::Backwards, + }); } } } + } - // Handle updates to the actual timeline content. - batch_opt = subscriber.next() => { - let Some(batch) = batch_opt else { break }; - let mut num_updates = 0; - // For now we always requery the latest event, but this can be better optimized. - let mut reobtain_latest_event = true; - let mut index_of_first_change = usize::MAX; - let mut index_of_last_change = usize::MIN; - // whether to clear the entire cache of drawn items - let mut clear_cache = false; - // whether the changes include items being appended to the end of the timeline - let mut is_append = false; - for diff in batch { - num_updates += 1; - match diff { - VectorDiff::Append { values } => { - let _values_len = values.len(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.extend(values); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; - is_append = true; - } - VectorDiff::Clear => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Clear"); } - clear_cache = true; - timeline_items.clear(); - reobtain_latest_event = true; + // Handle updates to the actual timeline content. + batch_opt = subscriber.next() => { + let Some(batch) = batch_opt else { break }; + let mut num_updates = 0; + // For now we always requery the latest event, but this can be better optimized. + let mut reobtain_latest_event = true; + let mut index_of_first_change = usize::MAX; + let mut index_of_last_change = usize::MIN; + // whether to clear the entire cache of drawn items + let mut clear_cache = false; + // whether the changes include items being appended to the end of the timeline + let mut is_append = false; + for diff in batch { + num_updates += 1; + match diff { + VectorDiff::Append { values } => { + let _values_len = values.len(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.extend(values); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; + is_append = true; + } + VectorDiff::Clear => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Clear"); } + clear_cache = true; + timeline_items.clear(); + reobtain_latest_event = true; + } + VectorDiff::PushFront { value } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PushFront"); } + if let Some((index, _ev)) = found_target_event_id.as_mut() { + *index += 1; // account for this new `value` being prepended. + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); } - VectorDiff::PushFront { value } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PushFront"); } - if let Some((index, _ev)) = found_target_event_id.as_mut() { - *index += 1; // account for this new `value` being prepended. - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); - } - clear_cache = true; - timeline_items.push_front(value); - reobtain_latest_event |= latest_event.is_none(); - } - VectorDiff::PushBack { value } => { - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.push_back(value); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; - is_append = true; + clear_cache = true; + timeline_items.push_front(value); + reobtain_latest_event |= latest_event.is_none(); + } + VectorDiff::PushBack { value } => { + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.push_back(value); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; + is_append = true; + } + VectorDiff::PopFront => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PopFront"); } + clear_cache = true; + timeline_items.pop_front(); + if let Some((i, _ev)) = found_target_event_id.as_mut() { + *i = i.saturating_sub(1); // account for the first item being removed. } - VectorDiff::PopFront => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PopFront"); } + // This doesn't affect whether we should reobtain the latest event. + } + VectorDiff::PopBack => { + timeline_items.pop_back(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); + index_of_last_change = usize::MAX; + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; + } + VectorDiff::Insert { index, value } => { + if index == 0 { clear_cache = true; - timeline_items.pop_front(); - if let Some((i, _ev)) = found_target_event_id.as_mut() { - *i = i.saturating_sub(1); // account for the first item being removed. - } - // This doesn't affect whether we should reobtain the latest event. - } - VectorDiff::PopBack => { - timeline_items.pop_back(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); + } else { + index_of_first_change = min(index_of_first_change, index); index_of_last_change = usize::MAX; - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; } - VectorDiff::Insert { index, value } => { - if index == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = usize::MAX; - } - if index >= timeline_items.len() { - is_append = true; - } + if index >= timeline_items.len() { + is_append = true; + } - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for this new `value` being inserted before the previously-found target event's index. - if index <= *i { - *i += 1; - } - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) - .map(|(i, ev)| (i + index, ev)); + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for this new `value` being inserted before the previously-found target event's index. + if index <= *i { + *i += 1; } - - timeline_items.insert(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; - } - VectorDiff::Set { index, value } => { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = max(index_of_last_change, index.saturating_add(1)); - timeline_items.set(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) + .map(|(i, ev)| (i + index, ev)); } - VectorDiff::Remove { index } => { - if index == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); - index_of_last_change = usize::MAX; - } - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for an item being removed before the previously-found target event's index. - if index <= *i { - *i = i.saturating_sub(1); - } - } - timeline_items.remove(index); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; + + timeline_items.insert(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; + } + VectorDiff::Set { index, value } => { + index_of_first_change = min(index_of_first_change, index); + index_of_last_change = max(index_of_last_change, index.saturating_add(1)); + timeline_items.set(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; + } + VectorDiff::Remove { index } => { + if index == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); + index_of_last_change = usize::MAX; } - VectorDiff::Truncate { length } => { - if length == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); - index_of_last_change = usize::MAX; + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for an item being removed before the previously-found target event's index. + if index <= *i { + *i = i.saturating_sub(1); } - timeline_items.truncate(length); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } - reobtain_latest_event = true; } - VectorDiff::Reset { values } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Reset, new length {}", values.len()); } - clear_cache = true; // we must assume all items have changed. - timeline_items = values; - reobtain_latest_event = true; + timeline_items.remove(index); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; + } + VectorDiff::Truncate { length } => { + if length == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); + index_of_last_change = usize::MAX; } + timeline_items.truncate(length); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } + reobtain_latest_event = true; + } + VectorDiff::Reset { values } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id} diff Reset, new length {}", values.len()); } + clear_cache = true; // we must assume all items have changed. + timeline_items = values; + reobtain_latest_event = true; } } + } - if num_updates > 0 { - let new_latest_event = if reobtain_latest_event { - timeline.latest_event().await - } else { - None - }; - - // Handle the case where back pagination inserts items at the beginning of the timeline - // (meaning the entire timeline needs to be re-drawn), - // but there is a virtual event at index 0 (e.g., a day divider). - // When that happens, we want the RoomScreen to treat this as if *all* events changed. - if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { - index_of_first_change = 0; - clear_cache = true; - } + if num_updates > 0 { + let new_latest_event = if reobtain_latest_event { + timeline.latest_event().await + } else { + None + }; - let changed_indices = index_of_first_change..index_of_last_change; + // Handle the case where back pagination inserts items at the beginning of the timeline + // (meaning the entire timeline needs to be re-drawn), + // but there is a virtual event at index 0 (e.g., a day divider). + // When that happens, we want the RoomScreen to treat this as if *all* events changed. + if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { + index_of_first_change = 0; + clear_cache = true; + } - if LOG_TIMELINE_DIFFS { - log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); - } - timeline_update_sender.send(TimelineUpdate::NewItems { - new_items: timeline_items.clone(), - changed_indices, - clear_cache, - is_append, - }).expect("Error: timeline update sender couldn't send update with new items!"); - - // We must send this update *after* the actual NewItems update, - // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. - if let Some((index, found_event_id)) = found_target_event_id.take() { - target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: found_event_id.clone(), - index, - } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}!") - ); - } + let changed_indices = index_of_first_change..index_of_last_change; - // Update the latest event for this room. - // We always do this in case a redaction or other event has changed the latest event. - if let Some(new_latest) = new_latest_event { - let room_avatar_changed = update_latest_event(&room, &new_latest, Some(&timeline_update_sender)); - if room_avatar_changed { - spawn_fetch_room_avatar(room.clone()); + if LOG_TIMELINE_DIFFS { + log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); + } + timeline_update_sender.send(TimelineUpdate::NewItems { + new_items: timeline_items.clone(), + changed_indices, + clear_cache, + is_append, + }).expect("Error: timeline update sender couldn't send update with new items!"); + + // We must send this update *after* the actual NewItems update, + // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. + if let Some((index, found_event_id)) = found_target_event_id.take() { + target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: found_event_id.clone(), + index, } - latest_event = Some(new_latest); - } + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}!") + ); + } - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); + // Update the latest event for this room. + // We always do this in case a redaction or other event has changed the latest event. + if let Some(new_latest) = new_latest_event { + let room_avatar_changed = update_latest_event(&room, &new_latest, Some(&timeline_update_sender)); + if room_avatar_changed { + spawn_fetch_room_avatar(room.clone()); + } + latest_event = Some(new_latest); } - } - else => { - break; + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); } } - } + + else => { + break; + } + } } error!("Error: unexpectedly ended timeline subscriber for room {room_id}."); } @@ -3162,7 +2907,7 @@ async fn timeline_subscriber_handler( fn update_latest_event( room: &Room, event_tl_item: &EventTimelineItem, - timeline_update_sender: Option<&crossbeam_channel::Sender>, + timeline_update_sender: Option<&crossbeam_channel::Sender> ) -> bool { let mut room_avatar_changed = false; @@ -3173,10 +2918,7 @@ fn update_latest_event( TimelineItemContent::OtherState(other) => { match other.content() { // Check for room name changes. - AnyOtherFullStateEventContent::RoomName(FullStateEventContent::Original { - content, - .. - }) => { + AnyOtherFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => { rooms_list::enqueue_rooms_list_update(RoomsListUpdate::UpdateRoomName { room_id: room_id.clone(), new_room_name: content.name.clone(), @@ -3187,19 +2929,9 @@ fn update_latest_event( room_avatar_changed = true; } // Check for an update to the current user's power levels in this room. - AnyOtherFullStateEventContent::RoomPowerLevels( - FullStateEventContent::Original { - content, - prev_content: _, - }, - ) => { - if let (Some(sender), Some(user_id)) = - (timeline_update_sender, current_user_id()) - { - if let Some(authorization_rules) = room - .version() - .and_then(|v| v.rules().map(|r| r.authorization)) - { + AnyOtherFullStateEventContent::RoomPowerLevels(FullStateEventContent::Original { content, prev_content: _ }) => { + if let (Some(sender), Some(user_id)) = (timeline_update_sender, current_user_id()) { + if let Some(authorization_rules) = room.version().and_then(|v| v.rules().map(|r| r.authorization)) { let user_power_levels = UserPowerLevels::from( &RoomPowerLevels::new( content.clone().into(), @@ -3210,21 +2942,14 @@ fn update_latest_event( ); match sender.send(TimelineUpdate::UserPowerLevels(user_power_levels)) { Ok(_) => SignalToUI::set_ui_signal(), - Err(e) => error!( - "Failed to send the new RoomPowerLevels from an updated latest event: {e}" - ), + Err(e) => error!("Failed to send the new RoomPowerLevels from an updated latest event: {e}"), } } } } // Check for room tombstone status changes. - AnyOtherFullStateEventContent::RoomTombstone(FullStateEventContent::Original { - content: _, - prev_content: _, - }) => { - enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { - room_id: room_id.clone(), - }); + AnyOtherFullStateEventContent::RoomTombstone(FullStateEventContent::Original { content: _, prev_content: _ }) => { + enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { room_id: room_id.clone()}); if let (Some(sender), Some(room)) = ( timeline_update_sender, get_client() @@ -3242,7 +2967,7 @@ fn update_latest_event( } } } - _ => {} + _ => { } } } TimelineItemContent::MembershipChange(room_membership_change) => { @@ -3251,13 +2976,11 @@ fn update_latest_event( Some(MembershipChange::InvitationAccepted | MembershipChange::Joined) ) { if current_user_id().as_deref() == Some(room_membership_change.user_id()) { - submit_async_request(MatrixRequest::GetRoomPowerLevels { - room_id: room_id.clone(), - }); + submit_async_request(MatrixRequest::GetRoomPowerLevels { room_id: room_id.clone() }); } } } - _ => {} + _ => { } } enqueue_rooms_list_update(RoomsListUpdate::UpdateLatestEvent { @@ -3289,13 +3012,8 @@ async fn room_avatar(room: &Room, room_name: Option<&str>) -> RoomPreviewAvatar _ => { if let Ok(room_members) = room.members(RoomMemberships::ACTIVE).await { if room_members.len() == 2 { - if let Some(non_account_member) = - room_members.iter().find(|m| !m.is_account_user()) - { - if let Ok(Some(avatar)) = non_account_member - .avatar(AVATAR_THUMBNAIL_FORMAT.into()) - .await - { + if let Some(non_account_member) = room_members.iter().find(|m| !m.is_account_user()) { + if let Ok(Some(avatar)) = non_account_member.avatar(AVATAR_THUMBNAIL_FORMAT.into()).await { return RoomPreviewAvatar::Image(avatar.into()); } } @@ -3324,8 +3042,7 @@ async fn spawn_sso_server( // Post a status update to inform the user that we're waiting for the client to be built. Cx::post_action(LoginAction::Status { title: "Initializing client...".into(), - status: "Please wait while Matrix builds and configures the client object for login." - .into(), + status: "Please wait while Matrix builds and configures the client object for login.".into(), }); // Wait for the notification that the client has been built @@ -3346,21 +3063,19 @@ async fn spawn_sso_server( // or if the homeserver_url is *not* empty and isn't the default, // we cannot use the DEFAULT_SSO_CLIENT, so we must build a new one. let mut build_client_error = None; - if client_and_session.is_none() - || (!homeserver_url.is_empty() + if client_and_session.is_none() || ( + !homeserver_url.is_empty() && homeserver_url != "matrix.org" && Url::parse(&homeserver_url) != Url::parse("https://matrix-client.matrix.org/") - && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/")) - { + && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/") + ) { match build_client( &Cli { homeserver: homeserver_url.is_empty().not().then_some(homeserver_url), ..Default::default() }, app_data_dir(), - ) - .await - { + ).await { Ok(success) => client_and_session = Some(success), Err(e) => build_client_error = Some(e), } @@ -3369,12 +3084,10 @@ async fn spawn_sso_server( let Some((client, client_session)) = client_and_session else { Cx::post_action(LoginAction::LoginFailure( if let Some(err) = build_client_error { - format!( - "Could not create client object. Please try to login again.\n\nError: {err}" - ) + format!("Could not create client object. Please try to login again.\n\nError: {err}") } else { String::from("Could not create client object. Please try to login again.") - }, + } )); // This ensures that the called to `DEFAULT_SSO_CLIENT_NOTIFIER.notified()` // at the top of this function will not block upon the next login attempt. @@ -3386,8 +3099,7 @@ async fn spawn_sso_server( let mut is_logged_in = false; Cx::post_action(LoginAction::Status { title: "Opening your browser...".into(), - status: "Please finish logging in using your browser, and then come back to Robrix." - .into(), + status: "Please finish logging in using your browser, and then come back to Robrix.".into(), }); match client .matrix_auth() @@ -3397,15 +3109,14 @@ async fn spawn_sso_server( if key == "redirectUrl" { let redirect_url = Url::parse(&value)?; Cx::post_action(LoginAction::SsoSetRedirectUrl(redirect_url)); - break; + break } } Uri::new(&sso_url).open().map_err(|err| { Error::UnknownError( - Box::new(io::Error::other(format!( - "Unable to open SSO login url. Error: {:?}", - err - ))) + Box::new(io::Error::other( + format!("Unable to open SSO login url. Error: {:?}", err), + )) .into(), ) }) @@ -3423,13 +3134,10 @@ async fn spawn_sso_server( }) { Ok(identity_provider_res) => { if !is_logged_in { - if let Err(e) = login_sender - .send(LoginRequest::LoginBySSOSuccess(client, client_session)) - .await - { + if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session)).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to async worker thread.", + "BUG: failed to send login request to async worker thread." ))); } enqueue_rooms_list_update(RoomsListUpdate::Status { @@ -3455,6 +3163,7 @@ async fn spawn_sso_server( }); } + bitflags! { /// The powers that a user has in a given room. #[derive(Copy, Clone, PartialEq, Eq)] @@ -3532,38 +3241,14 @@ impl UserPowerLevels { retval.set(UserPowerLevels::Invite, user_power >= power_levels.invite); retval.set(UserPowerLevels::Kick, user_power >= power_levels.kick); retval.set(UserPowerLevels::Redact, user_power >= power_levels.redact); - retval.set( - UserPowerLevels::NotifyRoom, - user_power >= power_levels.notifications.room, - ); - retval.set( - UserPowerLevels::Location, - user_power >= power_levels.for_message(MessageLikeEventType::Location), - ); - retval.set( - UserPowerLevels::Message, - user_power >= power_levels.for_message(MessageLikeEventType::Message), - ); - retval.set( - UserPowerLevels::Reaction, - user_power >= power_levels.for_message(MessageLikeEventType::Reaction), - ); - retval.set( - UserPowerLevels::RoomMessage, - user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage), - ); - retval.set( - UserPowerLevels::RoomRedaction, - user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction), - ); - retval.set( - UserPowerLevels::Sticker, - user_power >= power_levels.for_message(MessageLikeEventType::Sticker), - ); - retval.set( - UserPowerLevels::RoomPinnedEvents, - user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents), - ); + retval.set(UserPowerLevels::NotifyRoom, user_power >= power_levels.notifications.room); + retval.set(UserPowerLevels::Location, user_power >= power_levels.for_message(MessageLikeEventType::Location)); + retval.set(UserPowerLevels::Message, user_power >= power_levels.for_message(MessageLikeEventType::Message)); + retval.set(UserPowerLevels::Reaction, user_power >= power_levels.for_message(MessageLikeEventType::Reaction)); + retval.set(UserPowerLevels::RoomMessage, user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage)); + retval.set(UserPowerLevels::RoomRedaction, user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction)); + retval.set(UserPowerLevels::Sticker, user_power >= power_levels.for_message(MessageLikeEventType::Sticker)); + retval.set(UserPowerLevels::RoomPinnedEvents, user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents)); retval } @@ -3604,7 +3289,8 @@ impl UserPowerLevels { } pub fn can_send_message(self) -> bool { - self.contains(UserPowerLevels::RoomMessage) || self.contains(UserPowerLevels::Message) + self.contains(UserPowerLevels::RoomMessage) + || self.contains(UserPowerLevels::Message) } pub fn can_send_reaction(self) -> bool { @@ -3621,6 +3307,7 @@ impl UserPowerLevels { } } + /// Shuts down the current Tokio runtime completely and takes ownership to ensure proper cleanup. pub fn shutdown_background_tasks() { if let Some(runtime) = TOKIO_RUNTIME.lock().unwrap().take() { @@ -3633,27 +3320,20 @@ 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 - { + 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"); Ok(()) From e93fd1ed4e5d60b18ef1952f9c41d9ace995bf66 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Thu, 23 Oct 2025 15:33:13 +0800 Subject: [PATCH 03/20] fixed Conflict --- src/sliding_sync.rs | 44 ++++++++++++++++++-------------------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index ac0d6885..6255e2ee 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1049,7 +1049,6 @@ async fn async_worker( }; (room_info.timeline.clone(), room_info.timeline_update_sender.clone()) }; - let room_id2 = room_id.clone(); let subscribe_pinned_events_task = Handle::current().spawn(async move { // Send an initial update, as the stream may not update immediately. let pinned_events = timeline.room().pinned_event_ids().unwrap_or_default(); @@ -1060,7 +1059,6 @@ async fn async_worker( let update_receiver = timeline.room().pinned_event_ids_stream(); pin_mut!(update_receiver); while let Some(pinned_events) = update_receiver.next().await { - log!("Got pinned events update for room {room_id2:?}: {pinned_events:?}"); match sender.send(TimelineUpdate::PinnedEvents(pinned_events)) { Ok(()) => SignalToUI::set_ui_signal(), Err(e) => log!("Failed to send pinned events update: {e:?}"), @@ -1369,7 +1367,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 @@ -1379,7 +1377,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()) @@ -1392,20 +1389,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]); @@ -1429,7 +1425,6 @@ async fn async_worker( destination: destination.clone(), update_sender: update_sender.clone(), }); - } } Err(e) => { @@ -1453,7 +1448,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) => { @@ -1755,10 +1750,9 @@ fn username_to_full_user_id( /// determine what room data has changed since the last update. /// We can't just store the `matrix_sdk::Room` object itself, /// because that is a shallow reference to an inner room object within -/// the room list service +/// the room list service. #[derive(Clone)] struct RoomListServiceRoomInfo { - room: matrix_sdk::Room, room_id: OwnedRoomId, state: RoomState, is_direct: bool, @@ -1772,13 +1766,8 @@ struct RoomListServiceRoomInfo { room_avatar: Option, room: matrix_sdk::Room, } -impl From<&matrix_sdk::Room> for RoomListServiceRoomInfo { - fn from(room: &matrix_sdk::Room) -> Self { - room.clone().into() - } -} -impl From for RoomListServiceRoomInfo { - fn from(room: matrix_sdk::Room) -> Self { +impl RoomListServiceRoomInfo { + async fn from_room(room: matrix_sdk::Room) -> Self { Self { room_id: room.room_id().to_owned(), state: room.state(), @@ -1798,6 +1787,9 @@ impl From for RoomListServiceRoomInfo { room, } } + async fn from_room_ref(room: &matrix_sdk::Room) -> Self { + Self::from_room(room.clone()).await + } } async fn async_main_loop( @@ -2196,7 +2188,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. @@ -2564,7 +2556,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); @@ -3318,19 +3310,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"); From 1caf4ef6e00739ee291cf56a829d960ee96f59f2 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Sat, 25 Oct 2025 00:51:46 +0700 Subject: [PATCH 04/20] - Added a dedicated cpu_worker module and initialize it during app startup so CPU-bound work like member searches runs off the UI thread. - Reworked mention search to enqueue jobs on the CPU worker with cancellation tokens, unique search IDs, and better handling of loading state when the member list is still fetching. - Updated room loading and sync flow to fetch the full member list from the homeserver and deliver it through a new RoomMembersListFetched timeline update, removing the old MatrixRequest::SearchRoomMembers path. - Refactored the member search algorithm for batching, cancellation, richer match strategies, and added unit tests for the new helpers. --- src/app.rs | 4 +- src/cpu_worker.rs | 134 +++++ src/home/room_screen.rs | 22 +- src/lib.rs | 1 + src/room/member_search.rs | 727 ++++++++++++++++----------- src/shared/mentionable_text_input.rs | 198 +++++--- src/sliding_sync.rs | 40 +- 7 files changed, 731 insertions(+), 395 deletions(-) create mode 100644 src/cpu_worker.rs diff --git a/src/app.rs b/src/app.rs index 3eff1c71..6c5b257d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use makepad_widgets::{makepad_micro_serde::*, *}; use matrix_sdk::ruma::{OwnedRoomId, RoomId}; use crate::{ - avatar_cache::clear_avatar_cache, home::{ + avatar_cache::clear_avatar_cache, cpu_worker, home::{ main_desktop_ui::MainDesktopUiAction, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_screen::{clear_timeline_states, MessageAction}, rooms_list::{clear_all_invited_rooms, enqueue_rooms_list_update, RoomsListAction, RoomsListRef, RoomsListUpdate} }, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt @@ -197,6 +197,8 @@ impl MatchEvent for App { let _app_data_dir = crate::app_data_dir(); log!("App::handle_startup(): app_data_dir: {:?}", _app_data_dir); + cpu_worker::init(cx); + if let Err(e) = persistence::load_window_state(self.ui.window(id!(main_window)), cx) { error!("Failed to load window state: {}", e); } diff --git a/src/cpu_worker.rs b/src/cpu_worker.rs new file mode 100644 index 00000000..0b172df9 --- /dev/null +++ b/src/cpu_worker.rs @@ -0,0 +1,134 @@ +//! Lightweight single-thread CPU worker. +//! +//! This module keeps a single background thread alive for the duration of +//! the application. The worker owns an mpsc channel; the sender side lives +//! in a `OnceLock`, so the thread remains active until the process exits. +//! Jobs are executed immediately and their data drops afterwards, so there +//! is no memory leak risk even if the thread stays resident. +//! +//! TODO: +//! * add an explicit `shutdown()` helper that enqueues `CpuJob::Shutdown` +//! and joins the worker thread when the application is torn down. +//! * evaluate migrating to a small thread pool if we add more CPU-bound +//! tasks in the future or need increased throughput. +//! +use makepad_widgets::{log, Cx, CxOsApi}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::{ + atomic::AtomicBool, + mpsc::{self, Sender, TryRecvError}, + Arc, OnceLock, +}; +use std::thread; +use std::time::Duration; + +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), + Shutdown, +} + +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>, +} + +static CPU_WORKER_SENDER: OnceLock> = OnceLock::new(); + +/// Initializes the global CPU worker thread. Safe to call multiple times; only the first call will +/// spawn the worker. +pub fn init(cx: &mut Cx) { + if CPU_WORKER_SENDER.get().is_some() { + return; + } + + let (sender, receiver) = mpsc::channel::(); + + if CPU_WORKER_SENDER.set(sender).is_err() { + // Another thread managed to install the sender first; nothing to do. + return; + } + + cx.spawn_thread(move || loop { + match receiver.try_recv() { + Ok(job) => { + let continue_running = match catch_unwind(AssertUnwindSafe(|| dispatch_job(job))) { + Ok(should_continue) => should_continue, + Err(err) => { + log!("CPU worker job panicked: {:?}", err); + true + } + }; + + if !continue_running { + log!("CPU worker thread exiting"); + break; + } + } + Err(TryRecvError::Empty) => { + // No work at the moment; yield briefly to avoid busy-spinning + thread::sleep(Duration::from_millis(1)); + } + Err(TryRecvError::Disconnected) => { + log!("CPU worker channel disconnected, exiting thread"); + break; + } + } + }); +} + +fn dispatch_job(job: CpuJob) -> bool { + match job { + CpuJob::SearchRoomMembers(params) => { + run_member_search(params); + true + } + CpuJob::Shutdown => false, + } +} + +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 job on the dedicated CPU worker thread. +pub fn spawn_cpu_job(job: CpuJob) { + match CPU_WORKER_SENDER.get() { + Some(sender) => { + if sender.send(job).is_err() { + log!("Failed to submit job to CPU worker: worker thread has exited"); + } + } + None => { + log!("CPU worker not initialized; dropping job"); + } + } +} diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index b06e8850..d9727fad 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -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. @@ -1325,6 +1325,9 @@ 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; no additional action required here + // because we already request the full list when the room loads. } TimelineUpdate::RoomMembersListFetched { members } => { // RoomMembersListFetched: Received members for room @@ -1920,7 +1923,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. @@ -2039,7 +2041,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 { @@ -2048,10 +2050,10 @@ 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. - local_only: true, - }); + // Fetch directly from the server to ensure we have + // an up-to-date member list for mention suggestions. + local_only: false, + }); submit_async_request(MatrixRequest::SubscribeToTypingNotices { room_id: room_id.clone(), subscribe: true, @@ -3236,7 +3238,7 @@ fn populate_text_message_content( }; // Populate link previews if all required parameters are provided - if let (Some(link_preview_ref), Some(media_cache), Some(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, @@ -3335,7 +3337,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 { @@ -4171,7 +4173,7 @@ impl MessageRef { /// /// This function requires passing in a reference to `Cx`, /// which isn't used, but acts as a guarantee that this function -/// must only be called by the main UI thread. +/// must only be called by the main UI thread. pub fn clear_timeline_states(_cx: &mut Cx) { // Clear timeline states cache TIMELINE_STATES.with_borrow_mut(|states| { diff --git a/src/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 index bac2094a..5294aec2 100644 --- a/src/room/member_search.rs +++ b/src/room/member_search.rs @@ -3,15 +3,20 @@ //! This module provides efficient searching of room members with streaming results //! to support responsive UI when users type @mentions. -use std::sync::Arc; -use std::sync::mpsc::Sender; use std::collections::BinaryHeap; -use matrix_sdk::room::{RoomMember, RoomMemberRole}; +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 { @@ -139,184 +144,202 @@ fn role_to_rank(role: RoomMemberRole) -> u8 { } } -/// 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_room_members_streaming_with_sort(members, search_text, max_results, sender, None) +fn is_cancelled(token: &Option>) -> bool { + token + .as_ref() + .map(|flag| flag.load(Ordering::Relaxed)) + .unwrap_or(false) } -/// Search room members with optional pre-computed sort data -pub fn search_room_members_streaming_with_sort( - members: Arc>, - search_text: String, +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, - sender: Sender, - precomputed_sort: Option>, -) { - // Get current user ID to filter out self-mentions - // Note: We capture this once at the start to avoid repeated global state access - let current_user_id = current_user_id(); + current_user_id: Option<&OwnedUserId>, + precomputed_sort: Option<&PrecomputedMemberSort>, + cancel_token: &Option>, +) -> Option> { + if is_cancelled(cancel_token) { + return None; + } - // Constants for batching - const BATCH_SIZE: usize = 10; // Send results in batches + if let Some(sort_data) = precomputed_sort { + let mut indices: Vec = sort_data + .sorted_indices + .iter() + .take(max_results) + .copied() + .collect(); - // For empty search, use pre-computed sort if available - if search_text.is_empty() { - let all_results: Vec = if let Some(ref sort_data) = precomputed_sort { - // Ultra-fast path: O(K) - just take first K from pre-sorted indices - sort_data - .sorted_indices - .iter() - .take(max_results) - .copied() - .collect() - } else { - // Fallback: compute on the fly (should rarely happen) - let mut valid_members: Vec<(u8, u8, usize)> = Vec::with_capacity(members.len()); - - for (index, member) in members.iter().enumerate() { - // Skip the current user - if let Some(ref current_id) = current_user_id { - if member.user_id() == current_id { - continue; - } - } + if max_results == 0 { + indices.clear(); + } - // Get power level rank (0=highest priority) - let power_rank = role_to_rank(member.suggested_role_for_power_level()); + return Some(indices); + } - // 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()); - - // Determine name category based on stripped name for consistency - 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, // Letters - Some(c) if c.is_numeric() => 1, // Numbers - _ => 2, // Symbols - } - } else { - 2 // All symbols - }; + let mut valid_members: Vec<(u8, u8, usize)> = Vec::with_capacity(members.len()); - valid_members.push((power_rank, name_category, index)); - } + for (index, member) in members.iter().enumerate() { + if is_cancelled(cancel_token) { + return None; + } - // Sort all members by (power_rank, name_category, then by actual name) - 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 => { - // Only compute display names when needed for comparison - 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()); + if current_user_id.is_some_and(|id| member.user_id() == id) { + continue; + } - // Simple case-insensitive comparison without creating new strings - name_a - .chars() - .map(|c| c.to_ascii_lowercase()) - .cmp(name_b.chars().map(|c| c.to_ascii_lowercase())) - } - other => other, - }, - other => other, - } - }); + let power_rank = role_to_rank(member.suggested_role_for_power_level()); - // Take only the first max_results - valid_members.truncate(max_results); + let raw_name = member + .display_name() + .map(|n| n.trim()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| member.user_id().localpart()); - // Extract just the indices - valid_members.into_iter().map(|(_, _, idx)| idx).collect() + 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 }; - let mut sent_count = 0; + valid_members.push((power_rank, name_category, index)); + } - // Send in batches - while sent_count < all_results.len() { - let batch_end = (sent_count + BATCH_SIZE).min(all_results.len()); - let batch: Vec<_> = all_results[sent_count..batch_end].to_vec(); - sent_count = batch_end; + if is_cancelled(cancel_token) { + return None; + } - let is_last = sent_count >= all_results.len(); - let search_result = SearchResult { - results: batch, - is_complete: is_last, - search_text: search_text.clone(), - }; + 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()); - if sender.send(search_result).is_err() { - return; + name_a + .chars() + .map(|c| c.to_ascii_lowercase()) + .cmp(name_b.chars().map(|c| c.to_ascii_lowercase())) } - } + other => other, + }, + other => other, + }); - // If no results were sent, send completion signal - if all_results.is_empty() { - let completion_result = SearchResult { - results: Vec::new(), - is_complete: true, - search_text, - }; - let _ = sender.send(completion_result); - } - return; + if is_cancelled(cancel_token) { + return None; } - // Use a max-heap to keep only the top max_results (with best/smallest priorities) - // Max-heap keeps the worst element (highest priority value) at the top for easy replacement - let mut top_matches: BinaryHeap<(u8, usize)> = BinaryHeap::with_capacity(max_results); + valid_members.truncate(max_results); - // Track if we have enough high-priority matches to stop early + 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() { - // 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; - } + if is_cancelled(cancel_token) { + return None; + } + + if current_user_id.is_some_and(|id| member.user_id() == id) { + continue; } - // Check if this member matches the search text and get priority - if let Some(priority) = match_member_with_priority(member, &search_text) { - // Count high-priority matches (0-3 are exact or starts-with matches) + 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); - // Add to heap - maintain top K elements with smallest priorities if top_matches.len() < max_results { top_matches.push((priority, index)); } else if let Some(&(worst_priority, _)) = top_matches.peek() { - // Only add if this match is better (smaller priority) than the worst in heap if priority < worst_priority { - top_matches.pop(); // Remove worst element + top_matches.pop(); top_matches.push((priority, index)); } } - // Soft early exit: continue searching a bit more even after finding enough - // high-priority matches to ensure we don't miss better matches - // Only exit if we have significantly more high-priority matches than needed if max_results > 0 && high_priority_count >= max_results * 2 && top_matches.len() == max_results @@ -327,50 +350,35 @@ pub fn search_room_members_streaming_with_sort( } } - // Extract results from heap and sort them with stable secondary sorting + if is_cancelled(cancel_token) { + return None; + } + let mut all_matches: Vec<(u8, usize)> = top_matches.into_iter().collect(); - // Sort by priority first, then by power level, then by name category, then by sort_key - // This ensures consistency with empty search sorting all_matches.sort_by(|(priority_a, idx_a), (priority_b, idx_b)| { match priority_a.cmp(priority_b) { std::cmp::Ordering::Equal => { - // Same priority - use precomputed sort keys if available - if let Some(ref sort_data) = precomputed_sort { - // Get precomputed keys for efficient comparison + 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]; - // Sort by: power_rank → name_category → sort_key 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, - } - } + 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 { - // Fallback: compute on the fly (should rarely happen) let member_a = &members[*idx_a]; let member_b = &members[*idx_b]; - // Get power level ranks - let power_a = match member_a.suggested_role_for_power_level() { - RoomMemberRole::Administrator | RoomMemberRole::Creator => 0, - RoomMemberRole::Moderator => 1, - RoomMemberRole::User => 2, - }; - let power_b = match member_b.suggested_role_for_power_level() { - RoomMemberRole::Administrator | RoomMemberRole::Creator => 0, - RoomMemberRole::Moderator => 1, - RoomMemberRole::User => 2, - }; + 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 => { - // Same power level - sort by display name let name_a = member_a .display_name() .map(|n| n.trim()) @@ -382,7 +390,6 @@ pub fn search_room_members_streaming_with_sort( .filter(|n| !n.is_empty()) .unwrap_or_else(|| member_b.user_id().localpart()); - // Use efficient ASCII lowercase for ASCII strings if name_a.is_ascii() && name_b.is_ascii() { name_a .chars() @@ -400,52 +407,83 @@ pub fn search_room_members_streaming_with_sort( } }); - // Send results in sorted batches - let mut sent_count = 0; - let total_results = all_matches.len(); - - while sent_count < total_results { - let batch_end = (sent_count + BATCH_SIZE).min(total_results); - - let batch: Vec = all_matches - .get(sent_count..batch_end) - .map(|slice| slice.iter().map(|(_, idx)| *idx).collect()) - .unwrap_or_else(Vec::new); + if is_cancelled(cancel_token) { + return None; + } - if batch.is_empty() { - break; // Safety: prevent infinite loop - } + Some(all_matches.into_iter().map(|(_, idx)| idx).collect()) +} - sent_count = batch_end; - let is_last_batch = sent_count >= total_results; +/// 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, + ) +} - let search_result = SearchResult { - results: batch, - is_complete: is_last_batch, - search_text: search_text.clone(), - }; +/// 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(); - // Sending search results - if sender.send(search_result).is_err() { - log!("Failed to send search results - receiver dropped"); - return; - } + if is_cancelled(&cancel_token) { + return; } - // If we didn't send any results, send completion signal - if total_results == 0 { - // No search results found, sending completion signal - let completion_result = SearchResult { - results: Vec::new(), - is_complete: true, - search_text, - }; - if sender.send(completion_result).is_err() { - // Failed to send completion signal - receiver dropped + 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 @@ -555,7 +593,6 @@ fn grapheme_starts_with(haystack: &str, needle: &str, case_insensitive: bool) -> /// 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 { - // Early return for empty search - all members match with lowest priority if search_text.is_empty() { return Some(10); } @@ -563,124 +600,161 @@ fn match_member_with_priority(member: &RoomMember, search_text: &str) -> Option< let display_name = member.display_name(); let user_id = member.user_id().as_str(); let localpart = member.user_id().localpart(); - - // Determine if we should do case-insensitive search (only for pure ASCII text) 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(); - // Priority 0: Exact display name match (case-sensitive) - if display_name == Some(search_text) { - return Some(0); - } - - // Priority 1: Exact display name match (case-insensitive for ASCII) - if case_insensitive { - if let Some(display) = display_name { - if display.eq_ignore_ascii_case(search_text) { - return Some(1); - } - } - } - - // Priority 2: Exact user ID match (with or without @) - if user_id == search_text - || (!search_has_at - && user_id.starts_with('@') - && user_id.strip_prefix('@') == Some(search_text)) - { - return Some(2); - } - - // Priority 3: Exact user ID match (case-insensitive for ASCII) - if case_insensitive { - if 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))) - { - return Some(3); + 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); } } - // Priority 4: Display name starts with search text (case-sensitive) - if display_name.is_some_and(|d| d.starts_with(search_text)) { - return Some(4); - } - - // Priority 5: Display name starts with search text (case-insensitive for ASCII) - if case_insensitive { + if !case_insensitive && search_text.graphemes(true).count() != search_text.chars().count() { if let Some(display) = display_name { - if starts_with_ignore_ascii_case(display, search_text) { - return Some(5); + if grapheme_starts_with(display, search_text, false) { + return Some(8); } } } - // Priority 6: User ID/localpart starts with search text (case-sensitive) - if 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) - { - return Some(6); - } - - // Priority 7: User ID/localpart starts with search text (case-insensitive) - if case_insensitive { - if starts_with_ignore_ascii_case(user_id, 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))) - { - return Some(7); - } - } - - // Priority 8: Display name contains search text (at word boundary or anywhere) - if let Some(display) = display_name { - if check_word_boundary_match(display, search_text, case_insensitive) { - return Some(8); - } - - if display.contains(search_text) { - return Some(8); - } - - if case_insensitive && contains_ignore_ascii_case(display, search_text) { - return Some(8); - } - } + None +} - // Priority 9: User ID contains search text anywhere - if case_insensitive { - if contains_ignore_ascii_case(user_id, search_text) - || contains_ignore_ascii_case(localpart, search_text) - { - return Some(9); - } - } else if user_id.contains(search_text) || localpart.contains(search_text) { - return Some(9); - } +#[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, +} - // For non-ASCII text with complex graphemes, check grapheme-based matching - 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); // Treat as display name contains +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 } - - // No match found - None } /// Returns true if the `haystack` starts with `needle` ignoring ASCII case. @@ -721,6 +795,45 @@ fn substring_eq_ignore_ascii_case(haystack: &str, start: usize, needle: &str) -> 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() { diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index a542f7f9..5e15ba93 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -6,6 +6,7 @@ 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, *}; @@ -13,19 +14,22 @@ 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; +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: String, + pub search_text: Arc, } /// State machine for mention search functionality @@ -47,6 +51,8 @@ enum MentionSearchState { _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) @@ -374,12 +380,18 @@ pub struct MentionableTextInput { /// Whether the current user can notify everyone in the room (@room mention) #[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, } impl Widget for MentionableTextInput { @@ -388,6 +400,7 @@ impl Widget for MentionableTextInput { 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); @@ -489,6 +502,7 @@ impl Widget for MentionableTextInput { continue; } + self.members_available = true; if self.is_searching() { // Force a fresh search now that members are available let search_text = self.cmd_text_input.search_text(); @@ -546,6 +560,19 @@ impl MentionableTextInput { ) } + /// 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 + } + /// Get the current trigger position if in search mode fn get_trigger_position(&self) -> Option { match &self.search_state { @@ -787,8 +814,13 @@ impl MentionableTextInput { popup.set_visible(cx, true); self.cmd_text_input.text_input_ref().set_key_focus(cx); } else if accumulated_results.is_empty() { - // Search completed with no results - self.show_no_matches_indicator(cx); + if self.members_available { + // Search completed with no results + self.show_no_matches_indicator(cx); + } else { + // Still waiting for members from the homeserver + self.show_loading_indicator(cx); + } popup.set_visible(cx, true); self.cmd_text_input.text_input_ref().set_key_focus(cx); } else { @@ -860,6 +892,7 @@ impl MentionableTextInput { ); } + self.cancel_active_search(); self.search_state = MentionSearchState::JustCancelled; self.close_mention_popup(cx); } @@ -947,7 +980,7 @@ impl MentionableTextInput { 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 = String::new(); + let mut search_text: Option> = None; let mut any_results = false; let mut should_update_ui = false; let mut new_results = Vec::new(); @@ -956,12 +989,17 @@ impl MentionableTextInput { 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; + } + any_results = true; - search_text = result.search_text.clone(); + search_text = Some(result.search_text.clone()); is_complete = result.is_complete; // Collect results @@ -993,7 +1031,11 @@ impl MentionableTextInput { if !results_for_ui.is_empty() { // Results are already sorted in member_search.rs and indices are unique - self.update_ui_with_results(cx, scope, &search_text); + let query = search_text + .as_ref() + .map(|s| s.as_str()) + .unwrap_or_default(); + self.update_ui_with_results(cx, scope, query); } } @@ -1012,7 +1054,11 @@ impl MentionableTextInput { if final_results.is_empty() { // No user results, but still update UI (may show @room) - self.update_ui_with_results(cx, scope, &search_text); + 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 @@ -1138,63 +1184,86 @@ impl MentionableTextInput { MOBILE_MAX_VISIBLE_ITEMS }; - // Check if we have cached members - let has_members = matches!(&room_props.room_members, Some(members) if !members.is_empty()); + let cached_members = match &room_props.room_members { + Some(members) if !members.is_empty() => { + self.members_available = true; + members.clone() + } + _ => { + let already_waiting = matches!( + self.search_state, + MentionSearchState::WaitingForMembers { .. } + ); - if has_members { - // We have cached members, transition to Searching state - 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); - } else { - // No cached members yet, transition to WaitingForMembers state - self.search_state = MentionSearchState::WaitingForMembers { - trigger_position: trigger_pos, - pending_search_text: search_text.to_string(), - }; + self.cancel_active_search(); + self.members_available = false; - // 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 - } + if !already_waiting { + submit_async_request(MatrixRequest::GetRoomMembers { + room_id: room_props.room_id.clone(), + memberships: RoomMemberships::JOIN, + local_only: false, + }); + } - // Only submit search request if we have cached members - if let Some(cached_members) = &room_props.room_members { - // Create a new channel for this search - let (sender, receiver) = std::sync::mpsc::channel(); - - // Submit search request to background worker - let search_text_clone = search_text.to_string(); - let max_results = max_visible_items * SEARCH_BUFFER_MULTIPLIER; - - // Transition to Searching state with new receiver - self.search_state = MentionSearchState::Searching { - trigger_position: trigger_pos, - _search_text: search_text.to_string(), - receiver, - accumulated_results: Vec::new(), - }; + self.search_state = MentionSearchState::WaitingForMembers { + trigger_position: trigger_pos, + pending_search_text: search_text.to_string(), + }; - submit_async_request(MatrixRequest::SearchRoomMembers { - room_id: room_props.room_id.clone(), - search_text: search_text_clone, - sender, - max_results, - cached_members: cached_members.clone(), - precomputed_sort: room_props.room_members_sort.clone(), - }); + // 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 + } + }; - // Request next frame to check the channel - cx.new_next_frame(); + // 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(), + }; - // Try to check immediately for faster response - self.check_search_channel(cx, scope); - } + let precomputed_sort = room_props.room_members_sort.clone(); + let cancel_token_for_job = cancel_token.clone(); + cpu_worker::spawn_cpu_job(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 @@ -1365,14 +1434,25 @@ impl MentionableTextInput { // 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); + } + } + /// 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; + // Mark members as unavailable until we fetch them again + self.members_available = false; + // Clear list items self.cmd_text_input.clear_items(); } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 6255e2ee..25e3617f 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -42,7 +42,7 @@ use crate::{ user_profile::{AvatarState, UserProfile}, user_profile_cache::{enqueue_user_profile_update, UserProfileUpdate}, }, - room::{member_search::{search_room_members_streaming_with_sort, PrecomputedMemberSort}, RoomPreviewAvatar}, + room::RoomPreviewAvatar, shared::{ html_or_plaintext::MatrixLinkPillState, jump_to_bottom_button::UnreadMessageCount, @@ -330,15 +330,6 @@ pub enum MatrixRequest { /// * If `false` (recommended), details will be fetched from the server. local_only: bool, }, - /// Request to search room members in background thread - SearchRoomMembers { - room_id: OwnedRoomId, - search_text: String, - sender: std::sync::mpsc::Sender, - max_results: usize, - cached_members: Arc>, - precomputed_sort: Option>, - }, /// Request to fetch profile information for the given user ID. GetUserProfile { user_id: OwnedUserId, @@ -669,8 +660,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(); + } }); } @@ -767,12 +777,6 @@ async fn async_worker( }); } - MatrixRequest::SearchRoomMembers { room_id: _, search_text, sender, max_results, cached_members, precomputed_sort } => { - let _search_task = tokio::task::spawn_blocking(move || { - search_room_members_streaming_with_sort(cached_members, search_text, max_results, sender, precomputed_sort); - }); - } - MatrixRequest::GetUserProfile { user_id, room_id, local_only } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { From 777967d294e8c02a4b30ab527bd664897c8c34b2 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Sun, 26 Oct 2025 20:39:10 +0700 Subject: [PATCH 05/20] Introduced a CPU worker for member search and fixed the loading animation bug after re-login --- src/app.rs | 4 +- src/cpu_worker.rs | 107 ++++------------------ src/home/room_screen.rs | 40 +++++++- src/shared/mentionable_text_input.rs | 131 ++++++++++++++++++++++----- 4 files changed, 161 insertions(+), 121 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6c5b257d..3eff1c71 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use makepad_widgets::{makepad_micro_serde::*, *}; use matrix_sdk::ruma::{OwnedRoomId, RoomId}; use crate::{ - avatar_cache::clear_avatar_cache, cpu_worker, home::{ + avatar_cache::clear_avatar_cache, home::{ main_desktop_ui::MainDesktopUiAction, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_screen::{clear_timeline_states, MessageAction}, rooms_list::{clear_all_invited_rooms, enqueue_rooms_list_update, RoomsListAction, RoomsListRef, RoomsListUpdate} }, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt @@ -197,8 +197,6 @@ impl MatchEvent for App { let _app_data_dir = crate::app_data_dir(); log!("App::handle_startup(): app_data_dir: {:?}", _app_data_dir); - cpu_worker::init(cx); - if let Err(e) = persistence::load_window_state(self.ui.window(id!(main_window)), cx) { error!("Failed to load window state: {}", e); } diff --git a/src/cpu_worker.rs b/src/cpu_worker.rs index 0b172df9..f5e47c74 100644 --- a/src/cpu_worker.rs +++ b/src/cpu_worker.rs @@ -1,27 +1,18 @@ -//! Lightweight single-thread CPU worker. +//! Lightweight wrapper for CPU-bound tasks. //! -//! This module keeps a single background thread alive for the duration of -//! the application. The worker owns an mpsc channel; the sender side lives -//! in a `OnceLock`, so the thread remains active until the process exits. -//! Jobs are executed immediately and their data drops afterwards, so there -//! is no memory leak risk even if the thread stays resident. +//! 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. //! -//! TODO: -//! * add an explicit `shutdown()` helper that enqueues `CpuJob::Shutdown` -//! and joins the worker thread when the application is torn down. -//! * evaluate migrating to a small thread pool if we add more CPU-bound -//! tasks in the future or need increased throughput. -//! -use makepad_widgets::{log, Cx, CxOsApi}; -use std::panic::{catch_unwind, AssertUnwindSafe}; -use std::sync::{ - atomic::AtomicBool, - mpsc::{self, Sender, TryRecvError}, - Arc, OnceLock, -}; -use std::thread; -use std::time::Duration; - +//! ## 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, @@ -30,7 +21,6 @@ use matrix_sdk::room::RoomMember; pub enum CpuJob { SearchRoomMembers(SearchRoomMembersJob), - Shutdown, } pub struct SearchRoomMembersJob { @@ -43,60 +33,6 @@ pub struct SearchRoomMembersJob { pub cancel_token: Option>, } -static CPU_WORKER_SENDER: OnceLock> = OnceLock::new(); - -/// Initializes the global CPU worker thread. Safe to call multiple times; only the first call will -/// spawn the worker. -pub fn init(cx: &mut Cx) { - if CPU_WORKER_SENDER.get().is_some() { - return; - } - - let (sender, receiver) = mpsc::channel::(); - - if CPU_WORKER_SENDER.set(sender).is_err() { - // Another thread managed to install the sender first; nothing to do. - return; - } - - cx.spawn_thread(move || loop { - match receiver.try_recv() { - Ok(job) => { - let continue_running = match catch_unwind(AssertUnwindSafe(|| dispatch_job(job))) { - Ok(should_continue) => should_continue, - Err(err) => { - log!("CPU worker job panicked: {:?}", err); - true - } - }; - - if !continue_running { - log!("CPU worker thread exiting"); - break; - } - } - Err(TryRecvError::Empty) => { - // No work at the moment; yield briefly to avoid busy-spinning - thread::sleep(Duration::from_millis(1)); - } - Err(TryRecvError::Disconnected) => { - log!("CPU worker channel disconnected, exiting thread"); - break; - } - } - }); -} - -fn dispatch_job(job: CpuJob) -> bool { - match job { - CpuJob::SearchRoomMembers(params) => { - run_member_search(params); - true - } - CpuJob::Shutdown => false, - } -} - fn run_member_search(params: SearchRoomMembersJob) { let SearchRoomMembersJob { members, @@ -119,16 +55,9 @@ fn run_member_search(params: SearchRoomMembersJob) { ); } -/// Spawns a job on the dedicated CPU worker thread. -pub fn spawn_cpu_job(job: CpuJob) { - match CPU_WORKER_SENDER.get() { - Some(sender) => { - if sender.send(job).is_err() { - log!("Failed to submit job to CPU worker: worker thread has exited"); - } - } - None => { - log!("CPU worker not initialized; dropping job"); - } - } +/// 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 d9727fad..1f80889f 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -789,6 +789,7 @@ impl Widget for RoomScreen { 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() @@ -804,6 +805,7 @@ impl Widget for RoomScreen { room_id, room_members, room_members_sort, + room_members_sync_pending, room_display_name, room_avatar_url, } @@ -814,6 +816,7 @@ impl Widget for RoomScreen { room_id, room_members: None, room_members_sort: None, + room_members_sync_pending: false, room_display_name: None, room_avatar_url: None, } @@ -829,6 +832,7 @@ impl Widget for RoomScreen { 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, } @@ -1326,16 +1330,28 @@ impl RoomScreen { // 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; no additional action required here - // because we already request the full list when the room loads. + // 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 } => { // 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 => { @@ -1971,6 +1987,7 @@ impl RoomScreen { // 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(), @@ -2031,6 +2048,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() }); } @@ -2050,9 +2077,8 @@ impl RoomScreen { submit_async_request(MatrixRequest::GetRoomMembers { room_id: room_id.clone(), memberships: matrix_sdk::RoomMemberships::JOIN, - // Fetch directly from the server to ensure we have - // an up-to-date member list for mention suggestions. - local_only: false, + // Prefer cached members; background sync will refresh them as needed. + local_only: true, }); submit_async_request(MatrixRequest::SubscribeToTypingNotices { room_id: room_id.clone(), @@ -2126,6 +2152,7 @@ impl RoomScreen { // 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)); } @@ -2306,6 +2333,7 @@ pub struct RoomScreenProps { 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, } @@ -2460,6 +2488,8 @@ struct TimelineUiState { 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. diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 5e15ba93..a4bba155 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -345,7 +345,11 @@ pub enum MentionableTextInputAction { can_notify_room: bool, }, /// Notifies the MentionableTextInput that room members have been loaded. - RoomMembersLoaded { room_id: OwnedRoomId }, + RoomMembersLoaded { + room_id: OwnedRoomId, + /// Whether member sync is still in progress + sync_in_progress: bool, + }, } /// Widget that extends CommandTextInput with @mention capabilities @@ -392,6 +396,15 @@ pub struct MentionableTextInput { /// 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 { @@ -413,12 +426,14 @@ impl Widget for MentionableTextInput { // 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("RoomScreenProps should be available in scope 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 { @@ -497,17 +512,39 @@ impl Widget for MentionableTextInput { } } } - MentionableTextInputAction::RoomMembersLoaded { room_id } => { + MentionableTextInputAction::RoomMembersLoaded { + room_id, + sync_in_progress, + } => { if &scope_room_id != room_id { continue; } - self.members_available = true; - if self.is_searching() { + 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); } } } @@ -530,6 +567,7 @@ impl Widget for MentionableTextInput { .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() { @@ -814,11 +852,14 @@ impl MentionableTextInput { 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_available { - // Search completed with no results + 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 { - // Still waiting for members from the homeserver + // No members available yet. self.show_loading_indicator(cx); } popup.set_visible(cx, true); @@ -1041,6 +1082,7 @@ impl MentionableTextInput { // 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, @@ -1076,6 +1118,7 @@ impl MentionableTextInput { if disconnected { // Channel was closed - search completed or failed + self.search_results_pending = false; self.handle_search_channel_closed(cx, scope); } } @@ -1086,14 +1129,31 @@ impl MentionableTextInput { /// 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) { - // Clear old list items - self.cmd_text_input.clear_items(); - 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: usize = if is_desktop { DESKTOP_MAX_VISIBLE_ITEMS @@ -1187,6 +1247,8 @@ impl MentionableTextInput { 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() } _ => { @@ -1197,12 +1259,13 @@ impl MentionableTextInput { 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: false, + local_only: true, }); } @@ -1246,10 +1309,11 @@ impl MentionableTextInput { 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(CpuJob::SearchRoomMembers(SearchRoomMembersJob { + cpu_worker::spawn_cpu_job(cx, CpuJob::SearchRoomMembers(SearchRoomMembersJob { members: cached_members, search_text: search_text_clone, max_results, @@ -1338,22 +1402,36 @@ impl MentionableTextInput { /// Shows the loading indicator when waiting for initial members to be loaded fn show_loading_indicator(&mut self, cx: &mut Cx) { - // Clear any existing items + // 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; + } + + // Clear old items before creating new loading indicator self.cmd_text_input.clear_items(); - // Create loading indicator widget + // 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 + // 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()); + + // Now that the widget is in the UI tree, start the loading animation loading_item .bouncing_dots(id!(loading_animation)) .start_animation(cx); - - // Add the loading indicator to the popup - self.cmd_text_input.add_item(loading_item); + cx.new_next_frame(); // Setup popup dimensions for loading state let popup = self.cmd_text_input.view(id!(popup)); @@ -1385,6 +1463,7 @@ impl MentionableTextInput { // 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)); @@ -1438,6 +1517,7 @@ impl MentionableTextInput { 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 @@ -1449,9 +1529,12 @@ impl MentionableTextInput { // 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 self.cmd_text_input.clear_items(); From 048000bfbf7904e4e75637d00a6a563913613a5a Mon Sep 17 00:00:00 2001 From: AlexZ Date: Sat, 1 Nov 2025 02:30:44 +0800 Subject: [PATCH 06/20] fixed bug --- src/home/room_screen.rs | 15 +++++++++++++-- src/shared/mentionable_text_input.rs | 24 +++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 6722dfa9..48dd5dfe 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1352,10 +1352,21 @@ impl RoomScreen { 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 + + // For non-sync requests (like GetRoomMembers), there won't be a RoomMembersSynced event + // So we should clear the sync pending flag if we have members and we're not actually syncing + // This fixes the issue where local cache lookups would leave the loading indicator on + let sync_in_progress = if !tl.room_members_sync_pending { + // We're not syncing, this is likely from a local cache lookup + false + } else { + // We are syncing, wait for RoomMembersSynced to clear the flag + true + }; + cx.action(MentionableTextInputAction::RoomMembersLoaded { room_id: tl.room_id.clone(), - sync_in_progress: tl.room_members_sync_pending, + sync_in_progress, }); } TimelineUpdate::MediaFetched => { diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index a4bba155..6ed4c136 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -405,6 +405,9 @@ pub struct MentionableTextInput { /// Active loading indicator widget while we wait for members/results #[rust] loading_indicator_ref: Option, + /// Flag to track if popup cleanup is pending after focus loss + #[rust] + pending_popup_cleanup: bool, } impl Widget for MentionableTextInput { @@ -456,9 +459,17 @@ impl Widget for MentionableTextInput { // Handle item selection from mention popup if let Some(selected) = self.cmd_text_input.item_selected(actions) { + // Clear pending popup cleanup since we're processing a selection + self.pending_popup_cleanup = false; self.on_user_selected(cx, scope, selected); } + // If we had a pending cleanup but no selection occurred, do the cleanup now + if self.pending_popup_cleanup { + self.pending_popup_cleanup = false; + self.close_mention_popup(cx); + } + // Handle build items request if self.cmd_text_input.should_build_items(actions) { if has_focus { @@ -552,8 +563,16 @@ impl Widget for MentionableTextInput { } // Close popup if focus is lost while searching + // However, don't reset search state immediately to allow pending item selection events to process + // This fixes the issue where mouse clicks would clear state before the selection could be handled if !has_focus && self.is_searching() { - self.close_mention_popup(cx); + // Only close the visual popup, but keep the search state for a brief moment + // to allow any pending selection events to be processed + let popup = self.cmd_text_input.view(id!(popup)); + popup.set_visible(cx, false); + // Mark that we should clean up state after selection is processed + // The actual cleanup will happen in the next event cycle if no selection occurs + self.pending_popup_cleanup = true; } } @@ -879,6 +898,9 @@ impl MentionableTextInput { // This is good practice to maintain signature consistency with other methods // and allow for future scope-based enhancements + // Clear pending popup cleanup since we're processing a selection + self.pending_popup_cleanup = false; + let text_input_ref = self.cmd_text_input.text_input_ref(); let current_text = text_input_ref.text(); let head = text_input_ref.borrow().map_or(0, |p| p.cursor().index); From d31c12a392e155acf8bbc139137916f3008d203d Mon Sep 17 00:00:00 2001 From: AlexZ Date: Fri, 14 Nov 2025 00:43:02 +0800 Subject: [PATCH 07/20] Block @room mentions in direct rooms by plumbing is_direct through RoomScreenProps --- .gitignore | 2 ++ src/home/room_screen.rs | 16 ++++++++++++++++ src/home/rooms_list.rs | 22 ++++++++++++++++++++++ src/shared/mentionable_text_input.rs | 4 ++++ 4 files changed, 44 insertions(+) diff --git a/.gitignore b/.gitignore index 1f891a01..4af4918a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ .vscode .DS_Store CLAUDE.md +AGENTS.md proxychains.conf +/specs diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 48dd5dfe..50821f36 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -794,6 +794,7 @@ impl Widget for RoomScreen { let room_members = tl.room_members.clone(); let room_members_sort = tl.room_members_sort.clone(); let room_members_sync_pending = tl.room_members_sync_pending; + let is_direct_room = Self::is_direct_room(cx, &room_id); // Fetch room data once to avoid duplicate expensive lookups let (room_display_name, room_avatar_url) = get_client() @@ -812,9 +813,11 @@ impl Widget for RoomScreen { room_members_sync_pending, room_display_name, room_avatar_url, + is_direct_room, } } else if let Some(room_id) = self.room_id.clone() { // Fallback case: we have a room_id but no tl_state yet + let is_direct_room = Self::is_direct_room(cx, &room_id); RoomScreenProps { room_screen_widget_uid, room_id, @@ -823,6 +826,7 @@ impl Widget for RoomScreen { room_members_sync_pending: false, room_display_name: None, room_avatar_url: None, + is_direct_room, } } else { // No room selected yet, skip event handling that requires room context @@ -839,6 +843,7 @@ impl Widget for RoomScreen { room_members_sync_pending: false, room_display_name: None, room_avatar_url: None, + is_direct_room: false, } }; let mut room_scope = Scope::with_props(&room_props); @@ -1096,6 +1101,16 @@ impl Widget for RoomScreen { } } +impl RoomScreen { + fn is_direct_room(cx: &mut Cx, room_id: &OwnedRoomId) -> bool { + if cx.has_global::() { + cx.get_global::().is_direct_room(room_id) + } else { + false + } + } +} + impl RoomScreen { /// Processes all pending background updates to the currently-shown timeline. /// @@ -2351,6 +2366,7 @@ pub struct RoomScreenProps { pub room_members_sync_pending: bool, pub room_display_name: Option, pub room_avatar_url: Option, + pub is_direct_room: bool, } diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 16454f3b..72ea10d2 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -838,6 +838,20 @@ impl RoomsList { .map(|room_info| (room_info.room_avatar.clone(), room_info.room_name.clone())) }) } + + /// Returns whether the room is marked as direct, if known. + pub fn is_direct_room(&self, room_id: &OwnedRoomId) -> bool { + self.all_joined_rooms + .get(room_id) + .map(|room_info| room_info.is_direct) + .or_else(|| { + self.invited_rooms + .borrow() + .get(room_id) + .map(|room_info| room_info.is_direct) + }) + .unwrap_or(false) + } } impl Widget for RoomsList { @@ -1093,6 +1107,14 @@ impl RoomsListRef { let inner = self.borrow()?; inner.get_room_avatar_and_name(room_id) } + + /// Don't show @room option in direct messages + pub fn is_direct_room(&self, room_id: &OwnedRoomId) -> bool { + let Some(inner) = self.borrow() else { + return false; + }; + inner.is_direct_room(room_id) + } } pub struct RoomsListScopeProps { /// Whether the RoomsList's inner PortalList was scrolling diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 6ed4c136..44557fa6 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -658,6 +658,10 @@ impl MentionableTextInput { room_props: &RoomScreenProps, is_desktop: bool, ) -> bool { + // Don't show @room option in direct messages + if room_props.is_direct_room { + return false; + } if !self.can_notify_room || !("@room".contains(search_text) || search_text.is_empty()) { return false; } From bb916a0b8e4f549c4484ec80981b21c4778acf95 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Wed, 19 Nov 2025 04:44:45 +0800 Subject: [PATCH 08/20] Render the user list immediately once the local user data is retrieved --- src/home/room_screen.rs | 91 +++++++++++++-------- src/room/member_search.rs | 19 ----- src/shared/mentionable_text_input.rs | 118 ++++++++++++++++----------- src/sliding_sync.rs | 34 ++++++-- 4 files changed, 158 insertions(+), 104 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 50821f36..c7f254b7 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::{member_search::{precompute_member_sort, PrecomputedMemberSort}, room_input_bar::RoomInputBarState, typing_notice::TypingNoticeWidgetExt}, + room::{member_search::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 }, @@ -1352,36 +1352,44 @@ impl RoomScreen { // Room members have been synced; unconditionally clear pending flag // to fix bug where small rooms (< 50 members) would stay in loading state forever tl.room_members_sync_pending = false; + tl.room_members_remote_synced = true; // Notify MentionableTextInput that sync is complete + let has_members = tl + .room_members + .as_ref() + .is_some_and(|members| !members.is_empty()); + cx.action(MentionableTextInputAction::RoomMembersLoaded { room_id: tl.room_id.clone(), sync_in_progress: false, + has_members, }); } - TimelineUpdate::RoomMembersListFetched { members } => { + TimelineUpdate::RoomMembersListFetched { members, sort, is_local_fetch } => { // RoomMembersListFetched: Received members for room // Note: This can be sent from either GetRoomMembers (local cache lookup) // or SyncRoomMemberList (full server sync). We only clear the sync pending // flag when we receive RoomMembersSynced (which is only sent after full sync). - let sort_data = precompute_member_sort(&members); - tl.room_members = Some(Arc::new(members)); + let sort_data = sort; + let members = Arc::new(members); + let has_members = !members.is_empty(); + tl.room_members = Some(Arc::clone(&members)); tl.room_members_sort = Some(Arc::new(sort_data)); // For non-sync requests (like GetRoomMembers), there won't be a RoomMembersSynced event // So we should clear the sync pending flag if we have members and we're not actually syncing // This fixes the issue where local cache lookups would leave the loading indicator on - let sync_in_progress = if !tl.room_members_sync_pending { - // We're not syncing, this is likely from a local cache lookup + let sync_in_progress = if is_local_fetch { false } else { - // We are syncing, wait for RoomMembersSynced to clear the flag - true + tl.room_members_sync_pending }; cx.action(MentionableTextInputAction::RoomMembersLoaded { room_id: tl.room_id.clone(), sync_in_progress, + has_members, }); } TimelineUpdate::MediaFetched => { @@ -2018,6 +2026,7 @@ impl RoomScreen { room_members: None, room_members_sort: None, room_members_sync_pending: false, + room_members_remote_synced: false, // We assume timelines being viewed for the first time haven't been fully paginated. fully_paginated: false, items: Vector::new(), @@ -2038,6 +2047,20 @@ impl RoomScreen { (tl_state, true) }; + let has_cached_members = tl_state + .room_members + .as_ref() + .is_some_and(|members| !members.is_empty()); + let mut should_request_local_members = false; + if !has_cached_members { + should_request_local_members = true; + } + + let mut needs_remote_sync = !tl_state.room_members_remote_synced; + if !has_cached_members { + needs_remote_sync = true; + } + // It is possible that this room has already been loaded (received from the server) // but that the RoomsList doesn't yet know about it. // In that case, `is_first_time_being_loaded` will already be `true` here, @@ -2060,6 +2083,17 @@ impl RoomScreen { self.is_loaded = is_loaded_now; } + if is_first_time_being_loaded { + needs_remote_sync = true; + } + + let mut should_request_full_sync = false; + if needs_remote_sync && !tl_state.room_members_sync_pending { + should_request_full_sync = true; + tl_state.room_members_sync_pending = true; + tl_state.room_members_remote_synced = false; + } + self.view.restore_status_view(id!(restore_status_view)).set_visible(cx, !self.is_loaded); // Kick off a back pagination request if it's the first time loading this room, @@ -2074,21 +2108,6 @@ impl RoomScreen { direction: PaginationDirection::Backwards, }); } - - // Even though we specify that room member profiles should be lazy-loaded, - // the matrix server still doesn't consistently send them to our client properly. - // So we kick off a request to fetch the room members here upon first viewing the room. - 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() }); } // Hide the typing notice view initially. @@ -2104,12 +2123,14 @@ impl RoomScreen { submit_async_request(MatrixRequest::GetRoomPowerLevels { room_id: room_id.clone(), }); - submit_async_request(MatrixRequest::GetRoomMembers { - room_id: room_id.clone(), - memberships: matrix_sdk::RoomMemberships::JOIN, - // Prefer cached members; background sync will refresh them as needed. - local_only: true, - }); + if should_request_local_members { + submit_async_request(MatrixRequest::GetRoomMembers { + room_id: room_id.clone(), + memberships: matrix_sdk::RoomMemberships::JOIN, + // Prefer cached members; background sync will refresh them as needed. + local_only: true, + }); + } submit_async_request(MatrixRequest::SubscribeToTypingNotices { room_id: room_id.clone(), subscribe: true, @@ -2136,6 +2157,10 @@ impl RoomScreen { self.process_timeline_updates(cx, &self.portal_list(id!(list))); self.redraw(cx); + + if should_request_full_sync { + submit_async_request(MatrixRequest::SyncRoomMemberList { room_id: room_id.clone() }); + } } /// Invoke this when this RoomScreen/timeline is being hidden or no longer being shown. @@ -2179,10 +2204,6 @@ impl RoomScreen { room_input_bar_state: self.room_input_bar(id!(room_input_bar)).save_state(), }; tl.saved_state = state; - // 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)); } @@ -2466,6 +2487,8 @@ pub enum TimelineUpdate { /// but doesn't provide the actual data. RoomMembersListFetched { members: Vec, + sort: crate::room::member_search::PrecomputedMemberSort, + is_local_fetch: bool, }, /// A notice that one or more requested media items (images, videos, etc.) /// that should be displayed in this timeline have now been fetched and are available. @@ -2521,6 +2544,8 @@ struct TimelineUiState { room_members_sort: Option>, /// Whether a full member sync is still pending for this room. room_members_sync_pending: bool, + /// Whether we've successfully completed a remote member sync at least once. + room_members_remote_synced: bool, /// Whether this room's timeline has been fully paginated, which means /// that the oldest (first) event in the timeline is locally synced and available. diff --git a/src/room/member_search.rs b/src/room/member_search.rs index 5294aec2..9e042000 100644 --- a/src/room/member_search.rs +++ b/src/room/member_search.rs @@ -414,25 +414,6 @@ fn compute_non_empty_search_indices( 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>, diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 44557fa6..ef2b806e 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -48,7 +48,7 @@ enum MentionSearchState { /// Actively searching with background task Searching { trigger_position: usize, - _search_text: String, // Kept for debugging/future use + search_text: String, receiver: Receiver, accumulated_results: Vec, search_id: u64, @@ -349,6 +349,8 @@ pub enum MentionableTextInputAction { room_id: OwnedRoomId, /// Whether member sync is still in progress sync_in_progress: bool, + /// Whether we currently have cached members + has_members: bool, }, } @@ -526,29 +528,50 @@ impl Widget for MentionableTextInput { MentionableTextInputAction::RoomMembersLoaded { room_id, sync_in_progress, + has_members, } => { 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()); + log!( + "MentionableTextInput: RoomMembersLoaded room={} sync_in_progress={} has_members={} is_searching={}", + room_id, + sync_in_progress, + has_members, + self.is_searching() + ); - // 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); + self.members_available = *has_members; + + if self.members_available { + // CRITICAL FIX: Use saved state instead of reading from text input + // Reading from text input causes race condition (text may be empty when members arrive) + // Extract needed values first to avoid borrow checker issues + let action = match &self.search_state { + MentionSearchState::WaitingForMembers { + pending_search_text, + .. + } => Some((true, pending_search_text.clone())), + MentionSearchState::Searching { search_text, .. } => { + Some((false, search_text.clone())) + } + _ => None, + }; + + if let Some((is_waiting, search_text)) = action { + if is_waiting { + log!(" → Members loaded, resuming search with saved text='{}'", search_text); + self.last_search_text = None; + self.update_user_list(cx, &search_text, scope); + } else { + log!(" → Already searching, updating UI with search_text='{}'", search_text); + self.update_ui_with_results(cx, scope, &search_text); + } + } else { + log!(" → Not in searching state, ignoring member load"); + } } else if self.is_searching() { // Still no members returned yet; keep showing loading indicator. self.cmd_text_input.clear_items(); @@ -966,6 +989,8 @@ impl MentionableTextInput { /// Core text change handler that manages mention context fn handle_text_change(&mut self, cx: &mut Cx, scope: &mut Scope, text: String) { + log!("handle_text_change: text='{}' search_state={:?}", text, self.search_state); + // If search was just cancelled, clear the flag and don't re-trigger search if self.is_just_cancelled() { self.search_state = MentionSearchState::Idle; @@ -1160,23 +1185,9 @@ impl MentionableTextInput { .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 + // If we're in Searching state, we have local data - always show results + // Don't wait for remote sync to complete + // Remote sync will trigger update when it completes (if data changed) self.cmd_text_input.clear_items(); self.loading_indicator_ref = None; @@ -1227,6 +1238,23 @@ impl MentionableTextInput { /// Updates the mention suggestion list based on search fn update_user_list(&mut self, cx: &mut Cx, search_text: &str, scope: &mut Scope) { + // CRITICAL FIX: Get room_props FIRST to read real-time member state + // This avoids timing issues where self.members_available is stale + let room_props = scope + .props + .get::() + .expect("RoomScreenProps should be available in scope for MentionableTextInput"); + + // Immediately sync local state from props (don't rely on async actions) + let has_members_in_props = room_props.room_members + .as_ref() + .is_some_and(|m| !m.is_empty()); + self.members_available = has_members_in_props; + self.members_sync_pending = room_props.room_members_sync_pending; + + log!("update_user_list: search_text='{}' has_members_in_props={} self.members_available={} search_state={:?}", + search_text, has_members_in_props, self.members_available, self.search_state); + // Get trigger position from current state (if in searching mode) let trigger_pos = match &self.search_state { MentionSearchState::WaitingForMembers { @@ -1251,18 +1279,17 @@ impl MentionableTextInput { } }; - // Skip if search text hasn't changed (simple debounce) + // Skip if search text hasn't changed AND we're already in Searching state + // Don't skip if we're in WaitingForMembers - need to transition to Searching if self.last_search_text.as_deref() == Some(search_text) { - return; + if matches!(self.search_state, MentionSearchState::Searching { .. }) { + return; // Already searching with same text, skip + } + // In WaitingForMembers with same text -> need to start search now that members arrived } self.last_search_text = Some(search_text.to_string()); - let 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 @@ -1272,9 +1299,8 @@ impl MentionableTextInput { 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 available, continue to search + // (self.members_available already synced at function start) members.clone() } _ => { @@ -1284,7 +1310,7 @@ impl MentionableTextInput { ); self.cancel_active_search(); - self.members_available = false; + // Note: self.members_available already set to false at function start self.members_sync_pending = true; if !already_waiting { @@ -1329,7 +1355,7 @@ impl MentionableTextInput { let cancel_token = Arc::new(AtomicBool::new(false)); self.search_state = MentionSearchState::Searching { trigger_position: trigger_pos, - _search_text: search_text.to_string(), + search_text: search_text.to_string(), receiver, accumulated_results: Vec::new(), search_id, diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 25e3617f..66662e13 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -25,7 +25,8 @@ use tokio::{ sync::{mpsc::{Receiver, Sender, UnboundedReceiver, UnboundedSender}, watch, Notify}, task::JoinHandle, time::error::Elapsed, }; use url::Url; -use std::{cmp::{max, min}, collections::{BTreeMap, BTreeSet}, future::Future, iter::Peekable, ops::{Deref, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; +use std::{cmp::{max, min}, collections::{BTreeMap, BTreeSet}, future::Future, hash::{Hash, Hasher}, iter::Peekable, ops::{Deref, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; +use std::collections::hash_map::DefaultHasher; use std::io; use crate::{ app::AppStateAction, @@ -35,6 +36,7 @@ use crate::{ home::{ invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewRateLimitResponse, LinkPreviewDataNonNumeric}, room_screen::TimelineUpdate, rooms_list::{self, enqueue_rooms_list_update, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate}, rooms_list_header::RoomsListHeaderAction }, + room::member_search::precompute_member_sort, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{is_logout_in_progress, logout_with_state_machine, LogoutConfig}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, load_app_state, ClientSessionPersisted}, @@ -664,11 +666,27 @@ async fn async_worker( 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:?}"); + let mut hasher = DefaultHasher::new(); + for member in &members { + member.user_id().hash(&mut hasher); + if let Some(name) = member.display_name() { + name.hash(&mut hasher); + } + } + let digest = hasher.finish(); + let mut digests = ROOM_MEMBERS_DIGESTS.lock().unwrap(); + let previous = digests.get(&room_id).copied(); + if previous != Some(digest) { + digests.insert(room_id.clone(), digest); + log!("Fetched {count} members for room {room_id} after sync."); + let sort = precompute_member_sort(&members); + if let Err(err) = sender.send(TimelineUpdate::RoomMembersListFetched { members, sort, is_local_fetch: false }) { + warning!("Failed to send RoomMembersListFetched update for room {room_id}: {err:?}"); + } else { + SignalToUI::set_ui_signal(); + } } else { - SignalToUI::set_ui_signal(); + log!("Fetched {count} members for room {room_id} after sync (no change, skipping RoomMembersListFetched)."); } } Err(err) => { @@ -759,8 +777,11 @@ async fn async_worker( let send_update = |members: Vec, source: &str| { log!("{} {} members for room {}", source, members.len(), room_id); + let sort = precompute_member_sort(&members); sender.send(TimelineUpdate::RoomMembersListFetched { - members + members, + sort, + is_local_fetch: true, }).unwrap(); SignalToUI::set_ui_signal(); }; @@ -1657,6 +1678,7 @@ impl Drop for JoinedRoomDetails { /// Information about all joined rooms that our client currently know about. static ALL_JOINED_ROOMS: Mutex> = Mutex::new(BTreeMap::new()); +static ROOM_MEMBERS_DIGESTS: Mutex> = Mutex::new(BTreeMap::new()); /// The logged-in Matrix client, which can be freely and cheaply cloned. static CLIENT: Mutex> = Mutex::new(None); From 8d29838b3c7ea618f1aa10010999473347456cd5 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Sun, 23 Nov 2025 02:24:31 +0800 Subject: [PATCH 09/20] fixed logout-login bug --- src/app.rs | 5 +- src/home/room_screen.rs | 54 ++++++++++++++-- src/shared/mentionable_text_input.rs | 92 ++++++++++++++++++++++++++-- src/sliding_sync.rs | 48 +++++++++++++++ 4 files changed, 186 insertions(+), 13 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3eff1c71..a118ce28 100644 --- a/src/app.rs +++ b/src/app.rs @@ -240,12 +240,13 @@ impl MatchEvent for App { } if let Some(LogoutAction::ClearAppState { on_clear_appstate }) = action.downcast_ref() { - // Clear user profile cache, invited_rooms timeline states + // Clear user profile cache, invited_rooms timeline states clear_all_app_state(cx); // Reset all app state to its default. self.app_state = Default::default(); on_clear_appstate.notify_one(); - continue; + // Don't continue here - let the action propagate to child widgets (e.g., RoomScreen) + // so they can reset their state as well } if let Some(LoginAction::LoginSuccess) = action.downcast_ref() { diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index c7f254b7..4682f9af 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -566,6 +566,8 @@ pub struct RoomScreen { #[rust] is_loaded: bool, /// Whether or not all rooms have been loaded (received from the homeserver). #[rust] all_rooms_loaded: bool, + /// Room to reload after login (saved during logout to restore user's view) + #[rust] pending_room_to_reload: Option<(OwnedRoomId, String)>, } impl Drop for RoomScreen { fn drop(&mut self) { @@ -666,6 +668,42 @@ impl Widget for RoomScreen { self.handle_message_actions(cx, actions, &portal_list, &loading_pane); for action in actions { + // Handle logout: clear this RoomScreen's state so it will be reinitialized + // just like a first-time login when the user logs back in. + // We do NOT call show_timeline() here because the Matrix client has been cleared, + // so any requests would fail. Instead, we just clear the state and let the normal + // flow reinitialize it when the user interacts with the room after logging back in. + if let Some(crate::logout::logout_confirm_modal::LogoutAction::ClearAppState { .. }) = action.downcast_ref() { + if let Some(tl) = self.tl_state.take() { + log!("RoomScreen: clearing tl_state for room {} due to logout", tl.room_id); + } + // Save room_id for reloading after login, then clear state + let saved_room_id = self.room_id.clone(); + let saved_room_name = self.room_name.clone(); + + self.room_id = None; + self.room_name = String::new(); + self.is_loaded = false; + self.all_rooms_loaded = false; + + // Store the room to reload after login + if let Some(room_id) = saved_room_id { + self.pending_room_to_reload = Some((room_id, saved_room_name)); + log!("RoomScreen: saved room to reload after login"); + } + + log!("RoomScreen: fully reset state due to logout"); + // Don't return - allow other actions to be processed + } + + // Handle login success: reload the room if we were displaying one before logout + if let Some(crate::login::login_screen::LoginAction::LoginSuccess) = action.downcast_ref() { + if let Some((room_id, room_name)) = self.pending_room_to_reload.take() { + log!("RoomScreen: reloading room {} after successful login", room_id); + self.set_displayed_room(cx, room_id, room_name); + } + } + // Handle actions related to restoring the previously-saved state of rooms. if let Some(AppStateAction::RoomLoadedSuccessfully(room_id)) = action.downcast_ref() { if self.room_id.as_ref().is_some_and(|r| r == room_id) { @@ -816,14 +854,16 @@ impl Widget for RoomScreen { is_direct_room, } } else if let Some(room_id) = self.room_id.clone() { - // Fallback case: we have a room_id but no tl_state yet + // Fallback case: we have a room_id but no tl_state yet. + // This happens after logout clears tl_state but before show_timeline() is called again. + // Set room_members_sync_pending to true to show loading animation in MentionableTextInput. let is_direct_room = Self::is_direct_room(cx, &room_id); RoomScreenProps { room_screen_widget_uid, room_id, room_members: None, room_members_sort: None, - room_members_sync_pending: false, + room_members_sync_pending: true, room_display_name: None, room_avatar_url: None, is_direct_room, @@ -1377,12 +1417,14 @@ impl RoomScreen { tl.room_members = Some(Arc::clone(&members)); tl.room_members_sort = Some(Arc::new(sort_data)); - // For non-sync requests (like GetRoomMembers), there won't be a RoomMembersSynced event - // So we should clear the sync pending flag if we have members and we're not actually syncing - // This fixes the issue where local cache lookups would leave the loading indicator on + // For local fetches, check if remote sync is still in progress. + // If remote sync is ongoing, keep sync_in_progress=true to show loading animation. + // This prevents showing incomplete local cache (e.g., after logout) as final results. let sync_in_progress = if is_local_fetch { - false + // Local fetch: only clear sync_in_progress if no remote sync is pending + tl.room_members_sync_pending } else { + // Remote fetch: use the actual sync pending state tl.room_members_sync_pending }; diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index ef2b806e..d0cb7dbf 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -389,6 +389,9 @@ pub struct MentionableTextInput { /// Tracks whether we have a populated member list to avoid showing empty-state too early #[rust] members_available: bool, + /// Last known member count for the current room (used to detect refreshed data) + #[rust] + last_member_count: usize, /// Current state of the mention search functionality #[rust] search_state: MentionSearchState, @@ -431,13 +434,18 @@ impl Widget for MentionableTextInput { // 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 = { + let (scope_room_id, scope_member_count) = { 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() + let member_count = room_props + .room_members + .as_ref() + .map(|members| members.len()) + .unwrap_or(0); + (room_props.room_id.clone(), member_count) }; // Check search channel on every frame if we're searching @@ -542,6 +550,15 @@ impl Widget for MentionableTextInput { self.is_searching() ); + // Save old sync state to detect when remote sync completes + let was_sync_pending = self.members_sync_pending; + let previous_member_count = self.last_member_count; + let current_member_count = scope_member_count; + let member_count_changed = current_member_count != previous_member_count; + + // Track latest count so we can spot updates even while sync is in-flight + self.last_member_count = current_member_count; + self.members_sync_pending = *sync_in_progress; self.members_available = *has_members; @@ -561,16 +578,57 @@ impl Widget for MentionableTextInput { }; if let Some((is_waiting, search_text)) = action { + let member_set_updated = member_count_changed + && matches!(self.search_state, MentionSearchState::Searching { .. }); + if is_waiting { log!(" → Members loaded, resuming search with saved text='{}'", search_text); self.last_search_text = None; self.update_user_list(cx, &search_text, scope); } else { - log!(" → Already searching, updating UI with search_text='{}'", search_text); - self.update_ui_with_results(cx, scope, &search_text); + // Already in Searching state + // Check if remote sync just completed or member set changed - need to re-search with full member list + if member_set_updated { + log!( + " → Member list changed ({} -> {}), restarting search with text='{}'", + previous_member_count, + current_member_count, + search_text + ); + self.last_search_text = None; + self.update_user_list(cx, &search_text, scope); + } else if !*sync_in_progress && was_sync_pending { + log!(" → Remote sync completed while searching, re-searching with full member list, text='{}'", search_text); + self.last_search_text = None; + self.update_user_list(cx, &search_text, scope); + } else { + log!(" → Already searching, updating UI with search_text='{}'", search_text); + self.update_ui_with_results(cx, scope, &search_text); + } } } else { - log!(" → Not in searching state, ignoring member load"); + // Not in WaitingForMembers or Searching state + // Check if remote sync just completed - if so, refresh UI if there's an active mention trigger + if !*sync_in_progress && was_sync_pending { + log!(" → Remote sync just completed while not searching, checking for active mention trigger"); + // Check if there's currently an active mention trigger in the text + let text = self.cmd_text_input.text_input_ref().text(); + let cursor_pos = self.cmd_text_input.text_input_ref() + .borrow() + .map_or(0, |p| p.cursor().index); + + if let Some(_trigger_pos) = self.find_mention_trigger_position(&text, cursor_pos) { + // Extract search text and refresh UI + let search_text = self.cmd_text_input.search_text().to_lowercase(); + log!(" → Found active mention trigger, refreshing with text='{}'", search_text); + self.last_search_text = None; + self.update_user_list(cx, &search_text, scope); + } else { + log!(" → No active mention trigger found, sync complete"); + } + } else { + log!(" → Not in searching state, ignoring member load"); + } } } else if self.is_searching() { // Still no members returned yet; keep showing loading indicator. @@ -1229,6 +1287,29 @@ impl MentionableTextInput { items_added += user_items_added; } + // If remote sync is still in progress, add loading indicator after results + // This gives visual feedback that more members may be loading + // IMPORTANT: Don't call show_loading_indicator here as it calls clear_items() + // which would remove the user list we just added + if room_props.room_members_sync_pending { + log!("Remote sync still pending, adding loading indicator after partial results"); + + // Add loading indicator widget without clearing existing items + if let Some(ptr) = self.loading_indicator { + let loading_item = WidgetRef::new_from_ptr(cx, Some(ptr)); + self.cmd_text_input.add_item(loading_item.clone()); + self.loading_indicator_ref = Some(loading_item.clone()); + + // Start the loading animation + loading_item + .bouncing_dots(id!(loading_animation)) + .start_animation(cx); + cx.new_next_frame(); + + items_added += 1; + } + } + // Update popup visibility based on whether we have items self.update_popup_visibility(cx, items_added > 0); @@ -1586,6 +1667,7 @@ impl MentionableTextInput { // Mark members as unavailable until we fetch them again self.members_available = false; + self.last_member_count = 0; self.loading_indicator_ref = None; // Clear list items diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 66662e13..f10b8059 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -3332,6 +3332,39 @@ pub fn shutdown_background_tasks() { } pub async fn clean_app_state(config: &LogoutConfig) -> Result<()> { + // Get the database path before clearing CLIENT + // We need this to delete the Matrix SDK database after logout + let db_path_to_delete = if let Some(user_id) = current_user_id() { + use crate::persistence::session_file_path; + let session_file = session_file_path(&user_id); + if session_file.exists() { + match tokio::fs::read_to_string(&session_file).await { + Ok(serialized_session) => { + match serde_json::from_str::(&serialized_session) { + Ok(session) => { + log!("Will delete Matrix SDK database at: {:?}", session.client_session.db_path); + Some(session.client_session.db_path) + } + Err(e) => { + log!("Failed to parse session file during logout: {}", e); + None + } + } + } + Err(e) => { + log!("Failed to read session file during logout: {}", e); + None + } + } + } else { + log!("Session file not found during logout"); + None + } + } else { + log!("No current user ID found during logout"); + None + }; + // Clear resources normally, allowing them to be properly dropped // This prevents memory leaks when users logout and login again without closing the app CLIENT.lock().unwrap().take(); @@ -3346,6 +3379,21 @@ pub async fn clean_app_state(config: &LogoutConfig) -> Result<()> { IGNORED_USERS.lock().unwrap().clear(); ALL_JOINED_ROOMS.lock().unwrap().clear(); + // Delete the Matrix SDK database directory to ensure fresh state on next login + // This prevents stale cached data (like old room members) from appearing after re-login + if let Some(db_path) = db_path_to_delete { + match tokio::fs::remove_dir_all(&db_path).await { + Ok(_) => { + log!("Successfully deleted Matrix SDK database at: {:?}", db_path); + } + Err(e) => { + // Don't fail logout if database deletion fails + // Just log the error and continue + log!("Warning: Failed to delete Matrix SDK database at {:?}: {}", db_path, e); + } + } + } + let on_clear_appstate = Arc::new(Notify::new()); Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); From 89decb1873e5d8fda9cd44e2f0bcd77126b9a075 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Mon, 24 Nov 2025 13:40:20 +0800 Subject: [PATCH 10/20] refactor mentionable text input --- src/shared/mentionable_text_input.rs | 159 +++++++++++++++------------ 1 file changed, 88 insertions(+), 71 deletions(-) diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index d0cb7dbf..155ecf9c 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -386,12 +386,6 @@ pub struct MentionableTextInput { /// Whether the current user can notify everyone in the room (@room mention) #[rust] can_notify_room: bool, - /// Tracks whether we have a populated member list to avoid showing empty-state too early - #[rust] - members_available: bool, - /// Last known member count for the current room (used to detect refreshed data) - #[rust] - last_member_count: usize, /// Current state of the mention search functionality #[rust] search_state: MentionSearchState, @@ -404,13 +398,21 @@ pub struct MentionableTextInput { /// 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, - /// Flag to track if popup cleanup is pending after focus loss + /// Cached text analysis to avoid repeated grapheme parsing + /// Format: (text, graphemes_as_strings, byte_positions) + #[rust] + cached_text_analysis: Option<(String, Vec, Vec)>, + /// Last known member count - used ONLY for change detection (not rendering) + /// Rendering always uses props as source of truth + #[rust] + last_member_count: usize, + /// Last known sync pending state - used ONLY for change detection (not rendering) + #[rust] + last_sync_pending: bool, + /// Whether a deferred popup cleanup is pending after focus loss #[rust] pending_popup_cleanup: bool, } @@ -439,7 +441,6 @@ impl Widget for MentionableTextInput { .props .get::() .expect("RoomScreenProps should be available in scope for MentionableTextInput"); - self.members_sync_pending = room_props.room_members_sync_pending; let member_count = room_props .room_members .as_ref() @@ -457,6 +458,13 @@ impl Widget for MentionableTextInput { } } } + // Handle deferred cleanup after focus loss + if let Event::NextFrame(_) = event { + if self.pending_popup_cleanup { + self.pending_popup_cleanup = false; + self.close_mention_popup(cx); + } + } if let Event::Actions(actions) = event { let text_input_ref = self.cmd_text_input.text_input_ref(); @@ -469,17 +477,9 @@ impl Widget for MentionableTextInput { // Handle item selection from mention popup if let Some(selected) = self.cmd_text_input.item_selected(actions) { - // Clear pending popup cleanup since we're processing a selection - self.pending_popup_cleanup = false; self.on_user_selected(cx, scope, selected); } - // If we had a pending cleanup but no selection occurred, do the cleanup now - if self.pending_popup_cleanup { - self.pending_popup_cleanup = false; - self.close_mention_popup(cx); - } - // Handle build items request if self.cmd_text_input.should_build_items(actions) { if has_focus { @@ -550,19 +550,26 @@ impl Widget for MentionableTextInput { self.is_searching() ); - // Save old sync state to detect when remote sync completes - let was_sync_pending = self.members_sync_pending; + // CRITICAL: Use locally stored previous state for change detection + // (not from props, which is already the new state in the same frame) let previous_member_count = self.last_member_count; + let was_sync_pending = self.last_sync_pending; + + // Current state: always read from current frame's props to get latest data let current_member_count = scope_member_count; - let member_count_changed = current_member_count != previous_member_count; + let current_sync_pending = *sync_in_progress; - // Track latest count so we can spot updates even while sync is in-flight - self.last_member_count = current_member_count; + // Detect actual changes + let member_count_changed = current_member_count != previous_member_count + && current_member_count > 0 + && previous_member_count > 0; + let sync_just_completed = !current_sync_pending && was_sync_pending; - self.members_sync_pending = *sync_in_progress; - self.members_available = *has_members; + // Update local state for next comparison + self.last_member_count = current_member_count; + self.last_sync_pending = current_sync_pending; - if self.members_available { + if *has_members { // CRITICAL FIX: Use saved state instead of reading from text input // Reading from text input causes race condition (text may be empty when members arrive) // Extract needed values first to avoid borrow checker issues @@ -597,7 +604,7 @@ impl Widget for MentionableTextInput { ); self.last_search_text = None; self.update_user_list(cx, &search_text, scope); - } else if !*sync_in_progress && was_sync_pending { + } else if sync_just_completed { log!(" → Remote sync completed while searching, re-searching with full member list, text='{}'", search_text); self.last_search_text = None; self.update_user_list(cx, &search_text, scope); @@ -609,7 +616,7 @@ impl Widget for MentionableTextInput { } else { // Not in WaitingForMembers or Searching state // Check if remote sync just completed - if so, refresh UI if there's an active mention trigger - if !*sync_in_progress && was_sync_pending { + if sync_just_completed { log!(" → Remote sync just completed while not searching, checking for active mention trigger"); // Check if there's currently an active mention trigger in the text let text = self.cmd_text_input.text_input_ref().text(); @@ -643,21 +650,18 @@ impl Widget for MentionableTextInput { } } - // Close popup if focus is lost while searching - // However, don't reset search state immediately to allow pending item selection events to process - // This fixes the issue where mouse clicks would clear state before the selection could be handled + // Close popup and clean up search state if focus is lost while searching + // This prevents background search tasks from continuing when user is no longer interested if !has_focus && self.is_searching() { - // Only close the visual popup, but keep the search state for a brief moment - // to allow any pending selection events to be processed let popup = self.cmd_text_input.view(id!(popup)); popup.set_visible(cx, false); - // Mark that we should clean up state after selection is processed - // The actual cleanup will happen in the next event cycle if no selection occurs self.pending_popup_cleanup = true; } } // Check if we were waiting for members and they're now available + // When members arrive, always update regardless of focus state + // update_user_list will handle popup visibility based on current focus if let MentionSearchState::WaitingForMembers { trigger_position: _, pending_search_text, @@ -667,18 +671,11 @@ impl Widget for MentionableTextInput { .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() { - 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 = pending_search_text.clone(); - self.update_user_list(cx, &search_text, scope); - } + let search_text = pending_search_text.clone(); + self.update_user_list(cx, &search_text, scope); } } } @@ -932,9 +929,20 @@ impl MentionableTextInput { } /// Update popup visibility and layout based on current state - fn update_popup_visibility(&mut self, cx: &mut Cx, has_items: bool) { + fn update_popup_visibility(&mut self, cx: &mut Cx, scope: &mut Scope, has_items: bool) { let popup = self.cmd_text_input.view(id!(popup)); + // Get current state from props + let room_props = scope + .props + .get::() + .expect("RoomScreenProps should be available in scope"); + let members_sync_pending = room_props.room_members_sync_pending; + let members_available = room_props + .room_members + .as_ref() + .is_some_and(|m| !m.is_empty()); + match &self.search_state { MentionSearchState::Idle | MentionSearchState::JustCancelled => { // Not in search mode, hide popup @@ -956,10 +964,10 @@ impl MentionableTextInput { 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 { + if 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 { + } else if members_available { // Search completed with no results even though we have members. self.show_no_matches_indicator(cx); } else { @@ -983,9 +991,6 @@ impl MentionableTextInput { // This is good practice to maintain signature consistency with other methods // and allow for future scope-based enhancements - // Clear pending popup cleanup since we're processing a selection - self.pending_popup_cleanup = false; - let text_input_ref = self.cmd_text_input.text_input_ref(); let current_text = text_input_ref.text(); let head = text_input_ref.borrow().map_or(0, |p| p.cursor().index); @@ -1311,7 +1316,7 @@ impl MentionableTextInput { } // Update popup visibility based on whether we have items - self.update_popup_visibility(cx, items_added > 0); + self.update_popup_visibility(cx, scope, items_added > 0); // Force immediate redraw to ensure UI updates are visible cx.redraw_all(); @@ -1319,22 +1324,18 @@ impl MentionableTextInput { /// Updates the mention suggestion list based on search fn update_user_list(&mut self, cx: &mut Cx, search_text: &str, scope: &mut Scope) { - // CRITICAL FIX: Get room_props FIRST to read real-time member state - // This avoids timing issues where self.members_available is stale + // Get room_props to read real-time member state from props (single source of truth) let room_props = scope .props .get::() .expect("RoomScreenProps should be available in scope for MentionableTextInput"); - // Immediately sync local state from props (don't rely on async actions) let has_members_in_props = room_props.room_members .as_ref() .is_some_and(|m| !m.is_empty()); - self.members_available = has_members_in_props; - self.members_sync_pending = room_props.room_members_sync_pending; - log!("update_user_list: search_text='{}' has_members_in_props={} self.members_available={} search_state={:?}", - search_text, has_members_in_props, self.members_available, self.search_state); + log!("update_user_list: search_text='{}' has_members_in_props={} search_state={:?}", + search_text, has_members_in_props, self.search_state); // Get trigger position from current state (if in searching mode) let trigger_pos = match &self.search_state { @@ -1381,7 +1382,6 @@ impl MentionableTextInput { let cached_members = match &room_props.room_members { Some(members) if !members.is_empty() => { // Members available, continue to search - // (self.members_available already synced at function start) members.clone() } _ => { @@ -1391,8 +1391,6 @@ impl MentionableTextInput { ); self.cancel_active_search(); - // Note: self.members_available already set to false at function start - self.members_sync_pending = true; if !already_waiting { submit_async_request(MatrixRequest::GetRoomMembers { @@ -1464,17 +1462,36 @@ impl MentionableTextInput { } /// Detects valid mention trigger positions in text - fn find_mention_trigger_position(&self, text: &str, cursor_pos: usize) -> Option { + fn find_mention_trigger_position(&mut self, text: &str, cursor_pos: usize) -> Option { if cursor_pos == 0 { return None; } + // Check cache and rebuild if text changed (performance optimization) + let (text_graphemes_owned, byte_positions) = if let Some((cached_text, cached_graphemes, cached_positions)) = &self.cached_text_analysis { + if cached_text == text { + // Cache hit - use cached data + (cached_graphemes.clone(), cached_positions.clone()) + } else { + // Cache miss - rebuild and update cache + let graphemes_owned: Vec = text.graphemes(true).map(|s| s.to_string()).collect(); + let positions = utils::build_grapheme_byte_positions(text); + self.cached_text_analysis = Some((text.to_string(), graphemes_owned.clone(), positions.clone())); + (graphemes_owned, positions) + } + } else { + // No cache - build and cache + let graphemes_owned: Vec = text.graphemes(true).map(|s| s.to_string()).collect(); + let positions = utils::build_grapheme_byte_positions(text); + self.cached_text_analysis = Some((text.to_string(), graphemes_owned.clone(), positions.clone())); + (graphemes_owned, positions) + }; + + // Convert owned strings to slices for processing + let text_graphemes: Vec<&str> = text_graphemes_owned.iter().map(|s| s.as_str()).collect(); + // Use utility function to convert byte position to grapheme index let cursor_grapheme_idx = utils::byte_index_to_grapheme_index(text, cursor_pos); - let text_graphemes: Vec<&str> = text.graphemes(true).collect(); - - // Build byte position mapping to facilitate conversion back to byte positions - let byte_positions = utils::build_grapheme_byte_positions(text); // Simple logic: trigger when cursor is immediately after @ symbol // Only trigger if @ is preceded by whitespace or beginning of text @@ -1663,12 +1680,12 @@ impl MentionableTextInput { // Reset last search text to allow new searches self.last_search_text = None; self.search_results_pending = false; - self.members_sync_pending = false; + self.loading_indicator_ref = None; - // Mark members as unavailable until we fetch them again - self.members_available = false; + // Reset change detection state self.last_member_count = 0; - self.loading_indicator_ref = None; + self.last_sync_pending = false; + self.pending_popup_cleanup = false; // Clear list items self.cmd_text_input.clear_items(); From ac5414d0ee3cbc15d8572cc075a7a4626f089e2e Mon Sep 17 00:00:00 2001 From: AlexZ Date: Mon, 24 Nov 2025 17:07:24 +0800 Subject: [PATCH 11/20] refactor mentionable text input --- src/shared/mentionable_text_input.rs | 69 ++++++++++++++++++++++------ src/sliding_sync.rs | 13 ++++++ 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 155ecf9c..8f991009 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -462,7 +462,16 @@ impl Widget for MentionableTextInput { if let Event::NextFrame(_) = event { if self.pending_popup_cleanup { self.pending_popup_cleanup = false; - self.close_mention_popup(cx); + + // Only close if input still doesn't have focus and we're not actively searching + let text_input_ref = self.cmd_text_input.text_input_ref(); + let text_input_area = text_input_ref.area(); + let has_focus = cx.has_key_focus(text_input_area); + + // If user refocused or is actively typing/searching, don't cleanup + if !has_focus && !self.is_searching() { + self.close_mention_popup(cx); + } } } @@ -555,8 +564,12 @@ impl Widget for MentionableTextInput { let previous_member_count = self.last_member_count; let was_sync_pending = self.last_sync_pending; - // Current state: always read from current frame's props to get latest data - let current_member_count = scope_member_count; + // Current state: read fresh props to avoid stale snapshot from handle_event entry + let current_member_count = scope + .props + .get::() + .map(|p| p.room_members.as_ref().map(|m| m.len()).unwrap_or(0)) + .unwrap_or(scope_member_count); let current_sync_pending = *sync_in_progress; // Detect actual changes @@ -643,7 +656,11 @@ impl Widget for MentionableTextInput { 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); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } } } } @@ -656,6 +673,8 @@ impl Widget for MentionableTextInput { let popup = self.cmd_text_input.view(id!(popup)); popup.set_visible(cx, false); self.pending_popup_cleanup = true; + // Guarantee cleanup executes even if search completes and stops requesting frames + cx.new_next_frame(); } } @@ -953,7 +972,11 @@ impl MentionableTextInput { // 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); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } } MentionSearchState::Searching { accumulated_results, @@ -962,7 +985,11 @@ impl MentionableTextInput { 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); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } } else if accumulated_results.is_empty() { if members_sync_pending || self.search_results_pending { // Still fetching either member list or background search results. @@ -975,11 +1002,19 @@ impl MentionableTextInput { self.show_loading_indicator(cx); } popup.set_visible(cx, true); - self.cmd_text_input.text_input_ref().set_key_focus(cx); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } } else { // Has accumulated results but no items (should not happen) popup.set_visible(cx, true); - self.cmd_text_input.text_input_ref().set_key_focus(cx); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } } } } @@ -1414,12 +1449,16 @@ impl MentionableTextInput { } }; - // We have cached members, ensure popup is visible and focused + // We have cached members, ensure popup is visible 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); + // Only restore focus if input currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if cx.has_key_focus(text_input_area) { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } // Create a new channel for this search let (sender, receiver) = std::sync::mpsc::channel(); @@ -1594,8 +1633,9 @@ impl MentionableTextInput { // This avoids conflicts with list = { height: Fill } popup.set_visible(cx, true); - // Maintain text input focus - if self.is_searching() { + // Maintain text input focus only if it currently has focus + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if self.is_searching() && cx.has_key_focus(text_input_area) { self.cmd_text_input.text_input_ref().set_key_focus(cx); } } @@ -1625,8 +1665,9 @@ impl MentionableTextInput { // Let popup auto-size based on content popup.apply_over(cx, live! { height: Fit }); - // Maintain text input focus so user can continue typing - if self.is_searching() { + // Maintain text input focus so user can continue typing, but only if currently focused + let text_input_area = self.cmd_text_input.text_input_ref().area(); + if self.is_searching() && cx.has_key_focus(text_input_area) { self.cmd_text_input.text_input_ref().set_key_focus(cx); } } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index f10b8059..e9fb8a66 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -672,6 +672,19 @@ async fn async_worker( if let Some(name) = member.display_name() { name.hash(&mut hasher); } + // Include avatar URL in hash to detect avatar changes + if let Some(avatar) = member.avatar_url() { + avatar.as_str().hash(&mut hasher); + } + // Include power level role in hash to detect permission changes + // This ensures mention list re-sorts when power levels change + use matrix_sdk::room::RoomMemberRole; + let power_rank: u8 = match member.suggested_role_for_power_level() { + RoomMemberRole::Administrator | RoomMemberRole::Creator => 0, + RoomMemberRole::Moderator => 1, + RoomMemberRole::User => 2, + }; + power_rank.hash(&mut hasher); } let digest = hasher.finish(); let mut digests = ROOM_MEMBERS_DIGESTS.lock().unwrap(); From 75dc668ac6dd00b9c11cdfb62910388d3ac913cd Mon Sep 17 00:00:00 2001 From: AlexZ Date: Tue, 25 Nov 2025 14:50:45 +0800 Subject: [PATCH 12/20] Deduplicate member list updates by hashing avatar/role and hardening digest locking/sends in sliding sync --- src/sliding_sync.rs | 76 +++++++++++++++++++++++++++++++-------------- 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index e9fb8a66..016c3d8d 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -666,28 +666,14 @@ async fn async_worker( match timeline.room().members(RoomMemberships::JOIN).await { Ok(members) => { let count = members.len(); - let mut hasher = DefaultHasher::new(); - for member in &members { - member.user_id().hash(&mut hasher); - if let Some(name) = member.display_name() { - name.hash(&mut hasher); + let digest = compute_members_digest(&members); + let mut digests = match ROOM_MEMBERS_DIGESTS.lock() { + Ok(lock) => lock, + Err(err) => { + warning!("ROOM_MEMBERS_DIGESTS lock poisoned for room {room_id}: {err}"); + return; } - // Include avatar URL in hash to detect avatar changes - if let Some(avatar) = member.avatar_url() { - avatar.as_str().hash(&mut hasher); - } - // Include power level role in hash to detect permission changes - // This ensures mention list re-sorts when power levels change - use matrix_sdk::room::RoomMemberRole; - let power_rank: u8 = match member.suggested_role_for_power_level() { - RoomMemberRole::Administrator | RoomMemberRole::Creator => 0, - RoomMemberRole::Moderator => 1, - RoomMemberRole::User => 2, - }; - power_rank.hash(&mut hasher); - } - let digest = hasher.finish(); - let mut digests = ROOM_MEMBERS_DIGESTS.lock().unwrap(); + }; let previous = digests.get(&room_id).copied(); if previous != Some(digest) { digests.insert(room_id.clone(), digest); @@ -790,13 +776,31 @@ async fn async_worker( let send_update = |members: Vec, source: &str| { log!("{} {} members for room {}", source, members.len(), room_id); + let digest = compute_members_digest(&members); + + let mut digests = match ROOM_MEMBERS_DIGESTS.lock() { + Ok(lock) => lock, + Err(err) => { + warning!("ROOM_MEMBERS_DIGESTS lock poisoned for room {room_id}: {err}"); + return; + } + }; + if digests.get(&room_id) == Some(&digest) { + log!("{source} members for room {room_id} (no change, skipping RoomMembersListFetched)."); + return; + } + digests.insert(room_id.clone(), digest); + let sort = precompute_member_sort(&members); - sender.send(TimelineUpdate::RoomMembersListFetched { + if let Err(err) = sender.send(TimelineUpdate::RoomMembersListFetched { members, sort, is_local_fetch: true, - }).unwrap(); - SignalToUI::set_ui_signal(); + }) { + warning!("Failed to send RoomMembersListFetched update for room {room_id}: {err:?}"); + } else { + SignalToUI::set_ui_signal(); + } }; if local_only { @@ -1693,6 +1697,30 @@ impl Drop for JoinedRoomDetails { static ALL_JOINED_ROOMS: Mutex> = Mutex::new(BTreeMap::new()); static ROOM_MEMBERS_DIGESTS: Mutex> = Mutex::new(BTreeMap::new()); +fn power_rank_for_member(member: &RoomMember) -> u8 { + use matrix_sdk::room::RoomMemberRole; + match member.suggested_role_for_power_level() { + RoomMemberRole::Administrator | RoomMemberRole::Creator => 0, + RoomMemberRole::Moderator => 1, + RoomMemberRole::User => 2, + } +} + +fn compute_members_digest(members: &[RoomMember]) -> u64 { + let mut hasher = DefaultHasher::new(); + for member in members { + member.user_id().hash(&mut hasher); + if let Some(name) = member.display_name() { + name.hash(&mut hasher); + } + if let Some(avatar) = member.avatar_url() { + avatar.as_str().hash(&mut hasher); + } + power_rank_for_member(member).hash(&mut hasher); + } + hasher.finish() +} + /// The logged-in Matrix client, which can be freely and cheaply cloned. static CLIENT: Mutex> = Mutex::new(None); From bb5e83e92490055ee069b5d6b24079437bb8c1ba Mon Sep 17 00:00:00 2001 From: AlexZ Date: Tue, 25 Nov 2025 15:37:00 +0800 Subject: [PATCH 13/20] fixed conflict --- src/home/rooms_list.rs | 4 +-- src/shared/mentionable_text_input.rs | 42 +++++++++++++--------------- src/sliding_sync.rs | 2 +- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 8192fd6d..ef618435 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -828,7 +828,7 @@ impl RoomsList { } /// Returns a room's avatar and displayable name. - pub fn get_room_avatar_and_name(&self, room_id: &OwnedRoomId) -> Option<(RoomPreviewAvatar, Option)> { + pub fn get_room_avatar_and_name(&self, room_id: &OwnedRoomId) -> Option<(FetchedRoomAvatar, Option)> { self.all_joined_rooms.get(room_id) .map(|room_info| (room_info.avatar.clone(), room_info.room_name.clone())) .or_else(|| { @@ -1098,7 +1098,7 @@ impl RoomsListRef { } /// See [`RoomsList::get_room_avatar_and_name()`]. - pub fn get_room_avatar_and_name(&self, room_id: &OwnedRoomId) -> Option<(RoomPreviewAvatar, Option)> { + pub fn get_room_avatar_and_name(&self, room_id: &OwnedRoomId) -> Option<(FetchedRoomAvatar, Option)> { let inner = self.borrow()?; inner.get_room_avatar_and_name(room_id) } diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 6d38a3e4..64c62e07 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -647,7 +647,7 @@ impl Widget for MentionableTextInput { // 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)); + let popup = self.cmd_text_input.view(ids!(popup)); popup.set_visible(cx, true); // Only restore focus if input currently has focus let text_input_area = self.cmd_text_input.text_input_ref().area(); @@ -720,20 +720,16 @@ impl MentionableTextInput { id } - if members_are_empty && !self.members_loading { - // Members list is empty and we're not already showing loading - start loading state - self.members_loading = true; - self.show_loading_indicator(cx); - return true; - } else if !members_are_empty && self.members_loading { - // Members have been loaded, stop loading state - self.members_loading = false; - // Reset popup height to ensure proper calculation for user list - let popup = self.cmd_text_input.view(ids!(popup)); - popup.apply_over(cx, live! { height: Fit }); - } else if members_are_empty && self.members_loading { - // Still loading and members are empty - keep showing loading indicator - return true; + /// Get the current trigger position if in search mode + fn get_trigger_position(&self) -> Option { + match &self.search_state { + MentionSearchState::WaitingForMembers { + trigger_position, .. + } + | MentionSearchState::Searching { + trigger_position, .. + } => Some(*trigger_position), + _ => None, } } @@ -745,7 +741,7 @@ impl MentionableTextInput { /// Tries to add the `@room` mention item to the list of selectable popup mentions. /// /// Returns true if @room item was added to the list and will be displayed in the popup. - fn try_search_messages_mention_item( + fn try_add_room_mention_item( &mut self, cx: &mut Cx, search_text: &str, @@ -1026,7 +1022,7 @@ impl MentionableTextInput { let current_text = text_input_ref.text(); let head = text_input_ref.borrow().map_or(0, |p| p.cursor().index); - if let Some(start_idx) = self.current_mention_start_index { + if let Some(start_idx) = self.get_trigger_position() { let room_mention_label = selected.label(ids!(user_info.room_mention)); let room_mention_text = room_mention_label.text(); let room_user_id_text = selected.label(ids!(room_user_id)).text(); @@ -1294,7 +1290,7 @@ impl MentionableTextInput { let mut items_added = 0; // 4. Try to add @room mention item - let has_room_item = self.try_search_messages_mention_item(cx, search_text, room_props, is_desktop); + let has_room_item = self.try_add_room_mention_item(cx, search_text, room_props, is_desktop); if has_room_item { items_added += 1; } @@ -1338,7 +1334,7 @@ impl MentionableTextInput { // Start the loading animation loading_item - .bouncing_dots(id!(loading_animation)) + .bouncing_dots(ids!(loading_animation)) .start_animation(cx); cx.new_next_frame(); @@ -1446,8 +1442,8 @@ impl MentionableTextInput { }; // We have cached members, ensure popup is visible - let popup = self.cmd_text_input.view(id!(popup)); - let header_view = self.cmd_text_input.view(id!(popup.header_view)); + let popup = self.cmd_text_input.view(ids!(popup)); + let header_view = self.cmd_text_input.view(ids!(popup.header_view)); header_view.set_visible(cx, true); popup.set_visible(cx, true); // Only restore focus if input currently has focus @@ -1592,7 +1588,7 @@ impl MentionableTextInput { if let Some(ref existing_indicator) = self.loading_indicator_ref { // Already showing, just ensure animation is running existing_indicator - .bouncing_dots(id!(loading_animation)) + .bouncing_dots(ids!(loading_animation)) .start_animation(cx); cx.new_next_frame(); return; @@ -1612,7 +1608,7 @@ impl MentionableTextInput { // Now that the widget is in the UI tree, start the loading animation loading_item - .bouncing_dots(id!(loading_animation)) + .bouncing_dots(ids!(loading_animation)) .start_animation(cx); cx.new_next_frame(); diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 9a04f69d..a4e55257 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -3565,7 +3565,7 @@ pub fn shutdown_background_tasks() { } } -pub async fn clean_app_state(config: &LogoutConfig) -> Result<()> { +pub async fn clear_app_state(config: &LogoutConfig) -> Result<()> { // Get the database path before clearing CLIENT // We need this to delete the Matrix SDK database after logout let db_path_to_delete = if let Some(user_id) = current_user_id() { From df504c28cc1fadfb4c55bf368d4f518538407423 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Wed, 26 Nov 2025 04:22:20 +0800 Subject: [PATCH 14/20] Fix duplicate room tabs on restore and focus loss after mention selection --- src/home/main_desktop_ui.rs | 22 +++++++++++--- src/shared/mentionable_text_input.rs | 44 ++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 06fedf54..14e2b3db 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -119,14 +119,17 @@ impl MainDesktopUI { return; } - // If the room is already open, select (jump to) its existing tab - let room_id_as_live_id = LiveId::from_str(room.room_id().as_str()); - if self.open_rooms.contains_key(&room_id_as_live_id) { - dock.select_tab(cx, room_id_as_live_id); + // If the room is already open, select (jump to) its existing tab. + // We use `find_open_room_live_id` to look up by room_id, because the dock + // may store LiveIds with a prefix that differs from `LiveId::from_str(room_id)`. + if let Some(existing_live_id) = self.find_open_room_live_id(room.room_id()) { + dock.select_tab(cx, existing_live_id); self.most_recently_selected_room = Some(room); return; } + let room_id_as_live_id = LiveId::from_str(room.room_id().as_str()); + // Create a new tab for the room let (tab_bar, _pos) = dock.find_tab_bar_of_tab(id!(home_tab)).unwrap(); let (kind, name) = match &room { @@ -178,6 +181,17 @@ impl MainDesktopUI { self.most_recently_selected_room = Some(room); } + /// Finds the LiveId of an already-open room by its room_id. + /// + /// This is needed because the dock may store LiveIds with a prefix that differs + /// from the one generated by `LiveId::from_str(room_id)`. + fn find_open_room_live_id(&self, room_id: &OwnedRoomId) -> Option { + self.open_rooms + .iter() + .find(|(_, selected_room)| selected_room.room_id() == room_id) + .map(|(live_id, _)| *live_id) + } + /// Closes a tab in the dock and focuses on the latest open room. fn close_tab(&mut self, cx: &mut Cx, tab_id: LiveId) { let dock = self.view.dock(ids!(dock)); diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 64c62e07..706ea584 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -415,6 +415,9 @@ pub struct MentionableTextInput { /// Whether a deferred popup cleanup is pending after focus loss #[rust] pending_popup_cleanup: bool, + /// Whether focus should be restored in the next draw_walk cycle + #[rust] + pending_draw_focus_restore: bool, } impl Widget for MentionableTextInput { @@ -461,11 +464,11 @@ impl Widget for MentionableTextInput { // Handle deferred cleanup after focus loss if let Event::NextFrame(_) = event { if self.pending_popup_cleanup { + let text_input_ref = self.cmd_text_input.text_input_ref(); + let text_input_area = text_input_ref.area(); self.pending_popup_cleanup = false; // Only close if input still doesn't have focus and we're not actively searching - let text_input_ref = self.cmd_text_input.text_input_ref(); - let text_input_area = text_input_ref.area(); let has_focus = cx.has_key_focus(text_input_area); // If user refocused or is actively typing/searching, don't cleanup @@ -694,7 +697,30 @@ impl Widget for MentionableTextInput { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - self.cmd_text_input.draw_walk(cx, scope, walk) + let result = self.cmd_text_input.draw_walk(cx, scope, walk); + + // Restore focus after all child drawing is complete. + // This retries until focus is successfully restored, handling cases where + // finger_up events might steal focus after our initial restoration attempt. + if self.pending_draw_focus_restore { + let text_input_ref = self.cmd_text_input.text_input_ref(); + text_input_ref.set_key_focus(cx); + if let Some(mut ti) = text_input_ref.borrow_mut() { + ti.reset_blink_timer(cx); + } + // Check if we successfully got focus + let area = text_input_ref.area(); + if cx.has_key_focus(area) { + // Successfully restored focus, clear the flag + self.pending_draw_focus_restore = false; + } else { + // Focus restoration failed (likely due to finger_up event stealing focus) + // Keep the flag true and request another frame to retry + cx.new_next_frame(); + } + } + + result } } @@ -1074,13 +1100,17 @@ impl MentionableTextInput { self.cancel_active_search(); self.search_state = MentionSearchState::JustCancelled; + // Clear cleanup flag to prevent next-frame cleanup from interfering with focus + self.pending_popup_cleanup = false; self.close_mention_popup(cx); + // Schedule focus restoration for the draw cycle. + // This will retry until focus is successfully restored, handling cases where + // finger_up events might steal focus after our initial restoration attempt. + self.pending_draw_focus_restore = true; } /// Core text change handler that manages mention context fn handle_text_change(&mut self, cx: &mut Cx, scope: &mut Scope, text: String) { - log!("handle_text_change: text='{}' search_state={:?}", text, self.search_state); - // If search was just cancelled, clear the flag and don't re-trigger search if self.is_just_cancelled() { self.search_state = MentionSearchState::Idle; @@ -1744,7 +1774,9 @@ impl MentionableTextInput { // Ensure header view is reset to visible next time it's triggered // This will happen before update_user_list is called in handle_text_change - self.cmd_text_input.request_text_input_focus(); + // Note: Do NOT call request_text_input_focus() here. + // Focus restoration is handled solely via `pending_draw_focus_restore` in draw_walk + // to avoid race conditions between multiple focus mechanisms. self.cmd_text_input.redraw(cx); } From fccce8287f2e4b9bbbd6555d86c80a97bb9def93 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Wed, 10 Dec 2025 17:23:57 +0800 Subject: [PATCH 15/20] remove debug log --- .gitignore | 1 + src/shared/mentionable_text_input.rs | 67 +++++++++++----------------- src/sliding_sync.rs | 5 --- 3 files changed, 27 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index 4af4918a..8ce3af3c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ CLAUDE.md AGENTS.md proxychains.conf /specs +ai-docs/ diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 706ea584..fb5d9022 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -428,7 +428,15 @@ impl Widget for MentionableTextInput { if key_event.key_code == KeyCode::Escape { self.cancel_active_search(); self.search_state = MentionSearchState::JustCancelled; - self.close_mention_popup(cx); + + // UI cleanup only - do NOT call close_mention_popup() as it resets + // state to Idle via reset_search_state(), losing the JustCancelled marker + let popup = self.cmd_text_input.view(ids!(popup)); + popup.set_visible(cx, false); + self.cmd_text_input.clear_items(); + self.loading_indicator_ref = None; // Clear loading indicator + self.pending_popup_cleanup = false; // Prevent next frame from triggering cleanup + self.redraw(cx); return; // Don't process other events } @@ -547,14 +555,6 @@ impl Widget for MentionableTextInput { continue; } - log!( - "MentionableTextInput: RoomMembersLoaded room={} sync_in_progress={} has_members={} is_searching={}", - room_id, - sync_in_progress, - has_members, - self.is_searching() - ); - // CRITICAL: Use locally stored previous state for change detection // (not from props, which is already the new state in the same frame) let previous_member_count = self.last_member_count; @@ -578,6 +578,12 @@ impl Widget for MentionableTextInput { self.last_member_count = current_member_count; self.last_sync_pending = current_sync_pending; + // Skip processing if search was cancelled by ESC + // This prevents async callbacks from reopening the popup + if matches!(self.search_state, MentionSearchState::JustCancelled) { + continue; + } + if *has_members { // CRITICAL FIX: Use saved state instead of reading from text input // Reading from text input causes race condition (text may be empty when members arrive) @@ -598,27 +604,15 @@ impl Widget for MentionableTextInput { && matches!(self.search_state, MentionSearchState::Searching { .. }); if is_waiting { - log!(" → Members loaded, resuming search with saved text='{}'", search_text); self.last_search_text = None; self.update_user_list(cx, &search_text, scope); } else { // Already in Searching state // Check if remote sync just completed or member set changed - need to re-search with full member list - if member_set_updated { - log!( - " → Member list changed ({} -> {}), restarting search with text='{}'", - previous_member_count, - current_member_count, - search_text - ); - self.last_search_text = None; - self.update_user_list(cx, &search_text, scope); - } else if sync_just_completed { - log!(" → Remote sync completed while searching, re-searching with full member list, text='{}'", search_text); + if member_set_updated || sync_just_completed { self.last_search_text = None; self.update_user_list(cx, &search_text, scope); } else { - log!(" → Already searching, updating UI with search_text='{}'", search_text); self.update_ui_with_results(cx, scope, &search_text); } } @@ -626,24 +620,16 @@ impl Widget for MentionableTextInput { // Not in WaitingForMembers or Searching state // Check if remote sync just completed - if so, refresh UI if there's an active mention trigger if sync_just_completed { - log!(" → Remote sync just completed while not searching, checking for active mention trigger"); - // Check if there's currently an active mention trigger in the text let text = self.cmd_text_input.text_input_ref().text(); let cursor_pos = self.cmd_text_input.text_input_ref() .borrow() .map_or(0, |p| p.cursor().index); if let Some(_trigger_pos) = self.find_mention_trigger_position(&text, cursor_pos) { - // Extract search text and refresh UI let search_text = self.cmd_text_input.search_text().to_lowercase(); - log!(" → Found active mention trigger, refreshing with text='{}'", search_text); self.last_search_text = None; self.update_user_list(cx, &search_text, scope); - } else { - log!(" → No active mention trigger found, sync complete"); } - } else { - log!(" → Not in searching state, ignoring member load"); } } } else if self.is_searching() { @@ -1354,8 +1340,6 @@ impl MentionableTextInput { // IMPORTANT: Don't call show_loading_indicator here as it calls clear_items() // which would remove the user list we just added if room_props.room_members_sync_pending { - log!("Remote sync still pending, adding loading indicator after partial results"); - // Add loading indicator widget without clearing existing items if let Some(ptr) = self.loading_indicator { let loading_item = WidgetRef::new_from_ptr(cx, Some(ptr)); @@ -1387,13 +1371,6 @@ impl MentionableTextInput { .get::() .expect("RoomScreenProps should be available in scope for MentionableTextInput"); - let has_members_in_props = room_props.room_members - .as_ref() - .is_some_and(|m| !m.is_empty()); - - log!("update_user_list: search_text='{}' has_members_in_props={} search_state={:?}", - search_text, has_members_in_props, self.search_state); - // Get trigger position from current state (if in searching mode) let trigger_pos = match &self.search_state { MentionSearchState::WaitingForMembers { @@ -1725,8 +1702,16 @@ impl MentionableTextInput { } fn cancel_active_search(&mut self) { - if let MentionSearchState::Searching { cancel_token, .. } = &self.search_state { - cancel_token.store(true, Ordering::Relaxed); + match &self.search_state { + MentionSearchState::Searching { cancel_token, .. } => { + cancel_token.store(true, Ordering::Relaxed); + } + MentionSearchState::WaitingForMembers { .. } => { + // WaitingForMembers has no cancel_token, but we need to mark as cancelled. + // The state will be set to JustCancelled by the caller, which prevents + // RoomMembersLoaded from reopening the popup. + } + _ => {} } self.search_results_pending = false; } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index a4e55257..de037545 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -2396,11 +2396,6 @@ async fn update_room( if old_room.num_unread_messages != new_room.num_unread_messages || old_room.num_unread_mentions != new_room.num_unread_mentions { - log!("Updating room {}, unread messages {} --> {}, unread mentions {} --> {}", - new_room_id, - old_room.num_unread_messages, new_room.num_unread_messages, - old_room.num_unread_mentions, new_room.num_unread_mentions, - ); enqueue_rooms_list_update(RoomsListUpdate::UpdateNumUnreadMessages { room_id: new_room_id.clone(), unread_messages: UnreadMessageCount::Known(new_room.num_unread_messages), From a73dce131e46b762b13b66a400eff0742af405c0 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Fri, 12 Dec 2025 00:30:40 +0800 Subject: [PATCH 16/20] fixed @room bugs logout after re-login --- .gitignore | 1 + src/app.rs | 4 ++- src/room/member_search.rs | 16 +++++++++-- src/shared/mentionable_text_input.rs | 41 ++++++++++------------------ src/sliding_sync.rs | 11 ++++++++ 5 files changed, 43 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 8ce3af3c..66c84614 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ AGENTS.md proxychains.conf /specs ai-docs/ +.claude diff --git a/src/app.rs b/src/app.rs index ba95a337..c3a2c017 100644 --- a/src/app.rs +++ b/src/app.rs @@ -440,12 +440,14 @@ impl MatchEvent for App { } /// Clears all thread-local UI caches (user profiles, invited rooms, and timeline states). -/// The `cx` parameter ensures that these thread-local caches are cleared on the main UI thread, +/// The `cx` parameter ensures that these thread-local caches are cleared on the main UI thread, fn clear_all_app_state(cx: &mut Cx) { clear_user_profile_cache(cx); clear_all_invited_rooms(cx); clear_timeline_states(cx); clear_avatar_cache(cx); + // Clear room members digests to ensure members are re-fetched after re-login + crate::sliding_sync::clear_room_members_digests(); } impl AppMain for App { diff --git a/src/room/member_search.rs b/src/room/member_search.rs index 9e042000..99db6911 100644 --- a/src/room/member_search.rs +++ b/src/room/member_search.rs @@ -425,12 +425,14 @@ pub fn search_room_members_streaming_with_sort( cancel_token: Option>, ) { let current_user_id = current_user_id(); + let search_text_arc = Arc::new(search_text); + // Early exit if cancelled - send completion signal so UI doesn't wait indefinitely if is_cancelled(&cancel_token) { + let _ = send_search_update(&sender, &cancel_token, search_id, &search_text_arc, Vec::new(), true); return; } - let search_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; @@ -445,7 +447,11 @@ pub fn search_room_members_streaming_with_sort( cancel_ref, ) { Some(indices) => indices, - None => return, + None => { + // Cancelled during computation - send completion signal + let _ = send_search_update(&sender, &cancel_token, search_id, &search_text_arc, Vec::new(), true); + return; + } } } else { match compute_non_empty_search_indices( @@ -457,7 +463,11 @@ pub fn search_room_members_streaming_with_sort( cancel_ref, ) { Some(indices) => indices, - None => return, + None => { + // Cancelled during computation - send completion signal + let _ = send_search_update(&sender, &cancel_token, search_id, &search_text_arc, Vec::new(), true); + return; + } } }; diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index fb5d9022..266ea462 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -492,9 +492,6 @@ 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); @@ -534,8 +531,11 @@ impl Widget for MentionableTextInput { if &scope_room_id != room_id { continue; } + log!("PowerLevelsUpdated received: room_id={}, can_notify_room={}, scope_room_id={}", + room_id, can_notify_room, scope_room_id); if self.can_notify_room != *can_notify_room { + log!("Updating can_notify_room from {} to {}", self.can_notify_room, can_notify_room); self.can_notify_room = *can_notify_room; if self.is_searching() && has_focus { let search_text = @@ -1216,18 +1216,11 @@ impl MentionableTextInput { // 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() - }; - - if !results_for_ui.is_empty() { + if matches!( + &self.search_state, + MentionSearchState::Searching { accumulated_results, .. } + if !accumulated_results.is_empty() + ) { // Results are already sorted in member_search.rs and indices are unique let query = search_text .as_ref() @@ -1241,17 +1234,11 @@ impl MentionableTextInput { 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() { + if matches!( + &self.search_state, + MentionSearchState::Searching { accumulated_results, .. } + if accumulated_results.is_empty() + ) { // No user results, but still update UI (may show @room) let query = search_text .as_ref() @@ -1277,6 +1264,8 @@ impl MentionableTextInput { // Channel was closed - search completed or failed self.search_results_pending = false; self.handle_search_channel_closed(cx, scope); + // Stop checking - channel is closed, no more results will arrive + return false; } } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index de037545..febfc97f 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1710,6 +1710,17 @@ fn compute_members_digest(members: &[RoomMember]) -> u64 { hasher.finish() } +/// Clears the cached room members digests. +/// +/// This must be called during logout to ensure that after re-login, +/// the room members will be properly fetched and sent to the UI, +/// even if the member list hasn't changed. +pub fn clear_room_members_digests() { + if let Ok(mut digests) = ROOM_MEMBERS_DIGESTS.lock() { + digests.clear(); + } +} + /// The logged-in Matrix client, which can be freely and cheaply cloned. static CLIENT: Mutex> = Mutex::new(None); From f80f26d30932dd098f6aaa6cd9882b908c8d934f Mon Sep 17 00:00:00 2001 From: AlexZ Date: Fri, 12 Dec 2025 01:17:36 +0800 Subject: [PATCH 17/20] add docs comment and validate_path_within_app_data for db_path --- src/shared/mentionable_text_input.rs | 97 ++++++++++++++++++++- src/sliding_sync.rs | 122 +++++++++++++++++++++++++-- 2 files changed, 211 insertions(+), 8 deletions(-) diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 266ea462..ca82857b 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -1,5 +1,98 @@ -//! MentionableTextInput component provides text input with @mention capabilities -//! Can be used in any context where user mentions are needed (message input, editing) +//! MentionableTextInput component provides text input with @mention capabilities. +//! +//! Can be used in any context where user mentions are needed (message input, editing). +//! +//! # Architecture Overview +//! +//! This component uses a **state machine** pattern combined with **background thread execution** +//! to provide responsive @mention search functionality even in large rooms. +//! +//! ## State Machine +//! +//! The search functionality is driven by [`MentionSearchState`], which has four states: +//! +//! ```text +//! ┌──────────────────────────────────────────────────────────────────────────┐ +//! │ State Transitions │ +//! │ │ +//! │ ┌──────┐ user types @ ┌───────────────────┐ │ +//! │ │ Idle │ ─────────────────► │ WaitingForMembers │ (if no cached data) │ +//! │ └──────┘ └─────────┬─────────┘ │ +//! │ ▲ │ │ +//! │ │ │ members loaded │ +//! │ │ ▼ │ +//! │ │ ┌─────────────────────────────────────┐ │ +//! │ │ │ Searching │ │ +//! │ │ │ - receiver: channel for results │ │ +//! │ │ │ - accumulated_results: Vec │ │ +//! │ │ │ - cancel_token: Arc │ │ +//! │ │ └──────────────┬──────────────────────┘ │ +//! │ │ │ │ +//! │ │ ┌───────────────────┼───────────────────┐ │ +//! │ │ │ │ │ │ +//! │ │ ▼ ▼ ▼ │ +//! │ │ search user selects user presses │ +//! │ │ completes a mention ESC │ +//! │ │ │ │ │ │ +//! │ │ │ │ ▼ │ +//! │ │ │ │ ┌───────────────┐ │ +//! │ │ │ │ │ JustCancelled │ │ +//! │ │ │ │ └───────┬───────┘ │ +//! │ │ │ │ │ │ +//! │ └────┴───────────────────┴───────────────────┘ │ +//! │ reset to Idle │ +//! └──────────────────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! - **Idle**: Default state, no active search +//! - **WaitingForMembers**: Triggered @ detected, waiting for room member data to load +//! - **Searching**: Background search task running, receiving streaming results via channel +//! - **JustCancelled**: ESC pressed, prevents immediate re-trigger on next keystroke +//! +//! ## Background Thread Execution +//! +//! To keep the UI responsive during searches in large rooms, the actual member search +//! is offloaded to a background thread via [`cpu_worker::spawn_cpu_job`]: +//! +//! ```text +//! ┌─────────────────────┐ ┌─────────────────────┐ +//! │ UI Thread │ │ Background Thread │ +//! │ │ │ │ +//! │ update_user_list() │ │ │ +//! │ │ │ │ │ +//! │ ▼ │ │ │ +//! │ spawn_cpu_job() ───┼────────►│ SearchRoomMembers │ +//! │ │ │ │ │ │ +//! │ ▼ │ │ ▼ │ +//! │ cx.new_next_frame()│ │ search members... │ +//! │ │ │ │ │ │ +//! │ ▼ │ MPSC │ ▼ │ +//! │ check_search_ │◄────────┼─ send batch (10) │ +//! │ channel() │ Channel │ │ │ +//! │ │ │ │ ▼ │ +//! │ ▼ │ │ send batch (10) │ +//! │ update UI with │◄────────┼─ │ │ +//! │ streaming results │ │ ▼ │ +//! │ │ │ send completion │ +//! └─────────────────────┘ └─────────────────────┘ +//! ``` +//! +//! Key features: +//! - Results are streamed in batches of 10 for progressive UI updates +//! - Cancellation is supported via `Arc` token +//! - Each search has a unique `search_id` to ignore stale results +//! +//! ## Focus Management +//! +//! The component handles complex focus scenarios: +//! - `pending_popup_cleanup`: Defers popup closure when focus is lost during search +//! - `pending_draw_focus_restore`: Retries focus restoration in draw_walk until successful +//! +//! ## Key Components +//! +//! - [`SearchResult`]: Result type sent through the channel from background thread +//! - [`MentionSearchState`]: State machine enum managing search lifecycle +//! - [`MentionableTextInputAction`]: Actions for external communication (power levels, member updates) //! use crate::avatar_cache::*; use crate::shared::avatar::AvatarWidgetRefExt; diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index febfc97f..f47d2c64 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -3571,6 +3571,103 @@ pub fn shutdown_background_tasks() { } } +/// Validates that a path is safe to delete by ensuring it is strictly within the app data directory. +/// +/// # Security +/// +/// This function prevents arbitrary directory deletion attacks where a malicious or corrupted +/// session file could specify a path outside the app's data directory (e.g., `~` or `/`). +/// +/// The validation performs the following checks: +/// 1. Canonicalizes both the target path and the app data directory to resolve symlinks +/// 2. Verifies the target path starts with the app data directory prefix +/// 3. Ensures the path is not the app data directory itself (must be a subdirectory) +/// +/// # Returns +/// +/// - `Ok(PathBuf)` - The canonicalized path if it's safe to delete +/// - `Err(io::Error)` - If the path is outside the sandbox or canonicalization fails +fn validate_path_within_app_data(path: &Path) -> io::Result { + use std::path::PathBuf; + + // Get the app data directory and canonicalize it + let app_data = app_data_dir(); + let canonical_app_data = app_data.canonicalize().map_err(|e| { + io::Error::new( + io::ErrorKind::NotFound, + format!("Failed to canonicalize app data directory: {}", e), + ) + })?; + + // Canonicalize the target path to resolve any symlinks or relative components + // This prevents symlink attacks where the path appears to be inside app_data + // but actually points elsewhere + let canonical_path = path.canonicalize().map_err(|e| { + io::Error::new( + io::ErrorKind::NotFound, + format!("Failed to canonicalize target path {:?}: {}", path, e), + ) + })?; + + // Verify the canonical path starts with the canonical app data directory + if !canonical_path.starts_with(&canonical_app_data) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "Security: Path {:?} is outside app data directory {:?}", + canonical_path, canonical_app_data + ), + )); + } + + // Ensure we're not deleting the app data directory itself + if canonical_path == canonical_app_data { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Security: Cannot delete the app data directory itself", + )); + } + + // Additional check: ensure the path has at least one more component after app_data + // This prevents edge cases with trailing slashes or dot components + let relative = canonical_path.strip_prefix(&canonical_app_data).map_err(|_| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "Security: Failed to compute relative path within app data", + ) + })?; + + if relative.as_os_str().is_empty() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Security: Path resolves to app data directory itself", + )); + } + + Ok(canonical_path) +} + +/// Clears all application state during logout, including the Matrix SDK database. +/// +/// # Why Delete the Matrix SDK Database? +/// +/// Each login creates a **new database directory** with a timestamp-based name +/// (e.g., `db_2025-12-12_10_30_45_123456`). This database stores: +/// - Encryption keys and device information +/// - Room state and membership caches +/// - Message history and sync tokens +/// +/// If we don't delete the old database on logout: +/// 1. **Stale data leakage**: Old room member lists may appear after re-login +/// 2. **Disk space accumulation**: Each login creates a new directory, old ones never cleaned +/// 3. **Data confusion**: Some code paths might read cached data from previous sessions, +/// causing issues like showing wrong user's room members in @mention search +/// +/// # Security Note +/// +/// The database path is read from a session file that could potentially be tampered with. +/// Before deletion, the path is validated via [`validate_path_within_app_data`] to ensure +/// it is strictly within the app's data directory, preventing arbitrary directory deletion. pub async fn clear_app_state(config: &LogoutConfig) -> Result<()> { // Get the database path before clearing CLIENT // We need this to delete the Matrix SDK database after logout @@ -3621,15 +3718,28 @@ pub async fn clear_app_state(config: &LogoutConfig) -> Result<()> { // Delete the Matrix SDK database directory to ensure fresh state on next login // This prevents stale cached data (like old room members) from appearing after re-login + // + // SECURITY: The db_path comes from a session file which could be tampered with. + // We MUST validate that the path is strictly within our app data directory + // before performing any deletion to prevent arbitrary directory deletion attacks. if let Some(db_path) = db_path_to_delete { - match tokio::fs::remove_dir_all(&db_path).await { - Ok(_) => { - log!("Successfully deleted Matrix SDK database at: {:?}", db_path); + match validate_path_within_app_data(&db_path) { + Ok(validated_path) => { + match tokio::fs::remove_dir_all(&validated_path).await { + Ok(_) => { + log!("Successfully deleted Matrix SDK database at: {:?}", validated_path); + } + Err(e) => { + // Don't fail logout if database deletion fails + // Just log the error and continue + log!("Warning: Failed to delete Matrix SDK database at {:?}: {}", validated_path, e); + } + } } Err(e) => { - // Don't fail logout if database deletion fails - // Just log the error and continue - log!("Warning: Failed to delete Matrix SDK database at {:?}: {}", db_path, e); + // Security validation failed - do NOT delete the path + // This could indicate a tampered session file or symlink attack + error!("Security: Refusing to delete database path {:?}: {}", db_path, e); } } } From f4305b4b9baf594a31011a2c57ef394066dafeea Mon Sep 17 00:00:00 2001 From: AlexZ Date: Fri, 12 Dec 2025 02:20:15 +0800 Subject: [PATCH 18/20] add comment for find_open_room_live_id --- src/home/main_desktop_ui.rs | 12 ++++++++++++ src/sliding_sync.rs | 2 -- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 14e2b3db..572a7159 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -111,6 +111,18 @@ impl Widget for MainDesktopUI { impl MainDesktopUI { /// Focuses on a room if it is already open, otherwise creates a new tab for the room. + /// + /// # Duplicate Tab Prevention + /// + /// This method uses [`Self::find_open_room_live_id`] to check if the room is already open, + /// rather than directly comparing `LiveId::from_str(room_id)`. This is necessary because + /// the dock may store `LiveId`s with different prefixes depending on how the tab was created: + /// + /// - Tabs restored from saved app state may have prefixed `LiveId`s + /// - Tabs created directly use `LiveId::from_str(room_id)` without prefix + /// + /// By looking up the actual stored `LiveId` via room_id comparison, we avoid creating + /// duplicate tabs when a room is already open but its `LiveId` format differs. fn focus_or_create_tab(&mut self, cx: &mut Cx, room: SelectedRoom) { let dock = self.view.dock(ids!(dock)); diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index f47d2c64..36d6682a 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -3588,8 +3588,6 @@ pub fn shutdown_background_tasks() { /// - `Ok(PathBuf)` - The canonicalized path if it's safe to delete /// - `Err(io::Error)` - If the path is outside the sandbox or canonicalization fails fn validate_path_within_app_data(path: &Path) -> io::Result { - use std::path::PathBuf; - // Get the app data directory and canonicalize it let app_data = app_data_dir(); let canonical_app_data = app_data.canonicalize().map_err(|e| { From 270d6d13fcc3df383f64da0fa51b3ff80e855412 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Fri, 12 Dec 2025 10:40:51 +0800 Subject: [PATCH 19/20] update docs for focus_or_create_tab --- src/app.rs | 20 ++++++++++++++++++ src/home/main_desktop_ui.rs | 41 ++++++++++++++++++++++++++----------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/app.rs b/src/app.rs index 76295d6b..286072e4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -603,11 +603,31 @@ pub struct AppState { } /// A snapshot of the main dock: all state needed to restore the dock tabs/layout. +/// +/// # Cross-Version Hash Drift Warning +/// +/// The `LiveId` keys in `dock_items` and `open_rooms` are derived from `LiveId::from_str(room_id)`. +/// However, the hash value may change when upgrading Makepad or the Rust toolchain, causing +/// persisted `LiveId`s to no longer match freshly computed ones. This leads to duplicate tabs +/// when restoring state across versions. +/// +/// **Current mitigation**: `MainDesktopUI::find_open_room_live_id` performs a reverse-lookup +/// by `room_id` to find the actual stored `LiveId`, avoiding hash mismatch issues at runtime. +/// +// TODO: A more thorough fix would be to use `room_id` (String) as the persistence key instead +// of `LiveId`, and derive `LiveId` at runtime when needed. This would eliminate cross-version +// hash drift entirely and make the persisted format stable across Makepad/toolchain upgrades. #[derive(Clone, Default, Debug, DeRon, SerRon)] pub struct SavedDockState { /// All items contained in the dock, keyed by their LiveId. + /// + // TODO: Consider using `OwnedRoomId` (String) as the key instead of `LiveId` to avoid + // cross-version hash drift. See struct-level documentation for details. pub dock_items: HashMap, /// The rooms that are currently open, keyed by the LiveId of their tab. + /// + // TODO: Consider using `OwnedRoomId` (String) as the key instead of `LiveId` to avoid + // cross-version hash drift. See struct-level documentation for details. pub open_rooms: HashMap, /// The order in which the rooms were opened, in chronological order /// from first opened (at the beginning) to last opened (at the end). diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 572a7159..45bcea65 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -112,17 +112,30 @@ impl Widget for MainDesktopUI { impl MainDesktopUI { /// Focuses on a room if it is already open, otherwise creates a new tab for the room. /// - /// # Duplicate Tab Prevention + /// # Duplicate Tab Prevention (Cross-Version Hash Drift) /// /// This method uses [`Self::find_open_room_live_id`] to check if the room is already open, /// rather than directly comparing `LiveId::from_str(room_id)`. This is necessary because - /// the dock may store `LiveId`s with different prefixes depending on how the tab was created: + /// persisted `LiveId`s may differ from freshly computed ones due to **cross-version hash drift**. /// - /// - Tabs restored from saved app state may have prefixed `LiveId`s - /// - Tabs created directly use `LiveId::from_str(room_id)` without prefix + /// ## Root Cause /// - /// By looking up the actual stored `LiveId` via room_id comparison, we avoid creating - /// duplicate tabs when a room is already open but its `LiveId` format differs. + /// The 64-bit value of `LiveId::from_str` depends on Makepad's hash implementation (and + /// potentially compiler/stdlib hash seeds). When upgrading Makepad or the Rust toolchain, + /// the hash algorithm or seed may change. Persisted data (in `open_rooms`/`dock_items`) + /// contains "old hash values," while the new runtime computes "new hash values." This + /// causes `contains_key`/`select_tab` lookups to fail, and the room is incorrectly + /// treated as "not open," resulting in duplicate tabs. + /// + /// ## Current Fix + /// + /// By reverse-looking up the actual stored `LiveId` via `room_id` comparison (using + /// [`Self::find_open_room_live_id`]), we correctly identify already-open rooms regardless + /// of hash drift between versions. + /// + // TODO: A more thorough fix would be to use `room_id` (String) as the persistence key + // instead of `LiveId`, and derive `LiveId` at runtime. This would eliminate cross-version + // hash drift entirely. See `SavedDockState` in `src/app.rs`. fn focus_or_create_tab(&mut self, cx: &mut Cx, room: SelectedRoom) { let dock = self.view.dock(ids!(dock)); @@ -184,19 +197,23 @@ impl MainDesktopUI { ); } } + // Only update open_rooms after successful tab creation to avoid orphan entries + self.open_rooms.insert(room_id_as_live_id, room.clone()); + self.most_recently_selected_room = Some(room); cx.action(MainDesktopUiAction::SaveDockIntoAppState); } else { error!("BUG: failed to create tab for {room:?}"); } - - self.open_rooms.insert(room_id_as_live_id, room.clone()); - self.most_recently_selected_room = Some(room); } - /// Finds the LiveId of an already-open room by its room_id. + /// Finds the `LiveId` of an already-open room by its `room_id`. + /// + /// This reverse-lookup is necessary to handle **cross-version hash drift**: when Makepad + /// or the toolchain is upgraded, `LiveId::from_str(room_id)` may compute a different hash + /// than what was persisted. By matching on the stable `room_id` value instead of the + /// potentially-drifted `LiveId`, we correctly identify rooms regardless of version changes. /// - /// This is needed because the dock may store LiveIds with a prefix that differs - /// from the one generated by `LiveId::from_str(room_id)`. + /// See [`Self::focus_or_create_tab`] for more details on the root cause. fn find_open_room_live_id(&self, room_id: &OwnedRoomId) -> Option { self.open_rooms .iter() From 8b537fd216c67f327de70c0c23529377e4e9d4c7 Mon Sep 17 00:00:00 2001 From: AlexZ Date: Wed, 17 Dec 2025 17:21:40 +0800 Subject: [PATCH 20/20] fixed conflict for room_name_id --- src/home/main_desktop_ui.rs | 1 + src/home/room_screen.rs | 33 ++++++++++++---------------- src/home/rooms_list.rs | 8 +++++-- src/shared/mentionable_text_input.rs | 10 +++++---- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 8687545b..6ebc5766 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -169,6 +169,7 @@ impl MainDesktopUI { // If the room is already open, select (jump to) its existing tab. // We use `find_open_room_live_id` to look up by room_id, because the dock // may store LiveIds with a prefix that differs from `LiveId::from_str(room_id)`. + let dock = self.view.dock(ids!(dock)); if let Some(existing_live_id) = self.find_open_room_live_id(room.room_id()) { dock.select_tab(cx, existing_live_id); self.most_recently_selected_room = Some(room); diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 8ad94895..00af909f 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -565,7 +565,7 @@ pub struct RoomScreen { /// Whether or not all rooms have been loaded (received from the homeserver). #[rust] all_rooms_loaded: bool, /// Room to reload after login (saved during logout to restore user's view) - #[rust] pending_room_to_reload: Option<(OwnedRoomId, String)>, + #[rust] pending_room_to_reload: Option, } impl Drop for RoomScreen { fn drop(&mut self) { @@ -680,18 +680,15 @@ impl Widget for RoomScreen { if let Some(tl) = self.tl_state.take() { log!("RoomScreen: clearing tl_state for room {} due to logout", tl.room_id); } - // Save room_id for reloading after login, then clear state - let saved_room_id = self.room_id.clone(); - let saved_room_name = self.room_name.clone(); + // Save room_name_id for reloading after login, then clear state + let saved_room_name_id = self.room_name_id.take(); - self.room_id = None; - self.room_name = String::new(); self.is_loaded = false; self.all_rooms_loaded = false; // Store the room to reload after login - if let Some(room_id) = saved_room_id { - self.pending_room_to_reload = Some((room_id, saved_room_name)); + if let Some(room_name_id) = saved_room_name_id { + self.pending_room_to_reload = Some(room_name_id); log!("RoomScreen: saved room to reload after login"); } @@ -701,9 +698,9 @@ impl Widget for RoomScreen { // Handle login success: reload the room if we were displaying one before logout if let Some(crate::login::login_screen::LoginAction::LoginSuccess) = action.downcast_ref() { - if let Some((room_id, room_name)) = self.pending_room_to_reload.take() { - log!("RoomScreen: reloading room {} after successful login", room_id); - self.set_displayed_room(cx, room_id, room_name); + if let Some(room_name_id) = self.pending_room_to_reload.take() { + log!("RoomScreen: reloading room {} after successful login", room_name_id.room_id()); + self.set_displayed_room(cx, &room_name_id); } } @@ -847,24 +844,22 @@ impl Widget for RoomScreen { RoomScreenProps { room_screen_widget_uid, - room_name_id: RoomNameId::new(room_display_name, room_id), + room_name_id: RoomNameId::new(room_display_name.clone(), room_id), room_members, room_members_sort, room_members_sync_pending, - room_display_name, + room_display_name: Some(room_display_name.to_string()), room_avatar_url, is_direct_room, } - } else if let Some(room_id) = self.room_id.clone() { - // Fallback case: we have a room_id but no tl_state yet. + } else if let Some(room_name_id) = &self.room_name_id { + // Fallback case: we have a room_name_id but no tl_state yet. // This happens after logout clears tl_state but before show_timeline() is called again. // Set room_members_sync_pending to true to show loading animation in MentionableTextInput. - let is_direct_room = Self::is_direct_room(cx, &room_id); - } else if let Some(room_name) = &self.room_name_id { - // Fallback case: we have a room_name but no tl_state yet + let is_direct_room = Self::is_direct_room(cx, room_name_id.room_id()); RoomScreenProps { room_screen_widget_uid, - room_name_id: room_name.clone(), + room_name_id: room_name_id.clone(), room_members: None, room_members_sort: None, room_members_sync_pending: true, diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index edcdec0d..c3397fe9 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -928,10 +928,10 @@ impl RoomsList { /// Returns a room's avatar and displayable name. pub fn get_room_avatar_and_name(&self, room_id: &OwnedRoomId) -> Option<(FetchedRoomAvatar, Option)> { self.all_joined_rooms.get(room_id) - .map(|room_info| (room_info.avatar.clone(), room_info.room_name.clone())) + .map(|room_info| (room_info.avatar.clone(), room_info.room_name_id.name_for_avatar())) .or_else(|| { self.invited_rooms.borrow().get(room_id) - .map(|room_info| (room_info.room_avatar.clone(), room_info.room_name.clone())) + .map(|room_info| (room_info.room_avatar.clone(), room_info.room_name_id.name_for_avatar())) }) } @@ -947,6 +947,8 @@ impl RoomsList { .map(|room_info| room_info.is_direct) }) .unwrap_or(false) + } + /// Handle any incoming updates to spaces' room lists and pagination state. fn handle_space_room_list_action(&mut self, cx: &mut Cx, action: &SpaceRoomListAction) { match action { @@ -1331,6 +1333,8 @@ impl RoomsListRef { return false; }; inner.is_direct_room(room_id) + } + /// Returns the currently-selected space (the one selected in the SpacesBar). pub fn get_selected_space(&self) -> Option { self.borrow()?.selected_space.clone() diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 48602093..5be0aced 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -550,7 +550,7 @@ impl Widget for MentionableTextInput { .as_ref() .map(|members| members.len()) .unwrap_or(0); - (room_props.room_id.clone(), member_count) + (room_props.room_name_id.room_id().clone(), member_count) }; // Check search channel on every frame if we're searching @@ -872,9 +872,11 @@ impl MentionableTextInput { // Get room avatar fallback text from room name (with automatic ID fallback) let room_label = room_props.room_name_id.to_string(); let room_name_first_char = room_label - .as_ref() - .and_then(|name| name.graphemes(true).next().map(|s| s.to_uppercase())) + .graphemes(true) + .next() + .map(|s| s.to_uppercase()) .filter(|s| s != "@" && s.chars().all(|c| c.is_alphabetic())) + .unwrap_or_default(); if let Some(avatar_url) = &room_props.room_avatar_url { match get_or_fetch_avatar(cx, avatar_url.to_owned()) { @@ -1509,7 +1511,7 @@ impl MentionableTextInput { if !already_waiting { submit_async_request(MatrixRequest::GetRoomMembers { - room_id: room_props.room_id.clone(), + room_id: room_props.room_name_id.room_id().clone(), memberships: RoomMemberships::JOIN, local_only: true, });