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,