From 7711bb843529c31bbc515a3d4272880282f10f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tyrese=20Luo=20=28=E7=BE=85=E5=81=A5=E5=B3=AF=29?= Date: Fri, 10 Apr 2026 12:15:20 +0800 Subject: [PATCH 1/2] Improve mobile inline search and remote options UX --- resources/i18n/en.json | 8 +- resources/i18n/zh-CN.json | 8 +- ...-inline-search-with-remote-options.spec.md | 137 +++++ src/app.rs | 2 +- src/home/rooms_list_header.rs | 8 + src/home/rooms_sidebar.rs | 545 +++++++++++++++++- src/home/search_messages.rs | 2 +- src/shared/room_filter_input_bar.rs | 8 +- 8 files changed, 700 insertions(+), 18 deletions(-) create mode 100644 specs/task-mobile-inline-search-with-remote-options.spec.md diff --git a/resources/i18n/en.json b/resources/i18n/en.json index d91de0838..abfb93389 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -336,7 +336,7 @@ "rooms_list_header.tooltip.synced": "Fully synced", "room_filter_input.placeholder": "Filter rooms & spaces...", - "search_messages.button.todo": "Search (TODO)", + "search_messages.button.todo": "Search", "verification_badge.tooltip.verified": "This device is fully verified.", "verification_badge.tooltip.unverified": "This device is unverified. To view your encrypted message history, please verify Robrix from another client.", "verification_badge.tooltip.unknown": "Verification state is unknown.", @@ -514,9 +514,9 @@ "app.room_filter.no_server_results": "No server results for \"{query}\".", "app.room_filter.search_remote_failed": "Server search failed: {error}", "app.room_filter.searching_remote": "Searching {kind} on server...", - "app.room_filter.remote.people": "People", - "app.room_filter.remote.rooms": "Rooms", - "app.room_filter.remote.spaces": "Spaces", + "app.room_filter.remote.people": "Search People", + "app.room_filter.remote.rooms": "Search Rooms", + "app.room_filter.remote.spaces": "Search Spaces", "app.room_filter.remote.kind.people": "people", "app.room_filter.remote.kind.rooms": "rooms", "app.room_filter.remote.kind.spaces": "spaces", diff --git a/resources/i18n/zh-CN.json b/resources/i18n/zh-CN.json index 8c77b7696..7cb1cff08 100644 --- a/resources/i18n/zh-CN.json +++ b/resources/i18n/zh-CN.json @@ -334,7 +334,7 @@ "rooms_list_header.tooltip.synced": "已完全同步", "room_filter_input.placeholder": "筛选房间与空间...", - "search_messages.button.todo": "搜索(待实现)", + "search_messages.button.todo": "搜索", "verification_badge.tooltip.verified": "此设备已完全验证。", "verification_badge.tooltip.unverified": "此设备尚未验证。若要查看加密消息历史,请在其他客户端中验证 Robrix。", "verification_badge.tooltip.unknown": "验证状态未知。", @@ -512,9 +512,9 @@ "app.room_filter.no_server_results": "服务器中没有“{query}”的结果。", "app.room_filter.search_remote_failed": "服务器搜索失败:{error}", "app.room_filter.searching_remote": "正在服务器上搜索{kind}...", - "app.room_filter.remote.people": "联系人", - "app.room_filter.remote.rooms": "房间", - "app.room_filter.remote.spaces": "空间", + "app.room_filter.remote.people": "联网搜联系人", + "app.room_filter.remote.rooms": "联网搜房间", + "app.room_filter.remote.spaces": "联网搜空间", "app.room_filter.remote.kind.people": "联系人", "app.room_filter.remote.kind.rooms": "房间", "app.room_filter.remote.kind.spaces": "空间", diff --git a/specs/task-mobile-inline-search-with-remote-options.spec.md b/specs/task-mobile-inline-search-with-remote-options.spec.md new file mode 100644 index 000000000..80b732ed4 --- /dev/null +++ b/specs/task-mobile-inline-search-with-remote-options.spec.md @@ -0,0 +1,137 @@ +spec: task +name: "Mobile Room Search Inline Flow (No Modal)" +inherits: project +tags: [bugfix, mobile, search, ui, room-filter] +estimate: 2d +--- + +## Intent + +Fix the mobile search UX bug: desktop search is modal-driven, but mobile should not require opening a modal to search rooms/spaces. On mobile, users should type directly in the existing search input and get results in place. If there are no local results, mobile should present the same three remote search choices as desktop (`People`, `Rooms`, `Spaces`) and allow server-side search from that inline state. + +Also clean up mobile search UI so it no longer shows placeholder/todo affordances such as `Search (TODO)` and redundant mobile search icon affordances. + +## Constraints + +- Keep desktop behavior unchanged: + - desktop still opens `room_filter_modal` via the existing desktop entry + - desktop modal layout and interaction remain intact +- Keep existing Matrix async request path for remote directory search: + - `submit_async_request(MatrixRequest::SearchDirectory { query, kind, limit })` +- Keep existing remote search kinds and semantics: + - `RemoteDirectorySearchKind::People` + - `RemoteDirectorySearchKind::Rooms` + - `RemoteDirectorySearchKind::Spaces` +- Keep existing room-open / join / DM-open behavior for selected results +- Do not add new dependencies +- Do not run `cargo fmt` / `rustfmt` + +## Decisions + +- Mobile search flow is inline-only: + - typing in the mobile `RoomFilterInputBar` immediately drives search/filter behavior + - mobile must not require opening `room_filter_modal` +- Mobile no-result state for non-empty query shows three remote search buttons inline (People/Rooms/Spaces), matching desktop modal logic and text keys +- Clicking inline remote search buttons on mobile triggers the same remote search request path and result handling semantics as desktop +- Remote search loading, empty, and failure messaging on mobile reuses existing room-filter i18n messages (`app.room_filter.*`) for consistency +- Stale remote responses must be ignored when input query changed before response arrives +- Mobile UI cleanup: + - remove redundant mobile search icon affordance in the search area + - remove/replace visible `Search (TODO)` wording; no TODO-labelled search text remains in the mobile rooms sidebar + +## Boundaries + +### Allowed Changes +- `src/app.rs` +- `src/home/rooms_sidebar.rs` +- `src/shared/room_filter_input_bar.rs` +- `src/home/search_messages.rs` (only if needed for TODO text cleanup/removal) +- `src/home/rooms_list_header.rs` (only if needed for mobile/desktop search entry separation) +- `resources/i18n/en.json` +- `resources/i18n/zh-CN.json` + +### Forbidden +- Do not redesign desktop search modal UI +- Do not change unrelated navigation/tab behavior +- Do not change Matrix backend request payloads or protocol behavior +- Do not run formatting tools or reformat unrelated code + +## Acceptance Criteria + +Scenario: Desktop search entry and modal behavior remain unchanged + Test: manual_test_desktop_search_modal_unchanged + Given desktop layout is active + When user clicks the search entry in rooms header + Then `room_filter_modal` opens as before + And desktop search interaction remains unchanged + +Scenario: Mobile search works directly in search input without modal + Test: manual_test_mobile_inline_search_no_modal + Given mobile layout is active + When user types a non-empty query in mobile room filter input + Then local room/space results update inline + And no `room_filter_modal` is opened + +Scenario: Mobile no-local-results state shows remote options inline + Test: manual_test_mobile_no_results_shows_remote_options + Given mobile layout is active + And local results for query "qwerty-no-hit" are empty + When query is non-empty + Then inline empty-state text is shown + And three remote option buttons are visible: People, Rooms, Spaces + +Scenario: Mobile remote people search can be triggered from inline state + Test: manual_test_mobile_remote_people_search + Given mobile layout inline no-result state is shown + When user taps People remote option + Then app submits `MatrixRequest::SearchDirectory` with kind `People` + And loading state text is shown while request is in progress + +Scenario: Mobile remote rooms/spaces search can be triggered from inline state + Test: manual_test_mobile_remote_rooms_spaces_search + Given mobile layout inline no-result state is shown + When user taps Rooms or Spaces remote option + Then app submits `MatrixRequest::SearchDirectory` with matching kind + And returned results are shown inline and selectable + +Scenario: Mobile remote option buttons map to exact directory search kinds + Test: manual_test_mobile_remote_option_kind_mapping + Given mobile layout inline no-result state is shown + When user taps People, Rooms, and Spaces remote options + Then People maps to `RemoteDirectorySearchKind::People` + And Rooms maps to `RemoteDirectorySearchKind::Rooms` + And Spaces maps to `RemoteDirectorySearchKind::Spaces` + +Scenario: Mobile remote search selection keeps existing destination behavior + Test: manual_test_mobile_remote_result_selection_behavior + Given mobile inline remote search returns at least one result + When user selects a remote user result + Then app follows existing direct-message open/create behavior + When user selects a remote room/space result + Then app follows existing join/open flow + +Scenario: Mobile remote search failure shows error and allows retry + Test: manual_test_mobile_remote_search_failure_retry + Given mobile inline remote search request fails + Then inline error text is shown + And remote option buttons remain available for retry + +Scenario: Stale mobile remote results are discarded when query changed + Test: manual_test_mobile_remote_search_stale_results_ignored + Given user starts remote search for query "abc" + And user updates input to query "abcd" and this becomes the active inline query + When response for "abc" arrives after "abcd" is already active + Then response for "abc" does not overwrite current inline results for "abcd" + +Scenario: Mobile search UI no longer shows TODO copy or redundant icon affordance + Test: manual_test_mobile_search_ui_cleanup + Given mobile rooms sidebar is visible + Then no visible text contains `Search (TODO)` + And mobile search area no longer shows the redundant search icon affordance + +## Out of Scope + +- New global search features beyond current room/space/people directory search +- Redesign of desktop modal visuals/copy +- Changes to invite modal search behavior +- Changes to message timeline search feature scope diff --git a/src/app.rs b/src/app.rs index 2fb78e73f..93bab4721 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1803,7 +1803,7 @@ impl App { true, ); } else { - self.set_room_filter_modal_empty_state(cx, "", false); + self.set_room_filter_modal_empty_state(cx, "", true); } self.refresh_room_filter_modal_result_buttons(cx); diff --git a/src/home/rooms_list_header.rs b/src/home/rooms_list_header.rs index 34d446f72..e887eca97 100644 --- a/src/home/rooms_list_header.rs +++ b/src/home/rooms_list_header.rs @@ -280,6 +280,14 @@ impl RoomsListHeader { } } +impl RoomsListHeaderRef { + pub fn set_open_room_filter_button_visible(&self, cx: &mut Cx, visible: bool) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.view.view(cx, ids!(open_room_filter_modal_button)).set_visible(cx, visible); + inner.redraw(cx); + } +} + /// Actions that can be handled by the `RoomsListHeader`. #[derive(Debug)] pub enum RoomsListHeaderAction { diff --git a/src/home/rooms_sidebar.rs b/src/home/rooms_sidebar.rs index 5d2637ae2..e11d11e75 100644 --- a/src/home/rooms_sidebar.rs +++ b/src/home/rooms_sidebar.rs @@ -8,14 +8,90 @@ //! is at the top of the HomeScreen in Desktop view. use makepad_widgets::*; +use matrix_sdk::ruma::OwnedMxcUri; -use crate::home::rooms_list::RoomsListWidgetExt; -use crate::shared::room_filter_input_bar::{MainFilterAction, RoomFilterInputBarWidgetExt}; +use crate::{ + app::{AppState, RoomFilterRemoteSearchAction}, + avatar_cache::{self, AvatarCacheEntry}, + home::{ + rooms_list_header::RoomsListHeaderWidgetExt, + rooms_list::{RoomsListRef, RoomsListWidgetExt}, + spaces_bar::SpacesBarRef, + }, + i18n::{AppLanguage, tr_fmt, tr_key}, + join_leave_room_modal::{JoinLeaveModalKind, JoinLeaveRoomModalAction}, + profile::user_profile::UserProfile, + room::BasicRoomDetails, + shared::{ + avatar::{AvatarState, AvatarWidgetRefExt}, + room_filter_input_bar::{MainFilterAction, RoomFilterInputBarWidgetExt}, + }, + sliding_sync::{ + MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, + current_user_id, submit_async_request, + }, + utils::RoomNameId, +}; script_mod! { use mod.prelude.widgets.* use mod.widgets.* + let MobileRoomFilterResultItem = View { + visible: false + width: Fill + height: 48 + flow: Overlay + + row := View { + width: Fill + height: Fill + flow: Right + align: Align{y: 0.5} + spacing: 8 + padding: Inset{left: 8, right: 8, top: 5, bottom: 5} + + avatar := Avatar { width: 30, height: 30 } + + text_col := View { + width: Fill + height: Fit + flow: Down + spacing: 0 + + name_label := Label { + width: Fill + height: Fit + draw_text +: { + color: (COLOR_TEXT) + text_style: REGULAR_TEXT {font_size: 10} + } + } + + id_label := Label { + width: Fill + height: Fit + draw_text +: { + color: (COLOR_TEXT_INPUT_IDLE) + text_style: REGULAR_TEXT {font_size: 8.5} + } + } + } + } + + click_button := RobrixNeutralIconButton { + width: Fill + height: Fill + text: "" + icon_walk: Walk{width: 0, height: 0} + draw_bg +: { + color: #0000 + color_hover: #FFFFFF22 + color_down: #FFFFFF11 + } + } + } + mod.widgets.RoomsSideBar = #(RoomsSideBar::register_widget(vm)) { Desktop := SolidView { @@ -108,18 +184,80 @@ script_mod! { height: 45, flow: Right padding: Inset{top: 5, bottom: 2} - spacing: 5 + spacing: 0 align: Align{y: 0.5} CachedWidget { - room_filter_input_bar := RoomFilterInputBar {} + room_filter_input_bar := RoomFilterInputBar { + search_icon +: { + visible: false + } + } + } + } + + mobile_inline_search_panel := View { + visible: false + width: Fill + height: Fit + flow: Down + spacing: 4 + margin: Inset{top: 4} + + search_results_empty := Label { + visible: false + width: Fill, + height: Fit, + flow: Flow.Right{wrap: true}, + text: "" + draw_text +: { + color: (COLOR_TEXT) + text_style: REGULAR_TEXT {font_size: 10} + } } - search_messages_button := SearchMessagesButton { } + remote_search_options := View { + visible: false + width: Fill + height: Fit + flow: Right + spacing: 6 + margin: Inset{top: 2} + + mobile_remote_search_people_button := RobrixNeutralIconButton { + width: Fit, + text: "" + } + mobile_remote_search_rooms_button := RobrixNeutralIconButton { + width: Fit, + text: "" + } + mobile_remote_search_spaces_button := RobrixNeutralIconButton { + width: Fit, + text: "" + } + } + + search_results_list := View { + visible: false + width: Fill + height: Fit + flow: Down + spacing: 3 + + result_item_0 := MobileRoomFilterResultItem {} + result_item_1 := MobileRoomFilterResultItem {} + result_item_2 := MobileRoomFilterResultItem {} + result_item_3 := MobileRoomFilterResultItem {} + result_item_4 := MobileRoomFilterResultItem {} + result_item_5 := MobileRoomFilterResultItem {} + result_item_6 := MobileRoomFilterResultItem {} + result_item_7 := MobileRoomFilterResultItem {} + } } } - View { + rooms_list_container := View { padding: Inset{left: 15, right: 15} CachedWidget { @@ -130,6 +268,13 @@ script_mod! { } } +#[derive(Clone)] +enum MobileInlineSearchResultTarget { + RemoteSpace { space_name_id: RoomNameId, avatar_uri: Option }, + RemoteRoom { room_name_id: RoomNameId, avatar_uri: Option }, + RemoteUser(UserProfile), +} + /// A simple wrapper around `AdaptiveView` that contains several global singleton widgets. /// /// * In the mobile view, it serves as the root view of the StackNavigation, @@ -140,6 +285,10 @@ script_mod! { #[derive(Script, Widget)] pub struct RoomsSideBar { #[deref] view: AdaptiveView, + #[rust] app_language: AppLanguage, + #[rust(Timer::empty())] mobile_inline_search_debounce_timer: Timer, + #[rust] pending_mobile_inline_search_keywords: String, + #[rust] mobile_inline_search_results: Vec, } impl ScriptHook for RoomsSideBar { @@ -148,22 +297,404 @@ impl ScriptHook for RoomsSideBar { // Here we set the global singleton for the RoomsList widget, // which is used to access the list of rooms from anywhere in the app. cx.set_global(self.view.rooms_list(cx, ids!(rooms_list))); + self.set_app_language(cx, AppLanguage::default()); + self.sync_adaptive_search_ui(cx); }); } } impl Widget for RoomsSideBar { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.sync_adaptive_search_ui(cx); + + if self.mobile_inline_search_debounce_timer.is_event(event).is_some() { + self.mobile_inline_search_debounce_timer = Timer::empty(); + let keywords = std::mem::take(&mut self.pending_mobile_inline_search_keywords); + self.update_mobile_inline_local_state(cx, &keywords); + } + // If the main room filter input bar changed keywords, re-emit that action // as a MainFilterAction so that other widgets can handle it. if let Event::Actions(actions) = event { if let Some(keywords) = self.view.room_filter_input_bar(cx, ids!(room_filter_input_bar)).changed(actions) { - cx.action(MainFilterAction::Changed(keywords)); + cx.action(MainFilterAction::Changed(keywords.clone())); + cx.stop_timer(self.mobile_inline_search_debounce_timer); + if keywords.is_empty() { + self.pending_mobile_inline_search_keywords.clear(); + self.mobile_inline_search_debounce_timer = Timer::empty(); + self.update_mobile_inline_local_state(cx, ""); + } else { + self.pending_mobile_inline_search_keywords = keywords; + self.mobile_inline_search_debounce_timer = cx.start_timeout(0.12); + } + } + + if let Some(clicked_index) = self.clicked_mobile_inline_result_index(cx, actions) { + if let Some(target) = self.mobile_inline_search_results.get(clicked_index).cloned() { + match target { + MobileInlineSearchResultTarget::RemoteSpace { space_name_id, .. } => { + cx.action(JoinLeaveRoomModalAction::Open { + kind: JoinLeaveModalKind::JoinRoom { + details: BasicRoomDetails::Name(space_name_id), + is_space: true, + }, + show_tip: false, + }); + } + MobileInlineSearchResultTarget::RemoteRoom { room_name_id, .. } => { + cx.action(JoinLeaveRoomModalAction::Open { + kind: JoinLeaveModalKind::JoinRoom { + details: BasicRoomDetails::Name(room_name_id), + is_space: false, + }, + show_tip: false, + }); + } + MobileInlineSearchResultTarget::RemoteUser(user_profile) => { + let create_encrypted = scope.data.get::() + .map(|app_state| { + app_state.bot_settings.should_create_encrypted_dm( + user_profile.user_id.as_ref(), + current_user_id().as_deref(), + ) + }) + .unwrap_or(false); + submit_async_request(MatrixRequest::OpenOrCreateDirectMessage { + create_encrypted, + user_profile, + allow_create: false, + }); + } + } + return; + } + } + + if let Some(kind) = self.clicked_mobile_inline_remote_option(cx, actions) { + let query = self.current_mobile_filter_keywords(cx); + if !query.is_empty() { + let kind_text = match &kind { + RemoteDirectorySearchKind::People => tr_key(self.app_language, "app.room_filter.remote.kind.people"), + RemoteDirectorySearchKind::Rooms => tr_key(self.app_language, "app.room_filter.remote.kind.rooms"), + RemoteDirectorySearchKind::Spaces => tr_key(self.app_language, "app.room_filter.remote.kind.spaces"), + }; + let searching_text = tr_fmt(self.app_language, "app.room_filter.searching_remote", &[("kind", kind_text)]); + self.mobile_inline_search_results.clear(); + self.refresh_mobile_inline_result_buttons(cx); + self.set_mobile_inline_state(cx, &searching_text, false, false); + self.set_mobile_rooms_list_visible(cx, false); + submit_async_request(MatrixRequest::SearchDirectory { + query, + kind, + limit: 16, + }); + } + return; + } + + for action in actions { + match action.downcast_ref() { + Some(RoomFilterRemoteSearchAction::Results { query, kind: _, results }) => { + if self.current_mobile_filter_keywords(cx) != query.trim() { + continue; + } + self.mobile_inline_search_results.clear(); + for result in results { + match result { + RemoteDirectorySearchResult::User(user_profile) => { + self.mobile_inline_search_results.push(MobileInlineSearchResultTarget::RemoteUser(user_profile.clone())); + } + RemoteDirectorySearchResult::Room { room_name_id, avatar_uri } => { + self.mobile_inline_search_results.push(MobileInlineSearchResultTarget::RemoteRoom { + room_name_id: room_name_id.clone(), + avatar_uri: avatar_uri.clone(), + }); + } + RemoteDirectorySearchResult::Space { space_name_id, avatar_uri } => { + self.mobile_inline_search_results.push(MobileInlineSearchResultTarget::RemoteSpace { + space_name_id: space_name_id.clone(), + avatar_uri: avatar_uri.clone(), + }); + } + } + if self.mobile_inline_search_results.len() >= Self::MOBILE_INLINE_RESULT_ITEM_IDS.len() { + break; + } + } + self.refresh_mobile_inline_result_buttons(cx); + if self.mobile_inline_search_results.is_empty() { + self.set_mobile_inline_state( + cx, + &tr_fmt(self.app_language, "app.room_filter.no_server_results", &[ + ("query", query), + ]), + true, + false, + ); + } else { + self.set_mobile_inline_state(cx, "", false, true); + } + self.set_mobile_rooms_list_visible(cx, false); + continue; + } + Some(RoomFilterRemoteSearchAction::Failed { query, kind: _, error }) => { + if self.current_mobile_filter_keywords(cx) != query.trim() { + continue; + } + self.mobile_inline_search_results.clear(); + self.refresh_mobile_inline_result_buttons(cx); + self.set_mobile_inline_state( + cx, + &tr_fmt(self.app_language, "app.room_filter.search_remote_failed", &[ + ("error", error), + ]), + true, + false, + ); + self.set_mobile_rooms_list_visible(cx, false); + continue; + } + _ => {} + } } } self.view.handle_event(cx, event, scope); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.sync_adaptive_search_ui(cx); self.view.draw_walk(cx, scope, walk) } } + +impl RoomsSideBar { + const MOBILE_INLINE_RESULT_ITEM_IDS: [LiveId; 8] = [ + live_id!(result_item_0), live_id!(result_item_1), + live_id!(result_item_2), live_id!(result_item_3), + live_id!(result_item_4), live_id!(result_item_5), + live_id!(result_item_6), live_id!(result_item_7), + ]; + + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.sync_mobile_remote_option_labels(cx); + self.view.redraw(cx); + } + + fn sync_adaptive_search_ui(&self, cx: &mut Cx) { + let is_desktop = cx.display_context.is_desktop(); + let mobile_filter_visible = self.view.view(cx, ids!(room_filter_input_bar)).visible(); + let show_desktop_search_controls = is_desktop && !mobile_filter_visible; + self.view.rooms_list_header(cx, ids!(rooms_list_header)) + .set_open_room_filter_button_visible(cx, show_desktop_search_controls); + self.view.room_filter_input_bar(cx, ids!(room_filter_input_bar)) + .set_search_icon_visible(cx, show_desktop_search_controls); + } + + fn sync_mobile_remote_option_labels(&self, cx: &mut Cx) { + let options_view = self.view.view(cx, ids!(mobile_inline_search_panel.remote_search_options)); + options_view.button(cx, ids!(mobile_remote_search_people_button)) + .set_text(cx, tr_key(self.app_language, "app.room_filter.remote.people")); + options_view.button(cx, ids!(mobile_remote_search_rooms_button)) + .set_text(cx, tr_key(self.app_language, "app.room_filter.remote.rooms")); + options_view.button(cx, ids!(mobile_remote_search_spaces_button)) + .set_text(cx, tr_key(self.app_language, "app.room_filter.remote.spaces")); + } + + fn current_mobile_filter_keywords(&self, cx: &mut Cx) -> String { + self.view + .text_input(cx, ids!(room_filter_input_bar.input)) + .text() + .trim() + .to_owned() + } + + fn clicked_mobile_inline_result_index(&self, cx: &mut Cx, actions: &Actions) -> Option { + let list_view = self.view.view(cx, ids!(mobile_inline_search_panel.search_results_list)); + for (index, item_id) in Self::MOBILE_INLINE_RESULT_ITEM_IDS.iter().enumerate() { + if list_view.button(cx, &[*item_id, live_id!(click_button)]).clicked(actions) { + return Some(index); + } + } + None + } + + fn clicked_mobile_inline_remote_option(&self, cx: &mut Cx, actions: &Actions) -> Option { + let options_view = self.view.view(cx, ids!(mobile_inline_search_panel.remote_search_options)); + if options_view.button(cx, ids!(mobile_remote_search_people_button)).clicked(actions) { + return Some(RemoteDirectorySearchKind::People); + } + if options_view.button(cx, ids!(mobile_remote_search_rooms_button)).clicked(actions) { + return Some(RemoteDirectorySearchKind::Rooms); + } + if options_view.button(cx, ids!(mobile_remote_search_spaces_button)).clicked(actions) { + return Some(RemoteDirectorySearchKind::Spaces); + } + None + } + + fn set_mobile_rooms_list_visible(&self, cx: &mut Cx, visible: bool) { + self.view.view(cx, ids!(rooms_list_container)).set_visible(cx, visible); + } + + fn set_mobile_inline_state( + &self, + cx: &mut Cx, + text: &str, + show_remote_options: bool, + show_results_list: bool, + ) { + self.sync_mobile_remote_option_labels(cx); + let empty_label = self.view.label(cx, ids!(mobile_inline_search_panel.search_results_empty)); + empty_label.set_visible(cx, !text.is_empty()); + if !text.is_empty() { + empty_label.set_text(cx, text); + } + self.view.view(cx, ids!(mobile_inline_search_panel.remote_search_options)) + .set_visible(cx, show_remote_options); + self.view.view(cx, ids!(mobile_inline_search_panel.search_results_list)) + .set_visible(cx, show_results_list); + self.view.view(cx, ids!(mobile_inline_search_panel)) + .set_visible(cx, !text.is_empty() || show_remote_options || show_results_list); + } + + fn set_mobile_inline_result_avatar( + &self, + cx: &mut Cx, + avatar_ref: &crate::shared::avatar::AvatarRef, + fallback_text: &str, + remote_avatar_uri: Option<&OwnedMxcUri>, + remote_avatar_state: Option<&AvatarState>, + ) { + if let Some(avatar_state) = remote_avatar_state { + if let Some(image_data) = avatar_state.data() { + let res = avatar_ref.show_image( + cx, + None, + |cx, img_ref| crate::utils::load_png_or_jpg(&img_ref, cx, image_data), + ); + if res.is_ok() { + return; + } + } + if let Some(uri) = avatar_state.uri() { + if let AvatarCacheEntry::Loaded(image_data) = avatar_cache::get_or_fetch_avatar(cx, uri) { + let res = avatar_ref.show_image( + cx, + None, + |cx, img_ref| crate::utils::load_png_or_jpg(&img_ref, cx, &image_data), + ); + if res.is_ok() { + return; + } + } + } + } + + if let Some(uri) = remote_avatar_uri { + if let AvatarCacheEntry::Loaded(image_data) = avatar_cache::get_or_fetch_avatar(cx, uri) { + let res = avatar_ref.show_image( + cx, + None, + |cx, img_ref| crate::utils::load_png_or_jpg(&img_ref, cx, &image_data), + ); + if res.is_ok() { + return; + } + } + } + + avatar_ref.show_text(cx, None, None, fallback_text); + } + + fn refresh_mobile_inline_result_buttons(&self, cx: &mut Cx) { + let list_view = self.view.view(cx, ids!(mobile_inline_search_panel.search_results_list)); + for (index, item_id) in Self::MOBILE_INLINE_RESULT_ITEM_IDS.iter().enumerate() { + let item = list_view.view(cx, &[*item_id]); + if let Some(target) = self.mobile_inline_search_results.get(index) { + let (name, raw_id) = match target { + MobileInlineSearchResultTarget::RemoteSpace { space_name_id, .. } + | MobileInlineSearchResultTarget::RemoteRoom { room_name_id: space_name_id, .. } => { + (space_name_id.to_string(), space_name_id.room_id().to_string()) + } + MobileInlineSearchResultTarget::RemoteUser(user_profile) => { + (user_profile.displayable_name().to_owned(), user_profile.user_id.to_string()) + } + }; + + item.label(cx, ids!(row.text_col.name_label)).set_text(cx, &name); + item.label(cx, ids!(row.text_col.id_label)).set_text(cx, &raw_id); + + let avatar_ref = item.avatar(cx, ids!(row.avatar)); + match target { + MobileInlineSearchResultTarget::RemoteSpace { avatar_uri, .. } + | MobileInlineSearchResultTarget::RemoteRoom { avatar_uri, .. } => { + self.set_mobile_inline_result_avatar(cx, &avatar_ref, &name, avatar_uri.as_ref(), None); + } + MobileInlineSearchResultTarget::RemoteUser(user_profile) => { + self.set_mobile_inline_result_avatar( + cx, + &avatar_ref, + &name, + None, + Some(&user_profile.avatar_state), + ); + } + } + + item.set_visible(cx, true); + } else { + item.set_visible(cx, false); + } + } + } + + fn update_mobile_inline_local_state(&mut self, cx: &mut Cx, keywords: &str) { + let keywords = keywords.trim(); + self.mobile_inline_search_results.clear(); + self.refresh_mobile_inline_result_buttons(cx); + if keywords.is_empty() { + self.set_mobile_inline_state(cx, "", false, false); + self.set_mobile_rooms_list_visible(cx, true); + return; + } + + let max_results = Self::MOBILE_INLINE_RESULT_ITEM_IDS.len(); + let mut local_result_count = 0; + let space_items = cx.get_global::() + .get_matching_space_items(keywords, 4); + for _ in &space_items { + local_result_count += 1; + if local_result_count >= max_results { + break; + } + } + if local_result_count < max_results { + let room_items = cx.get_global::() + .get_matching_room_items(keywords, max_results - local_result_count); + local_result_count += room_items.len(); + } + + if local_result_count == 0 { + self.set_mobile_inline_state( + cx, + &tr_fmt( + self.app_language, + "app.room_filter.no_local_results", + &[("keywords", keywords)], + ), + true, + false, + ); + self.set_mobile_rooms_list_visible(cx, false); + } else { + self.set_mobile_inline_state(cx, "", true, false); + self.set_mobile_rooms_list_visible(cx, true); + } + } +} diff --git a/src/home/search_messages.rs b/src/home/search_messages.rs index 75ac7afa9..a6eafc28a 100644 --- a/src/home/search_messages.rs +++ b/src/home/search_messages.rs @@ -34,7 +34,7 @@ script_mod! { icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -1, right: -2} } // text: "Search Messages" - text: "Search (TODO)" + text: "Search" draw_text +: { color: (COLOR_FG_DISABLED) // color: (COLOR_PRIMARY), diff --git a/src/shared/room_filter_input_bar.rs b/src/shared/room_filter_input_bar.rs index d948d52cd..b2d7309ed 100644 --- a/src/shared/room_filter_input_bar.rs +++ b/src/shared/room_filter_input_bar.rs @@ -28,7 +28,7 @@ script_mod! { spacing: 4, align: Align{x: 0.0, y: 0.5}, - Icon { + search_icon := Icon { draw_icon +: { svg: (ICON_SEARCH), color: (COLOR_TEXT_INPUT_IDLE), @@ -141,6 +141,12 @@ impl RoomFilterInputBarRef { pub fn changed(&self, actions: &Actions) -> Option { self.borrow().and_then(|inner| inner.changed(actions)) } + + pub fn set_search_icon_visible(&self, cx: &mut Cx, visible: bool) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.view.view(cx, ids!(search_icon)).set_visible(cx, visible); + inner.redraw(cx); + } } impl WidgetMatchEvent for RoomFilterInputBar { From 9c8777a283e03a806a79f2b8e914c319a2c66638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tyrese=20Luo=20=28=E7=BE=85=E5=81=A5=E5=B3=AF=29?= Date: Fri, 10 Apr 2026 12:31:23 +0800 Subject: [PATCH 2/2] Add remote-search hint copy above server options --- resources/i18n/en.json | 7 ++++--- resources/i18n/zh-CN.json | 7 ++++--- src/app.rs | 15 +++++++++++++++ src/home/rooms_sidebar.rs | 15 +++++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/resources/i18n/en.json b/resources/i18n/en.json index abfb93389..172649fc0 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -514,9 +514,10 @@ "app.room_filter.no_server_results": "No server results for \"{query}\".", "app.room_filter.search_remote_failed": "Server search failed: {error}", "app.room_filter.searching_remote": "Searching {kind} on server...", - "app.room_filter.remote.people": "Search People", - "app.room_filter.remote.rooms": "Search Rooms", - "app.room_filter.remote.spaces": "Search Spaces", + "app.room_filter.remote.people": "People", + "app.room_filter.remote.rooms": "Rooms", + "app.room_filter.remote.spaces": "Spaces", + "app.room_filter.remote.hint": "No local results? Try searching on server:", "app.room_filter.remote.kind.people": "people", "app.room_filter.remote.kind.rooms": "rooms", "app.room_filter.remote.kind.spaces": "spaces", diff --git a/resources/i18n/zh-CN.json b/resources/i18n/zh-CN.json index 7cb1cff08..e6a741417 100644 --- a/resources/i18n/zh-CN.json +++ b/resources/i18n/zh-CN.json @@ -512,9 +512,10 @@ "app.room_filter.no_server_results": "服务器中没有“{query}”的结果。", "app.room_filter.search_remote_failed": "服务器搜索失败:{error}", "app.room_filter.searching_remote": "正在服务器上搜索{kind}...", - "app.room_filter.remote.people": "联网搜联系人", - "app.room_filter.remote.rooms": "联网搜房间", - "app.room_filter.remote.spaces": "联网搜空间", + "app.room_filter.remote.people": "联系人", + "app.room_filter.remote.rooms": "房间", + "app.room_filter.remote.spaces": "空间", + "app.room_filter.remote.hint": "没有搜索结果,尝试选择从服务器搜索?", "app.room_filter.remote.kind.people": "联系人", "app.room_filter.remote.kind.rooms": "房间", "app.room_filter.remote.kind.spaces": "空间", diff --git a/src/app.rs b/src/app.rs index 93bab4721..a1f750408 100644 --- a/src/app.rs +++ b/src/app.rs @@ -219,6 +219,17 @@ script_mod! { } } + remote_search_hint := Label { + visible: false + width: Fill, + height: Fit, + text: "" + draw_text +: { + color: (COLOR_TEXT_INPUT_IDLE) + text_style: REGULAR_TEXT {font_size: 9.5} + } + } + remote_search_options := View { visible: false width: Fill, @@ -1551,6 +1562,8 @@ impl App { .set_text(cx, tr_key(app_language, "app.room_filter.search_results_title")); self.ui.label(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.search_results_empty)) .set_text(cx, tr_key(app_language, "app.room_filter.empty_hint")); + self.ui.label(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_hint)) + .set_text(cx, tr_key(app_language, "app.room_filter.remote.hint")); self.ui.button(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_options.remote_search_people_button)) .set_text(cx, tr_key(app_language, "app.room_filter.remote.people")); self.ui.button(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_options.remote_search_rooms_button)) @@ -1636,6 +1649,8 @@ impl App { if !text.is_empty() { empty_label.set_text(cx, text); } + self.ui.label(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_hint)) + .set_visible(cx, show_remote_options); self.ui.view(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_options)) .set_visible(cx, show_remote_options); } diff --git a/src/home/rooms_sidebar.rs b/src/home/rooms_sidebar.rs index e11d11e75..d087ef48c 100644 --- a/src/home/rooms_sidebar.rs +++ b/src/home/rooms_sidebar.rs @@ -216,6 +216,17 @@ script_mod! { } } + remote_search_hint := Label { + visible: false + width: Fill, + height: Fit, + text: "" + draw_text +: { + color: (COLOR_TEXT_INPUT_IDLE) + text_style: REGULAR_TEXT {font_size: 9.5} + } + } + remote_search_options := View { visible: false width: Fill @@ -497,6 +508,8 @@ impl RoomsSideBar { } fn sync_mobile_remote_option_labels(&self, cx: &mut Cx) { + self.view.label(cx, ids!(mobile_inline_search_panel.remote_search_hint)) + .set_text(cx, tr_key(self.app_language, "app.room_filter.remote.hint")); let options_view = self.view.view(cx, ids!(mobile_inline_search_panel.remote_search_options)); options_view.button(cx, ids!(mobile_remote_search_people_button)) .set_text(cx, tr_key(self.app_language, "app.room_filter.remote.people")); @@ -555,6 +568,8 @@ impl RoomsSideBar { if !text.is_empty() { empty_label.set_text(cx, text); } + self.view.label(cx, ids!(mobile_inline_search_panel.remote_search_hint)) + .set_visible(cx, show_remote_options); self.view.view(cx, ids!(mobile_inline_search_panel.remote_search_options)) .set_visible(cx, show_remote_options); self.view.view(cx, ids!(mobile_inline_search_panel.search_results_list))