From 74fc07947a30d4ff03a325803a3464944ad66a39 Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Sun, 25 Oct 2026 20:11:23 +0100 Subject: [PATCH] InviteScreen: show details immediately to avoid jumps we already had almost all the details when first swhoing the invite, so we don't actually need to fetch the full room summary. But we do need that for member count and join rule. This avoids the UI jumping around, which is one of my pet peeves. The accept/reject invite buttons were getting pushed down as the extra room info loaded. now that's all fixed and it doesn't jump around. During the process we also fixed a few other minor issues: * the accept and reject buttons wrap if the screen is too narrow * the join rule and member count also wrap * the whole topic is visible in full now, but initially shown collapsed with line breaks converted to spaces. previously it was just ellipsized & truncated after a few lines. --- src/home/invite_screen.rs | 123 +++++++++++++----------- src/home/rooms_list.rs | 18 +++- src/room/reply_preview.rs | 30 +----- src/shared/collapsible_label.rs | 159 ++++++++++++++++++++++++++++++++ src/shared/mod.rs | 2 + src/sliding_sync.rs | 20 +++- 6 files changed, 270 insertions(+), 82 deletions(-) create mode 100644 src/shared/collapsible_label.rs diff --git a/src/home/invite_screen.rs b/src/home/invite_screen.rs index 1b5be349d..e158240f8 100644 --- a/src/home/invite_screen.rs +++ b/src/home/invite_screen.rs @@ -6,7 +6,7 @@ use std::{ops::Deref, rc::Rc}; use makepad_widgets::*; -use matrix_sdk::{RoomState, ruma::{OwnedRoomId, room::JoinRuleSummary}}; +use matrix_sdk::{RoomState, ruma::{OwnedRoomAliasId, OwnedRoomId, room::JoinRuleSummary}}; use crate::{app::AppStateAction, avatar_cache::{self, AvatarCacheEntry}, block_user_modal::{BlockUserModalAction, BlockUserRequest}, home::rooms_list::RoomsListRef, join_leave_room_modal::{JoinLeaveModalKind, JoinLeaveRoomModalAction}, room::{BasicRoomDetails, FetchedRoomAvatar, FetchedRoomPreview}, room_preview_cache::{self, CachedRoomPreview}, shared::{avatar::AvatarWidgetRefExt, restore_status_view::RestoreStatusViewWidgetExt}, sliding_sync::{is_user_blocked, submit_async_request, MatrixRequest}, utils::{self, RoomNameId}}; @@ -18,6 +18,19 @@ script_mod! { use mod.widgets.* + // One part of an invite's details row: the join rule or the member count. + mod.widgets.InviteRoomDetailsLabel = Label { + width: Fit, height: Fit + padding: 0 + text: "" + draw_text +: { + text_style: REGULAR_TEXT { + font_size: 11, + }, + color: (COLOR_TEXT_SUBTLE) + } + } + mod.widgets.InviteScreen = set_type_default() do #(InviteScreen::register_widget(vm)) { ..mod.widgets.SolidView @@ -158,46 +171,46 @@ script_mod! { } } - room_details := Label { - width: Fill, height: Fit, - align: Align{x: 0.5, y: 0}, - flow: Flow.Right{wrap: true}, - text: "" - draw_text +: { - text_style: REGULAR_TEXT { - font_size: 11, - }, - color: (COLOR_TEXT_SUBTLE) - } + room_details := View { + width: Fill, height: Fit + flow: Flow.Right{wrap: true} + align: Align{x: 0.5, y: 0} + padding: 6 + spacing: 16 + // The same spacing between two regular wrapped lines of text. + wrap_spacing: 3.52 + + room_join_rule := mod.widgets.InviteRoomDetailsLabel {} + room_member_count := mod.widgets.InviteRoomDetailsLabel {} } - room_topic := Label { - width: Fill, height: Fit, - align: Align{x: 0.5, y: 0}, - margin: Inset{top: 5}, - flow: Flow.Right{wrap: true}, - max_lines: 3 - text_overflow: Ellipsis - text: "" - draw_text +: { - text_style: REGULAR_TEXT { - font_size: 12, - }, - color: (COLOR_INVITE_SCREEN_TOPIC_TEXT) + room_topic := CollapsibleLabel { + width: Fill {max: 500} + margin: Inset{top: 16, bottom: 6} + padding: Inset{left: 6, right: 6} + label +: { + align: Align{x: 0.5, y: 0} + draw_text +: { + text_style: REGULAR_TEXT { + font_size: 12, + }, + color: (COLOR_INVITE_SCREEN_TOPIC_TEXT) + } } } } buttons := View { width: Fill, height: Fit - // We'd like to use RightWrap, but it doesn't work with x-centered alignment - // flow: Flow.Right{wrap: true}, - flow: Right, + flow: Flow.Right{wrap: true}, align: Align{x: 0.5, y: 0.5} margin: Inset{top: 20} spacing: 40 + // matches the gap above reject_and_block_button. + wrap_spacing: 22 cancel_button := RobrixNegativeIconButton { + width: 135 align: Align{x: 0.5, y: 0.5} padding: 15, draw_icon.svg: (ICON_FORBIDDEN) @@ -206,6 +219,7 @@ script_mod! { } accept_button := RobrixPositiveIconButton { + width: 135 align: Align{x: 0.5, y: 0.5} padding: 15, draw_icon.svg: (ICON_CHECKMARK) @@ -247,6 +261,9 @@ script_mod! { pub struct InviteDetails { pub room_info: BasicRoomDetails, pub inviter: Option, + pub canonical_alias: Option, + pub topic: Option, + pub join_rule: Option, } impl Deref for InviteDetails { type Target = BasicRoomDetails; @@ -552,38 +569,34 @@ impl Widget for InviteScreen { let invite_room_label = info.room_name_id().to_string(); room_view.label(cx, ids!(room_name)).set_text(cx, &invite_room_label); - // Third, show extra details about the room/space from the fetched room preview, if we have it - let preview = self.room_preview.as_deref(); - let alias = preview.and_then(|p| p.canonical_alias.as_ref()); + // Third, show extra details about the room/space. + // These come from the invite itself, except the member count, which only the room preview has. let room_alias = room_view.label(cx, ids!(room_alias)); - room_alias.set_visible(cx, alias.is_some()); - room_alias.set_text(cx, alias.map_or("", |a| a.as_str())); + room_alias.set_visible(cx, info.canonical_alias.is_some()); + room_alias.set_text(cx, info.canonical_alias.as_ref().map_or("", |a| a.as_str())); - let join_rule = preview.and_then(|p| p.join_rule.as_ref()).map(|rule| match rule { + let join_rule = info.join_rule.as_ref().map(|rule| match rule { JoinRuleSummary::Public => "🌐 Public", _ => "🔒 Private", }); let room_or_space = if self.is_space { "space" } else { "room" }; + let room_join_rule = room_view.label(cx, ids!(room_join_rule)); + room_join_rule.set_visible(cx, join_rule.is_some()); + if let Some(rule) = join_rule { + room_join_rule.set_text(cx, &format!("{rule} {room_or_space}")); + } // If there are 0 members, that actually means we don't know how many members there are. - let num_members = preview.map(|p| p.num_joined_members).filter(|n| *n > 0); + let num_members = self.room_preview.as_deref().map(|p| p.num_joined_members).filter(|n| *n > 0); let plural = if num_members == Some(1) { "" } else { "s" }; - let details = match (join_rule, num_members) { - (Some(rule), Some(n)) => format!("{rule} {room_or_space} · {n} member{plural}"), - (Some(rule), None) => format!("{rule} {room_or_space}"), - (None, Some(n)) => format!("{n} member{plural}"), - (None, None) => String::new(), - }; - let room_details = room_view.label(cx, ids!(room_details)); - room_details.set_visible(cx, !details.is_empty()); - room_details.set_text(cx, &details); - - let topic = preview.and_then(|p| p.topic.as_deref()) - .map(str::trim) - .filter(|t| !t.is_empty()) - .map(|t| utils::replace_linebreaks_separators(t, false)); - let room_topic = room_view.label(cx, ids!(room_topic)); - room_topic.set_visible(cx, topic.is_some()); - room_topic.set_text(cx, topic.as_deref().unwrap_or_default()); + let member_count = num_members.map_or_else( + || String::from("👥 ?? members"), + |n| format!("👥 {n} member{plural}"), + ); + room_view.label(cx, ids!(room_member_count)).set_text(cx, &member_count); + + let room_topic = room_view.widget(cx, ids!(room_topic)); + room_topic.set_visible(cx, info.topic.is_some()); + room_topic.set_text(cx, info.topic.as_deref().unwrap_or_default()); // Fourth, set the buttons' text based on the invite state. let cancel_button = self.view.button(cx, ids!(cancel_button)); @@ -652,6 +665,12 @@ impl InviteScreen { room_avatar: invite.room_avatar.clone(), }, inviter: invite.inviter_info.clone(), + canonical_alias: invite.canonical_alias.clone(), + topic: invite.topic.as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_owned), + join_rule: invite.join_rule.clone(), }); self.invite_state = invite.invite_state; self.is_space = invite.is_space; diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 2e0dc5b08..1fff79827 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -22,7 +22,7 @@ use makepad_widgets::*; use matrix_sdk_ui::spaces::room_list::SpaceRoomListPaginationState; use ruma::events::tag::TagName; use tokio::sync::mpsc::UnboundedSender; -use matrix_sdk::{RoomState, ruma::{events::tag::Tags, MilliSecondsSinceUnixEpoch, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedServerName, OwnedUserId, RoomAliasId}}; +use matrix_sdk::{RoomState, ruma::{events::tag::Tags, room::JoinRuleSummary, MilliSecondsSinceUnixEpoch, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedServerName, OwnedUserId, RoomAliasId}}; use crate::{ app::{AppState, AppStateAction, SelectedRoom}, home::{ @@ -231,6 +231,12 @@ pub enum RoomsListUpdate { room_id: OwnedRoomId, is_space: bool, }, + /// Update the topic and join rule of the given invited room. + UpdateTopicAndJoinRule { + room_id: OwnedRoomId, + topic: Option, + join_rule: Option, + }, /// Remove the given room from the rooms list RemoveRoom { room_id: OwnedRoomId, @@ -396,6 +402,10 @@ pub struct InvitedRoomInfo { pub canonical_alias: Option, /// The alternative aliases for this room, if any. pub alt_aliases: Vec, + /// The topic of this room, if any. + pub topic: Option, + /// The join rule of this room, if known. + pub join_rule: Option, /// The avatar for this room: either an array of bytes holding the avatar image /// or a string holding the first Unicode character of the room name. pub room_avatar: FetchedRoomAvatar, @@ -1016,6 +1026,12 @@ impl RoomsList { cx.action(InviteScreenAction::IsSpace { room_id, is_space }); } } + RoomsListUpdate::UpdateTopicAndJoinRule { room_id, topic, join_rule } => { + if let Some(room) = self.invited_rooms.borrow_mut().get_mut(&room_id) { + room.topic = topic; + room.join_rule = join_rule; + } + } RoomsListUpdate::RemoveRoom { room_id, new_state, is_space } => { // TODO: once we have a dedicated LoadingScreen widget, we should emit an action // to replace this room (if it's currently open) with the LoadingScreen widget, diff --git a/src/room/reply_preview.rs b/src/room/reply_preview.rs index 494be525e..14c817670 100644 --- a/src/room/reply_preview.rs +++ b/src/room/reply_preview.rs @@ -99,32 +99,6 @@ script_mod! { } } - // The show more button (to expand) and show less button (to collapse) a tall reply preview. - mod.widgets.ReplyToggleButton = RobrixIconButton { - visible: false - width: Fit, height: Fit - spacing: 4 - padding: Inset{ top: 4, bottom: 4, left: 8, right: 8 } - draw_icon +: { color: (COLOR_TEXT_MUTED) } - icon_walk: Walk{ width: 10, height: 10 } - draw_text +: { - text_style: theme.font_regular { font_size: 10.0, line_spacing: 1.2 } - color: (COLOR_TEXT_MUTED) - color_hover: (COLOR_TEXT_MUTED) - color_down: (COLOR_TEXT_MUTED) - } - draw_bg +: { - color: (COLOR_BG_PREVIEW) - color_hover: (COLOR_BG_PREVIEW_HOVER) - color_down: (COLOR_BG_PREVIEW_DOWN) - border_size: 1.0 - border_color: (COLOR_PREVIEW_TOGGLE_BUTTON_BORDER) - border_color_hover: (COLOR_PREVIEW_TOGGLE_BUTTON_BORDER) - border_color_down: (COLOR_PREVIEW_TOGGLE_BUTTON_BORDER) - border_radius: 4.0 - } - } - // Wraps a reply preview in a collapsible view. mod.widgets.CollapsiblePreview = set_type_default() do #(CollapsiblePreview::register_widget(vm)) { visible: false @@ -156,12 +130,12 @@ script_mod! { } } - reply_expand_button := mod.widgets.ReplyToggleButton { + reply_expand_button := mod.widgets.ShowMoreLessButton { draw_icon +: { svg: (ICON_TRIANGLE_DOWN) } text: "Show more…" } - reply_collapse_button := mod.widgets.ReplyToggleButton { + reply_collapse_button := mod.widgets.ShowMoreLessButton { draw_icon +: { svg: (ICON_TRIANGLE_UP) } text: "Show less" } diff --git a/src/shared/collapsible_label.rs b/src/shared/collapsible_label.rs new file mode 100644 index 000000000..ac7464b98 --- /dev/null +++ b/src/shared/collapsible_label.rs @@ -0,0 +1,159 @@ +//! A `CollapsibleLabel` shows only the first few lines of a long text, +//! with "Show more" and "Show less" buttons to expand and collapse it. +//! +//! While collapsed, line breaks become spaces, so those few lines hold as much text as they can. +//! Once expanded, it shows the full text as written. + +use makepad_widgets::*; + +use crate::utils; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + // The show more button (to expand) and show less button (to collapse) tall content. + mod.widgets.ShowMoreLessButton = RobrixIconButton { + visible: false + width: Fit, height: Fit + spacing: 4 + padding: Inset{ top: 4, bottom: 4, left: 8, right: 8 } + draw_icon +: { color: (COLOR_TEXT_MUTED) } + icon_walk: Walk{ width: 10, height: 10 } + draw_text +: { + text_style: theme.font_regular { font_size: 10.0, line_spacing: 1.2 } + color: (COLOR_TEXT_MUTED) + color_hover: (COLOR_TEXT_MUTED) + color_down: (COLOR_TEXT_MUTED) + } + draw_bg +: { + color: (COLOR_BG_PREVIEW) + color_hover: (COLOR_BG_PREVIEW_HOVER) + color_down: (COLOR_BG_PREVIEW_DOWN) + border_size: 1.0 + border_color: (COLOR_PREVIEW_TOGGLE_BUTTON_BORDER) + border_color_hover: (COLOR_PREVIEW_TOGGLE_BUTTON_BORDER) + border_color_down: (COLOR_PREVIEW_TOGGLE_BUTTON_BORDER) + border_radius: 4.0 + } + } + + mod.widgets.CollapsibleLabel = #(CollapsibleLabel::register_widget(vm)) { + width: Fill, height: Fit + flow: Down + align: Align{x: 0.5, y: 0} + collapsed_lines: 2 + + label := Label { + width: Fill, height: Fit + padding: 0 + margin: 0 + flow: Flow.Right{wrap: true} + text_overflow: Ellipsis + text: "" + } + + expand_button := mod.widgets.ShowMoreLessButton { + margin: Inset{top: 6} + draw_icon +: { svg: (ICON_TRIANGLE_DOWN) } + text: "Show more…" + } + + collapse_button := mod.widgets.ShowMoreLessButton { + margin: Inset{top: 6} + draw_icon +: { svg: (ICON_TRIANGLE_UP) } + text: "Show less" + } + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct CollapsibleLabel { + #[deref] view: View, + #[walk] walk: Walk, + #[layout] layout: Layout, + #[redraw] #[area] #[rust] area: Area, + /// How many lines of text to show while collapsed. + #[live] collapsed_lines: usize, + #[rust] text: String, + /// The text with its line breaks replaced by spaces. + #[rust] collapsed_text: String, + #[rust] is_expanded: bool, +} + +impl Widget for CollapsibleLabel { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + if let Event::Actions(actions) = event { + let expand_button = self.view.button(cx, ids!(expand_button)); + let collapse_button = self.view.button(cx, ids!(collapse_button)); + if expand_button.clicked(actions) || collapse_button.clicked(actions) { + self.is_expanded = !self.is_expanded; + expand_button.reset_hover(cx); + collapse_button.reset_hover(cx); + self.redraw(cx); + } + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let label = self.view.label(cx, ids!(label)); + label.set_text(cx, if self.is_expanded { &self.text } else { &self.collapsed_text }); + if let Some(mut label) = label.borrow_mut() { + label.max_lines = if self.is_expanded { 0 } else { self.collapsed_lines }; + } + cx.begin_turtle(walk, self.layout); + let label_walk = label.walk(cx); + let text_width = cx.turtle().max_width(label_walk); + label.draw_all(cx, scope); + + // We can only tell if the collapsed text overflows once it's laid out, + // so we check that right after drawing the label. + let is_collapsible = !self.collapsed_text.is_empty() && label.borrow().is_some_and(|label| { + let laidout = label.draw_text.layout( + cx, + 0.0, + 0.0, + text_width.map(|w| (w.max(0.0) as f32) / label.draw_text.font_scale.max(0.0001)), + true, + label.align, + &self.collapsed_text, + ); + if self.is_expanded { + laidout.rows.len() > self.collapsed_lines + } else { + laidout.is_truncated + } + }); + let expand_button = self.view.button(cx, ids!(expand_button)); + let collapse_button = self.view.button(cx, ids!(collapse_button)); + let (shown, hidden) = if self.is_expanded { + (collapse_button, expand_button) + } else { + (expand_button, collapse_button) + }; + hidden.set_visible(cx, false); + shown.set_visible(cx, is_collapsible); + if is_collapsible { + shown.draw_all(cx, scope); + } + cx.end_turtle_with_area(&mut self.area); + DrawStep::done() + } + + fn text(&self) -> String { + self.text.clone() + } + + /// Sets the text, collapsing it again if it changed. + fn set_text(&mut self, cx: &mut Cx, text: &str) { + if self.text == text { + return; + } + self.text.clear(); + self.text.push_str(text); + self.collapsed_text = utils::replace_linebreaks_separators(text, false).into_owned(); + self.is_expanded = false; + self.redraw(cx); + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 23800c46c..1dfb1e914 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -4,6 +4,7 @@ use crate::settings::app_preferences::Appearance; pub mod attachment_download; pub mod avatar; pub mod collapsible_header; +pub mod collapsible_label; pub mod context_menu; pub mod expand_arrow; pub mod confirmation_modal; @@ -52,6 +53,7 @@ pub fn script_mod(vm: &mut ScriptVm, appearance: Appearance) { expand_arrow::script_mod(vm); unread_badge::script_mod(vm); collapsible_header::script_mod(vm); + collapsible_label::script_mod(vm); timestamp::script_mod(vm); room_filter_input_bar::script_mod(vm); room_input_popup_menu::script_mod(vm); diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index a167e2588..1d2fde0b4 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -26,7 +26,7 @@ use matrix_sdk_ui::{ }; #[cfg(not(target_os = "ios"))] use robius_open::Uri; -use ruma::{OwnedRoomAliasId, OwnedRoomOrAliasId, RoomId, events::tag::Tags}; +use ruma::{OwnedRoomAliasId, OwnedRoomOrAliasId, RoomId, events::tag::Tags, room::JoinRuleSummary}; use tokio::{ runtime::Handle, sync::{broadcast, mpsc::{Sender, UnboundedReceiver, UnboundedSender}, watch, Notify, Semaphore}, task::JoinHandle, time::{Instant, error::Elapsed}, @@ -3689,6 +3689,10 @@ struct RoomListServiceRoomInfo { alt_aliases: Vec, /// Only ever set for invited rooms. inviter_info: Option, + /// Only ever set for invited rooms. + topic: Option, + /// Only ever set for invited rooms. + join_rule: Option, num_joined_members: u64, num_invited_members: u64, /// Whether all of this room's members are fully available in the local store. @@ -3728,6 +3732,7 @@ impl RoomListServiceRoomInfo { } ); + let is_invited = room.state() == RoomState::Invited; Self { room_id: room.room_id().to_owned(), state: room.state(), @@ -3746,6 +3751,8 @@ impl RoomListServiceRoomInfo { canonical_alias: room.canonical_alias(), alt_aliases: room.alt_aliases(), inviter_info, + topic: is_invited.then(|| room.topic()).flatten(), + join_rule: is_invited.then(|| room.join_rule().map(JoinRuleSummary::from)).flatten(), num_joined_members: room.joined_members_count(), num_invited_members: room.invited_members_count(), members_synced: room.are_members_synced(), @@ -4580,6 +4587,15 @@ async fn update_room( }); } + // An invite's topic and join rule might arrive after the invite itself. + if old_room.topic != new_room.topic || old_room.join_rule != new_room.join_rule { + enqueue_rooms_list_update(RoomsListUpdate::UpdateTopicAndJoinRule { + room_id: new_room_id.clone(), + topic: new_room.topic.clone(), + join_rule: new_room.join_rule.clone(), + }); + } + // Check to see if any room aliases have changed. if old_room.canonical_alias != new_room.canonical_alias || old_room.alt_aliases != new_room.alt_aliases @@ -4777,6 +4793,8 @@ async fn add_new_room( room_avatar, canonical_alias: new_room.canonical_alias.clone(), alt_aliases: new_room.alt_aliases.clone(), + topic: new_room.topic.clone(), + join_rule: new_room.join_rule.clone(), invite_state: Default::default(), is_selected: false, is_direct: new_room.is_direct,