From f80b1596792bac2fac19386091ea8637d57fc096 Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Sat, 22 Nov 2025 00:37:25 -0800 Subject: [PATCH 01/12] [no ci] WIP implementing "go to space", showing Space-only rooms list showing SpaceLobby entry in the RoomsList, etc. SpaceLobby itself not yet impl'd. Full usage of `SelectedRoom::Space` not yet impl'd. --- src/app.rs | 6 + src/home/mod.rs | 2 + src/home/navigation_tab_bar.rs | 37 +++- src/home/rooms_list.rs | 169 +++++++++++++- src/home/space_lobby.rs | 221 +++++++++++++++++++ src/home/spaces_bar.rs | 4 - src/shared/styles.rs | 2 + src/sliding_sync.rs | 12 +- src/space_service_sync.rs | 388 ++++++++++++++++++++++++--------- 9 files changed, 711 insertions(+), 130 deletions(-) create mode 100644 src/home/space_lobby.rs diff --git a/src/app.rs b/src/app.rs index 39388125..70993ae4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -633,6 +633,10 @@ pub enum SelectedRoom { room_id: OwnedRoomIdRon, room_name: Option, }, + Space { + space_id: OwnedRoomIdRon, + space_name: String, + }, } impl SelectedRoom { @@ -640,6 +644,7 @@ impl SelectedRoom { match self { SelectedRoom::JoinedRoom { room_id, .. } => room_id, SelectedRoom::InvitedRoom { room_id, .. } => room_id, + SelectedRoom::Space { space_id, .. } => space_id, } } @@ -647,6 +652,7 @@ impl SelectedRoom { match self { SelectedRoom::JoinedRoom { room_name, .. } => room_name.as_ref(), SelectedRoom::InvitedRoom { room_name, .. } => room_name.as_ref(), + SelectedRoom::Space { space_name, .. } => Some(&space_name), } } diff --git a/src/home/mod.rs b/src/home/mod.rs index 9e6fc09b..27e6a8b1 100644 --- a/src/home/mod.rs +++ b/src/home/mod.rs @@ -17,6 +17,7 @@ pub mod rooms_list_entry; pub mod rooms_list_header; pub mod rooms_sidebar; pub mod search_messages; +pub mod space_lobby; pub mod spaces_bar; pub mod navigation_tab_bar; pub mod welcome_screen; @@ -29,6 +30,7 @@ pub fn live_design(cx: &mut Cx) { home_screen::live_design(cx); loading_pane::live_design(cx); location_preview::live_design(cx); + space_lobby::live_design(cx); rooms_list_entry::live_design(cx); rooms_list_header::live_design(cx); rooms_list::live_design(cx); diff --git a/src/home/navigation_tab_bar.rs b/src/home/navigation_tab_bar.rs index c8515a53..f096c359 100644 --- a/src/home/navigation_tab_bar.rs +++ b/src/home/navigation_tab_bar.rs @@ -57,9 +57,6 @@ live_design! { use crate::shared::icon_button::*; use crate::home::spaces_bar::*; - ICON_HOME = dep("crate://self/resources/icons/home.svg") - ICON_SETTINGS = dep("crate://self/resources/icons/settings.svg") - // A RadioButton styled to fit within our NavigationTabBar. pub NavigationTabButton = { width: Fill, @@ -503,6 +500,26 @@ pub enum SelectedTab { /// Actions for navigating through the top-level views of the app, /// e.g., when the user clicks/taps on a button in the NavigationTabBar. +/// +/// The most important variant is `TabSelected`, which is most likely the action +/// that you want to handle in other widgets, if you care about which +/// top-level navigation tab is currently selected. +/// This is because the `TabSelected` variant will always occur even if the +/// other actions do not occur --- for example, if the user chooses to jump +/// to a different view (or back to a previous view) without explicitly clicking +/// a navigation tab button, e.g., via a keyboard shortcut, or programmatically. +/// +/// There are 3 kinds of actions within this one enum: +/// 1. "Leading-edge" ("request") actions emitted by the NavigationTabBar +/// when the user selects a particular button/space. +/// * Includes `GoToHome`, `GoToAddRoom`, `GoToSpace`, `OpenSettings`, `CloseSettings`. +/// 2. "Trailing-edge" ("response") actions that are emitted by the `HomeScreen` widget +/// in response to a leading-edge action. +/// * This includes only the `TabSelected` variant. +/// * This is what all other widgets should handle if they want/need to respond +/// to changes in the top-level app-wide navigation selection. +/// 3. Other actions that aren't requests/responses to navigate to a different view. +/// * This only includes the `ToggleSpacesBar` variant. #[derive(Debug, PartialEq)] pub enum NavigationBarAction { /// Go to the main rooms content view. @@ -511,18 +528,20 @@ pub enum NavigationBarAction { GoToAddRoom, /// Go to the Settings view (open the `SettingsScreen`). OpenSettings, - /// Close the Settings view (`SettingsScreen`), returning to the previous page. + /// Close the Settings view (`SettingsScreen`), returning to the previous view. CloseSettings, - /// The given tab was selected programmatically, e.g., after closing the settings screen. - /// This is needed just to ensure that the proper tab radio button is marked as selected. + /// Go the space screen for the given space. + GoToSpace { space_id: OwnedRoomId }, + + // TODO: add GoToAlertsInbox, once we add that button/screen + + /// The given tab was selected as the active top-level view. + /// This is needed to ensure that the proper tab is marked as selected TabSelected(SelectedTab), /// Toggle whether the SpacesBar is shown, i.e., show/hide it. /// This is only applicable in the Mobile view mode, because the SpacesBar /// is always shown in Desktop view mode. ToggleSpacesBar, - /// Go the space screen for the given space. - GoToSpace { space_id: OwnedRoomId }, - // GoToAlertsInbox } diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index c2f5142e..14185cf3 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -1,9 +1,10 @@ -use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc}; +use std::{cell::RefCell, collections::{HashMap, HashSet}, rc::Rc, sync::Arc}; use crossbeam_queue::SegQueue; use makepad_widgets::*; use matrix_sdk::{ruma::{events::tag::Tags, MilliSecondsSinceUnixEpoch, OwnedRoomAliasId, OwnedRoomId, OwnedUserId}, RoomState}; +use tokio::sync::mpsc::UnboundedSender; use crate::{ - app::{AppState, SelectedRoom}, room::{FetchedRoomAvatar, room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria, SortFn}}, shared::{collapsible_header::{CollapsibleHeaderAction, CollapsibleHeaderWidgetRefExt, HeaderCategory}, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupItem, PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction}, sliding_sync::{MatrixRequest, PaginationDirection, submit_async_request}, utils::room_name_or_id + app::{AppState, SelectedRoom}, home::{navigation_tab_bar::{NavigationBarAction, SelectedTab}, space_lobby::SpaceLobbyAction}, room::{FetchedRoomAvatar, room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria, SortFn}}, shared::{collapsible_header::{CollapsibleHeaderAction, CollapsibleHeaderWidgetRefExt, HeaderCategory}, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupItem, PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction}, sliding_sync::{MatrixRequest, PaginationDirection, submit_async_request}, space_service_sync::SpaceRequest, utils::room_name_or_id }; use super::rooms_list_entry::RoomsListEntryAction; @@ -52,6 +53,7 @@ live_design! { use crate::shared::html_or_plaintext::HtmlOrPlaintext; use crate::shared::collapsible_header::*; use crate::home::rooms_list_entry::*; + use crate::home::space_lobby::*; StatusLabel = { width: Fill, height: Fit, @@ -76,6 +78,8 @@ live_design! { flow: Down cursor: Default, + space_lobby_entry = {} + list = { keep_invisible: false, auto_tail: false, @@ -163,6 +167,9 @@ pub enum RoomsListUpdate { }, /// Scroll to the given room. ScrollToRoom(OwnedRoomId), + /// The background space service is now listening for requests, + /// and the sender-side channel endpoint is included. + SpaceRequestSender(UnboundedSender), } static PENDING_ROOM_UPDATES: SegQueue = SegQueue::new(); @@ -177,7 +184,7 @@ pub fn enqueue_rooms_list_update(update: RoomsListUpdate) { /// Actions emitted by the RoomsList widget. #[derive(Debug, Clone, DefaultNone)] pub enum RoomsListAction { - /// A new room was selected. + /// A new room or space was selected. Selected(SelectedRoom), /// A new room was joined from an accepted invite, /// meaning that the existing `InviteScreen` should be converted @@ -311,6 +318,23 @@ pub struct RoomsList { /// This includes both direct rooms and regular rooms. #[rust] all_joined_rooms: HashMap, + /// The space that is currently selected as a display filter for the rooms list, if any. + /// * If `None` (default), no space is selected, and all rooms can be shown. + /// * If `Some`, the rooms list is in "space" mode. A special "Space Lobby" entry + /// is shown at the top, and only child rooms within this space will be displayed. + /// The ID and displayable name of the space is contained. + #[rust] selected_space: Option<(OwnedRoomId, String)>, + + /// The sender used to send Space-related requests to the background service. + #[rust] space_request_sender: Option>, + + /// A flattened map of all spaces known to the client, and the rooms/spaces they contain. + /// + /// The key is the space ID, and the value is a set of room IDs or space IDs that are + /// direct descendants of that space. + /// This can include both joined and non-joined spaces. + #[rust] space_map: HashMap>, + /// The currently-active filter function for the list of rooms. /// /// Note: for performance reasons, this does not get automatically applied @@ -629,10 +653,13 @@ impl RoomsList { // Scroll to just above the room to make it more obviously visible. portal_list.smooth_scroll_to(cx, portal_list_index.saturating_sub(1), speed, Some(15)); } + RoomsListUpdate::SpaceRequestSender(sender) => { + self.space_request_sender = Some(sender); + num_updates -= 1; // this does not require a redraw. + } } } if num_updates > 0 { - // log!("RoomsList: processed {} updates to the list of all rooms", num_updates); self.redraw(cx); } } @@ -664,8 +691,8 @@ impl RoomsList { || self.displayed_regular_rooms.contains(room) } - /// Updates the lists of displayed rooms based on the current search filter - /// and redraws the RoomsList. + /// Updates the display filter and recalculates the lists of displayed rooms + /// based on the current search filter. Also redraws the RoomsList. fn update_displayed_rooms(&mut self, cx: &mut Cx, keywords: &str) { let portal_list = self.view.portal_list(ids!(list)); if keywords.is_empty() { @@ -840,11 +867,11 @@ impl Widget for RoomsList { let props = RoomsListScopeProps { was_scrolling: self.view.portal_list(ids!(list)).was_scrolling(), }; - let list_actions = cx.capture_actions( + let rooms_list_actions = cx.capture_actions( |cx| self.view.handle_event(cx, event, &mut Scope::with_props(&props)) ); - for list_action in list_actions { - if let RoomsListEntryAction::Clicked(clicked_room_id) = list_action.as_widget_action().cast() { + for action in rooms_list_actions { + if let RoomsListEntryAction::Clicked(clicked_room_id) = action.as_widget_action().cast() { let new_selected_room = if let Some(jr) = self.all_joined_rooms.get(&clicked_room_id) { SelectedRoom::JoinedRoom { room_id: jr.room_id.clone().into(), @@ -868,7 +895,7 @@ impl Widget for RoomsList { ); self.redraw(cx); } - else if let CollapsibleHeaderAction::Toggled { category } = list_action.as_widget_action().cast() { + else if let CollapsibleHeaderAction::Toggled { category } = action.as_widget_action().cast() { match category { HeaderCategory::Invites => { self.is_invited_rooms_header_expanded = !self.is_invited_rooms_header_expanded; @@ -884,6 +911,21 @@ impl Widget for RoomsList { } self.redraw(cx); } + else if let Some(SpaceLobbyAction::SpaceLobbyEntryClicked) = action.downcast_ref() { + let Some((space_id, space_name)) = self.selected_space.clone() else { continue }; + self.current_active_room = Some(space_id.clone()); + let new_selected_space = SelectedRoom::Space { + space_id: space_id.into(), + space_name, + }; + cx.widget_action( + self.widget_uid(), + &scope.path, + RoomsListAction::Selected(new_selected_space), + ); + self.redraw(cx); + self.redraw(cx); + } } if let Event::Actions(actions) = event { @@ -892,6 +934,28 @@ impl Widget for RoomsList { self.update_displayed_rooms(cx, &keywords); continue; } + + if let Some(NavigationBarAction::TabSelected(tab)) = action.downcast_ref() { + match tab { + SelectedTab::Space { space_id } => { + self.selected_space = Some(space_id.clone()); + self.view.view(ids!(space_lobby_entry)).set_visible(cx, true); + TODO: + * show SpaceLobby at top of roomslist + * submit spaces request to get list of children in this space + * modify logic in `update_selected_rooms` to also filter for rooms in the selected_space + * tell dock to save state and close rooms *not* in this space, (or maybe the dock can listen for this action separately) + } + _ => { + self.selected_space = None; + self.view.view(ids!(space_lobby_entry)).set_visible(cx, false); + } + } + + + continue; + + } } } } @@ -1096,3 +1160,88 @@ struct RoomCategoryIndexes { /// The index after the last room in this category, which is where the next category should start. after_rooms_index: usize, } + + + + +impl Widget for SpaceLobbyEntry { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + if self.animator_handle_event(cx, event).must_redraw() { + self.redraw(cx); + } + + let area = self.draw_bg.area(); + let emit_hover_in_action = |this: &Self, cx: &mut Cx| { + cx.widget_action( + this.widget_uid(), + &scope.path, + TooltipAction::HoverIn { + widget_rect: area.rect(cx), + text: this.space_name.clone(), + bg_color: None, + text_color: None, + }, + ); + }; + + match event.hits(cx, area) { + Hit::FingerHoverIn(_) => { + self.animator_play(cx, ids!(hover.on)); + emit_hover_in_action(self, cx); + } + Hit::FingerHoverOver(_) => { + emit_hover_in_action(self, cx); + } + Hit::FingerHoverOut(_) => { + self.animator_play(cx, ids!(hover.off)); + cx.widget_action( + self.widget_uid(), + &scope.path, + TooltipAction::HoverOut, + ); + } + Hit::FingerDown(fe) => { + self.animator_play(cx, ids!(hover.down)); + if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { + if let Some(space_id) = self.space_id.clone() { + cx.widget_action( + self.widget_uid(), + &scope.path, + SpacesBarAction::ButtonSecondaryClicked { space_id }, + ); + } + } + } + Hit::FingerLongPress(_lp) => { + self.animator_play(cx, ids!(hover.down)); + emit_hover_in_action(self, cx); + if let Some(space_id) = self.space_id.clone() { + cx.widget_action( + self.widget_uid(), + &scope.path, + SpacesBarAction::ButtonSecondaryClicked { space_id }, + ); + } + } + Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { + self.animator_play(cx, ids!(hover.on)); + if let Some(space_id) = self.space_id.clone() { + cx.widget_action( + self.widget_uid(), + &scope.path, + SpacesBarAction::ButtonClicked { space_id }, + ); + } + } + Hit::FingerUp(fe) if !fe.is_over => { + self.animator_play(cx, ids!(hover.off)); + } + Hit::FingerMove(_fe) => { } + _ => {} + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} diff --git a/src/home/space_lobby.rs b/src/home/space_lobby.rs new file mode 100644 index 00000000..dfd78552 --- /dev/null +++ b/src/home/space_lobby.rs @@ -0,0 +1,221 @@ +//! Contains two widgets related to the top-level view of a space. +//! +//! 1. `SpaceLobby`: shows details about a space, including its name, avatar, +//! members, topic, and the full list of rooms and sub-spaces within it. +//! 2. `SpaceLobbyEntry`: the button that can be shown in a RoomsList +//! that allows the user to click on it to show the `SpaceLobby`. +//! + +use makepad_widgets::*; + + +live_design! { + use link::theme::*; + use link::shaders::*; + use link::widgets::*; + + use crate::shared::styles::*; + use crate::shared::helpers::*; + use crate::shared::avatar::*; + + // An entry in the RoomsList that will show the SpaceLobby when clicked. + SpaceLobbyEntry = {{SpaceLobbyEntry}} { + visible: false, // only visible when a space is selected + width: Fill, + height: Fit, + flow: Right, + align: {y: 0.5} + cursor: Hand + + show_bg: true + draw_bg: { + instance hover: 0.0 + instance active: 0.0 + + color: (COLOR_NAVIGATION_TAB_BG) + uniform color_hover: (COLOR_NAVIGATION_TAB_BG_HOVER) + uniform color_active: (COLOR_NAVIGATION_TAB_BG_ACTIVE) + + border_size: 0.0 + border_color: #0000 + uniform inset: vec4(0.0, 0.0, 0.0, 0.0) + border_radius: 4.0 + + fn get_color(self) -> vec4 { + return mix( + mix( + self.color, + self.color_hover, + self.hover + ), + self.color_active, + self.active + ) + } + + fn get_border_color(self) -> vec4 { + return self.border_color + } + + fn pixel(self) -> vec4 { + let sdf = Sdf2d::viewport(self.pos * self.rect_size) + sdf.box( + self.inset.x + self.border_size, + self.inset.y + self.border_size, + self.rect_size.x - (self.inset.x + self.inset.z + self.border_size * 2.0), + self.rect_size.y - (self.inset.y + self.inset.w + self.border_size * 2.0), + max(1.0, self.border_radius) + ) + sdf.fill_keep(self.get_color()) + if self.border_size > 0.0 { + sdf.stroke(self.get_border_color(), self.border_size) + } + return sdf.result; + } + } + + icon = { + width: 30, + height: 30, + align: {x: 0.5, y: 0.5} + draw_icon: { + svg_file: (ICON_HOME) + + instance active: 0.0 + instance hover: 0.0 + instance down: 0.0 + + color: (COLOR_TEXT) + uniform color_hover: (COLOR_TEXT) + uniform color_active: (COLOR_PRIMARY) + + fn get_color(self) -> vec4 { + return mix( + mix( + self.color, + self.color_hover, + self.hover + ), + self.color_active, + self.active + ) + } + } + icon_walk: { width: 25, height: 25 } + } + + space_lobby_label =