From 03a8ac662cde0e95b17c7fbdd6039e88adc50598 Mon Sep 17 00:00:00 2001 From: Kevin Boos Date: Fri, 13 Feb 2026 01:48:43 -0800 Subject: [PATCH 01/14] [wip] initial support for a threaded view --- src/app.rs | 35 +- src/home/editing_pane.rs | 37 +- src/home/event_reaction_list.rs | 1 + src/home/main_desktop_ui.rs | 57 ++- src/home/main_mobile_ui.rs | 14 +- src/home/new_message_context_menu.rs | 2 + src/home/room_screen.rs | 510 ++++++++++++++++++++----- src/home/rooms_list.rs | 2 + src/persistence/matrix_state.rs | 3 + src/room/room_input_bar.rs | 71 +++- src/shared/avatar.rs | 1 + src/sliding_sync.rs | 539 ++++++++++++++++++++++++--- src/utils.rs | 1 + 13 files changed, 1093 insertions(+), 180 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8737aa5a..394ad80d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ use std::{cell::RefCell, collections::HashMap}; use makepad_widgets::*; -use matrix_sdk::{RoomState, ruma::{OwnedRoomId, RoomId}}; +use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, RoomId}}; use serde::{Deserialize, Serialize}; use crate::{ avatar_cache::clear_avatar_cache, home::{ @@ -380,6 +380,7 @@ impl MatchEvent for App { SelectedRoom::JoinedRoom { room_name_id } => room_name_id.to_string(), SelectedRoom::InvitedRoom { room_name_id } => room_name_id.to_string(), SelectedRoom::Space { space_name_id } => format!("[Space] {}", space_name_id), + SelectedRoom::Thread { room_name_id, .. } => format!("Thread · {}", room_name_id), }; self.app_state.selected_room = Some(selected_room); // Set the Stack Navigation header to show the name of the newly-selected room. @@ -858,6 +859,10 @@ pub enum SelectedRoom { JoinedRoom { room_name_id: RoomNameId }, InvitedRoom { room_name_id: RoomNameId }, Space { space_name_id: RoomNameId }, + Thread { + room_name_id: RoomNameId, + thread_root_event_id: OwnedEventId, + }, } impl SelectedRoom { @@ -866,6 +871,7 @@ impl SelectedRoom { SelectedRoom::JoinedRoom { room_name_id } => room_name_id.room_id(), SelectedRoom::InvitedRoom { room_name_id } => room_name_id.room_id(), SelectedRoom::Space { space_name_id } => space_name_id.room_id(), + SelectedRoom::Thread { room_name_id, .. } => room_name_id.room_id(), } } @@ -874,6 +880,14 @@ impl SelectedRoom { SelectedRoom::JoinedRoom { room_name_id } => room_name_id, SelectedRoom::InvitedRoom { room_name_id } => room_name_id, SelectedRoom::Space { space_name_id } => space_name_id, + SelectedRoom::Thread { room_name_id, .. } => room_name_id, + } + } + + pub fn thread_root_event_id(&self) -> Option<&OwnedEventId> { + match self { + SelectedRoom::Thread { thread_root_event_id, .. } => Some(thread_root_event_id), + _ => None, } } @@ -898,7 +912,24 @@ impl SelectedRoom { } impl PartialEq for SelectedRoom { fn eq(&self, other: &Self) -> bool { - self.room_id() == other.room_id() + match (self, other) { + ( + SelectedRoom::Thread { + room_name_id: lhs_room_name_id, + thread_root_event_id: lhs_thread_root_event_id, + }, + SelectedRoom::Thread { + room_name_id: rhs_room_name_id, + thread_root_event_id: rhs_thread_root_event_id, + }, + ) => { + lhs_room_name_id.room_id() == rhs_room_name_id.room_id() + && lhs_thread_root_event_id == rhs_thread_root_event_id + } + (SelectedRoom::Thread { .. }, _) + | (_, SelectedRoom::Thread { .. }) => false, + _ => self.room_id() == other.room_id(), + } } } impl Eq for SelectedRoom {} diff --git a/src/home/editing_pane.rs b/src/home/editing_pane.rs index 42005192..66869838 100644 --- a/src/home/editing_pane.rs +++ b/src/home/editing_pane.rs @@ -2,6 +2,7 @@ use makepad_widgets::{text::selection::Cursor, *}; use matrix_sdk::{ room::edit::EditedContent, ruma::{ + OwnedEventId, OwnedRoomId, events::{ poll::unstable_start::{UnstablePollAnswer, UnstablePollStartContentBlock}, @@ -151,6 +152,7 @@ pub enum EditingPaneAction { struct EditingPaneInfo { event_tl_item: EventTimelineItem, room_id: OwnedRoomId, + thread_root_event_id: Option, } /// A view that slides in from the bottom of the screen to allow editing a message. @@ -370,6 +372,7 @@ impl Widget for EditingPane { submit_async_request(MatrixRequest::EditMessage { room_id: info.room_id.clone(), + thread_root_event_id: info.thread_root_event_id.clone(), timeline_event_item_id: info.event_tl_item.identifier(), edited_content, }); @@ -426,7 +429,13 @@ impl EditingPane { } /// Shows the editing pane and sets it up to edit the given `event`'s content. - pub fn show(&mut self, cx: &mut Cx, event_tl_item: EventTimelineItem, room_id: OwnedRoomId) { + pub fn show( + &mut self, + cx: &mut Cx, + event_tl_item: EventTimelineItem, + room_id: OwnedRoomId, + thread_root_event_id: Option, + ) { if !event_tl_item.is_editable() { enqueue_popup_notification( "That message cannot be edited.", @@ -452,7 +461,11 @@ impl EditingPane { } - self.info = Some(EditingPaneInfo { event_tl_item, room_id: room_id.clone() }); + self.info = Some(EditingPaneInfo { + event_tl_item, + room_id: room_id.clone(), + thread_root_event_id, + }); self.visible = true; self.button(ids!(accept_button)).reset_hover(cx); @@ -488,12 +501,17 @@ impl EditingPane { cx: &mut Cx, editing_pane_state: EditingPaneState, room_id: OwnedRoomId, + thread_root_event_id: Option, ) { let EditingPaneState { event_tl_item, text_input_state } = editing_pane_state; self.mentionable_text_input(ids!(editing_content.edit_text_input)) .text_input_ref() .restore_state(cx, text_input_state); - self.info = Some(EditingPaneInfo { event_tl_item, room_id: room_id.clone() }); + self.info = Some(EditingPaneInfo { + event_tl_item, + room_id: room_id.clone(), + thread_root_event_id, + }); self.visible = true; self.button(ids!(accept_button)).reset_hover(cx); self.button(ids!(cancel_button)).reset_hover(cx); @@ -537,11 +555,17 @@ impl EditingPaneRef { } /// See [`EditingPane::show()`]. - pub fn show(&self, cx: &mut Cx, event_tl_item: EventTimelineItem, room_id: OwnedRoomId) { + pub fn show( + &self, + cx: &mut Cx, + event_tl_item: EventTimelineItem, + room_id: OwnedRoomId, + thread_root_event_id: Option, + ) { let Some(mut inner) = self.borrow_mut() else { return; }; - inner.show(cx, event_tl_item, room_id); + inner.show(cx, event_tl_item, room_id, thread_root_event_id); } /// See [`EditingPane::save_state()`]. @@ -557,9 +581,10 @@ impl EditingPaneRef { cx: &mut Cx, editing_pane_state: EditingPaneState, room_id: OwnedRoomId, + thread_root_event_id: Option, ) { let Some(mut inner) = self.borrow_mut() else { return }; - inner.restore_state(cx, editing_pane_state, room_id); + inner.restore_state(cx, editing_pane_state, room_id, thread_root_event_id); } /// Hides the editing pane immediately and clears its state without animating it out. diff --git a/src/home/event_reaction_list.rs b/src/home/event_reaction_list.rs index cf1656f4..9063ffe1 100644 --- a/src/home/event_reaction_list.rs +++ b/src/home/event_reaction_list.rs @@ -176,6 +176,7 @@ impl Widget for ReactionList { }; submit_async_request(MatrixRequest::ToggleReaction { room_id: room_id.clone(), + thread_root_event_id: None, timeline_event_id: timeline_event_id.clone(), reaction: reaction_data.reaction.clone(), }); diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 3b331da6..63e62b05 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -134,6 +134,23 @@ impl Widget for MainDesktopUI { } impl MainDesktopUI { + fn selected_room_tab_id(selected_room: &SelectedRoom) -> LiveId { + match selected_room { + SelectedRoom::Thread { + room_name_id, + thread_root_event_id, + } => LiveId::from_str( + format!( + "{}::thread::{}", + room_name_id.room_id(), + thread_root_event_id, + ) + .as_str(), + ), + _ => LiveId::from_str(selected_room.room_id().as_str()), + } + } + /// Focuses on a room if it is already open, otherwise creates a new tab for the room. fn focus_or_create_tab(&mut self, cx: &mut Cx, room: SelectedRoom) { // Do nothing if the room to select is already created and focused. @@ -144,9 +161,9 @@ impl MainDesktopUI { let dock = self.view.dock(ids!(dock)); // 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); + let room_tab_id = Self::selected_room_tab_id(&room); + if self.open_rooms.contains_key(&room_tab_id) { + dock.select_tab(cx, room_tab_id); self.most_recently_selected_room = Some(room); return; } @@ -165,13 +182,17 @@ impl MainDesktopUI { id!(space_lobby_screen), format!("[Space] {}", space_name_id), ), + SelectedRoom::Thread { room_name_id, .. } => ( + id!(room_screen), + format!("{room_name_id} · Thread"), + ), }; // Insert the tab after the currently-selected room's tab, if possible. // Otherwise, insert it after the home tab, which should always exist. let (tab_bar, insert_after) = self.most_recently_selected_room.as_ref() .and_then(|curr_room| { - let curr_room_id = LiveId::from_str(curr_room.room_id().as_str()); + let curr_room_id = Self::selected_room_tab_id(curr_room); dock.find_tab_bar_of_tab(curr_room_id) }) .unwrap_or_else(|| dock.find_tab_bar_of_tab(id!(home_tab)).unwrap()); @@ -179,7 +200,7 @@ impl MainDesktopUI { let new_tab_widget = dock.create_and_select_tab( cx, tab_bar, - room_id_as_live_id, + room_tab_id, kind, name, id!(CloseableTab), @@ -194,6 +215,7 @@ impl MainDesktopUI { new_widget.as_room_screen().set_displayed_room( cx, room_name_id, + None, ); } SelectedRoom::InvitedRoom { room_name_id } => { @@ -208,13 +230,23 @@ impl MainDesktopUI { space_name_id, ); } + SelectedRoom::Thread { + room_name_id, + thread_root_event_id, + } => { + new_widget.as_room_screen().set_displayed_room( + cx, + room_name_id, + Some(thread_root_event_id.clone()), + ); + } } 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.open_rooms.insert(room_tab_id, room.clone()); self.most_recently_selected_room = Some(room); } @@ -291,7 +323,7 @@ impl MainDesktopUI { // Set the info to be displayed in the newly-replaced RoomScreen.. new_widget .as_room_screen() - .set_displayed_room(cx, room_name_id); + .set_displayed_room(cx, room_name_id, None); // Go through all existing `SelectedRoom` instances and replace the // `SelectedRoom::InvitedRoom`s with `SelectedRoom::JoinedRoom`s. @@ -365,6 +397,7 @@ impl MainDesktopUI { widget.as_room_screen().set_displayed_room( cx, room_name_id, + None, ); } Some(SelectedRoom::InvitedRoom { room_name_id }) => { @@ -379,6 +412,16 @@ impl MainDesktopUI { space_name_id, ); } + Some(SelectedRoom::Thread { + room_name_id, + thread_root_event_id, + }) => { + widget.as_room_screen().set_displayed_room( + cx, + room_name_id, + Some(thread_root_event_id.clone()), + ); + } None => { } } } diff --git a/src/home/main_mobile_ui.rs b/src/home/main_mobile_ui.rs index 3709f1ff..9fc35007 100644 --- a/src/home/main_mobile_ui.rs +++ b/src/home/main_mobile_ui.rs @@ -89,7 +89,7 @@ impl Widget for MainMobileUI { // Get a reference to the `RoomScreen` widget and tell it which room's data to show. self.view .room_screen(ids!(room_screen)) - .set_displayed_room(cx, room_name_id); + .set_displayed_room(cx, room_name_id, None); } Some(SelectedRoom::InvitedRoom { room_name_id }) => { show_welcome = false; @@ -109,6 +109,18 @@ impl Widget for MainMobileUI { .space_lobby_screen(ids!(space_lobby_screen)) .set_displayed_space(cx, space_name_id); } + Some(SelectedRoom::Thread { + room_name_id, + thread_root_event_id, + }) => { + show_welcome = false; + show_room = true; + show_invite = false; + show_space_lobby = false; + self.view + .room_screen(ids!(room_screen)) + .set_displayed_room(cx, room_name_id, Some(thread_root_event_id.clone())); + } None => { show_welcome = true; show_room = false; diff --git a/src/home/new_message_context_menu.rs b/src/home/new_message_context_menu.rs index 1efd2ddc..7cb21b3a 100644 --- a/src/home/new_message_context_menu.rs +++ b/src/home/new_message_context_menu.rs @@ -283,6 +283,8 @@ pub struct MessageDetails { /// The event ID of the message that this message is related to, if any, /// such as the replied-to message. pub related_event_id: Option, + /// The event ID of the thread root if this message is a thread root. + pub thread_root_event_id: Option, /// The widget ID of the RoomScreen that contains this message. pub room_screen_widget_uid: WidgetUid, /// Whether this message should be highlighted, i.e., diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 75cb7a5a..012c0bf5 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -25,7 +25,7 @@ use matrix_sdk_ui::timeline::{ use ruma::{OwnedUserId, events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent}}; use crate::{ - app::{AppStateAction, ConfirmDeleteAction}, 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_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, room_image_viewer::{get_image_name_and_filesize, populate_matrix_image_modal}, rooms_list::RoomsListRef, tombstone_footer::SuccessorRoomDetails}, media_cache::{MediaCache, MediaCacheEntry}, profile::{ + app::{AppStateAction, ConfirmDeleteAction, SelectedRoom}, 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_room_membership_change, text_preview_of_timeline_item}, home::{edited_indicator::EditedIndicatorWidgetRefExt, link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, room_image_viewer::{get_image_name_and_filesize, populate_matrix_image_modal}, rooms_list::{RoomsListAction, RoomsListRef}, tombstone_footer::SuccessorRoomDetails}, media_cache::{MediaCache, MediaCacheEntry}, profile::{ user_profile::{ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache, }, @@ -237,6 +237,40 @@ live_design! { reaction_list = { } avatar_row = {} } + + thread_root_summary = { + visible: false + width: Fill, + height: Fit + flow: Right, + spacing: 5.0 + margin: { top: 5.0 } + padding: { left: 8.0, right: 8.0, top: 6.0, bottom: 6.0 } + cursor: Hand + show_bg: true + draw_bg: { + color: #f4f9ff + } + + thread_summary_count =