From dd8fe418218a2a30c00a814f81d1d1d70fc63e77 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: Tue, 24 Mar 2026 03:11:22 +0800 Subject: [PATCH 01/66] Fix stale session restore and in-app signup flow --- src/login/login_screen.rs | 323 +++-- src/persistence/matrix_state.rs | 77 +- src/sliding_sync.rs | 2228 +++++++++++++++++++++---------- 3 files changed, 1769 insertions(+), 859 deletions(-) diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index 3b3c322a1..dfa25fee7 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -3,7 +3,9 @@ use std::ops::Not; use makepad_widgets::*; use url::Url; -use crate::sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest}; +use crate::sliding_sync::{ + submit_async_request, LoginByPassword, LoginRequest, MatrixRequest, RegisterAccount, +}; use super::login_status_modal::{LoginStatusModalAction, LoginStatusModalWidgetExt}; @@ -60,7 +62,7 @@ script_mod! { show_bg: true, draw_bg.color: (COLOR_SECONDARY) // draw_bg.color: (COLOR_PRIMARY) // TODO: once Makepad supports `Fill {max: 375}`, change this back to COLOR_PRIMARY - + // allow the view to be scrollable but hide the actual scroll bar scroll_bars: { scroll_bar_y: { @@ -123,6 +125,19 @@ script_mod! { is_password: true, } + confirm_password_wrapper := View { + width: 275, height: Fit, + visible: false, + + confirm_password_input := RobrixTextInput { + width: 275, height: Fit + flow: Right, // do not wrap + padding: 10, + empty_text: "Confirm password" + is_password: true, + } + } + View { width: 275, height: Fit, flow: Down, @@ -160,7 +175,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } } } - + login_button := RobrixIconButton { width: 275, @@ -171,54 +186,61 @@ script_mod! { text: "Login" } - LineH { - width: 275 - margin: Inset{bottom: -5} - draw_bg.color: #C8C8C8 - } + login_only_view := View { + width: Fit, height: Fit, + flow: Down, + align: Align{x: 0.5, y: 0.5} + spacing: 15.0 - Label { - width: Fit, height: Fit - padding: 0, - draw_text +: { - color: (COLOR_TEXT) - text_style: TITLE_TEXT {font_size: 11.0} + LineH { + width: 275 + margin: Inset{bottom: -5} + draw_bg.color: #C8C8C8 } - text: "Or, login with an SSO provider:" - } - sso_view := View { - width: 275, height: Fit, - margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide - flow: Flow.Right{wrap: true}, - apple_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/apple.png") + Label { + width: Fit, height: Fit + padding: 0, + draw_text +: { + color: (COLOR_TEXT) + text_style: TITLE_TEXT {font_size: 11.0} } + text: "Or, login with an SSO provider:" } - facebook_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/facebook.png") + + sso_view := View { + width: 275, height: Fit, + margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide + flow: Flow.Right{wrap: true}, + apple_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/apple.png") + } } - } - github_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/github.png") + facebook_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/facebook.png") + } } - } - gitlab_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/gitlab.png") + github_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/github.png") + } } - } - google_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/google.png") + gitlab_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/gitlab.png") + } } - } - twitter_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/x.png") + google_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/google.png") + } + } + twitter_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/x.png") + } } } } @@ -233,7 +255,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } - Label { + account_prompt_label := Label { width: Fit, height: Fit padding: Inset{left: 1, right: 1, top: 0, bottom: 0} draw_text +: { @@ -245,8 +267,8 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } } - - signup_button := RobrixIconButton { + + mode_toggle_button := RobrixIconButton { width: Fit, height: Fit padding: Inset{left: 15, right: 15, top: 10, bottom: 10} margin: Inset{bottom: 5} @@ -270,18 +292,77 @@ script_mod! { } } -static MATRIX_SIGN_UP_URL: &str = "https://matrix.org/docs/chat_basics/matrix-for-im/#creating-a-matrix-account"; - #[derive(Script, ScriptHook, Widget)] pub struct LoginScreen { - #[source] source: ScriptObjectRef, - #[deref] view: View, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + /// Whether the screen is showing the in-app sign-up flow. + #[rust] + signup_mode: bool, /// Boolean to indicate if the SSO login process is still in flight - #[rust] sso_pending: bool, + #[rust] + sso_pending: bool, /// The URL to redirect to after logging in with SSO. - #[rust] sso_redirect_url: Option, + #[rust] + sso_redirect_url: Option, + /// The most recent login failure message shown to the user. + #[rust] + last_failure_message_shown: Option, } +impl LoginScreen { + fn set_signup_mode(&mut self, cx: &mut Cx, signup_mode: bool) { + self.signup_mode = signup_mode; + self.view + .view(cx, ids!(confirm_password_wrapper)) + .set_visible(cx, signup_mode); + self.view + .view(cx, ids!(login_only_view)) + .set_visible(cx, !signup_mode); + self.view.label(cx, ids!(title)).set_text( + cx, + if signup_mode { + "Create your Robrix account" + } else { + "Login to Robrix" + }, + ); + self.view.button(cx, ids!(login_button)).set_text( + cx, + if signup_mode { + "Create account" + } else { + "Login" + }, + ); + self.view.label(cx, ids!(account_prompt_label)).set_text( + cx, + if signup_mode { + "Already have an account?" + } else { + "Don't have an account?" + }, + ); + self.view.button(cx, ids!(mode_toggle_button)).set_text( + cx, + if signup_mode { + "Back to login" + } else { + "Sign up here" + }, + ); + + if !signup_mode { + self.view + .text_input(cx, ids!(confirm_password_input)) + .set_text(cx, ""); + } + + self.redraw(cx); + } +} impl Widget for LoginScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { @@ -297,27 +378,31 @@ impl Widget for LoginScreen { impl MatchEvent for LoginScreen { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { let login_button = self.view.button(cx, ids!(login_button)); - let signup_button = self.view.button(cx, ids!(signup_button)); + let mode_toggle_button = self.view.button(cx, ids!(mode_toggle_button)); let user_id_input = self.view.text_input(cx, ids!(user_id_input)); let password_input = self.view.text_input(cx, ids!(password_input)); + let confirm_password_input = self.view.text_input(cx, ids!(confirm_password_input)); let homeserver_input = self.view.text_input(cx, ids!(homeserver_input)); let login_status_modal = self.view.modal(cx, ids!(login_status_modal)); - let login_status_modal_inner = self.view.login_status_modal(cx, ids!(login_status_modal_inner)); + let login_status_modal_inner = self + .view + .login_status_modal(cx, ids!(login_status_modal_inner)); - if signup_button.clicked(actions) { - log!("Opening URL \"{}\"", MATRIX_SIGN_UP_URL); - let _ = robius_open::Uri::new(MATRIX_SIGN_UP_URL).open(); + if mode_toggle_button.clicked(actions) { + self.set_signup_mode(cx, !self.signup_mode); } if login_button.clicked(actions) || user_id_input.returned(actions).is_some() || password_input.returned(actions).is_some() + || (self.signup_mode && confirm_password_input.returned(actions).is_some()) || homeserver_input.returned(actions).is_some() { - let user_id = user_id_input.text(); + let user_id = user_id_input.text().trim().to_owned(); let password = password_input.text(); - let homeserver = homeserver_input.text(); + let confirm_password = confirm_password_input.text(); + let homeserver = homeserver_input.text().trim().to_owned(); if user_id.is_empty() { login_status_modal_inner.set_title(cx, "Missing User ID"); login_status_modal_inner.set_status(cx, "Please enter a valid User ID."); @@ -326,27 +411,59 @@ impl MatchEvent for LoginScreen { login_status_modal_inner.set_title(cx, "Missing Password"); login_status_modal_inner.set_status(cx, "Please enter a valid password."); login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); + } else if self.signup_mode && password != confirm_password { + login_status_modal_inner.set_title(cx, "Passwords do not match"); + login_status_modal_inner.set_status( + cx, + "Please enter the same password in both password fields.", + ); + login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); } else { - login_status_modal_inner.set_title(cx, "Logging in..."); - login_status_modal_inner.set_status(cx, "Waiting for a login response..."); - login_status_modal_inner.button_ref(cx).set_text(cx, "Cancel"); - submit_async_request(MatrixRequest::Login(LoginRequest::LoginByPassword(LoginByPassword { - user_id, - password, - homeserver: homeserver.is_empty().not().then_some(homeserver), - }))); + self.last_failure_message_shown = None; + login_status_modal_inner.set_title( + cx, + if self.signup_mode { + "Creating account..." + } else { + "Logging in..." + }, + ); + login_status_modal_inner.set_status( + cx, + if self.signup_mode { + "Waiting for the homeserver to create your account..." + } else { + "Waiting for a login response..." + }, + ); + login_status_modal_inner + .button_ref(cx) + .set_text(cx, "Cancel"); + submit_async_request(MatrixRequest::Login(if self.signup_mode { + LoginRequest::Register(RegisterAccount { + user_id, + password, + homeserver: homeserver.is_empty().not().then_some(homeserver), + }) + } else { + LoginRequest::LoginByPassword(LoginByPassword { + user_id, + password, + homeserver: homeserver.is_empty().not().then_some(homeserver), + }) + })); } login_status_modal.open(cx); self.redraw(cx); } - + let provider_brands = ["apple", "facebook", "github", "gitlab", "google", "twitter"]; let button_set: &[&[LiveId]] = ids_array!( - apple_button, - facebook_button, - github_button, - gitlab_button, - google_button, + apple_button, + facebook_button, + github_button, + gitlab_button, + google_button, twitter_button ); for action in actions { @@ -356,21 +473,24 @@ impl MatchEvent for LoginScreen { // Handle login-related actions received from background async tasks. match action.downcast_ref() { - Some(LoginAction::CliAutoLogin { user_id, homeserver }) => { + Some(LoginAction::CliAutoLogin { + user_id, + homeserver, + }) => { + self.last_failure_message_shown = None; user_id_input.set_text(cx, user_id); password_input.set_text(cx, ""); homeserver_input.set_text(cx, homeserver.as_deref().unwrap_or_default()); login_status_modal_inner.set_title(cx, "Logging in via CLI..."); - login_status_modal_inner.set_status( - cx, - &format!("Auto-logging in as user {user_id}...") - ); + login_status_modal_inner + .set_status(cx, &format!("Auto-logging in as user {user_id}...")); let login_status_modal_button = login_status_modal_inner.button_ref(cx); login_status_modal_button.set_text(cx, "Cancel"); login_status_modal_button.set_enabled(cx, false); // Login cancel not yet supported login_status_modal.open(cx); } Some(LoginAction::Status { title, status }) => { + self.last_failure_message_shown = None; login_status_modal_inner.set_title(cx, title); login_status_modal_inner.set_status(cx, status); let login_status_modal_button = login_status_modal_inner.button_ref(cx); @@ -382,14 +502,28 @@ impl MatchEvent for LoginScreen { Some(LoginAction::LoginSuccess) => { // The main `App` component handles showing the main screen // and hiding the login screen & login status modal. + self.last_failure_message_shown = None; + self.set_signup_mode(cx, false); user_id_input.set_text(cx, ""); password_input.set_text(cx, ""); + confirm_password_input.set_text(cx, ""); homeserver_input.set_text(cx, ""); login_status_modal.close(cx); self.redraw(cx); } Some(LoginAction::LoginFailure(error)) => { - login_status_modal_inner.set_title(cx, "Login Failed."); + if self.last_failure_message_shown.as_deref() == Some(error.as_str()) { + continue; + } + self.last_failure_message_shown = Some(error.clone()); + login_status_modal_inner.set_title( + cx, + if self.signup_mode { + "Account Creation Failed." + } else { + "Login Failed." + }, + ); login_status_modal_inner.set_status(cx, error); let login_status_modal_button = login_status_modal_inner.button_ref(cx); login_status_modal_button.set_text(cx, "Okay"); @@ -399,9 +533,15 @@ impl MatchEvent for LoginScreen { } Some(LoginAction::SsoPending(pending)) => { let mask = if *pending { 1.0 } else { 0.0 }; - let cursor = if *pending { MouseCursor::NotAllowed } else { MouseCursor::Hand }; + let cursor = if *pending { + MouseCursor::NotAllowed + } else { + MouseCursor::Hand + }; for view_ref in self.view_set(cx, button_set).iter() { - let Some(mut view_mut) = view_ref.borrow_mut() else { continue }; + let Some(mut view_mut) = view_ref.borrow_mut() else { + continue; + }; let mut image = view_mut.image(cx, ids!(image)); script_apply_eval!(cx, image, { draw_bg.mask: #(mask) @@ -414,7 +554,7 @@ impl MatchEvent for LoginScreen { Some(LoginAction::SsoSetRedirectUrl(url)) => { self.sso_redirect_url = Some(url.to_string()); } - _ => { } + _ => {} } } @@ -423,7 +563,10 @@ impl MatchEvent for LoginScreen { let login_status_modal_button = login_status_modal_inner.button_ref(cx); if login_status_modal_button.clicked(actions) { let request_id = id!(SSO_CANCEL_BUTTON); - let request = HttpRequest::new(format!("{}/?login_token=",sso_redirect_url), HttpMethod::GET); + let request = HttpRequest::new( + format!("{}/?login_token=", sso_redirect_url), + HttpMethod::GET, + ); cx.http_request(request_id, request); self.sso_redirect_url = None; } @@ -432,15 +575,14 @@ impl MatchEvent for LoginScreen { // Handle any of the SSO login buttons being clicked for (view_ref, brand) in self.view_set(cx, button_set).iter().zip(&provider_brands) { if view_ref.finger_up(actions).is_some() && !self.sso_pending { - submit_async_request(MatrixRequest::SpawnSSOServer{ - identity_provider_id: format!("oidc-{}",brand), + submit_async_request(MatrixRequest::SpawnSSOServer { + identity_provider_id: format!("oidc-{}", brand), brand: brand.to_string(), - homeserver_url: homeserver_input.text() + homeserver_url: homeserver_input.text(), }); } } } - } /// Actions sent to or from the login screen. @@ -451,10 +593,7 @@ pub enum LoginAction { /// A negative response from the backend Matrix task to the login screen. LoginFailure(String), /// A login-related status message to display to the user. - Status { - title: String, - status: String, - }, + Status { title: String, status: String }, /// The given login info was specified on the command line (CLI), /// and the login process is underway. CliAutoLogin { @@ -465,9 +604,9 @@ pub enum LoginAction { /// informing it that the SSO login process is either still in flight (`true`) or has finished (`false`). /// /// Note that an inner value of `false` does *not* imply that the login request has - /// successfully finished. + /// successfully finished. /// The login screen can use this to prevent the user from submitting - /// additional SSO login requests while a previous request is in flight. + /// additional SSO login requests while a previous request is in flight. SsoPending(bool), /// Set the SSO redirect URL in the LoginScreen. /// diff --git a/src/persistence/matrix_state.rs b/src/persistence/matrix_state.rs index d99855b7c..f984a2f3b 100644 --- a/src/persistence/matrix_state.rs +++ b/src/persistence/matrix_state.rs @@ -6,15 +6,11 @@ use makepad_widgets::{log, Cx}; use matrix_sdk::{ authentication::matrix::MatrixSession, ruma::{OwnedUserId, UserId}, - sliding_sync, - Client, + sliding_sync, Client, }; use serde::{Deserialize, Serialize}; -use crate::{ - app_data_dir, - login::login_screen::LoginAction, -}; +use crate::{app_data_dir, login::login_screen::LoginAction}; /// The data needed to re-build a client. #[derive(Clone, Serialize, Deserialize)] @@ -57,11 +53,11 @@ pub struct FullSessionPersisted { pub sync_token: Option, /// The sliding sync version to use for this client session. - /// + /// /// This determines the sync protocol used by the Matrix client: /// - `Native`: Uses the server's native sliding sync implementation for efficient syncing /// - `None`: Falls back to standard Matrix sync (without sliding sync optimizations) - /// + /// /// The value is restored and applied to the client via `client.set_sliding_sync_version()` /// when rebuilding the session from persistent storage. #[serde(default)] @@ -93,9 +89,7 @@ impl From for SlidingSyncVersion { } fn user_id_to_file_name(user_id: &UserId) -> String { - user_id.as_str() - .replace(":", "_") - .replace("@", "") + user_id.as_str().replace(":", "_").replace("@", "") } /// Returns the path to the persistent state directory for the given user. @@ -114,14 +108,12 @@ const LATEST_USER_ID_FILE_NAME: &str = "latest_user_id.txt"; /// Returns the user ID of the most recently-logged in user session. pub async fn most_recent_user_id() -> Option { - tokio::fs::read_to_string( - app_data_dir().join(LATEST_USER_ID_FILE_NAME) - ) - .await - .ok()? - .trim() - .try_into() - .ok() + tokio::fs::read_to_string(app_data_dir().join(LATEST_USER_ID_FILE_NAME)) + .await + .ok()? + .trim() + .try_into() + .ok() } /// Save which user was the most recently logged in. @@ -129,17 +121,17 @@ async fn save_latest_user_id(user_id: &UserId) -> anyhow::Result<()> { tokio::fs::write( app_data_dir().join(LATEST_USER_ID_FILE_NAME), user_id.as_str(), - ).await?; + ) + .await?; Ok(()) } - /// Restores the given user's previous session from the filesystem. /// /// If no User ID is specified, the ID of the most recently-logged in user /// is retrieved from the filesystem. pub async fn restore_session( - user_id: Option + user_id: Option, ) -> anyhow::Result<(Client, Option)> { let user_id = if let Some(user_id) = user_id { Some(user_id) @@ -165,8 +157,12 @@ pub async fn restore_session( // The session was serialized as JSON in a file. let serialized_session = tokio::fs::read_to_string(session_file).await?; - let FullSessionPersisted { client_session, user_session, sync_token, sliding_sync_version } = - serde_json::from_str(&serialized_session)?; + let FullSessionPersisted { + client_session, + user_session, + sync_token, + sliding_sync_version, + } = serde_json::from_str(&serialized_session)?; let status_str = format!( "Loaded session file for:\n{user_id}\n\nTrying to connect to homeserver...\n{}", @@ -189,7 +185,10 @@ pub async fn restore_session( .await?; let sliding_sync_version = sliding_sync_version.into(); client.set_sliding_sync_version(sliding_sync_version); - let status_str = format!("Authenticating previous login session for {}...", user_session.meta.user_id); + let status_str = format!( + "Authenticating previous login session for {}...", + user_session.meta.user_id + ); log!("{status_str}"); Cx::post_action(LoginAction::Status { title: "Authenticating session".into(), @@ -226,7 +225,7 @@ pub async fn save_session( client_session, user_session, sync_token: None, - sliding_sync_version + sliding_sync_version, })?; if let Some(parent) = session_file.parent() { tokio::fs::create_dir_all(parent).await?; @@ -238,19 +237,39 @@ pub async fn save_session( } /// Remove the LATEST_USER_ID_FILE_NAME file if it exists -/// +/// /// Returns: /// - Ok(true) if file was found and deleted /// - Ok(false) if file didn't exist /// - Err if deletion failed pub async fn delete_latest_user_id() -> anyhow::Result { let last_login_path = app_data_dir().join(LATEST_USER_ID_FILE_NAME); - + if last_login_path.exists() { - tokio::fs::remove_file(&last_login_path).await + tokio::fs::remove_file(&last_login_path) + .await .map_err(|e| anyhow::anyhow!("Failed to remove latest user file: {e}")) .map(|_| true) } else { Ok(false) } } + +/// Remove the persisted Matrix session file for the given user if it exists. +/// +/// Returns: +/// - Ok(true) if the session file was found and deleted +/// - Ok(false) if the session file didn't exist +/// - Err if deletion failed +pub async fn delete_session(user_id: &UserId) -> anyhow::Result { + let session_file = session_file_path(user_id); + + if session_file.exists() { + tokio::fs::remove_file(&session_file) + .await + .map_err(|e| anyhow::anyhow!("Failed to remove session file {session_file:?}: {e}")) + .map(|_| true) + } else { + Ok(false) + } +} diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 30fccc5a2..99f799ae0 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -8,37 +8,110 @@ use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ - config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ - api::{Direction, client::{profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType}}, events::{ + config::RequestConfig, + encryption::EncryptionSettings, + event_handler::EventHandlerDropGuard, + media::MediaRequestParameters, + room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, + ruma::{ + api::{ + Direction, + client::{ + account::register::v3::Request as RegistrationRequest, + error::ErrorKind, + profile::{AvatarUrl, DisplayName}, + receipt::create_receipt::v3::ReceiptType, + uiaa::{AuthData, AuthType, Dummy}, + }, + }, + events::{ relation::RelationType, - room::{ - message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource - }, MessageLikeEventType, StateEventType - }, matrix_uri::MatrixId, EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, uint - }, sliding_sync::VersionBuilder, Client, ClientBuildError, Error, OwnedServerName, Room, RoomDisplayName, RoomMemberships, RoomState, SessionChange, SuccessorRoom + room::{message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource}, + MessageLikeEventType, StateEventType, + }, + matrix_uri::MatrixId, + EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, + OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, uint, + }, + sliding_sync::VersionBuilder, + Client, ClientBuildError, Error, OwnedServerName, Room, RoomDisplayName, RoomMemberships, + RoomState, SessionChange, SuccessorRoom, }; use matrix_sdk_ui::{ - RoomListService, Timeline, encryption_sync_service, room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator, filters}, sync_service::{self, SyncService}, timeline::{LatestEventValue, RoomExt, TimelineEventItemId, TimelineFocus, TimelineItem, TimelineReadReceiptTracking, TimelineDetails} + RoomListService, Timeline, encryption_sync_service, + room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator, filters}, + sync_service::{self, SyncService}, + timeline::{ + LatestEventValue, RoomExt, TimelineEventItemId, TimelineFocus, TimelineItem, + TimelineReadReceiptTracking, TimelineDetails, + }, }; use robius_open::Uri; use ruma::{OwnedRoomOrAliasId, RoomId, events::tag::Tags}; use tokio::{ runtime::Handle, - sync::{broadcast, mpsc::{Sender, UnboundedReceiver, UnboundedSender}, watch, Notify}, task::JoinHandle, time::error::Elapsed, + sync::{ + broadcast, + mpsc::{Sender, UnboundedReceiver, UnboundedSender}, + watch, Notify, + }, + task::JoinHandle, + time::error::Elapsed, }; use url::Url; -use std::{borrow::Cow, cmp::{max, min}, future::Future, hash::{BuildHasherDefault, DefaultHasher}, iter::Peekable, ops::{Deref, DerefMut, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; +use std::{ + borrow::Cow, + cmp::{max, min}, + future::Future, + hash::{BuildHasherDefault, DefaultHasher}, + iter::Peekable, + ops::{Deref, DerefMut, Not}, + path::Path, + sync::{Arc, LazyLock, Mutex}, + time::Duration, +}; use std::io; use hashbrown::{HashMap, HashSet}; use crate::{ - app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ - add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails - }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ + app::AppStateAction, + app_data_dir, + avatar_cache::AvatarUpdate, + event_preview::{ + BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item, + }, + home::{ + add_room::KnockResultAction, + invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, + link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, + room_screen::{InviteResultAction, TimelineUpdate}, + rooms_list::{ + self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, + enqueue_rooms_list_update, + }, + rooms_list_header::RoomsListHeaderAction, + tombstone_footer::SuccessorRoomDetails, + }, + login::login_screen::LoginAction, + logout::{ + logout_confirm_modal::LogoutAction, + logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}, + }, + media_cache::{MediaCacheEntry, MediaCacheEntryRef}, + persistence::{self, ClientSessionPersisted, load_app_state}, + profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, - }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ - avatar::AvatarState, html_or_plaintext::MatrixLinkPillState, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification} - }, space_service_sync::space_service_loop, utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, verification::add_verification_event_handlers_and_sync_client + }, + room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, + shared::{ + avatar::AvatarState, + html_or_plaintext::MatrixLinkPillState, + jump_to_bottom_button::UnreadMessageCount, + popup_list::{PopupKind, enqueue_popup_notification}, + }, + space_service_sync::space_service_loop, + utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, + verification::add_verification_event_handlers_and_sync_client, }; #[derive(Parser, Default)] @@ -84,9 +157,28 @@ impl std::fmt::Debug for Cli { impl From for Cli { fn from(login: LoginByPassword) -> Self { Self { - user_id: login.user_id, + user_id: login.user_id.trim().to_owned(), password: login.password, - homeserver: login.homeserver, + homeserver: login + .homeserver + .map(|homeserver| homeserver.trim().to_owned()) + .filter(|homeserver| !homeserver.is_empty()), + proxy: None, + login_screen: false, + verbose: false, + } + } +} + +impl From for Cli { + fn from(registration: RegisterAccount) -> Self { + Self { + user_id: registration.user_id.trim().to_owned(), + password: registration.password, + homeserver: registration + .homeserver + .map(|homeserver| homeserver.trim().to_owned()) + .filter(|homeserver| !homeserver.is_empty()), proxy: None, login_screen: false, verbose: false, @@ -94,6 +186,151 @@ impl From for Cli { } } +fn infer_homeserver_from_user_id(user_id: &str) -> Option { + let user_id: OwnedUserId = user_id.trim().try_into().ok()?; + Some(user_id.server_name().to_string()) +} + +async fn finalize_authenticated_client( + client: Client, + client_session: ClientSessionPersisted, + fallback_user_id: &str, +) -> Result<(Client, Option)> { + if client.matrix_auth().logged_in() { + let logged_in_user_id = client + .user_id() + .map(ToString::to_string) + .unwrap_or_else(|| fallback_user_id.to_owned()); + log!("Logged in successfully."); + let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + if let Err(e) = persistence::save_session(&client, client_session).await { + let err_msg = format!("Failed to save session state to storage: {e}"); + error!("{err_msg}"); + enqueue_popup_notification(err_msg, PopupKind::Error, None); + } + Ok((client, None)) + } else { + let err_msg = format!( + "Authentication succeeded for {fallback_user_id}, but the homeserver did not return a login session." + ); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.clone(), + }); + bail!(err_msg); + } +} + +fn registration_localpart(user_id: &str) -> Result { + let trimmed = user_id.trim(); + if trimmed.is_empty() { + bail!("Please enter a valid username or Matrix user ID."); + } + + if let Ok(full_user_id) = >::try_from(trimmed) { + return Ok(full_user_id.localpart().to_owned()); + } + + let localpart = trimmed.trim_start_matches('@'); + if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) + { + bail!("Please enter a valid username or full Matrix user ID."); + } + + Ok(localpart.to_owned()) +} + +fn registration_request( + username: &str, + password: &str, + session: Option, +) -> RegistrationRequest { + let mut request = RegistrationRequest::new(); + request.username = Some(username.to_owned()); + request.password = Some(password.to_owned()); + request.initial_device_display_name = Some("robrix-un-pw".to_owned()); + request.refresh_token = true; + if let Some(session) = session { + let mut dummy = Dummy::new(); + dummy.session = Some(session); + request.auth = Some(AuthData::Dummy(dummy)); + } + request +} + +fn registration_uiaa_error_message(error: &matrix_sdk::Error) -> String { + if let matrix_sdk::Error::Http(http_error) = error { + match http_error.client_api_error_kind() { + Some(ErrorKind::UserInUse) => { + return "That user ID is already taken. Please choose another one.".to_owned(); + } + Some(ErrorKind::InvalidUsername) => { + return "That user ID is invalid. Use a username like `alice` or a full Matrix ID like `@alice:matrix.org`.".to_owned(); + } + Some(ErrorKind::WeakPassword) => { + return "That password is too weak. Please choose a stronger password.".to_owned(); + } + Some(ErrorKind::Forbidden { .. }) => { + return "This homeserver does not allow open registration.".to_owned(); + } + Some(ErrorKind::LimitExceeded { .. }) => { + return "The homeserver is rate limiting account creation right now. Please try again shortly.".to_owned(); + } + _ => {} + } + } + + format!("Could not create account: {error}") +} + +fn unsupported_registration_flow_message( + flows: &[matrix_sdk::ruma::api::client::uiaa::AuthFlow], +) -> String { + let supports_registration_token = flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::RegistrationToken)) + }); + if supports_registration_token { + return "This homeserver requires a registration token. Robrix does not support token-based registration yet.".to_owned(); + } + + let supports_terms = flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::Terms)) + }); + if supports_terms { + return "This homeserver requires an interactive terms-of-service step. Robrix does not support that registration flow yet.".to_owned(); + } + + "This homeserver requires an unsupported registration flow. Please try another homeserver or register with a different client.".to_owned() +} + +async fn clear_persisted_session(user_id: Option<&UserId>) { + let Some(user_id) = user_id else { + return; + }; + + if let Err(e) = persistence::delete_session(user_id).await { + warning!("Failed to delete persisted session for {user_id}: {e}"); + } + + let latest_user_id = persistence::most_recent_user_id().await; + if latest_user_id.as_deref() == Some(user_id) { + if let Err(e) = persistence::delete_latest_user_id().await { + warning!("Failed to delete latest user id for {user_id}: {e}"); + } + } +} + +fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { + matches!( + error.client_api_error_kind(), + Some(ErrorKind::UnknownToken { .. } | ErrorKind::MissingToken) + ) +} /// Build a new client. async fn build_client( @@ -116,9 +353,14 @@ async fn build_client( .collect() }; - let homeserver_url = cli.homeserver.as_deref() + let inferred_homeserver = infer_homeserver_from_user_id(&cli.user_id); + let homeserver_url = cli + .homeserver + .as_deref() + .filter(|homeserver| !homeserver.trim().is_empty()) + .or(inferred_homeserver.as_deref()) .unwrap_or("https://matrix-client.matrix.org/"); - // .unwrap_or("https://matrix.org/"); + // .unwrap_or("https://matrix.org/"); let mut builder = Client::builder() .server_name_or_homeserver_url(homeserver_url) @@ -146,13 +388,11 @@ async fn build_client( // Use a 60 second timeout for all requests to the homeserver. // Yes, this is a long timeout, but the standard matrix homeserver is often very slow. - builder = builder.request_config( - RequestConfig::new() - .timeout(std::time::Duration::from_secs(60)) - ); + builder = + builder.request_config(RequestConfig::new().timeout(std::time::Duration::from_secs(60))); let client = builder.build().await?; - let homeserver_url = client.homeserver().to_string(); + let homeserver_url = client.homeserver().to_string(); Ok(( client, ClientSessionPersisted { @@ -168,10 +408,7 @@ async fn build_client( /// This function is used by the login screen to log in to the Matrix server. /// /// Upon success, this function returns the logged-in client and an optional sync token. -async fn login( - cli: &Cli, - login_request: LoginRequest, -) -> Result<(Client, Option)> { +async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option)> { match login_request { LoginRequest::LoginByCli | LoginRequest::LoginByPassword(_) => { let cli = if let LoginRequest::LoginByPassword(login_by_password) = login_request { @@ -191,23 +428,75 @@ async fn login( .initial_device_display_name("robrix-un-pw") .send() .await?; - if client.matrix_auth().logged_in() { - log!("Logged in successfully."); - let status = format!("Logged in as {}.\n → Loading rooms...", cli.user_id); - // enqueue_popup_notification(status.clone()); - enqueue_rooms_list_update(RoomsListUpdate::Status { status }); - if let Err(e) = persistence::save_session(&client, client_session).await { - let err_msg = format!("Failed to save session state to storage: {e}"); - error!("{err_msg}"); - enqueue_popup_notification(err_msg, PopupKind::Error, None); - } - Ok((client, None)) - } else { + if !client.matrix_auth().logged_in() { let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.clone(), + }); + bail!(err_msg); + } + finalize_authenticated_client(client, client_session, &cli.user_id).await + } + + LoginRequest::Register(registration) => { + let cli = Cli::from(RegisterAccount { + user_id: registration.user_id.clone(), + password: registration.password.clone(), + homeserver: registration.homeserver.clone(), + }); + let localpart = registration_localpart(®istration.user_id)?; + let (client, client_session) = build_client(&cli, app_data_dir()).await?; + Cx::post_action(LoginAction::Status { + title: "Creating account".into(), + status: format!("Creating account {localpart}..."), + }); + + let auth = client.matrix_auth(); + let initial_request = registration_request(&localpart, ®istration.password, None); + let register_result = match auth.register(initial_request).await { + Ok(response) => Ok(response), + Err(error) => { + if let Some(uiaa_info) = error.as_uiaa_response() { + let supports_dummy = uiaa_info.flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::Dummy)) + }); + if supports_dummy { + Cx::post_action(LoginAction::Status { + title: "Completing sign up".into(), + status: "Confirming registration with the homeserver...".into(), + }); + auth.register(registration_request( + &localpart, + ®istration.password, + uiaa_info.session.clone(), + )) + .await + } else { + bail!(unsupported_registration_flow_message(&uiaa_info.flows)); + } + } else { + bail!(registration_uiaa_error_message(&error)); + } + } + }?; + + if !client.matrix_auth().logged_in() { + let err_msg = format!( + "Account {} was created, but the homeserver did not return a login session. Please log in manually.", + register_result.user_id, + ); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.clone(), + }); bail!(err_msg); } + + finalize_authenticated_client(client, client_session, register_result.user_id.as_str()) + .await } LoginRequest::LoginBySSOSuccess(client, client_session) => { @@ -222,7 +511,6 @@ async fn login( } } - /// Which direction to paginate in. /// /// * `Forwards` will retrieve later events (towards the end of the timeline), @@ -291,7 +579,6 @@ pub type OnLinkPreviewFetchedFn = fn( Option>, ); - /// Actions emitted in response to a [`MatrixRequest::GenerateMatrixLink`]. #[derive(Clone, Debug)] pub enum MatrixLinkAction { @@ -322,9 +609,7 @@ pub enum DirectMessageRoomAction { room_name_id: RoomNameId, }, /// A direct message room didn't exist, and we didn't attempt to create a new one. - DidNotExist { - user_profile: UserProfile, - }, + DidNotExist { user_profile: UserProfile }, /// A direct message room didn't exist, but we successfully created a new one. NewlyCreated { user_profile: UserProfile, @@ -359,7 +644,10 @@ impl TimelineKind { pub fn thread_root_event_id(&self) -> Option<&OwnedEventId> { match self { TimelineKind::MainRoom { .. } => None, - TimelineKind::Thread { thread_root_event_id, .. } => Some(thread_root_event_id), + TimelineKind::Thread { + thread_root_event_id, + .. + } => Some(thread_root_event_id), } } } @@ -367,7 +655,10 @@ impl std::fmt::Display for TimelineKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TimelineKind::MainRoom { room_id } => write!(f, "MainRoom({})", room_id), - TimelineKind::Thread { room_id, thread_root_event_id } => { + TimelineKind::Thread { + room_id, + thread_root_event_id, + } => { write!(f, "Thread({}, {})", room_id, thread_root_event_id) } } @@ -380,9 +671,7 @@ pub enum MatrixRequest { /// Request from the login screen to log in with the given credentials. Login(LoginRequest), /// Request to logout. - Logout { - is_desktop: bool, - }, + Logout { is_desktop: bool }, /// Request to paginate the older (or newer) events of a room or thread timeline. PaginateTimeline { timeline_kind: TimelineKind, @@ -414,9 +703,7 @@ pub enum MatrixRequest { /// /// Even though it operates on a room itself, this accepts a `TimelineKind` /// in order to be able to send the fetched room member list to a specific timeline UI. - SyncRoomMemberList { - timeline_kind: TimelineKind, - }, + SyncRoomMemberList { timeline_kind: TimelineKind }, /// Request to create a thread timeline focused on the given thread root event in the given room. CreateThreadTimeline { room_id: OwnedRoomId, @@ -435,13 +722,9 @@ pub enum MatrixRequest { user_id: OwnedUserId, }, /// Request to join the given room. - JoinRoom { - room_id: OwnedRoomId, - }, + JoinRoom { room_id: OwnedRoomId }, /// Request to leave the given room. - LeaveRoom { - room_id: OwnedRoomId, - }, + LeaveRoom { room_id: OwnedRoomId }, /// Request to get the actual list of members in a room. /// /// This returns the list of members that can be displayed in the UI. @@ -464,9 +747,7 @@ pub enum MatrixRequest { via: Vec, }, /// Request to fetch the full details (the room preview) of a tombstoned room. - GetSuccessorRoomDetails { - tombstoned_room_id: OwnedRoomId, - }, + GetSuccessorRoomDetails { tombstoned_room_id: OwnedRoomId }, /// Request to create or open a direct message room with the given user. /// /// If there is no existing DM room with the given user, this will create a new DM room @@ -491,9 +772,7 @@ pub enum MatrixRequest { local_only: bool, }, /// Request to fetch the number of unread messages in the given room. - GetNumberUnreadMessages { - timeline_kind: TimelineKind, - }, + GetNumberUnreadMessages { timeline_kind: TimelineKind }, /// Request to set the unread flag for the given room. SetUnreadFlag { room_id: OwnedRoomId, @@ -578,15 +857,12 @@ pub enum MatrixRequest { /// This request does not return a response or notify the UI thread, and /// furthermore, there is no need to send a follow-up request to stop typing /// (though you certainly can do so). - SendTypingNotice { - room_id: OwnedRoomId, - typing: bool, - }, + SendTypingNotice { room_id: OwnedRoomId, typing: bool }, /// Spawn an async task to login to the given Matrix homeserver using the given SSO identity provider ID. /// /// While an SSO request is in flight, the login screen will temporarily prevent the user /// from submitting another redundant request, until this request has succeeded or failed. - SpawnSSOServer{ + SpawnSSOServer { brand: String, homeserver_url: String, identity_provider_id: String, @@ -631,9 +907,7 @@ pub enum MatrixRequest { /// /// Even though it operates on a room itself, this accepts a `TimelineKind` /// in order to be able to send the fetched room member list to a specific timeline UI. - GetRoomPowerLevels { - timeline_kind: TimelineKind, - }, + GetRoomPowerLevels { timeline_kind: TimelineKind }, /// Toggles the given reaction to the given event in the given room. ToggleReaction { timeline_kind: TimelineKind, @@ -659,7 +933,7 @@ pub enum MatrixRequest { /// The MatrixLinkPillInfo::Loaded variant is sent back to the main UI thread via. GetMatrixRoomLinkPillInfo { matrix_id: MatrixId, - via: Vec + via: Vec, }, /// Request to fetch URL preview from the Matrix homeserver. GetUrlPreview { @@ -673,18 +947,19 @@ pub enum MatrixRequest { /// Submits a request to the worker thread to be executed asynchronously. pub fn submit_async_request(req: MatrixRequest) { if let Some(sender) = REQUEST_SENDER.lock().unwrap().as_ref() { - sender.send(req) + sender + .send(req) .expect("BUG: matrix worker task receiver has died!"); } } /// Details of a login request that get submitted within [`MatrixRequest::Login`]. -pub enum LoginRequest{ +pub enum LoginRequest { LoginByPassword(LoginByPassword), + Register(RegisterAccount), LoginBySSOSuccess(Client, ClientSessionPersisted), LoginByCli, HomeserverLoginTypesQuery(String), - } /// Information needed to log in to a Matrix homeserver. pub struct LoginByPassword { @@ -693,6 +968,13 @@ pub struct LoginByPassword { pub homeserver: Option, } +/// Information needed to register a new account on a Matrix homeserver. +#[derive(Clone)] +pub struct RegisterAccount { + pub user_id: String, + pub password: String, + pub homeserver: Option, +} /// The entry point for the worker task that runs Matrix-related operations. /// @@ -704,7 +986,8 @@ async fn matrix_worker_task( ) -> Result<()> { log!("Started matrix_worker_task."); // The async tasks that are spawned to subscribe to changes in our own user's read receipts for each timeline. - let mut subscribers_own_user_read_receipts: HashMap> = HashMap::new(); + let mut subscribers_own_user_read_receipts: HashMap> = + HashMap::new(); // The async tasks that are spawned to subscribe to changes in the pinned events for each room. let mut subscribers_pinned_events: HashMap> = HashMap::new(); @@ -714,7 +997,7 @@ async fn matrix_worker_task( if let Err(e) = login_sender.send(login_request).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to login worker task." + "BUG: failed to send login request to login worker task.", ))); } } @@ -727,7 +1010,7 @@ async fn matrix_worker_task( match logout_with_state_machine(is_desktop).await { Ok(()) => { log!("Logout completed successfully via state machine"); - }, + } Err(e) => { error!("Logout failed: {e:?}"); } @@ -735,7 +1018,11 @@ async fn matrix_worker_task( }); } - MatrixRequest::PaginateTimeline {timeline_kind, num_events, direction} => { + MatrixRequest::PaginateTimeline { + timeline_kind, + num_events, + direction, + } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("Skipping pagination request for unknown {timeline_kind}"); continue; @@ -777,7 +1064,11 @@ async fn matrix_worker_task( }); } - MatrixRequest::EditMessage { timeline_kind, timeline_event_item_id, edited_content } => { + MatrixRequest::EditMessage { + timeline_kind, + timeline_event_item_id, + edited_content, + } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for edit request"); continue; @@ -799,7 +1090,10 @@ async fn matrix_worker_task( }); } - MatrixRequest::FetchDetailsForEvent { timeline_kind, event_id } => { + MatrixRequest::FetchDetailsForEvent { + timeline_kind, + event_id, + } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for fetch details for event request"); continue; @@ -816,7 +1110,10 @@ async fn matrix_worker_task( // error!("Error fetching details for event {event_id} in {timeline_kind}: {_e:?}"); } } - if sender.send(TimelineUpdate::EventDetailsFetched { event_id, result }).is_err() { + if sender + .send(TimelineUpdate::EventDetailsFetched { event_id, result }) + .is_err() + { error!("Failed to send fetched event details to UI for {timeline_kind}"); } SignalToUI::set_ui_signal(); @@ -870,17 +1167,27 @@ async fn matrix_worker_task( }); } - MatrixRequest::CreateThreadTimeline { room_id, thread_root_event_id } => { + MatrixRequest::CreateThreadTimeline { + room_id, + thread_root_event_id, + } => { let main_room_timeline = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - error!("BUG: room info not found for create thread timeline request, room {room_id}"); + error!( + "BUG: room info not found for create thread timeline request, room {room_id}" + ); continue; }; - if room_info.thread_timelines.contains_key(&thread_root_event_id) { + if room_info + .thread_timelines + .contains_key(&thread_root_event_id) + { continue; } - let newly_pending = room_info.pending_thread_timelines.insert(thread_root_event_id.clone()); + let newly_pending = room_info + .pending_thread_timelines + .insert(thread_root_event_id.clone()); if !newly_pending { continue; } @@ -952,11 +1259,18 @@ async fn matrix_worker_task( }); } - MatrixRequest::Knock { room_or_alias_id, reason, server_names } => { + MatrixRequest::Knock { + room_or_alias_id, + reason, + server_names, + } => { let Some(client) = get_client() else { continue }; let _knock_room_task = Handle::current().spawn(async move { log!("Sending request to knock on room {room_or_alias_id}..."); - match client.knock(room_or_alias_id.clone(), reason, server_names).await { + match client + .knock(room_or_alias_id.clone(), reason, server_names) + .await + { Ok(room) => { let _ = room.display_name().await; // populate this room's display name cache Cx::post_action(KnockResultAction::Knocked { @@ -980,23 +1294,21 @@ async fn matrix_worker_task( if let Some(room) = client.get_room(&room_id) { log!("Sending request to invite user {user_id} to room {room_id}..."); match room.invite_user_by_id(&user_id).await { - Ok(_) => Cx::post_action(InviteResultAction::Sent { - room_id, - user_id, - }), + Ok(_) => Cx::post_action(InviteResultAction::Sent { room_id, user_id }), Err(error) => Cx::post_action(InviteResultAction::Failed { room_id, user_id, error, }), } - } - else { + } else { error!("Room/Space not found for invite user request {room_id}, {user_id}"); Cx::post_action(InviteResultAction::Failed { room_id, user_id, - error: matrix_sdk::Error::UnknownError("Room/Space not found in client's known list.".into()), + error: matrix_sdk::Error::UnknownError( + "Room/Space not found in client's known list.".into(), + ), }) } }); @@ -1017,8 +1329,7 @@ async fn matrix_worker_task( JoinRoomResultAction::Failed { room_id, error: e } } } - } - else { + } else { match client.join_room_by_id(&room_id).await { Ok(_room) => { log!("Successfully joined new unknown room {room_id}."); @@ -1053,14 +1364,20 @@ async fn matrix_worker_task( error!("BUG: client could not get room with ID {room_id}"); LeaveRoomResultAction::Failed { room_id, - error: matrix_sdk::Error::UnknownError("Client couldn't locate room to leave it.".into()), + error: matrix_sdk::Error::UnknownError( + "Client couldn't locate room to leave it.".into(), + ), } }; Cx::post_action(result_action); }); } - MatrixRequest::GetRoomMembers { timeline_kind, memberships, local_only } => { + MatrixRequest::GetRoomMembers { + timeline_kind, + memberships, + local_only, + } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for get room members request"); continue; @@ -1069,7 +1386,9 @@ async fn matrix_worker_task( let _get_members_task = Handle::current().spawn(async move { let send_update = |members: Vec, source: &str| { log!("{} {} members for {timeline_kind}", source, members.len()); - sender.send(TimelineUpdate::RoomMembersListFetched { members }).unwrap(); + sender + .send(TimelineUpdate::RoomMembersListFetched { members }) + .unwrap(); SignalToUI::set_ui_signal(); }; @@ -1086,7 +1405,10 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetRoomPreview { room_or_alias_id, via } => { + MatrixRequest::GetRoomPreview { + room_or_alias_id, + via, + } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { let res = fetch_room_preview_with_avatar(&client, &room_or_alias_id, via).await; @@ -1099,7 +1421,9 @@ async fn matrix_worker_task( let (sender, successor_room) = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&tombstoned_room_id) else { - error!("BUG: tombstoned room {tombstoned_room_id} info not found for get successor room details request"); + error!( + "BUG: tombstoned room {tombstoned_room_id} info not found for get successor room details request" + ); continue; }; ( @@ -1115,7 +1439,10 @@ async fn matrix_worker_task( ); } - MatrixRequest::OpenOrCreateDirectMessage { user_profile, allow_create } => { + MatrixRequest::OpenOrCreateDirectMessage { + user_profile, + allow_create, + } => { let Some(client) = get_client() else { continue }; let _create_dm_task = Handle::current().spawn(async move { if let Some(room) = client.get_dm_room(&user_profile.user_id) { @@ -1138,7 +1465,7 @@ async fn matrix_worker_task( user_profile, room_name_id: RoomNameId::from_room(&room).await, }); - }, + } Err(error) => { error!("Failed to create DM with {user_profile:?}: {error}"); Cx::post_action(DirectMessageRoomAction::FailedToCreate { @@ -1150,7 +1477,11 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetUserProfile { user_id, room_id, local_only } => { + MatrixRequest::GetUserProfile { + user_id, + room_id, + local_only, + } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { // log!("Sending get user profile request: user: {user_id}, \ @@ -1244,7 +1575,10 @@ async fn matrix_worker_task( }); } - MatrixRequest::SetUnreadFlag { room_id, mark_as_unread } => { + MatrixRequest::SetUnreadFlag { + room_id, + mark_as_unread, + } => { let Some(main_timeline) = get_room_timeline(&room_id) else { log!("BUG: skipping set unread flag request for not-yet-known room {room_id}"); continue; @@ -1253,35 +1587,64 @@ async fn matrix_worker_task( let result = main_timeline.room().set_unread_flag(mark_as_unread).await; match result { Ok(_) => log!("Set unread flag to {} for room {}", mark_as_unread, room_id), - Err(e) => error!("Failed to set unread flag to {} for room {}: {:?}", mark_as_unread, room_id, e), + Err(e) => error!( + "Failed to set unread flag to {} for room {}: {:?}", + mark_as_unread, room_id, e + ), } }); } - MatrixRequest::SetIsFavorite { room_id, is_favorite } => { + MatrixRequest::SetIsFavorite { + room_id, + is_favorite, + } => { let Some(main_timeline) = get_room_timeline(&room_id) else { - log!("BUG: skipping set favorite flag request for not-yet-known room {room_id}"); + log!( + "BUG: skipping set favorite flag request for not-yet-known room {room_id}" + ); continue; }; let _set_favorite_task = Handle::current().spawn(async move { - let result = main_timeline.room().set_is_favourite(is_favorite, None).await; + let result = main_timeline + .room() + .set_is_favourite(is_favorite, None) + .await; match result { Ok(_) => log!("Set favorite to {} for room {}", is_favorite, room_id), - Err(e) => error!("Failed to set favorite to {} for room {}: {:?}", is_favorite, room_id, e), + Err(e) => error!( + "Failed to set favorite to {} for room {}: {:?}", + is_favorite, room_id, e + ), } }); } - MatrixRequest::SetIsLowPriority { room_id, is_low_priority } => { + MatrixRequest::SetIsLowPriority { + room_id, + is_low_priority, + } => { let Some(main_timeline) = get_room_timeline(&room_id) else { - log!("BUG: skipping set low priority flag request for not-yet-known room {room_id}"); + log!( + "BUG: skipping set low priority flag request for not-yet-known room {room_id}" + ); continue; }; let _set_lp_task = Handle::current().spawn(async move { - let result = main_timeline.room().set_is_low_priority(is_low_priority, None).await; + let result = main_timeline + .room() + .set_is_low_priority(is_low_priority, None) + .await; match result { - Ok(_) => log!("Set low priority to {} for room {}", is_low_priority, room_id), - Err(e) => error!("Failed to set low priority to {} for room {}: {:?}", is_low_priority, room_id, e), + Ok(_) => log!( + "Set low priority to {} for room {}", + is_low_priority, + room_id + ), + Err(e) => error!( + "Failed to set low priority to {} for room {}: {:?}", + is_low_priority, room_id, e + ), } }); } @@ -1290,15 +1653,24 @@ async fn matrix_worker_task( let Some(client) = get_client() else { continue }; let _set_avatar_task = Handle::current().spawn(async move { let is_removing = avatar_url.is_none(); - log!("Sending request to {} avatar...", if is_removing { "remove" } else { "set" }); + log!( + "Sending request to {} avatar...", + if is_removing { "remove" } else { "set" } + ); let result = client.account().set_avatar_url(avatar_url.as_deref()).await; match result { Ok(_) => { - log!("Successfully {} avatar.", if is_removing { "removed" } else { "set" }); + log!( + "Successfully {} avatar.", + if is_removing { "removed" } else { "set" } + ); Cx::post_action(AccountDataAction::AvatarChanged(avatar_url)); } Err(e) => { - let err_msg = format!("Failed to {} avatar: {e}", if is_removing { "remove" } else { "set" }); + let err_msg = format!( + "Failed to {} avatar: {e}", + if is_removing { "remove" } else { "set" } + ); Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); } } @@ -1309,57 +1681,87 @@ async fn matrix_worker_task( let Some(client) = get_client() else { continue }; let _set_display_name_task = Handle::current().spawn(async move { let is_removing = new_display_name.is_none(); - log!("Sending request to {} display name{}...", + log!( + "Sending request to {} display name{}...", if is_removing { "remove" } else { "set" }, - new_display_name.as_ref().map(|n| format!(" to '{n}'")).unwrap_or_default() + new_display_name + .as_ref() + .map(|n| format!(" to '{n}'")) + .unwrap_or_default() ); - let result = client.account().set_display_name(new_display_name.as_deref()).await; + let result = client + .account() + .set_display_name(new_display_name.as_deref()) + .await; match result { Ok(_) => { - log!("Successfully {} display name.", if is_removing { "removed" } else { "set" }); - Cx::post_action(AccountDataAction::DisplayNameChanged(new_display_name)); + log!( + "Successfully {} display name.", + if is_removing { "removed" } else { "set" } + ); + Cx::post_action(AccountDataAction::DisplayNameChanged( + new_display_name, + )); } Err(e) => { - let err_msg = format!("Failed to {} display name: {e}", if is_removing { "remove" } else { "set" }); + let err_msg = format!( + "Failed to {} display name: {e}", + if is_removing { "remove" } else { "set" } + ); Cx::post_action(AccountDataAction::DisplayNameChangeFailed(err_msg)); } } }); } - MatrixRequest::GenerateMatrixLink { room_id, event_id, use_matrix_scheme, join_on_click } => { + MatrixRequest::GenerateMatrixLink { + room_id, + event_id, + use_matrix_scheme, + join_on_click, + } => { let Some(client) = get_client() else { continue }; let _gen_link_task = Handle::current().spawn(async move { if let Some(room) = client.get_room(&room_id) { let result = if use_matrix_scheme { if let Some(event_id) = event_id { - room.matrix_event_permalink(event_id).await + room.matrix_event_permalink(event_id) + .await .map(MatrixLinkAction::MatrixUri) } else { - room.matrix_permalink(join_on_click).await + room.matrix_permalink(join_on_click) + .await .map(MatrixLinkAction::MatrixUri) } } else { if let Some(event_id) = event_id { - room.matrix_to_event_permalink(event_id).await + room.matrix_to_event_permalink(event_id) + .await .map(MatrixLinkAction::MatrixToUri) } else { - room.matrix_to_permalink().await + room.matrix_to_permalink() + .await .map(MatrixLinkAction::MatrixToUri) } }; - + match result { Ok(action) => Cx::post_action(action), Err(e) => Cx::post_action(MatrixLinkAction::Error(e.to_string())), } } else { - Cx::post_action(MatrixLinkAction::Error(format!("Room {room_id} not found"))); + Cx::post_action(MatrixLinkAction::Error(format!( + "Room {room_id} not found" + ))); } }); } - MatrixRequest::IgnoreUser { ignore, room_member, room_id } => { + MatrixRequest::IgnoreUser { + ignore, + room_member, + room_id, + } => { let Some(client) = get_client() else { continue }; let _ignore_task = Handle::current().spawn(async move { let user_id = room_member.user_id(); @@ -1414,7 +1816,9 @@ async fn matrix_worker_task( MatrixRequest::SendTypingNotice { room_id, typing } => { let Some(main_room_timeline) = get_room_timeline(&room_id) else { - log!("BUG: skipping send typing notice request for not-yet-known room {room_id}"); + log!( + "BUG: skipping send typing notice request for not-yet-known room {room_id}" + ); continue; }; let _typing_task = Handle::current().spawn(async move { @@ -1428,16 +1832,21 @@ async fn matrix_worker_task( let (main_timeline, timeline_update_sender, mut typing_notice_receiver) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(jrd) = all_joined_rooms.get_mut(&room_id) else { - log!("BUG: room info not found for subscribe to typing notices request, room {room_id}"); + log!( + "BUG: room info not found for subscribe to typing notices request, room {room_id}" + ); continue; }; let (main_timeline, receiver) = if subscribe { if jrd.typing_notice_subscriber.is_some() { - warning!("Note: room {room_id} is already subscribed to typing notices."); + warning!( + "Note: room {room_id} is already subscribed to typing notices." + ); continue; } else { let main_timeline = jrd.main_timeline.timeline.clone(); - let (drop_guard, receiver) = main_timeline.room().subscribe_to_typing_notifications(); + let (drop_guard, receiver) = + main_timeline.room().subscribe_to_typing_notifications(); jrd.typing_notice_subscriber = Some(drop_guard); (main_timeline, receiver) } @@ -1446,7 +1855,11 @@ async fn matrix_worker_task( continue; }; // Here: we don't have an existing subscriber running, so we fall through and start one. - (main_timeline, jrd.main_timeline.timeline_update_sender.clone(), receiver) + ( + main_timeline, + jrd.main_timeline.timeline_update_sender.clone(), + receiver, + ) }; let _typing_notices_task = Handle::current().spawn(async move { @@ -1473,15 +1886,22 @@ async fn matrix_worker_task( }); } - MatrixRequest::SubscribeToOwnUserReadReceiptsChanged { timeline_kind, subscribe } => { + MatrixRequest::SubscribeToOwnUserReadReceiptsChanged { + timeline_kind, + subscribe, + } => { if !subscribe { - if let Some(task_handler) = subscribers_own_user_read_receipts.remove(&timeline_kind) { + if let Some(task_handler) = + subscribers_own_user_read_receipts.remove(&timeline_kind) + { task_handler.abort(); } continue; } let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { - log!("BUG: skipping subscribe to own user read receipts changed request for {timeline_kind}"); + log!( + "BUG: skipping subscribe to own user read receipts changed request for {timeline_kind}" + ); continue; }; @@ -1523,7 +1943,8 @@ async fn matrix_worker_task( } } }); - subscribers_own_user_read_receipts.insert(timeline_kind_clone, subscribe_own_read_receipt_task); + subscribers_own_user_read_receipts + .insert(timeline_kind_clone, subscribe_own_read_receipt_task); } MatrixRequest::SubscribeToPinnedEvents { room_id, subscribe } => { @@ -1533,9 +1954,13 @@ async fn matrix_worker_task( } continue; } - let kind = TimelineKind::MainRoom { room_id: room_id.clone() }; + let kind = TimelineKind::MainRoom { + room_id: room_id.clone(), + }; let Some((main_timeline, sender)) = get_timeline_and_sender(&kind) else { - log!("BUG: skipping subscribe to pinned events request for unknown room {room_id}"); + log!( + "BUG: skipping subscribe to pinned events request for unknown room {room_id}" + ); continue; }; let subscribe_pinned_events_task = Handle::current().spawn(async move { @@ -1557,8 +1982,18 @@ async fn matrix_worker_task( subscribers_pinned_events.insert(room_id, subscribe_pinned_events_task); } - MatrixRequest::SpawnSSOServer { brand, homeserver_url, identity_provider_id} => { - spawn_sso_server(brand, homeserver_url, identity_provider_id, login_sender.clone()).await; + MatrixRequest::SpawnSSOServer { + brand, + homeserver_url, + identity_provider_id, + } => { + spawn_sso_server( + brand, + homeserver_url, + identity_provider_id, + login_sender.clone(), + ) + .await; } MatrixRequest::ResolveRoomAlias(room_alias) => { @@ -1571,7 +2006,10 @@ async fn matrix_worker_task( }); } - MatrixRequest::FetchAvatar { mxc_uri, on_fetched } => { + MatrixRequest::FetchAvatar { + mxc_uri, + on_fetched, + } => { let Some(client) = get_client() else { continue }; Handle::current().spawn(async move { // log!("Sending fetch avatar request for {mxc_uri:?}..."); @@ -1581,13 +2019,21 @@ async fn matrix_worker_task( }; let res = client.media().get_media_content(&media_request, true).await; // log!("Fetched avatar for {mxc_uri:?}, succeeded? {}", res.is_ok()); - on_fetched(AvatarUpdate { mxc_uri, avatar_data: res.map(|v| v.into()) }); + on_fetched(AvatarUpdate { + mxc_uri, + avatar_data: res.map(|v| v.into()), + }); }); } - MatrixRequest::FetchMedia { media_request, on_fetched, destination, update_sender } => { + MatrixRequest::FetchMedia { + media_request, + on_fetched, + destination, + update_sender, + } => { let Some(client) = get_client() else { continue }; - + let _fetch_task = Handle::current().spawn(async move { // log!("Sending fetch media request for {media_request:?}..."); let res = client.media().get_media_content(&media_request, true).await; @@ -1692,7 +2138,11 @@ async fn matrix_worker_task( }); } - MatrixRequest::ReadReceipt { timeline_kind, event_id, receipt_type } => { + MatrixRequest::ReadReceipt { + timeline_kind, + event_id, + receipt_type, + } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found when sending read receipt, {event_id}"); continue; @@ -1713,7 +2163,7 @@ async fn matrix_worker_task( }); } }); - }, + } MatrixRequest::GetRoomPowerLevels { timeline_kind } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { @@ -1721,15 +2171,21 @@ async fn matrix_worker_task( continue; }; - let Some(user_id) = current_user_id() else { continue }; + let Some(user_id) = current_user_id() else { + continue; + }; let _power_levels_task = Handle::current().spawn(async move { match timeline.room().power_levels().await { Ok(power_levels) => { log!("Successfully fetched power levels for {timeline_kind}."); - if sender.send(TimelineUpdate::UserPowerLevels( - UserPowerLevels::from(&power_levels, &user_id), - )).is_err() { + if sender + .send(TimelineUpdate::UserPowerLevels(UserPowerLevels::from( + &power_levels, + &user_id, + ))) + .is_err() + { error!("Failed to send room power levels to UI.") } SignalToUI::set_ui_signal(); @@ -1739,9 +2195,13 @@ async fn matrix_worker_task( } } }); - }, + } - MatrixRequest::ToggleReaction { timeline_kind, timeline_event_id, reaction } => { + MatrixRequest::ToggleReaction { + timeline_kind, + timeline_event_id, + reaction, + } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found for toggle reaction request"); continue; @@ -1749,17 +2209,26 @@ async fn matrix_worker_task( let _toggle_reaction_task = Handle::current().spawn(async move { log!("Sending toggle reaction {reaction:?} to {timeline_kind}: ..."); - match timeline.toggle_reaction(&timeline_event_id, &reaction).await { + match timeline + .toggle_reaction(&timeline_event_id, &reaction) + .await + { Ok(_send_handle) => { log!("Sent toggle reaction {reaction:?} to {timeline_kind}."); SignalToUI::set_ui_signal(); - }, - Err(_e) => error!("Failed to send toggle reaction to {timeline_kind}; error: {_e:?}"), + } + Err(_e) => error!( + "Failed to send toggle reaction to {timeline_kind}; error: {_e:?}" + ), } }); - }, + } - MatrixRequest::RedactMessage { timeline_kind, timeline_event_id, reason } => { + MatrixRequest::RedactMessage { + timeline_kind, + timeline_event_id, + reason, + } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found for redact message request"); continue; @@ -1778,9 +2247,13 @@ async fn matrix_worker_task( } } }); - }, + } - MatrixRequest::PinEvent { timeline_kind, event_id, pin } => { + MatrixRequest::PinEvent { + timeline_kind, + event_id, + pin, + } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for pin event request"); continue; @@ -1792,7 +2265,11 @@ async fn matrix_worker_task( } else { timeline.unpin_event(&event_id).await }; - match sender.send(TimelineUpdate::PinResult { event_id, pin, result }) { + match sender.send(TimelineUpdate::PinResult { + event_id, + pin, + result, + }) { Ok(_) => SignalToUI::set_ui_signal(), Err(_) => log!("Failed to send UI update for pin event."), } @@ -1826,7 +2303,12 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetUrlPreview { url, on_fetched, destination, update_sender } => { + MatrixRequest::GetUrlPreview { + url, + on_fetched, + destination, + update_sender, + } => { // const MAX_LOG_RESPONSE_BODY_LENGTH: usize = 1000; // log!("Starting URL preview fetch for: {}", url); let _fetch_url_preview_task = Handle::current().spawn(async move { @@ -1836,17 +2318,19 @@ async fn matrix_worker_task( // error!("Matrix client not available for URL preview: {}", url); UrlPreviewError::ClientNotAvailable })?; - + let token = client.access_token().ok_or_else(|| { // error!("Access token not available for URL preview: {}", url); UrlPreviewError::AccessTokenNotAvailable })?; // Official Doc: https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv1mediapreview_url // Element desktop is using /_matrix/media/v3/preview_url - let endpoint_url = client.homeserver().join("/_matrix/client/v1/media/preview_url") + let endpoint_url = client + .homeserver() + .join("/_matrix/client/v1/media/preview_url") .map_err(UrlPreviewError::UrlParse)?; // log!("Fetching URL preview from endpoint: {} for URL: {}", endpoint_url, url); - + let response = client .http_client() .get(endpoint_url.clone()) @@ -1859,20 +2343,20 @@ async fn matrix_worker_task( // error!("HTTP request failed for URL preview {}: {}", url, e); UrlPreviewError::Request(e) })?; - + let status = response.status(); // log!("URL preview response status for {}: {}", url, status); - + if !status.is_success() && status.as_u16() != 429 { // error!("URL preview request failed with status {} for URL: {}", status, url); return Err(UrlPreviewError::HttpStatus(status.as_u16())); } - + let text = response.text().await.map_err(|e| { // error!("Failed to read response text for URL preview {}: {}", url, e); UrlPreviewError::Request(e) })?; - + // log!("URL preview response body length for {}: {} bytes", url, text.len()); // if text.len() > MAX_LOG_RESPONSE_BODY_LENGTH { // log!("URL preview response body preview for {}: {}...", url, &text[..MAX_LOG_RESPONSE_BODY_LENGTH]); @@ -1881,22 +2365,25 @@ async fn matrix_worker_task( // } // This request is rate limited, retry after a duration we get from the server. if status.as_u16() == 429 { - let link_preview_429_res = serde_json::from_str::(&text) - .map_err(|e| { - // error!("Failed to parse as LinkPreviewRateLimitResponse for URL preview {}: {}", url, e); - UrlPreviewError::Json(e) - }); + let link_preview_429_res = + serde_json::from_str::(&text) + .map_err(|e| { + // error!("Failed to parse as LinkPreviewRateLimitResponse for URL preview {}: {}", url, e); + UrlPreviewError::Json(e) + }); match link_preview_429_res { Ok(link_preview_429_res) => { if let Some(retry_after) = link_preview_429_res.retry_after_ms { - tokio::time::sleep(Duration::from_millis(retry_after.into())).await; - submit_async_request(MatrixRequest::GetUrlPreview{ + tokio::time::sleep(Duration::from_millis( + retry_after.into(), + )) + .await; + submit_async_request(MatrixRequest::GetUrlPreview { url: url.clone(), on_fetched, destination: destination.clone(), update_sender: update_sender.clone(), }); - } } Err(_e) => { @@ -1916,11 +2403,12 @@ async fn matrix_worker_task( // error!("Response body that failed to parse: {}", text); UrlPreviewError::Json(e) }) - }.await; + } + .await; // match &result { // Ok(preview_data) => { - // log!("Successfully fetched URL preview for {}: title: {:?}, site_name: {:?}", + // log!("Successfully fetched URL preview for {}: title: {:?}, site_name: {:?}", // url, preview_data.title, preview_data.site_name); // } // Err(e) => { @@ -1939,7 +2427,6 @@ async fn matrix_worker_task( bail!("matrix_worker_task task ended unexpectedly") } - /// The single global Tokio runtime that is used by all async tasks. static TOKIO_RUNTIME: Mutex> = Mutex::new(None); @@ -1952,7 +2439,8 @@ static REQUEST_SENDER: Mutex>> = Mutex::ne static DEFAULT_SSO_CLIENT: Mutex> = Mutex::new(None); /// Used to notify the SSO login task that the async creation of the `DEFAULT_SSO_CLIENT` has finished. -static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = LazyLock::new(|| Arc::new(Notify::new())); +static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = + LazyLock::new(|| Arc::new(Notify::new())); /// Blocks the current thread until the given future completes. /// @@ -1963,36 +2451,45 @@ pub fn block_on_async_with_timeout( timeout: Option, async_future: impl Future, ) -> Result { - let rt = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - ).handle().clone(); + let rt = TOKIO_RUNTIME + .lock() + .unwrap() + .get_or_insert_with(|| { + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + }) + .handle() + .clone(); if let Some(timeout) = timeout { - rt.block_on(async { - tokio::time::timeout(timeout, async_future).await - }) + rt.block_on(async { tokio::time::timeout(timeout, async_future).await }) } else { Ok(rt.block_on(async_future)) } } - /// The primary initialization routine for starting the Matrix client sync /// and the async tokio runtime. /// /// Returns a handle to the Tokio runtime that is used to run async background tasks. pub fn start_matrix_tokio() -> Result { // Create a Tokio runtime, and save it in a static variable to ensure it isn't dropped. - let rt_handle = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| { - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - }).handle().clone(); + let rt_handle = TOKIO_RUNTIME + .lock() + .unwrap() + .get_or_insert_with(|| { + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + }) + .handle() + .clone(); // Proactively build a Matrix Client in the background so that the SSO Server // can have a quicker start if needed (as it's rather slow to build this client). rt_handle.spawn(async move { match build_client(&Cli::default(), app_data_dir()).await { Ok(client_and_session) => { - DEFAULT_SSO_CLIENT.lock().unwrap() + DEFAULT_SSO_CLIENT + .lock() + .unwrap() .get_or_insert(client_and_session); } Err(e) => error!("Error: could not create DEFAULT_SSO_CLIENT object: {e}"), @@ -2009,7 +2506,6 @@ pub fn start_matrix_tokio() -> Result { Ok(rt_handle) } - /// A tokio::watch channel sender for sending requests from the RoomScreen UI widget /// to the corresponding background async task for that room (its `timeline_subscriber_handler`). pub type TimelineRequestSender = watch::Sender>; @@ -2076,13 +2572,13 @@ impl Drop for JoinedRoomDetails { } } - /// A const-compatible hasher, used for `static` items containing `HashMap`s or `HashSet`s. type ConstHasher = BuildHasherDefault; /// Information about all joined rooms that our client currently know about. /// We use a `HashMap` for O(1) lookups, as this is accessed frequently (e.g. every timeline update). -static ALL_JOINED_ROOMS: Mutex> = Mutex::new(HashMap::with_hasher(BuildHasherDefault::new())); +static ALL_JOINED_ROOMS: Mutex> = + Mutex::new(HashMap::with_hasher(BuildHasherDefault::new())); /// Returns the timeline and timeline update sender for the given joined room/thread timeline. fn get_per_timeline_details<'a>( @@ -2092,7 +2588,10 @@ fn get_per_timeline_details<'a>( let room_info = all_joined_rooms.get_mut(kind.room_id())?; match kind { TimelineKind::MainRoom { .. } => Some(&mut room_info.main_timeline), - TimelineKind::Thread { thread_root_event_id, .. } => room_info.thread_timelines.get_mut(thread_root_event_id), + TimelineKind::Thread { + thread_root_event_id, + .. + } => room_info.thread_timelines.get_mut(thread_root_event_id), } } @@ -2103,14 +2602,22 @@ fn get_timeline(kind: &TimelineKind) -> Option> { } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the timeline and timeline update sender for the given timeline kind. -fn get_timeline_and_sender(kind: &TimelineKind) -> Option<(Arc, crossbeam_channel::Sender)> { - get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind) - .map(|details| (details.timeline.clone(), details.timeline_update_sender.clone())) +fn get_timeline_and_sender( + kind: &TimelineKind, +) -> Option<(Arc, crossbeam_channel::Sender)> { + get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind).map(|details| { + ( + details.timeline.clone(), + details.timeline_update_sender.clone(), + ) + }) } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the main timeline for the given room. fn get_room_timeline(room_id: &RoomId) -> Option> { - ALL_JOINED_ROOMS.lock().unwrap() + ALL_JOINED_ROOMS + .lock() + .unwrap() .get(room_id) .map(|jrd| jrd.main_timeline.timeline.clone()) } @@ -2124,15 +2631,16 @@ pub fn get_client() -> Option { /// Returns the user ID of the currently logged-in user, if any. pub fn current_user_id() -> Option { - CLIENT.lock().unwrap().as_ref().and_then(|c| - c.session_meta().map(|m| m.user_id.clone()) - ) + CLIENT + .lock() + .unwrap() + .as_ref() + .and_then(|c| c.session_meta().map(|m| m.user_id.clone())) } /// The singleton sync service. static SYNC_SERVICE: Mutex>> = Mutex::new(None); - /// Get a reference to the current sync service, if available. pub fn get_sync_service() -> Option> { SYNC_SERVICE.lock().ok()?.as_ref().cloned() @@ -2141,7 +2649,8 @@ pub fn get_sync_service() -> Option> { /// The list of users that the current user has chosen to ignore. /// Ideally we shouldn't have to maintain this list ourselves, /// but the Matrix SDK doesn't currently properly maintain the list of ignored users. -static IGNORED_USERS: Mutex> = Mutex::new(HashSet::with_hasher(BuildHasherDefault::new())); +static IGNORED_USERS: Mutex> = + Mutex::new(HashSet::with_hasher(BuildHasherDefault::new())); /// Returns a deep clone of the current list of ignored users. pub fn get_ignored_users() -> HashSet { @@ -2153,7 +2662,6 @@ pub fn is_user_ignored(user_id: &UserId) -> bool { IGNORED_USERS.lock().unwrap().contains(user_id) } - /// Returns three channel endpoints related to the timeline for the given joined room or thread. /// /// 1. A timeline update sender. @@ -2167,7 +2675,10 @@ pub fn take_timeline_endpoints(kind: &TimelineKind) -> Option let jrd = all_joined_rooms.get_mut(kind.room_id())?; let details = match kind { TimelineKind::MainRoom { .. } => &mut jrd.main_timeline, - TimelineKind::Thread { thread_root_event_id, .. } => jrd.thread_timelines.get_mut(thread_root_event_id)?, + TimelineKind::Thread { + thread_root_event_id, + .. + } => jrd.thread_timelines.get_mut(thread_root_event_id)?, }; let (update_receiver, request_sender) = details.timeline_singleton_endpoints.take()?; Some(TimelineEndpoints { @@ -2180,25 +2691,18 @@ pub fn take_timeline_endpoints(kind: &TimelineKind) -> Option const DEFAULT_HOMESERVER: &str = "matrix.org"; -fn username_to_full_user_id( - username: &str, - homeserver: Option<&str>, -) -> Option { - username - .try_into() - .ok() - .or_else(|| { - let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); - let user_id_str = if username.starts_with("@") { - format!("{}:{}", username, homeserver_url) - } else { - format!("@{}:{}", username, homeserver_url) - }; - user_id_str.as_str().try_into().ok() - }) +fn username_to_full_user_id(username: &str, homeserver: Option<&str>) -> Option { + username.try_into().ok().or_else(|| { + let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); + let user_id_str = if username.starts_with("@") { + format!("{}:{}", username, homeserver_url) + } else { + format!("@{}:{}", username, homeserver_url) + }; + user_id_str.as_str().try_into().ok() + }) } - /// Info we store about a room received by the room list service. /// /// This struct is necessary in order for us to track the previous state @@ -2226,18 +2730,14 @@ struct RoomListServiceRoomInfo { impl RoomListServiceRoomInfo { async fn from_room(room: matrix_sdk::Room, current_user_id: &Option) -> Self { // Parallelize fetching of independent room data. - let (is_direct, tags, display_name, user_power_levels) = tokio::join!( - room.is_direct(), - room.tags(), - room.display_name(), - async { + let (is_direct, tags, display_name, user_power_levels) = + tokio::join!(room.is_direct(), room.tags(), room.display_name(), async { if let Some(user_id) = current_user_id { UserPowerLevels::from_room(&room, user_id.deref()).await } else { None } - } - ); + }); Self { room_id: room.room_id().to_owned(), @@ -2279,48 +2779,57 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let most_recent_user_id = persistence::most_recent_user_id().await; log!("Most recent user ID: {most_recent_user_id:?}"); let cli_parse_result = Cli::try_parse(); - let cli_has_valid_username_password = cli_parse_result.as_ref() + let cli_has_valid_username_password = cli_parse_result + .as_ref() .is_ok_and(|cli| !cli.user_id.is_empty() && !cli.password.is_empty()); - log!("CLI parsing succeeded? {}. CLI has valid UN+PW? {}", + log!( + "CLI parsing succeeded? {}. CLI has valid UN+PW? {}", cli_parse_result.as_ref().is_ok(), cli_has_valid_username_password, ); - let wait_for_login = !cli_has_valid_username_password && ( - most_recent_user_id.is_none() - || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login") - ); + let wait_for_login = !cli_has_valid_username_password + && (most_recent_user_id.is_none() + || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login")); log!("Waiting for login? {}", wait_for_login); - let new_login_opt = if !wait_for_login { - let specified_username = cli_parse_result.as_ref().ok().and_then(|cli| - username_to_full_user_id( - &cli.user_id, - cli.homeserver.as_deref(), - ) - ); - log!("Trying to restore session for user: {:?}", + let new_login_opt: Option<(Client, Option, bool)> = if !wait_for_login { + let specified_username = cli_parse_result + .as_ref() + .ok() + .and_then(|cli| username_to_full_user_id(&cli.user_id, cli.homeserver.as_deref())); + log!( + "Trying to restore session for user: {:?}", specified_username.as_ref().or(most_recent_user_id.as_ref()) ); - match persistence::restore_session(specified_username).await { - Ok(session) => Some(session), + match persistence::restore_session(specified_username.clone()).await { + Ok((client, sync_token)) => Some((client, sync_token, true)), Err(e) => { let status_err = "Could not restore previous user session.\n\nPlease login again."; log!("{status_err} Error: {e:?}"); + clear_persisted_session( + specified_username + .as_deref() + .or(most_recent_user_id.as_deref()), + ) + .await; Cx::post_action(LoginAction::LoginFailure(status_err.to_string())); if let Ok(cli) = &cli_parse_result { - log!("Attempting auto-login from CLI arguments as user '{}'...", cli.user_id); + log!( + "Attempting auto-login from CLI arguments as user '{}'...", + cli.user_id + ); Cx::post_action(LoginAction::CliAutoLogin { user_id: cli.user_id.clone(), homeserver: cli.homeserver.clone(), }); match login(cli, LoginRequest::LoginByCli).await { - Ok(new_login) => Some(new_login), + Ok((client, sync_token)) => Some((client, sync_token, false)), Err(e) => { error!("CLI-based login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure( - format!("Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}") - )); + Cx::post_action(LoginAction::LoginFailure(format!( + "Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}" + ))); enqueue_rooms_list_update(RoomsListUpdate::Status { status: format!("Login failed: {e:?}"), }); @@ -2342,44 +2851,61 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let mut initial_client_opt = new_login_opt; let (client, sync_service, logged_in_user_id) = 'login_loop: loop { - let (client, _sync_token) = match initial_client_opt.take() { + let (client, _sync_token, validate_session) = match initial_client_opt.take() { Some(login) => login, - None => { - loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => { - match login(&cli, login_request).await { - Ok((client, sync_token)) => break (client, sync_token), - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), - }); - } - } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); - Cx::post_action(LoginAction::LoginFailure(err.clone())); + None => loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => match login(&cli, login_request).await { + Ok((client, sync_token)) => break (client, sync_token, false), + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err, + status: format!("Login failed: {e}"), }); - return; } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + let err = String::from( + "Please restart Robrix.\n\nUnable to listen for login requests.", + ); + Cx::post_action(LoginAction::LoginFailure(err.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err }); + return; } } - } + }, }; + if validate_session { + match client.whoami().await { + Ok(_) => {} + Err(e) if is_invalid_token_http_error(&e) => { + clear_persisted_session(client.user_id()).await; + let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; + Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.to_string(), + }); + continue 'login_loop; + } + Err(e) => { + warning!( + "Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}" + ); + } + } + } + // Deallocate the default SSO client after a successful login. if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { let _ = client_opt.take(); } - let logged_in_user_id: OwnedUserId = client.user_id() + let logged_in_user_id: OwnedUserId = client + .user_id() .expect("BUG: Client::user_id() returned None after successful login!") .to_owned(); let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); @@ -2387,7 +2913,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // Store this active client in our global Client state so that other tasks can access it. if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { - error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); + error!( + "BUG: unexpectedly replaced an existing client when initializing the matrix client." + ); } // Listen for changes to our verification status and incoming verification requests. @@ -2396,9 +2924,6 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // Listen for updates to the ignored user list. handle_ignore_user_list_subscriber(client.clone()); - // Listen for session changes, e.g., when the access token becomes invalid. - handle_session_changes(client.clone()); - Cx::post_action(LoginAction::Status { title: "Connecting".into(), status: "Setting up sync service...".into(), @@ -2416,6 +2941,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { } else { format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") }; + if is_invalid_token_error(&e) { + clear_persisted_session(client.user_id()).await; + } Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); @@ -2426,6 +2954,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { } }; + // Listen for session changes, e.g., when the access token becomes invalid. + handle_session_changes(client.clone()); + break 'login_loop (client, sync_service, logged_in_user_id); }; @@ -2444,7 +2975,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let room_list_service = sync_service.room_list_service(); if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { - error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); + error!( + "BUG: unexpectedly replaced an existing sync service when initializing the matrix client." + ); } let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); @@ -2535,7 +3068,6 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { } } - /// The main async task that listens for changes to all rooms. async fn room_list_service_loop(room_list_service: Arc) -> Result<()> { let all_rooms_list = room_list_service.all_rooms().await?; @@ -2549,13 +3081,13 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu // 1. not spaces (those are handled by the SpaceService), // 2. not left (clients don't typically show rooms that the user has already left), // 3. not outdated (don't show tombstoned rooms whose successor is already joined). - room_list_dynamic_entries_controller.set_filter(Box::new( - filters::new_filter_all(vec![ - Box::new(filters::new_filter_not(Box::new(filters::new_filter_space()))), - Box::new(filters::new_filter_non_left()), - Box::new(filters::new_filter_deduplicate_versions()), - ]) - )); + room_list_dynamic_entries_controller.set_filter(Box::new(filters::new_filter_all(vec![ + Box::new(filters::new_filter_not(Box::new( + filters::new_filter_space(), + ))), + Box::new(filters::new_filter_non_left()), + Box::new(filters::new_filter_deduplicate_versions()), + ]))); let mut all_known_rooms: Vector = Vector::new(); let current_user_id = current_user_id(); @@ -2571,7 +3103,13 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu // Append and Reset are identical, except for Reset first clears all rooms. let _num_new_rooms = new_rooms.len(); if is_reset { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Reset, old length {}, new length {}", all_known_rooms.len(), new_rooms.len()); } + if LOG_ROOM_LIST_DIFFS { + log!( + "room_list: diff Reset, old length {}, new length {}", + all_known_rooms.len(), + new_rooms.len() + ); + } // Iterate manually so we can know which rooms are being removed. while let Some(room) = all_known_rooms.pop_back() { remove_room(&room); @@ -2582,20 +3120,35 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); } else { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Append, old length {}, adding {} new items", all_known_rooms.len(), _num_new_rooms); } + if LOG_ROOM_LIST_DIFFS { + log!( + "room_list: diff Append, old length {}, adding {} new items", + all_known_rooms.len(), + _num_new_rooms + ); + } } // Parallelize creating each room's RoomListServiceRoomInfo and adding that new room. // We combine `from_room` and `add_new_room` into a single async task per room. - let new_room_infos: Vec = join_all( - new_rooms.into_iter().map(|room| async { - let room_info = RoomListServiceRoomInfo::from_room(room.into_inner(), ¤t_user_id).await; - if let Err(e) = add_new_room(&room_info, &room_list_service, false).await { - error!("Failed to add new room: {:?} ({}); error: {:?}", room_info.display_name, room_info.room_id, e); + let new_room_infos: Vec = + join_all(new_rooms.into_iter().map(|room| async { + let room_info = RoomListServiceRoomInfo::from_room( + room.into_inner(), + ¤t_user_id, + ) + .await; + if let Err(e) = + add_new_room(&room_info, &room_list_service, false).await + { + error!( + "Failed to add new room: {:?} ({}); error: {:?}", + room_info.display_name, room_info.room_id, e + ); } room_info - }) - ).await; + })) + .await; // Send room order update with the new room IDs let (room_id_refs, room_ids) = { @@ -2609,43 +3162,57 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu }; if !room_ids.is_empty() { enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Append { values: room_ids } + VecDiff::Append { values: room_ids }, )); room_list_service.subscribe_to_rooms(&room_id_refs).await; all_known_rooms.extend(new_room_infos); } } VectorDiff::Clear => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Clear"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Clear"); + } all_known_rooms.clear(); ALL_JOINED_ROOMS.lock().unwrap().clear(); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); } VectorDiff::PushFront { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushFront"); } - let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PushFront"); + } + let new_room = + RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) + .await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushFront { value: room_id } + VecDiff::PushFront { value: room_id }, )); all_known_rooms.push_front(new_room); } VectorDiff::PushBack { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushBack"); } - let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PushBack"); + } + let new_room = + RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) + .await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushBack { value: room_id } + VecDiff::PushBack { value: room_id }, )); all_known_rooms.push_back(new_room); } remove_diff @ VectorDiff::PopFront => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopFront"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PopFront"); + } if let Some(room) = all_known_rooms.pop_front() { - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PopFront)); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::PopFront, + )); optimize_remove_then_add_into_update( remove_diff, &room, @@ -2653,13 +3220,18 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ).await?; + ) + .await?; } } remove_diff @ VectorDiff::PopBack => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopBack"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PopBack"); + } if let Some(room) = all_known_rooms.pop_back() { - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PopBack)); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::PopBack, + )); optimize_remove_then_add_into_update( remove_diff, &room, @@ -2667,38 +3239,61 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ).await?; + ) + .await?; } } - VectorDiff::Insert { index, value: new_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Insert at {index}"); } - let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; + VectorDiff::Insert { + index, + value: new_room, + } => { + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Insert at {index}"); + } + let new_room = + RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) + .await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Insert { index, value: room_id } - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Insert { + index, + value: room_id, + })); all_known_rooms.insert(index, new_room); } - VectorDiff::Set { index, value: changed_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Set at {index}"); } - let changed_room = RoomListServiceRoomInfo::from_room(changed_room.into_inner(), ¤t_user_id).await; + VectorDiff::Set { + index, + value: changed_room, + } => { + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Set at {index}"); + } + let changed_room = RoomListServiceRoomInfo::from_room( + changed_room.into_inner(), + ¤t_user_id, + ) + .await; if let Some(old_room) = all_known_rooms.get(index) { update_room(old_room, &changed_room, &room_list_service).await?; } else { error!("BUG: room list diff: Set index {index} was out of bounds."); } // Send order update (room ID at this index may have changed) - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Set { index, value: changed_room.room_id.clone() } - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Set { + index, + value: changed_room.room_id.clone(), + })); all_known_rooms.set(index, changed_room); } remove_diff @ VectorDiff::Remove { index } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Remove at {index}"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Remove at {index}"); + } if index < all_known_rooms.len() { let room = all_known_rooms.remove(index); - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Remove { index })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::Remove { index }, + )); optimize_remove_then_add_into_update( remove_diff, &room, @@ -2706,13 +3301,19 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ).await?; + ) + .await?; } else { - error!("BUG: room_list: diff Remove index {index} out of bounds, len {}", all_known_rooms.len()); + error!( + "BUG: room_list: diff Remove index {index} out of bounds, len {}", + all_known_rooms.len() + ); } } VectorDiff::Truncate { length } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Truncate to {length}"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Truncate to {length}"); + } // Iterate manually so we can know which rooms are being removed. while all_known_rooms.len() > length { if let Some(room) = all_known_rooms.pop_back() { @@ -2721,7 +3322,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu } all_known_rooms.truncate(length); // sanity check enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Truncate { length } + VecDiff::Truncate { length }, )); } } @@ -2731,7 +3332,6 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu bail!("room list service sync loop ended unexpectedly") } - /// Attempts to optimize a common RoomListService operation of remove + add. /// /// If a `Remove` diff (or `PopBack` or `PopFront`) is immediately followed by @@ -2751,48 +3351,58 @@ async fn optimize_remove_then_add_into_update( ) -> Result<()> { let next_diff_was_handled: bool; match peekable_diffs.peek() { - Some(VectorDiff::Insert { index: insert_index, value: new_room }) - if room.room_id == new_room.room_id() => - { + Some(VectorDiff::Insert { + index: insert_index, + value: new_room, + }) if room.room_id == new_room.room_id() => { if LOG_ROOM_LIST_DIFFS { - log!("Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", room.room_id); + log!( + "Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", + room.room_id + ); } - let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = + RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the insert - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Insert { index: *insert_index, value: new_room.room_id.clone() } - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Insert { + index: *insert_index, + value: new_room.room_id.clone(), + })); all_known_rooms.insert(*insert_index, new_room); next_diff_was_handled = true; } - Some(VectorDiff::PushFront { value: new_room }) - if room.room_id == new_room.room_id() => - { + Some(VectorDiff::PushFront { value: new_room }) if room.room_id == new_room.room_id() => { if LOG_ROOM_LIST_DIFFS { - log!("Optimizing {remove_diff:?} + PushFront into Update for room {}", room.room_id); + log!( + "Optimizing {remove_diff:?} + PushFront into Update for room {}", + room.room_id + ); } - let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = + RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the push front - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushFront { value: new_room.room_id.clone() } - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PushFront { + value: new_room.room_id.clone(), + })); all_known_rooms.push_front(new_room); next_diff_was_handled = true; } - Some(VectorDiff::PushBack { value: new_room }) - if room.room_id == new_room.room_id() => - { + Some(VectorDiff::PushBack { value: new_room }) if room.room_id == new_room.room_id() => { if LOG_ROOM_LIST_DIFFS { - log!("Optimizing {remove_diff:?} + PushBack into Update for room {}", room.room_id); + log!( + "Optimizing {remove_diff:?} + PushBack into Update for room {}", + room.room_id + ); } - let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = + RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the push back - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushBack { value: new_room.room_id.clone() } - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PushBack { + value: new_room.room_id.clone(), + })); all_known_rooms.push_back(new_room); next_diff_was_handled = true; } @@ -2806,7 +3416,6 @@ async fn optimize_remove_then_add_into_update( Ok(()) } - /// Invoked when the room list service has received an update that changes an existing room. async fn update_room( old_room: &RoomListServiceRoomInfo, @@ -2817,18 +3426,29 @@ async fn update_room( if old_room.room_id == new_room_id { // Handle state transitions for a room. if LOG_ROOM_LIST_DIFFS { - log!("Room {:?} ({new_room_id}) state went from {:?} --> {:?}", new_room.display_name, old_room.state, new_room.state); + log!( + "Room {:?} ({new_room_id}) state went from {:?} --> {:?}", + new_room.display_name, + old_room.state, + new_room.state + ); } if old_room.state != new_room.state { match new_room.state { RoomState::Banned => { // TODO: handle rooms that this user has been banned from. - log!("Removing Banned room: {:?} ({new_room_id})", new_room.display_name); + log!( + "Removing Banned room: {:?} ({new_room_id})", + new_room.display_name + ); remove_room(new_room); return Ok(()); } RoomState::Left => { - log!("Removing Left room: {:?} ({new_room_id})", new_room.display_name); + log!( + "Removing Left room: {:?} ({new_room_id})", + new_room.display_name + ); // TODO: instead of removing this, we could optionally add it to // a separate list of left rooms, which would be collapsed by default. // Upon clicking a left room, we could show a splash page @@ -2838,11 +3458,17 @@ async fn update_room( return Ok(()); } RoomState::Joined => { - log!("update_room(): adding new Joined room: {:?} ({new_room_id})", new_room.display_name); + log!( + "update_room(): adding new Joined room: {:?} ({new_room_id})", + new_room.display_name + ); return add_new_room(new_room, room_list_service, true).await; } RoomState::Invited => { - log!("update_room(): adding new Invited room: {:?} ({new_room_id})", new_room.display_name); + log!( + "update_room(): adding new Invited room: {:?} ({new_room_id})", + new_room.display_name + ); return add_new_room(new_room, room_list_service, true).await; } RoomState::Knocked => { @@ -2860,7 +3486,12 @@ async fn update_room( spawn_fetch_room_avatar(new_room); } if old_room.display_name != new_room.display_name { - log!("Updating room {} name: {:?} --> {:?}", new_room_id, old_room.display_name, new_room.display_name); + log!( + "Updating room {} name: {:?} --> {:?}", + new_room_id, + old_room.display_name, + new_room.display_name + ); enqueue_rooms_list_update(RoomsListUpdate::UpdateRoomName { new_room_name: (new_room.display_name.clone(), new_room_id.clone()).into(), @@ -2870,12 +3501,15 @@ async fn update_room( // Then, we check for changes to room data that is only relevant to joined rooms: // including the latest event, tags, unread counts, is_direct, tombstoned state, power levels, etc. // Invited or left rooms don't care about these details. - if matches!(new_room.state, RoomState::Joined) { + if matches!(new_room.state, RoomState::Joined) { // For some reason, the latest event API does not reliably catch *all* changes // to the latest event in a given room, such as redactions. // Thus, we have to re-obtain the latest event on *every* update, regardless of timestamp. // - let update_latest = match (old_room.latest_event_timestamp, new_room.room.latest_event_timestamp()) { + let update_latest = match ( + old_room.latest_event_timestamp, + new_room.room.latest_event_timestamp(), + ) { (Some(old_ts), Some(new_ts)) => new_ts >= old_ts, (None, Some(_)) => true, _ => false, @@ -2884,9 +3518,13 @@ async fn update_room( update_latest_event(&new_room.room).await; } - if old_room.tags != new_room.tags { - log!("Updating room {} tags from {:?} to {:?}", new_room_id, old_room.tags, new_room.tags); + log!( + "Updating room {} tags from {:?} to {:?}", + new_room_id, + old_room.tags, + new_room.tags + ); enqueue_rooms_list_update(RoomsListUpdate::Tags { room_id: new_room_id.clone(), new_tags: new_room.tags.clone().unwrap_or_default(), @@ -2897,11 +3535,15 @@ async fn update_room( || old_room.num_unread_messages != new_room.num_unread_messages || old_room.num_unread_mentions != new_room.num_unread_mentions { - log!("Updating room {}, marked unread {} --> {}, unread messages {} --> {}, unread mentions {} --> {}", + log!( + "Updating room {}, marked unread {} --> {}, unread messages {} --> {}, unread mentions {} --> {}", new_room_id, - old_room.is_marked_unread, new_room.is_marked_unread, - old_room.num_unread_messages, new_room.num_unread_messages, - old_room.num_unread_mentions, new_room.num_unread_mentions, + old_room.is_marked_unread, + new_room.is_marked_unread, + old_room.num_unread_messages, + new_room.num_unread_messages, + old_room.num_unread_mentions, + new_room.num_unread_mentions, ); enqueue_rooms_list_update(RoomsListUpdate::UpdateNumUnreadMessages { room_id: new_room_id.clone(), @@ -2912,7 +3554,8 @@ async fn update_room( } if old_room.is_direct != new_room.is_direct { - log!("Updating room {} is_direct from {} to {}", + log!( + "Updating room {} is_direct from {} to {}", new_room_id, old_room.is_direct, new_room.is_direct, @@ -2927,7 +3570,8 @@ async fn update_room( let mut get_timeline_update_sender = |room_id| { if __timeline_update_sender_opt.is_none() { if let Some(jrd) = ALL_JOINED_ROOMS.lock().unwrap().get(room_id) { - __timeline_update_sender_opt = Some(jrd.main_timeline.timeline_update_sender.clone()); + __timeline_update_sender_opt = + Some(jrd.main_timeline.timeline_update_sender.clone()); } } __timeline_update_sender_opt.clone() @@ -2936,7 +3580,9 @@ async fn update_room( if !old_room.is_tombstoned && new_room.is_tombstoned { let successor_room = new_room.room.successor_room(); log!("Updating room {new_room_id} to be tombstoned, {successor_room:?}"); - enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { room_id: new_room_id.clone() }); + enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { + room_id: new_room_id.clone(), + }); if let Some(timeline_update_sender) = get_timeline_update_sender(&new_room_id) { spawn_fetch_successor_room_preview( room_list_service.client().clone(), @@ -2945,7 +3591,9 @@ async fn update_room( timeline_update_sender, ); } else { - error!("BUG: could not find JoinedRoomDetails for newly-tombstoned room {new_room_id}"); + error!( + "BUG: could not find JoinedRoomDetails for newly-tombstoned room {new_room_id}" + ); } } @@ -2956,37 +3604,38 @@ async fn update_room( log!("Updating room {new_room_id} user power levels."); match timeline_update_sender.send(TimelineUpdate::UserPowerLevels(nupl)) { Ok(_) => SignalToUI::set_ui_signal(), - Err(_) => error!("Failed to send the UserPowerLevels update to room {new_room_id}"), + Err(_) => error!( + "Failed to send the UserPowerLevels update to room {new_room_id}" + ), } } else { - error!("BUG: could not find JoinedRoomDetails for room {new_room_id} where power levels changed."); + error!( + "BUG: could not find JoinedRoomDetails for room {new_room_id} where power levels changed." + ); } } } Ok(()) - } - else { - warning!("UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", - old_room.room_id, new_room_id, + } else { + warning!( + "UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", + old_room.room_id, + new_room_id, ); remove_room(old_room); add_new_room(new_room, room_list_service, true).await } } - /// Invoked when the room list service has received an update to remove an existing room. fn remove_room(room: &RoomListServiceRoomInfo) { ALL_JOINED_ROOMS.lock().unwrap().remove(&room.room_id); - enqueue_rooms_list_update( - RoomsListUpdate::RemoveRoom { - room_id: room.room_id.clone(), - new_state: room.state, - } - ); + enqueue_rooms_list_update(RoomsListUpdate::RemoveRoom { + room_id: room.room_id.clone(), + new_state: room.state, + }); } - /// Invoked when the room list service has received an update with a brand new room. async fn add_new_room( new_room: &RoomListServiceRoomInfo, @@ -2995,26 +3644,39 @@ async fn add_new_room( ) -> Result<()> { match new_room.state { RoomState::Knocked => { - log!("Got new Knocked room: {:?} ({})", new_room.display_name, new_room.room_id); + log!( + "Got new Knocked room: {:?} ({})", + new_room.display_name, + new_room.room_id + ); // Note: here we could optionally display Knocked rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Banned => { - log!("Got new Banned room: {:?} ({})", new_room.display_name, new_room.room_id); + log!( + "Got new Banned room: {:?} ({})", + new_room.display_name, + new_room.room_id + ); // Note: here we could optionally display Banned rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Left => { - log!("Got new Left room: {:?} ({:?})", new_room.display_name, new_room.room_id); + log!( + "Got new Left room: {:?} ({:?})", + new_room.display_name, + new_room.room_id + ); // Note: here we could optionally display Left rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Invited => { let invite_details = new_room.room.invite_details().await.ok(); - let room_name_id = RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); + let room_name_id = + RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); // Start with a basic text avatar; the avatar image will be fetched asynchronously below. let room_avatar = avatar_from_room_name(room_name_id.name_for_avatar()); let inviter_info = if let Some(inviter) = invite_details.and_then(|d| d.inviter) { @@ -3031,18 +3693,20 @@ async fn add_new_room( } else { None }; - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom(InvitedRoomInfo { - room_name_id: room_name_id.clone(), - inviter_info, - room_avatar, - canonical_alias: new_room.room.canonical_alias(), - alt_aliases: new_room.room.alt_aliases(), - // we don't actually display the latest event for Invited rooms, so don't bother. - latest: None, - invite_state: Default::default(), - is_selected: false, - is_direct: new_room.is_direct, - })); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom( + InvitedRoomInfo { + room_name_id: room_name_id.clone(), + inviter_info, + room_avatar, + canonical_alias: new_room.room.canonical_alias(), + alt_aliases: new_room.room.alt_aliases(), + // we don't actually display the latest event for Invited rooms, so don't bother. + latest: None, + invite_state: Default::default(), + is_selected: false, + is_direct: new_room.is_direct, + }, + )); Cx::post_action(AppStateAction::RoomLoadedSuccessfully { room_name_id, is_invite: true, @@ -3050,17 +3714,21 @@ async fn add_new_room( spawn_fetch_room_avatar(new_room); return Ok(()); } - RoomState::Joined => { } // Fall through to adding the joined room below. + RoomState::Joined => {} // Fall through to adding the joined room below. } // If we didn't already subscribe to this room, do so now. // This ensures we will properly receive all of its states and latest event. if subscribe { - room_list_service.subscribe_to_rooms(&[&new_room.room_id]).await; + room_list_service + .subscribe_to_rooms(&[&new_room.room_id]) + .await; } let timeline = Arc::new( - new_room.room.timeline_builder() + new_room + .room + .timeline_builder() .with_focus(TimelineFocus::Live { // we show threads as separate timelines in their own RoomScreen hide_threaded_events: true, @@ -3068,7 +3736,12 @@ async fn add_new_room( .track_read_marker_and_receipts(TimelineReadReceiptTracking::AllEvents) .build() .await - .map_err(|e| anyhow::anyhow!("BUG: Failed to build timeline for room {}: {e}", new_room.room_id))?, + .map_err(|e| { + anyhow::anyhow!( + "BUG: Failed to build timeline for room {}: {e}", + new_room.room_id + ) + })?, ); let (timeline_update_sender, timeline_update_receiver) = crossbeam_channel::unbounded(); @@ -3084,7 +3757,11 @@ async fn add_new_room( // We need to add the room to the `ALL_JOINED_ROOMS` list before we can send // an `AddJoinedRoom` update to the RoomsList widget, because that widget might // immediately issue a `MatrixRequest` that relies on that room being in `ALL_JOINED_ROOMS`. - log!("Adding new joined room {}, name: {:?}", new_room.room_id, new_room.display_name); + log!( + "Adding new joined room {}, name: {:?}", + new_room.room_id, + new_room.display_name + ); ALL_JOINED_ROOMS.lock().unwrap().insert( new_room.room_id.clone(), JoinedRoomDetails { @@ -3105,7 +3782,8 @@ async fn add_new_room( let latest = get_latest_event_details( &new_room.room.latest_event().await, room_list_service.client(), - ).await; + ) + .await; let room_name_id = RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); // Start with a basic text avatar; the avatar image will be fetched asynchronously below. let room_avatar = avatar_from_room_name(room_name_id.name_for_avatar()); @@ -3136,7 +3814,8 @@ async fn add_new_room( #[allow(unused)] async fn current_ignore_user_list(client: &Client) -> Option> { use matrix_sdk::ruma::events::ignored_user_list::IgnoredUserListEventContent; - let ignored_users = client.account() + let ignored_users = client + .account() .account_data::() .await .ok()?? @@ -3200,7 +3879,9 @@ fn handle_load_app_state(user_id: OwnedUserId) { && !app_state.saved_dock_state_home.dock_items.is_empty() { log!("Loaded room panel state from app data directory. Restoring now..."); - Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState(app_state)); + Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState( + app_state, + )); } } Err(_e) => { @@ -3219,12 +3900,12 @@ fn handle_load_app_state(user_id: OwnedUserId) { fn is_invalid_token_error(e: &sync_service::Error) -> bool { use matrix_sdk::ruma::api::client::error::ErrorKind; let sdk_error = match e { - sync_service::Error::RoomList( - matrix_sdk_ui::room_list_service::Error::SlidingSync(err) - ) => err, - sync_service::Error::EncryptionSync( - encryption_sync_service::Error::SlidingSync(err) - ) => err, + sync_service::Error::RoomList(matrix_sdk_ui::room_list_service::Error::SlidingSync( + err, + )) => err, + sync_service::Error::EncryptionSync(encryption_sync_service::Error::SlidingSync(err)) => { + err + } _ => return false, }; matches!( @@ -3250,6 +3931,7 @@ fn handle_session_changes(client: Client) { "Your login token is no longer valid.\n\nPlease log in again." }; error!("Session token is no longer valid (soft_logout: {soft_logout}). Prompting re-login."); + clear_persisted_session(client.user_id()).await; Cx::post_action(LoginAction::LoginFailure(msg.to_string())); } Ok(SessionChange::TokensRefreshed) => {} @@ -3299,14 +3981,12 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { const SYNC_INDICATOR_DELAY: Duration = Duration::from_millis(100); /// Duration for sync indicator delay before hiding const SYNC_INDICATOR_HIDE_DELAY: Duration = Duration::from_millis(200); - let sync_indicator_stream = sync_service.room_list_service() - .sync_indicator( - SYNC_INDICATOR_DELAY, - SYNC_INDICATOR_HIDE_DELAY - ); - + let sync_indicator_stream = sync_service + .room_list_service() + .sync_indicator(SYNC_INDICATOR_DELAY, SYNC_INDICATOR_HIDE_DELAY); + Handle::current().spawn(async move { - let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); + let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); while let Some(indicator) = sync_indicator_stream.next().await { let is_syncing = match indicator { @@ -3319,7 +3999,10 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { } fn handle_room_list_service_loading_state(mut loading_state: Subscriber) { - log!("Initial room list loading state is {:?}", loading_state.get()); + log!( + "Initial room list loading state is {:?}", + loading_state.get() + ); Handle::current().spawn(async move { while let Some(state) = loading_state.next().await { log!("Received a room list loading state update: {state:?}"); @@ -3327,8 +4010,12 @@ fn handle_room_list_service_loading_state(mut loading_state: Subscriber { enqueue_rooms_list_update(RoomsListUpdate::NotLoaded); } - RoomListLoadingState::Loaded { maximum_number_of_rooms } => { - enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { max_rooms: maximum_number_of_rooms }); + RoomListLoadingState::Loaded { + maximum_number_of_rooms, + } => { + enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { + max_rooms: maximum_number_of_rooms, + }); // The SDK docs state that we cannot move from the `Loaded` state // back to the `NotLoaded` state, so we can safely exit this task here. return; @@ -3351,12 +4038,12 @@ fn spawn_fetch_successor_room_preview( Handle::current().spawn(async move { log!("Updating room {tombstoned_room_id} to be tombstoned, {successor_room:?}"); let srd = if let Some(SuccessorRoom { room_id, reason }) = successor_room { - match fetch_room_preview_with_avatar( - &client, - room_id.deref().into(), - Vec::new(), - ).await { - Ok(room_preview) => SuccessorRoomDetails::Full { room_preview, reason }, + match fetch_room_preview_with_avatar(&client, room_id.deref().into(), Vec::new()).await + { + Ok(room_preview) => SuccessorRoomDetails::Full { + room_preview, + reason, + }, Err(e) => { log!("Failed to fetch preview of successor room {room_id}, error: {e:?}"); SuccessorRoomDetails::Basic(SuccessorRoom { room_id, reason }) @@ -3390,12 +4077,18 @@ async fn fetch_room_preview_with_avatar( }; match client.media().get_media_content(&media_request, true).await { Ok(avatar_content) => { - log!("Fetched avatar for room preview {:?} ({})", room_preview.name, room_preview.room_id); + log!( + "Fetched avatar for room preview {:?} ({})", + room_preview.name, + room_preview.room_id + ); FetchedRoomAvatar::Image(avatar_content.into()) } Err(e) => { - log!("Failed to fetch avatar for room preview {:?} ({}), error: {e:?}", - room_preview.name, room_preview.room_id + log!( + "Failed to fetch avatar for room preview {:?} ({}), error: {e:?}", + room_preview.name, + room_preview.room_id ); avatar_from_room_name(room_preview.name.as_deref()) } @@ -3415,7 +4108,10 @@ async fn fetch_room_preview_with_avatar( async fn fetch_thread_summary_details( room: &Room, thread_root_event_id: &EventId, -) -> (u32, Option) { +) -> ( + u32, + Option, +) { let mut num_replies = 0; let mut latest_reply_event = None; @@ -3473,10 +4169,7 @@ async fn fetch_latest_thread_reply_event( } /// Counts all replies in the given thread by paginating `/relations` in batches. -async fn count_thread_replies( - room: &Room, - thread_root_event_id: &EventId, -) -> Option { +async fn count_thread_replies(room: &Room, thread_root_event_id: &EventId) -> Option { let mut total_replies: u32 = 0; let mut next_batch_token = None; @@ -3489,7 +4182,10 @@ async fn count_thread_replies( ..Default::default() }; - let relations = room.relations(thread_root_event_id.to_owned(), options).await.ok()?; + let relations = room + .relations(thread_root_event_id.to_owned(), options) + .await + .ok()?; if relations.chunk.is_empty() { break; } @@ -3515,7 +4211,8 @@ async fn text_preview_of_latest_thread_reply( Ok(Some(rm)) => Some(rm), _ => room.get_member(&sender_id).await.ok().flatten(), }; - let sender_name = sender_room_member.as_ref() + let sender_name = sender_room_member + .as_ref() .and_then(|rm| rm.display_name()) .unwrap_or(sender_id.as_str()); let text_preview = text_preview_of_raw_timeline_event(raw, sender_name).unwrap_or_else(|| { @@ -3532,7 +4229,6 @@ async fn text_preview_of_latest_thread_reply( } } - /// Returns the timestamp and an HTML-formatted text preview of the given `latest_event`. /// /// If the sender profile of the event is not yet available, this function will @@ -3556,29 +4252,37 @@ async fn get_latest_event_details( match latest_event_value { LatestEventValue::None => None, - LatestEventValue::Remote { timestamp, sender, is_own, profile, content } => { + LatestEventValue::Remote { + timestamp, + sender, + is_own, + profile, + content, + } => { let sender_username = get_sender_username!(profile, sender, *is_own); - let latest_message_text = text_preview_of_timeline_item( - content, - sender, - &sender_username, - ).format_with(&sender_username, true); + let latest_message_text = + text_preview_of_timeline_item(content, sender, &sender_username) + .format_with(&sender_username, true); Some((*timestamp, latest_message_text)) } - LatestEventValue::Local { timestamp, sender, profile, content, state: _ } => { + LatestEventValue::Local { + timestamp, + sender, + profile, + content, + state: _, + } => { // TODO: use the `state` enum to augment the preview text with more details. // Example: "Sending... {msg}" or // "Failed to send {msg}" let is_own = current_user_id().is_some_and(|id| &id == sender); let sender_username = get_sender_username!(profile, sender, is_own); - let latest_message_text = text_preview_of_timeline_item( - content, - sender, - &sender_username, - ).format_with(&sender_username, true); + let latest_message_text = + text_preview_of_timeline_item(content, sender, &sender_username) + .format_with(&sender_username, true); Some((*timestamp, latest_message_text)) } - } + } } /// Handles the given updated latest event for the given room. @@ -3586,10 +4290,9 @@ async fn get_latest_event_details( /// This function sends a `RoomsListUpdate::UpdateLatestEvent` /// to update the latest event in the RoomsListEntry for the given room. async fn update_latest_event(room: &Room) { - if let Some((timestamp, latest_message_text)) = get_latest_event_details( - &room.latest_event().await, - &room.client(), - ).await { + if let Some((timestamp, latest_message_text)) = + get_latest_event_details(&room.latest_event().await, &room.client()).await + { enqueue_rooms_list_update(RoomsListUpdate::UpdateLatestEvent { room_id: room.room_id().to_owned(), timestamp, @@ -3626,7 +4329,6 @@ async fn timeline_subscriber_handler( mut request_receiver: watch::Receiver>, thread_root_event_id: Option, ) { - /// An inner function that searches the given new timeline items for a target event. /// /// If the target event is found, it is removed from the `target_event_id_opt` and returned, @@ -3635,14 +4337,13 @@ async fn timeline_subscriber_handler( target_event_id_opt: &mut Option, mut new_items_iter: impl Iterator>, ) -> Option<(usize, OwnedEventId)> { - let found_index = target_event_id_opt - .as_ref() - .and_then(|target_event_id| new_items_iter - .position(|new_item| new_item + let found_index = target_event_id_opt.as_ref().and_then(|target_event_id| { + new_items_iter.position(|new_item| { + new_item .as_event() .is_some_and(|new_ev| new_ev.event_id() == Some(target_event_id)) - ) - ); + }) + }); if let Some(index) = found_index { target_event_id_opt.take().map(|ev| (index, ev)) @@ -3651,11 +4352,13 @@ async fn timeline_subscriber_handler( } } - let room_id = room.room_id().to_owned(); log!("Starting timeline subscriber for room {room_id}, thread {thread_root_event_id:?}..."); let (mut timeline_items, mut subscriber) = timeline.subscribe().await; - log!("Received initial timeline update of {} items for room {room_id}, thread {thread_root_event_id:?}.", timeline_items.len()); + log!( + "Received initial timeline update of {} items for room {room_id}, thread {thread_root_event_id:?}.", + timeline_items.len() + ); timeline_update_sender.send(TimelineUpdate::FirstUpdate { initial_items: timeline_items.clone(), @@ -3668,262 +4371,266 @@ async fn timeline_subscriber_handler( // the timeline index and event ID of the target event, if it has been found. let mut found_target_event_id: Option<(usize, OwnedEventId)> = None; - loop { tokio::select! { - // we should check for new requests before handling new timeline updates, - // because the request might influence how we handle a timeline update. - biased; - - // Handle updates to the current backwards pagination requests. - Ok(()) = request_receiver.changed() => { - let prev_target_event_id = target_event_id.clone(); - let new_request_details = request_receiver - .borrow_and_update() - .iter() - .find_map(|req| req.room_id - .eq(&room_id) - .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) - ); - - target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); + loop { + tokio::select! { + // we should check for new requests before handling new timeline updates, + // because the request might influence how we handle a timeline update. + biased; + + // Handle updates to the current backwards pagination requests. + Ok(()) = request_receiver.changed() => { + let prev_target_event_id = target_event_id.clone(); + let new_request_details = request_receiver + .borrow_and_update() + .iter() + .find_map(|req| req.room_id + .eq(&room_id) + .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) + ); - // If we received a new request, start searching backwards for the target event. - if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { - if prev_target_event_id.as_ref() != Some(&new_target_event_id) { - let starting_index = if current_tl_len == timeline_items.len() { - starting_index - } else { - // The timeline has changed since the request was made, so we can't rely on the `starting_index`. - // Instead, we have no choice but to start from the end of the timeline. - timeline_items.len() - }; - // log!("Received new request to search for event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} starting from index {starting_index} (tl len {}).", timeline_items.len()); - // Search backwards for the target event in the timeline, starting from the given index. - if let Some(target_event_tl_index) = timeline_items - .focus() - .narrow(..starting_index) - .into_iter() - .rev() - .position(|i| i.as_event() - .and_then(|e| e.event_id()) - .is_some_and(|ev_id| ev_id == new_target_event_id) - ) - .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) - { - // log!("Found existing target event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} at index {target_event_tl_index}."); + target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); - // Nice! We found the target event in the current timeline items, - // so there's no need to actually proceed with backwards pagination; - // thus, we can clear the locally-tracked target event ID. - target_event_id = None; - found_target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: new_target_event_id.clone(), - index: target_event_tl_index, - } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}, thread {thread_root_event_id:?}!") - ); - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); - } - else { - log!("Target event not in timeline. Starting backwards pagination \ - in room {room_id}, thread {thread_root_event_id:?} to find target event \ - {new_target_event_id} starting from index {starting_index}.", - ); - // If we didn't find the target event in the current timeline items, - // we need to start loading previous items into the timeline. - submit_async_request(MatrixRequest::PaginateTimeline { - timeline_kind: if let Some(thread_root_event_id) = thread_root_event_id.clone() { - TimelineKind::Thread { - room_id: room_id.clone(), - thread_root_event_id, - } - } else { - TimelineKind::MainRoom { - room_id: room_id.clone(), + // If we received a new request, start searching backwards for the target event. + if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { + if prev_target_event_id.as_ref() != Some(&new_target_event_id) { + let starting_index = if current_tl_len == timeline_items.len() { + starting_index + } else { + // The timeline has changed since the request was made, so we can't rely on the `starting_index`. + // Instead, we have no choice but to start from the end of the timeline. + timeline_items.len() + }; + // log!("Received new request to search for event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} starting from index {starting_index} (tl len {}).", timeline_items.len()); + // Search backwards for the target event in the timeline, starting from the given index. + if let Some(target_event_tl_index) = timeline_items + .focus() + .narrow(..starting_index) + .into_iter() + .rev() + .position(|i| i.as_event() + .and_then(|e| e.event_id()) + .is_some_and(|ev_id| ev_id == new_target_event_id) + ) + .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) + { + // log!("Found existing target event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} at index {target_event_tl_index}."); + + // Nice! We found the target event in the current timeline items, + // so there's no need to actually proceed with backwards pagination; + // thus, we can clear the locally-tracked target event ID. + target_event_id = None; + found_target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: new_target_event_id.clone(), + index: target_event_tl_index, } - }, - num_events: 50, - direction: PaginationDirection::Backwards, - }); + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}, thread {thread_root_event_id:?}!") + ); + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); + } + else { + log!("Target event not in timeline. Starting backwards pagination \ + in room {room_id}, thread {thread_root_event_id:?} to find target event \ + {new_target_event_id} starting from index {starting_index}.", + ); + // If we didn't find the target event in the current timeline items, + // we need to start loading previous items into the timeline. + submit_async_request(MatrixRequest::PaginateTimeline { + timeline_kind: if let Some(thread_root_event_id) = thread_root_event_id.clone() { + TimelineKind::Thread { + room_id: room_id.clone(), + thread_root_event_id, + } + } else { + TimelineKind::MainRoom { + room_id: room_id.clone(), + } + }, + num_events: 50, + direction: PaginationDirection::Backwards, + }); + } } } } - } - // Handle updates to the actual timeline content. - batch_opt = subscriber.next() => { - let Some(batch) = batch_opt else { break }; - let mut num_updates = 0; - let mut index_of_first_change = usize::MAX; - let mut index_of_last_change = usize::MIN; - // whether to clear the entire cache of drawn items - let mut clear_cache = false; - // whether the changes include items being appended to the end of the timeline - let mut is_append = false; - for diff in batch { - num_updates += 1; - match diff { - VectorDiff::Append { values } => { - let _values_len = values.len(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.extend(values); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } - is_append = true; - } - VectorDiff::Clear => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Clear"); } - clear_cache = true; - timeline_items.clear(); - } - VectorDiff::PushFront { value } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushFront"); } - if let Some((index, _ev)) = found_target_event_id.as_mut() { - *index += 1; // account for this new `value` being prepended. - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); + // Handle updates to the actual timeline content. + batch_opt = subscriber.next() => { + let Some(batch) = batch_opt else { break }; + let mut num_updates = 0; + let mut index_of_first_change = usize::MAX; + let mut index_of_last_change = usize::MIN; + // whether to clear the entire cache of drawn items + let mut clear_cache = false; + // whether the changes include items being appended to the end of the timeline + let mut is_append = false; + for diff in batch { + num_updates += 1; + match diff { + VectorDiff::Append { values } => { + let _values_len = values.len(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.extend(values); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } + is_append = true; } - - clear_cache = true; - timeline_items.push_front(value); - } - VectorDiff::PushBack { value } => { - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.push_back(value); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } - is_append = true; - } - VectorDiff::PopFront => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopFront"); } - clear_cache = true; - timeline_items.pop_front(); - if let Some((i, _ev)) = found_target_event_id.as_mut() { - *i = i.saturating_sub(1); // account for the first item being removed. + VectorDiff::Clear => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Clear"); } + clear_cache = true; + timeline_items.clear(); } - // This doesn't affect whether we should reobtain the latest event. - } - VectorDiff::PopBack => { - timeline_items.pop_back(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); - index_of_last_change = usize::MAX; - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Insert { index, value } => { - if index == 0 { + VectorDiff::PushFront { value } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushFront"); } + if let Some((index, _ev)) = found_target_event_id.as_mut() { + *index += 1; // account for this new `value` being prepended. + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); + } + clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = usize::MAX; + timeline_items.push_front(value); } - if index >= timeline_items.len() { + VectorDiff::PushBack { value } => { + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.push_back(value); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } is_append = true; } - - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for this new `value` being inserted before the previously-found target event's index. - if index <= *i { - *i += 1; + VectorDiff::PopFront => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopFront"); } + clear_cache = true; + timeline_items.pop_front(); + if let Some((i, _ev)) = found_target_event_id.as_mut() { + *i = i.saturating_sub(1); // account for the first item being removed. } - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) - .map(|(i, ev)| (i + index, ev)); + // This doesn't affect whether we should reobtain the latest event. } - - timeline_items.insert(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Set { index, value } => { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = max(index_of_last_change, index.saturating_add(1)); - timeline_items.set(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Remove { index } => { - if index == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); + VectorDiff::PopBack => { + timeline_items.pop_back(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); index_of_last_change = usize::MAX; + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Insert { index, value } => { + if index == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, index); + index_of_last_change = usize::MAX; + } + if index >= timeline_items.len() { + is_append = true; + } + + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for this new `value` being inserted before the previously-found target event's index. + if index <= *i { + *i += 1; + } + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) + .map(|(i, ev)| (i + index, ev)); + } + + timeline_items.insert(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Set { index, value } => { + index_of_first_change = min(index_of_first_change, index); + index_of_last_change = max(index_of_last_change, index.saturating_add(1)); + timeline_items.set(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } } - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for an item being removed before the previously-found target event's index. - if index <= *i { - *i = i.saturating_sub(1); + VectorDiff::Remove { index } => { + if index == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); + index_of_last_change = usize::MAX; + } + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for an item being removed before the previously-found target event's index. + if index <= *i { + *i = i.saturating_sub(1); + } } + timeline_items.remove(index); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } } - timeline_items.remove(index); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Truncate { length } => { - if length == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); - index_of_last_change = usize::MAX; + VectorDiff::Truncate { length } => { + if length == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); + index_of_last_change = usize::MAX; + } + timeline_items.truncate(length); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Reset { values } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Reset, new length {}", values.len()); } + clear_cache = true; // we must assume all items have changed. + timeline_items = values; } - timeline_items.truncate(length); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Reset { values } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Reset, new length {}", values.len()); } - clear_cache = true; // we must assume all items have changed. - timeline_items = values; } } - } - if num_updates > 0 { - // Handle the case where back pagination inserts items at the beginning of the timeline - // (meaning the entire timeline needs to be re-drawn), - // but there is a virtual event at index 0 (e.g., a day divider). - // When that happens, we want the RoomScreen to treat this as if *all* events changed. - if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { - index_of_first_change = 0; - clear_cache = true; - } + if num_updates > 0 { + // Handle the case where back pagination inserts items at the beginning of the timeline + // (meaning the entire timeline needs to be re-drawn), + // but there is a virtual event at index 0 (e.g., a day divider). + // When that happens, we want the RoomScreen to treat this as if *all* events changed. + if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { + index_of_first_change = 0; + clear_cache = true; + } - let changed_indices = index_of_first_change..index_of_last_change; + let changed_indices = index_of_first_change..index_of_last_change; - if LOG_TIMELINE_DIFFS { - log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, thread {thread_root_event_id:?}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); - } - timeline_update_sender.send(TimelineUpdate::NewItems { - new_items: timeline_items.clone(), - changed_indices, - clear_cache, - is_append, - }).expect("Error: timeline update sender couldn't send update with new items!"); - - // We must send this update *after* the actual NewItems update, - // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. - if let Some((index, found_event_id)) = found_target_event_id.take() { - target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: found_event_id.clone(), - index, - } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}, thread {thread_root_event_id:?}!") - ); - } + if LOG_TIMELINE_DIFFS { + log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, thread {thread_root_event_id:?}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); + } + timeline_update_sender.send(TimelineUpdate::NewItems { + new_items: timeline_items.clone(), + changed_indices, + clear_cache, + is_append, + }).expect("Error: timeline update sender couldn't send update with new items!"); + + // We must send this update *after* the actual NewItems update, + // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. + if let Some((index, found_event_id)) = found_target_event_id.take() { + target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: found_event_id.clone(), + index, + } + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}, thread {thread_root_event_id:?}!") + ); + } - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); + } } - } - else => { - break; + else => { + break; + } } - } } + } - error!("Error: unexpectedly ended timeline subscriber for room {room_id}, thread {thread_root_event_id:?}."); + error!( + "Error: unexpectedly ended timeline subscriber for room {room_id}, thread {thread_root_event_id:?}." + ); } /// Spawn a new async task to fetch the room's new avatar. @@ -3948,8 +4655,13 @@ async fn room_avatar(room: &Room, room_name_id: &RoomNameId) -> FetchedRoomAvata _ => { if let Ok(room_members) = room.members(RoomMemberships::ACTIVE).await { if room_members.len() == 2 { - if let Some(non_account_member) = room_members.iter().find(|m| !m.is_account_user()) { - if let Ok(Some(avatar)) = non_account_member.avatar(AVATAR_THUMBNAIL_FORMAT.into()).await { + if let Some(non_account_member) = + room_members.iter().find(|m| !m.is_account_user()) + { + if let Ok(Some(avatar)) = non_account_member + .avatar(AVATAR_THUMBNAIL_FORMAT.into()) + .await + { return FetchedRoomAvatar::Image(avatar.into()); } } @@ -3978,7 +4690,8 @@ async fn spawn_sso_server( // Post a status update to inform the user that we're waiting for the client to be built. Cx::post_action(LoginAction::Status { title: "Initializing client...".into(), - status: "Please wait while Matrix builds and configures the client object for login.".into(), + status: "Please wait while Matrix builds and configures the client object for login." + .into(), }); // Wait for the notification that the client has been built @@ -3999,19 +4712,21 @@ async fn spawn_sso_server( // or if the homeserver_url is *not* empty and isn't the default, // we cannot use the DEFAULT_SSO_CLIENT, so we must build a new one. let mut build_client_error = None; - if client_and_session.is_none() || ( - !homeserver_url.is_empty() + if client_and_session.is_none() + || (!homeserver_url.is_empty() && homeserver_url != "matrix.org" && Url::parse(&homeserver_url) != Url::parse("https://matrix-client.matrix.org/") - && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/") - ) { + && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/")) + { match build_client( &Cli { homeserver: homeserver_url.is_empty().not().then_some(homeserver_url), ..Default::default() }, app_data_dir(), - ).await { + ) + .await + { Ok(success) => client_and_session = Some(success), Err(e) => build_client_error = Some(e), } @@ -4020,10 +4735,12 @@ async fn spawn_sso_server( let Some((client, client_session)) = client_and_session else { Cx::post_action(LoginAction::LoginFailure( if let Some(err) = build_client_error { - format!("Could not create client object. Please try to login again.\n\nError: {err}") + format!( + "Could not create client object. Please try to login again.\n\nError: {err}" + ) } else { String::from("Could not create client object. Please try to login again.") - } + }, )); // This ensures that the called to `DEFAULT_SSO_CLIENT_NOTIFIER.notified()` // at the top of this function will not block upon the next login attempt. @@ -4035,7 +4752,8 @@ async fn spawn_sso_server( let mut is_logged_in = false; Cx::post_action(LoginAction::Status { title: "Opening your browser...".into(), - status: "Please finish logging in using your browser, and then come back to Robrix.".into(), + status: "Please finish logging in using your browser, and then come back to Robrix." + .into(), }); match client .matrix_auth() @@ -4045,12 +4763,15 @@ async fn spawn_sso_server( if key == "redirectUrl" { let redirect_url = Url::parse(&value)?; Cx::post_action(LoginAction::SsoSetRedirectUrl(redirect_url)); - break + break; } } - Uri::new(&sso_url).open().map_err(|err| - Error::Io(io::Error::other(format!("Unable to open SSO login url. Error: {:?}", err))) - ) + Uri::new(&sso_url).open().map_err(|err| { + Error::Io(io::Error::other(format!( + "Unable to open SSO login url. Error: {:?}", + err + ))) + }) }) .identity_provider_id(&identity_provider_id) .initial_device_display_name(&format!("robrix-sso-{brand}")) @@ -4065,10 +4786,13 @@ async fn spawn_sso_server( }) { Ok(identity_provider_res) => { if !is_logged_in { - if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session)).await { + if let Err(e) = login_sender + .send(LoginRequest::LoginBySSOSuccess(client, client_session)) + .await + { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to matrix worker thread." + "BUG: failed to send login request to matrix worker thread.", ))); } enqueue_rooms_list_update(RoomsListUpdate::Status { @@ -4094,7 +4818,6 @@ async fn spawn_sso_server( }); } - bitflags! { /// The powers that a user has in a given room. #[derive(Copy, Clone, PartialEq, Eq)] @@ -4172,14 +4895,38 @@ impl UserPowerLevels { retval.set(UserPowerLevels::Invite, user_power >= power_levels.invite); retval.set(UserPowerLevels::Kick, user_power >= power_levels.kick); retval.set(UserPowerLevels::Redact, user_power >= power_levels.redact); - retval.set(UserPowerLevels::NotifyRoom, user_power >= power_levels.notifications.room); - retval.set(UserPowerLevels::Location, user_power >= power_levels.for_message(MessageLikeEventType::Location)); - retval.set(UserPowerLevels::Message, user_power >= power_levels.for_message(MessageLikeEventType::Message)); - retval.set(UserPowerLevels::Reaction, user_power >= power_levels.for_message(MessageLikeEventType::Reaction)); - retval.set(UserPowerLevels::RoomMessage, user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage)); - retval.set(UserPowerLevels::RoomRedaction, user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction)); - retval.set(UserPowerLevels::Sticker, user_power >= power_levels.for_message(MessageLikeEventType::Sticker)); - retval.set(UserPowerLevels::RoomPinnedEvents, user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents)); + retval.set( + UserPowerLevels::NotifyRoom, + user_power >= power_levels.notifications.room, + ); + retval.set( + UserPowerLevels::Location, + user_power >= power_levels.for_message(MessageLikeEventType::Location), + ); + retval.set( + UserPowerLevels::Message, + user_power >= power_levels.for_message(MessageLikeEventType::Message), + ); + retval.set( + UserPowerLevels::Reaction, + user_power >= power_levels.for_message(MessageLikeEventType::Reaction), + ); + retval.set( + UserPowerLevels::RoomMessage, + user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage), + ); + retval.set( + UserPowerLevels::RoomRedaction, + user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction), + ); + retval.set( + UserPowerLevels::Sticker, + user_power >= power_levels.for_message(MessageLikeEventType::Sticker), + ); + retval.set( + UserPowerLevels::RoomPinnedEvents, + user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents), + ); retval } @@ -4225,8 +4972,7 @@ impl UserPowerLevels { } pub fn can_send_message(self) -> bool { - self.contains(UserPowerLevels::RoomMessage) - || self.contains(UserPowerLevels::Message) + self.contains(UserPowerLevels::RoomMessage) || self.contains(UserPowerLevels::Message) } pub fn can_send_reaction(self) -> bool { @@ -4243,7 +4989,6 @@ impl UserPowerLevels { } } - /// Shuts down the current Tokio runtime completely and takes ownership to ensure proper cleanup. pub fn shutdown_background_tasks() { if let Some(runtime) = TOKIO_RUNTIME.lock().unwrap().take() { @@ -4261,9 +5006,16 @@ pub async fn clear_app_state(config: &LogoutConfig) -> Result<()> { ALL_JOINED_ROOMS.lock().unwrap().clear(); let on_clear_appstate = Arc::new(Notify::new()); - Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); - - match tokio::time::timeout(config.app_state_cleanup_timeout, on_clear_appstate.notified()).await { + Cx::post_action(LogoutAction::ClearAppState { + on_clear_appstate: on_clear_appstate.clone(), + }); + + match tokio::time::timeout( + config.app_state_cleanup_timeout, + on_clear_appstate.notified(), + ) + .await + { Ok(_) => { log!("Received signal that UI-side app state was cleaned successfully"); Ok(()) From 7149b00fa9021e48d46d945971d55e7eb59f06fd 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: Tue, 24 Mar 2026 03:12:25 +0800 Subject: [PATCH 02/66] Commit remaining workspace changes --- src/app.rs | 285 +++-- src/avatar_cache.rs | 26 +- src/event_preview.rs | 584 +++++---- src/home/add_room.rs | 307 +++-- src/home/edited_indicator.rs | 18 +- src/home/editing_pane.rs | 134 ++- src/home/event_reaction_list.rs | 57 +- src/home/event_source_modal.rs | 64 +- src/home/home_screen.rs | 70 +- src/home/invite_modal.rs | 62 +- src/home/invite_screen.rs | 163 ++- src/home/link_preview.rs | 110 +- src/home/loading_pane.rs | 98 +- src/home/location_preview.rs | 28 +- src/home/main_desktop_ui.rs | 151 ++- src/home/main_mobile_ui.rs | 35 +- src/home/navigation_tab_bar.rs | 128 +- src/home/new_message_context_menu.rs | 175 +-- src/home/room_context_menu.rs | 87 +- src/home/room_image_viewer.rs | 5 +- src/home/room_read_receipt.rs | 8 +- src/home/room_screen.rs | 1662 ++++++++++++++++---------- src/home/rooms_list.rs | 682 +++++++---- src/home/rooms_list_entry.rs | 139 ++- src/home/rooms_list_header.rs | 46 +- src/home/rooms_sidebar.rs | 7 +- src/home/search_messages.rs | 6 +- src/home/space_lobby.rs | 451 ++++--- src/home/spaces_bar.rs | 242 ++-- src/home/tombstone_footer.rs | 79 +- src/join_leave_room_modal.rs | 212 ++-- src/lib.rs | 3 - src/location.rs | 14 +- src/login/login_status_modal.rs | 5 +- src/logout/logout_confirm_modal.rs | 57 +- src/logout/logout_errors.rs | 2 +- src/logout/logout_state_machine.rs | 280 +++-- src/main.rs | 5 +- src/media_cache.rs | 85 +- src/persistence/app_state.rs | 19 +- src/persistence/tsp_state.rs | 18 +- src/profile/user_profile.rs | 223 ++-- src/profile/user_profile_cache.rs | 146 ++- src/room/mod.rs | 24 +- src/room/room_display_filter.rs | 52 +- src/room/room_input_bar.rs | 287 +++-- src/room/typing_notice.rs | 26 +- src/settings/account_settings.rs | 247 ++-- src/settings/settings_screen.rs | 48 +- src/shared/avatar.rs | 136 ++- src/shared/bouncing_dots.rs | 24 +- src/shared/collapsible_header.rs | 45 +- src/shared/command_text_input.rs | 28 +- src/shared/confirmation_modal.rs | 60 +- src/shared/expand_arrow.rs | 30 +- src/shared/html_or_plaintext.rs | 173 ++- src/shared/image_viewer.rs | 187 +-- src/shared/jump_to_bottom_button.rs | 48 +- src/shared/mentionable_text_input.rs | 29 +- src/shared/mod.rs | 1 - src/shared/popup_list.rs | 39 +- src/shared/restore_status_view.rs | 24 +- src/shared/room_filter_input_bar.rs | 17 +- src/shared/styles.rs | 31 +- src/shared/text_or_image.rs | 49 +- src/shared/timestamp.rs | 22 +- src/shared/unread_badge.rs | 44 +- src/shared/verification_badge.rs | 6 +- src/space_service_sync.rs | 852 +++++++------ src/temp_storage.rs | 2 - src/tsp/create_did_modal.rs | 52 +- src/tsp/create_wallet_modal.rs | 58 +- src/tsp/mod.rs | 761 +++++++----- src/tsp/tsp_settings_screen.rs | 201 +++- src/tsp/tsp_sign_indicator.rs | 29 +- src/tsp/tsp_verification_modal.rs | 52 +- src/tsp/verify_user.rs | 66 +- src/tsp/wallet_entry/mod.rs | 80 +- src/utils.rs | 255 ++-- src/verification.rs | 156 ++- src/verification_modal.rs | 59 +- 81 files changed, 6991 insertions(+), 4287 deletions(-) diff --git a/src/app.rs b/src/app.rs index f04e177d5..e506eb4b0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,10 @@ use std::{cell::RefCell, collections::HashMap}; use makepad_widgets::*; -use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, RoomId}}; +use matrix_sdk::{ + RoomState, + ruma::{OwnedEventId, OwnedRoomId, RoomId}, +}; use serde::{Deserialize, Serialize}; use crate::{ avatar_cache::clear_avatar_cache, home::{ @@ -51,7 +54,7 @@ script_mod! { close +: { draw_bg +: {color: #0, color_hover: #E81123, color_down: #FF0015} } } } - + body +: { padding: 0, @@ -80,7 +83,7 @@ script_mod! { image_viewer_modal_inner := ImageViewer {} } } - + // Context menus should be shown in front of other UI elements, // but behind verification modals. new_message_context_menu := NewMessageContextMenu { } @@ -164,9 +167,11 @@ app_main!(App); #[derive(Script)] pub struct App { - #[live] ui: WidgetRef, + #[live] + ui: WidgetRef, /// The top-level app state, shared across various parts of the app. - #[rust] app_state: AppState, + #[rust] + app_state: AppState, /// The details of a room we're waiting on to be loaded so that we can navigate to it. /// This can be either a room we're waiting to join, or one we're waiting to be invited to. /// Also includes an optional room ID to be closed once the awaited room has been loaded. @@ -198,15 +203,27 @@ impl MatchEvent for App { let _ = tracing_subscriber::fmt::try_init(); // Override Makepad's new default-JSON logger. We just want regular formatting. - fn regular_log(file_name: &str, line_start: u32, column_start: u32, _line_end: u32, _column_end: u32, message: String, level: LogLevel) { + fn regular_log( + file_name: &str, + line_start: u32, + column_start: u32, + _line_end: u32, + _column_end: u32, + message: String, + level: LogLevel, + ) { let l = match level { - LogLevel::Panic => "[!]", - LogLevel::Error => "[E]", + LogLevel::Panic => "[!]", + LogLevel::Error => "[E]", LogLevel::Warning => "[W]", - LogLevel::Log => "[I]", - LogLevel::Wait => "[.]", + LogLevel::Log => "[I]", + LogLevel::Wait => "[.]", }; - println!("{l} {file_name}:{}:{}: {message}", line_start + 1, column_start + 1); + println!( + "{l} {file_name}:{}:{}: {message}", + line_start + 1, + column_start + 1 + ); } *LOG_WITH_LEVEL.write().unwrap() = regular_log; @@ -233,41 +250,52 @@ impl MatchEvent for App { log!("App::Startup: starting matrix sdk loop"); let _tokio_rt_handle = crate::sliding_sync::start_matrix_tokio().unwrap(); - #[cfg(feature = "tsp")] { + #[cfg(feature = "tsp")] + { log!("App::Startup: initializing TSP (Trust Spanning Protocol) module."); crate::tsp::tsp_init(_tokio_rt_handle).unwrap(); } } fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { - let invite_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(invite_confirmation_modal_inner)); + let invite_confirmation_modal_inner = self + .ui + .confirmation_modal(cx, ids!(invite_confirmation_modal_inner)); if let Some(_accepted) = invite_confirmation_modal_inner.closed(actions) { self.ui.modal(cx, ids!(invite_confirmation_modal)).close(cx); } - let delete_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(delete_confirmation_modal_inner)); + let delete_confirmation_modal_inner = self + .ui + .confirmation_modal(cx, ids!(delete_confirmation_modal_inner)); if let Some(_accepted) = delete_confirmation_modal_inner.closed(actions) { self.ui.modal(cx, ids!(delete_confirmation_modal)).close(cx); } - let positive_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(positive_confirmation_modal_inner)); + let positive_confirmation_modal_inner = self + .ui + .confirmation_modal(cx, ids!(positive_confirmation_modal_inner)); if let Some(_accepted) = positive_confirmation_modal_inner.closed(actions) { - self.ui.modal(cx, ids!(positive_confirmation_modal)).close(cx); + self.ui + .modal(cx, ids!(positive_confirmation_modal)) + .close(cx); } for action in actions { match action.downcast_ref() { Some(LogoutConfirmModalAction::Open) => { - self.ui.logout_confirm_modal(cx, ids!(logout_confirm_modal_inner)).reset_state(cx); + self.ui + .logout_confirm_modal(cx, ids!(logout_confirm_modal_inner)) + .reset_state(cx); self.ui.modal(cx, ids!(logout_confirm_modal)).open(cx); continue; - }, + } Some(LogoutConfirmModalAction::Close { was_internal, .. }) => { if *was_internal { self.ui.modal(cx, ids!(logout_confirm_modal)).close(cx); } continue; - }, + } _ => {} } @@ -279,8 +307,8 @@ impl MatchEvent for App { self.ui.redraw(cx); continue; } - Some(LogoutAction::ClearAppState { on_clear_appstate }) => { - // Clear user profile cache, invited_rooms timeline states + Some(LogoutAction::ClearAppState { on_clear_appstate }) => { + // Clear user profile cache, invited_rooms timeline states clear_all_app_state(cx); // Reset all app state to its default. self.app_state = Default::default(); @@ -303,7 +331,9 @@ impl MatchEvent for App { // When not yet logged in, the login_screen widget handles displaying the failure modal. if let Some(LoginAction::LoginFailure(_)) = action.downcast_ref() { if self.app_state.logged_in { - log!("Received LoginAction::LoginFailure while logged in; showing login screen."); + log!( + "Received LoginAction::LoginFailure while logged in; showing login screen." + ); self.app_state.logged_in = false; self.update_login_visibility(cx); self.ui.redraw(cx); @@ -312,9 +342,13 @@ impl MatchEvent for App { } // Handle an action requesting to open the new message context menu. - if let MessageAction::OpenMessageContextMenu { details, abs_pos } = action.as_widget_action().cast() { + if let MessageAction::OpenMessageContextMenu { details, abs_pos } = + action.as_widget_action().cast() + { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); - let new_message_context_menu = self.ui.new_message_context_menu(cx, ids!(new_message_context_menu)); + let new_message_context_menu = self + .ui + .new_message_context_menu(cx, ids!(new_message_context_menu)); let expected_dimensions = new_message_context_menu.show(cx, details); // Ensure the context menu does not spill over the window's bounds. let rect = self.ui.window(cx, ids!(main_window)).area().rect(cx); @@ -335,7 +369,9 @@ impl MatchEvent for App { } // Handle an action requesting to open the room context menu. - if let RoomsListAction::OpenRoomContextMenu { details, pos } = action.as_widget_action().cast() { + if let RoomsListAction::OpenRoomContextMenu { details, pos } = + action.as_widget_action().cast() + { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); let room_context_menu = self.ui.room_context_menu(cx, ids!(room_context_menu)); let expected_dimensions = room_context_menu.show(cx, details); @@ -413,18 +449,25 @@ impl MatchEvent for App { cx.action(MainDesktopUiAction::LoadDockFromAppState); continue; } - Some(AppStateAction::NavigateToRoom { room_to_close, destination_room }) => { + Some(AppStateAction::NavigateToRoom { + room_to_close, + destination_room, + }) => { self.navigate_to_room(cx, room_to_close.as_ref(), destination_room); continue; } // If we successfully loaded a room that we were waiting on, // we can now navigate to it and optionally close a previous room. - Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) if - self.waiting_to_navigate_to_room.as_ref() + Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) + if self + .waiting_to_navigate_to_room + .as_ref() .is_some_and(|(dr, _)| dr.room_id() == room_name_id.room_id()) => { log!("Loaded awaited room {room_name_id:?}, navigating to it now..."); - if let Some((dest_room, room_to_close)) = self.waiting_to_navigate_to_room.take() { + if let Some((dest_room, room_to_close)) = + self.waiting_to_navigate_to_room.take() + { self.navigate_to_room(cx, room_to_close.as_ref(), &dest_room); } continue; @@ -434,18 +477,22 @@ impl MatchEvent for App { // Handle actions for showing or hiding the tooltip. match action.as_widget_action().cast() { - TooltipAction::HoverIn { text, widget_rect, options } => { + TooltipAction::HoverIn { + text, + widget_rect, + options, + } => { // Don't show any tooltips if the message context menu is currently shown. - if self.ui.new_message_context_menu(cx, ids!(new_message_context_menu)).is_currently_shown(cx) { + if self + .ui + .new_message_context_menu(cx, ids!(new_message_context_menu)) + .is_currently_shown(cx) + { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); - } - else { - self.ui.callout_tooltip(cx, ids!(app_tooltip)).show_with_options( - cx, - &text, - widget_rect, - options, - ); + } else { + self.ui + .callout_tooltip(cx, ids!(app_tooltip)) + .show_with_options(cx, &text, widget_rect, options); } continue; } @@ -479,7 +526,8 @@ impl MatchEvent for App { // // Note: other verification actions are handled by the verification modal itself. if let Some(VerificationAction::RequestReceived(state)) = action.downcast_ref() { - self.ui.verification_modal(cx, ids!(verification_modal_inner)) + self.ui + .verification_modal(cx, ids!(verification_modal_inner)) .initialize_with_data(cx, state.clone()); self.ui.modal(cx, ids!(verification_modal)).open(cx); continue; @@ -500,12 +548,23 @@ impl MatchEvent for App { _ => {} } // Handle actions to open/close the TSP verification modal. - #[cfg(feature = "tsp")] { + #[cfg(feature = "tsp")] + { use std::ops::Deref; - use crate::tsp::{tsp_verification_modal::{TspVerificationModalAction, TspVerificationModalWidgetRefExt}, TspIdentityAction}; + use crate::tsp::{ + tsp_verification_modal::{ + TspVerificationModalAction, TspVerificationModalWidgetRefExt, + }, + TspIdentityAction, + }; - if let Some(TspIdentityAction::ReceivedDidAssociationRequest { details, wallet_db }) = action.downcast_ref() { - self.ui.tsp_verification_modal(cx, ids!(tsp_verification_modal_inner)) + if let Some(TspIdentityAction::ReceivedDidAssociationRequest { + details, + wallet_db, + }) = action.downcast_ref() + { + self.ui + .tsp_verification_modal(cx, ids!(tsp_verification_modal_inner)) .initialize_with_details(cx, details.clone(), wallet_db.deref().clone()); self.ui.modal(cx, ids!(tsp_verification_modal)).open(cx); continue; @@ -517,7 +576,9 @@ impl MatchEvent for App { } // Handle a request to show the invite confirmation modal. - if let Some(InviteAction::ShowInviteConfirmationModal(content_opt)) = action.downcast_ref() { + if let Some(InviteAction::ShowInviteConfirmationModal(content_opt)) = + action.downcast_ref() + { if let Some(content) = content_opt.borrow_mut().take() { invite_confirmation_modal_inner.show(cx, content); self.ui.modal(cx, ids!(invite_confirmation_modal)).open(cx); @@ -526,10 +587,13 @@ impl MatchEvent for App { } // Handle a request to show the generic positive confirmation modal. - if let Some(PositiveConfirmationModalAction::Show(content_opt)) = action.downcast_ref() { + if let Some(PositiveConfirmationModalAction::Show(content_opt)) = action.downcast_ref() + { if let Some(content) = content_opt.borrow_mut().take() { positive_confirmation_modal_inner.show(cx, content); - self.ui.modal(cx, ids!(positive_confirmation_modal)).open(cx); + self.ui + .modal(cx, ids!(positive_confirmation_modal)) + .open(cx); } continue; } @@ -537,7 +601,9 @@ impl MatchEvent for App { // Handle a request to show the delete confirmation modal. if let Some(ConfirmDeleteAction::Show(content_opt)) = action.downcast_ref() { if let Some(content) = content_opt.borrow_mut().take() { - self.ui.confirmation_modal(cx, ids!(delete_confirmation_modal_inner)).show(cx, content); + self.ui + .confirmation_modal(cx, ids!(delete_confirmation_modal_inner)) + .show(cx, content); self.ui.modal(cx, ids!(delete_confirmation_modal)).open(cx); } continue; @@ -546,8 +612,10 @@ impl MatchEvent for App { // Handle InviteModalAction to open/close the invite modal. match action.downcast_ref() { Some(InviteModalAction::Open(room_name_id)) => { - self.ui.invite_modal(cx, ids!(invite_modal_inner)).show(cx, room_name_id.clone()); - self.ui.modal(cx, ids!(invite_modal)).open(cx); + self.ui + .invite_modal(cx, ids!(invite_modal_inner)) + .show(cx, room_name_id.clone()); + self.ui.modal(cx, ids!(invite_modal)).open(cx); continue; } Some(InviteModalAction::Close) => { @@ -559,8 +627,13 @@ impl MatchEvent for App { // Handle EventSourceModalAction to open/close the event source modal. match action.downcast_ref() { - Some(EventSourceModalAction::Open { room_id, event_id, original_json }) => { - self.ui.event_source_modal(cx, ids!(event_source_modal_inner)) + Some(EventSourceModalAction::Open { + room_id, + event_id, + original_json, + }) => { + self.ui + .event_source_modal(cx, ids!(event_source_modal_inner)) .show(cx, room_id.clone(), event_id.clone(), original_json.clone()); self.ui.modal(cx, ids!(event_source_modal)).open(cx); continue; @@ -575,7 +648,11 @@ impl MatchEvent for App { // Handle DirectMessageRoomActions match action.downcast_ref() { Some(DirectMessageRoomAction::FoundExisting { room_name_id, .. }) => { - self.navigate_to_room(cx, None, &BasicRoomDetails::RoomId(room_name_id.clone())); + self.navigate_to_room( + cx, + None, + &BasicRoomDetails::RoomId(room_name_id.clone()), + ); } Some(DirectMessageRoomAction::DidNotExist { user_profile }) => { let user_profile = user_profile.clone(); @@ -583,8 +660,7 @@ impl MatchEvent for App { Some(un) if !un.is_empty() => format!( "You don't have an existing direct message room with {} ({}).\n\n\ Would you like to create one now?", - un, - user_profile.user_id, + un, user_profile.user_id, ), _ => format!( "You don't have an existing direct message room with {}.\n\n\ @@ -612,17 +688,29 @@ impl MatchEvent for App { ..Default::default() }, ); - self.ui.modal(cx, ids!(positive_confirmation_modal)).open(cx); + self.ui + .modal(cx, ids!(positive_confirmation_modal)) + .open(cx); } - Some(DirectMessageRoomAction::FailedToCreate { user_profile, error }) => { + Some(DirectMessageRoomAction::FailedToCreate { + user_profile, + error, + }) => { enqueue_popup_notification( - format!("Failed to create a new DM room with {}.\n\nError: {error}", user_profile.displayable_name()), + format!( + "Failed to create a new DM room with {}.\n\nError: {error}", + user_profile.displayable_name() + ), PopupKind::Error, None, ); } Some(DirectMessageRoomAction::NewlyCreated { room_name_id, .. }) => { - self.navigate_to_room(cx, None, &BasicRoomDetails::RoomId(room_name_id.clone())); + self.navigate_to_room( + cx, + None, + &BasicRoomDetails::RoomId(room_name_id.clone()), + ); } _ => {} } @@ -631,7 +719,7 @@ impl MatchEvent for App { } /// Clears all thread-local UI caches (user profiles, invited rooms, and timeline states). -/// The `cx` parameter ensures that these thread-local caches are cleared on the main UI thread, +/// The `cx` parameter ensures that these thread-local caches are cleared on the main UI thread, fn clear_all_app_state(cx: &mut Cx) { clear_user_profile_cache(cx); clear_all_invited_rooms(cx); @@ -683,27 +771,34 @@ impl AppMain for App { error!("Failed to save app state. Error: {e}"); } } - #[cfg(feature = "tsp")] { + #[cfg(feature = "tsp")] + { // Save the TSP wallet state, if it exists, with a 3-second timeout. let tsp_state = std::mem::take(&mut *crate::tsp::tsp_state_ref().lock().unwrap()); let res = crate::sliding_sync::block_on_async_with_timeout( Some(std::time::Duration::from_secs(3)), async move { match tsp_state.close_and_serialize().await { - Ok(saved_state) => match persistence::save_tsp_state_async(saved_state).await { - Ok(_) => { } - Err(e) => error!("Failed to save TSP wallet state. Error: {e}"), + Ok(saved_state) => { + match persistence::save_tsp_state_async(saved_state).await { + Ok(_) => {} + Err(e) => error!("Failed to save TSP wallet state. Error: {e}"), + } + } + Err(e) => { + error!("Failed to close and serialize TSP wallet state. Error: {e}") } - Err(e) => error!("Failed to close and serialize TSP wallet state. Error: {e}"), } }, ); if let Err(_e) = res { - error!("Failed to save TSP wallet state before app shutdown. Error: Timed Out."); + error!( + "Failed to save TSP wallet state before app shutdown. Error: Timed Out." + ); } } } - + // Forward events to the MatchEvent trait implementation. self.match_event(cx, event); let scope = &mut Scope::with_data(&mut self.app_state); @@ -751,8 +846,12 @@ impl App { .modal(cx, ids!(login_screen_view.login_screen.login_status_modal)) .close(cx); } - self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, show_login); - self.ui.view(cx, ids!(home_screen_view)).set_visible(cx, !show_login); + self.ui + .view(cx, ids!(login_screen_view)) + .set_visible(cx, show_login); + self.ui + .view(cx, ids!(home_screen_view)) + .set_visible(cx, !show_login); } /// Navigates to the given `destination_room`, optionally closing the `room_to_close`. @@ -767,16 +866,17 @@ impl App { let tab_id = LiveId::from_str(to_close.as_str()); let widget_uid = self.ui.widget_uid(); move |cx: &mut Cx| { - cx.widget_action( - widget_uid, - DockAction::TabCloseWasPressed(tab_id), - ); - enqueue_rooms_list_update(RoomsListUpdate::HideRoom { room_id: to_close.clone() }); + cx.widget_action(widget_uid, DockAction::TabCloseWasPressed(tab_id)); + enqueue_rooms_list_update(RoomsListUpdate::HideRoom { + room_id: to_close.clone(), + }); } }); let destination_room_id = destination_room.room_id(); - let room_state = cx.get_global::().get_room_state(destination_room_id); + let room_state = cx + .get_global::() + .get_room_state(destination_room_id); let new_selected_room = match room_state { Some(RoomState::Joined) => SelectedRoom::JoinedRoom { room_name_id: destination_room.room_name_id().clone(), @@ -786,11 +886,12 @@ impl App { }, // If the destination room is not yet loaded, show a join modal. _ => { - log!("Destination room {:?} not loaded, showing join modal...", destination_room.room_name_id()); - self.waiting_to_navigate_to_room = Some(( - destination_room.clone(), - room_to_close.cloned(), - )); + log!( + "Destination room {:?} not loaded, showing join modal...", + destination_room.room_name_id() + ); + self.waiting_to_navigate_to_room = + Some((destination_room.clone(), room_to_close.cloned())); cx.action(JoinLeaveRoomModalAction::Open { kind: JoinLeaveModalKind::JoinRoom { details: destination_room.clone(), @@ -802,8 +903,8 @@ impl App { } }; - - log!("Navigating to destination room {:?}, closing room {:?}", + log!( + "Navigating to destination room {:?}, closing room {:?}", destination_room.room_name_id(), room_to_close, ); @@ -814,7 +915,7 @@ impl App { cx.action(NavigationBarAction::GoToHome); } cx.widget_action( - self.ui.widget_uid(), + self.ui.widget_uid(), RoomsListAction::Selected(new_selected_room), ); // Select and scroll to the destination room in the rooms list. @@ -966,7 +1067,6 @@ pub struct SavedDockState { pub selected_room: Option, } - /// Represents a room currently or previously selected by the user. /// /// ## PartialEq/Eq equality comparison behavior @@ -1023,9 +1123,7 @@ impl SelectedRoom { match self { SelectedRoom::InvitedRoom { room_name_id } if room_name_id.room_id() == room_id => { let name = room_name_id.clone(); - *self = SelectedRoom::JoinedRoom { - room_name_id: name, - }; + *self = SelectedRoom::JoinedRoom { room_name_id: name }; true } _ => false, @@ -1035,11 +1133,14 @@ impl SelectedRoom { /// Returns the `LiveId` of the room tab corresponding to this `SelectedRoom`. pub fn tab_id(&self) -> LiveId { match self { - SelectedRoom::Thread { room_name_id, thread_root_event_id } => { - LiveId::from_str( - &format!("{}##{}", room_name_id.room_id(), thread_root_event_id) - ) - } + SelectedRoom::Thread { + room_name_id, + thread_root_event_id, + } => LiveId::from_str(&format!( + "{}##{}", + room_name_id.room_id(), + thread_root_event_id + )), other => LiveId::from_str(other.room_id().as_str()), } } diff --git a/src/avatar_cache.rs b/src/avatar_cache.rs index 4d6d240b7..85bf71b65 100644 --- a/src/avatar_cache.rs +++ b/src/avatar_cache.rs @@ -6,7 +6,6 @@ use matrix_sdk::ruma::OwnedMxcUri; use crate::sliding_sync::{submit_async_request, MatrixRequest}; - thread_local! { /// A cache of Avatar images, indexed by Matrix URI. /// @@ -65,21 +64,16 @@ pub fn process_avatar_updates(_cx: &mut Cx) { /// This function requires passing in a reference to `Cx`, /// which isn't used, but acts as a guarantee that this function /// must only be called by the main UI thread. -pub fn get_or_fetch_avatar( - _cx: &mut Cx, - avatar_uri: &OwnedMxcUri, -) -> AvatarCacheEntry { - AVATAR_NEW_CACHE.with_borrow_mut(|cache| { - match cache.raw_entry_mut().from_key(avatar_uri) { - RawEntryMut::Occupied(occupied) => occupied.get().clone(), - RawEntryMut::Vacant(vacant) => { - vacant.insert(avatar_uri.clone(), AvatarCacheEntry::Requested); - submit_async_request(MatrixRequest::FetchAvatar { - mxc_uri: avatar_uri.clone(), - on_fetched: enqueue_avatar_update, - }); - AvatarCacheEntry::Requested - } +pub fn get_or_fetch_avatar(_cx: &mut Cx, avatar_uri: &OwnedMxcUri) -> AvatarCacheEntry { + AVATAR_NEW_CACHE.with_borrow_mut(|cache| match cache.raw_entry_mut().from_key(avatar_uri) { + RawEntryMut::Occupied(occupied) => occupied.get().clone(), + RawEntryMut::Vacant(vacant) => { + vacant.insert(avatar_uri.clone(), AvatarCacheEntry::Requested); + submit_async_request(MatrixRequest::FetchAvatar { + mxc_uri: avatar_uri.clone(), + on_fetched: enqueue_avatar_update, + }); + AvatarCacheEntry::Requested } }) } diff --git a/src/event_preview.rs b/src/event_preview.rs index d4e0cde25..6a34ab655 100644 --- a/src/event_preview.rs +++ b/src/event_preview.rs @@ -7,9 +7,28 @@ use std::borrow::Cow; -use matrix_sdk::{ruma::{OwnedUserId, events::{room::{guest_access::GuestAccess, history_visibility::HistoryVisibility, join_rules::JoinRule, message::{MessageFormat, MessageType}}, AnySyncMessageLikeEvent, AnySyncTimelineEvent, FullStateEventContent, SyncMessageLikeEvent}, serde::Raw, UserId}}; +use matrix_sdk::{ + ruma::{ + OwnedUserId, + events::{ + room::{ + guest_access::GuestAccess, + history_visibility::HistoryVisibility, + join_rules::JoinRule, + message::{MessageFormat, MessageType}, + }, + AnySyncMessageLikeEvent, AnySyncTimelineEvent, FullStateEventContent, + SyncMessageLikeEvent, + }, + serde::Raw, + UserId, + }, +}; use matrix_sdk_base::crypto::types::events::UtdCause; -use matrix_sdk_ui::timeline::{self, AnyOtherFullStateEventContent, EncryptedMessage, EventTimelineItem, MemberProfileChange, MembershipChange, MsgLikeKind, OtherMessageLike, RoomMembershipChange, TimelineItemContent}; +use matrix_sdk_ui::timeline::{ + self, AnyOtherFullStateEventContent, EncryptedMessage, EventTimelineItem, MemberProfileChange, + MembershipChange, MsgLikeKind, OtherMessageLike, RoomMembershipChange, TimelineItemContent, +}; use crate::utils; @@ -38,22 +57,24 @@ impl From<(String, BeforeText)> for TextPreview { } impl TextPreview { /// Formats the text preview with the appropriate preceding username. - pub fn format_with( - self, - username: &str, - as_html: bool, - ) -> String { + pub fn format_with(self, username: &str, as_html: bool) -> String { let Self { text, before_text } = self; match before_text { BeforeText::Nothing => text, - BeforeText::UsernameWithColon => if as_html { - format!("{}: {}", htmlize::escape_text(username), text) - } else { - format!("{}: {}", username, text) - }, + BeforeText::UsernameWithColon => { + if as_html { + format!("{}: {}", htmlize::escape_text(username), text) + } else { + format!("{}: {}", username, text) + } + } BeforeText::UsernameWithoutColon => format!( "{} {}", - if as_html { htmlize::escape_text(username) } else { username.into() }, + if as_html { + htmlize::escape_text(username) + } else { + username.into() + }, text, ), } @@ -67,52 +88,53 @@ pub fn text_preview_of_timeline_item( sender_username: &str, ) -> TextPreview { match content { - TimelineItemContent::MsgLike(msg_like_content) => { - match &msg_like_content.kind { - MsgLikeKind::Message(msg) => text_preview_of_message(msg.msgtype(), sender_username), - MsgLikeKind::Sticker(sticker) => TextPreview::from(( - format!("[Sticker]: {}", htmlize::escape_text(&sticker.content().body)), - BeforeText::UsernameWithColon, - )), - MsgLikeKind::Poll(poll_state) => TextPreview::from(( - format!( - "[Poll]: {}", - htmlize::escape_text( - poll_state.fallback_text() - .unwrap_or_else(|| poll_state.results().question) - ), + TimelineItemContent::MsgLike(msg_like_content) => match &msg_like_content.kind { + MsgLikeKind::Message(msg) => text_preview_of_message(msg.msgtype(), sender_username), + MsgLikeKind::Sticker(sticker) => TextPreview::from(( + format!( + "[Sticker]: {}", + htmlize::escape_text(&sticker.content().body) + ), + BeforeText::UsernameWithColon, + )), + MsgLikeKind::Poll(poll_state) => TextPreview::from(( + format!( + "[Poll]: {}", + htmlize::escape_text( + poll_state + .fallback_text() + .unwrap_or_else(|| poll_state.results().question) ), - BeforeText::UsernameWithColon, - )), - MsgLikeKind::Redacted => { - let mut preview = text_preview_of_redacted_message( - None, - sender_user_id, - sender_username, - ); - preview.text = htmlize::escape_text(&preview.text).into(); - preview - } - MsgLikeKind::UnableToDecrypt(em) => text_preview_of_encrypted_message(em), - MsgLikeKind::Other(oml) => text_preview_of_other_message_like(oml), + ), + BeforeText::UsernameWithColon, + )), + MsgLikeKind::Redacted => { + let mut preview = + text_preview_of_redacted_message(None, sender_user_id, sender_username); + preview.text = htmlize::escape_text(&preview.text).into(); + preview } - } + MsgLikeKind::UnableToDecrypt(em) => text_preview_of_encrypted_message(em), + MsgLikeKind::Other(oml) => text_preview_of_other_message_like(oml), + }, TimelineItemContent::MembershipChange(membership_change) => { - text_preview_of_room_membership_change(membership_change, true) - .unwrap_or_else(|| TextPreview::from(( + text_preview_of_room_membership_change(membership_change, true).unwrap_or_else(|| { + TextPreview::from(( String::from("underwent a membership change"), BeforeText::UsernameWithoutColon, - ))) + )) + }) } TimelineItemContent::ProfileChange(profile_change) => { text_preview_of_member_profile_change(profile_change, sender_username, true) } TimelineItemContent::OtherState(other_state) => { - text_preview_of_other_state(other_state, true) - .unwrap_or_else(|| TextPreview::from(( + text_preview_of_other_state(other_state, true).unwrap_or_else(|| { + TextPreview::from(( String::from("initiated another state change"), BeforeText::UsernameWithoutColon, - ))) + )) + }) } TimelineItemContent::FailedToParseMessageLike { event_type, .. } => TextPreview::from(( format!("[Failed to parse {} message]", event_type), @@ -133,83 +155,94 @@ pub fn text_preview_of_timeline_item( } } - - /// Returns the plaintext `body` of the given timeline event. -pub fn plaintext_body_of_timeline_item( - event_tl_item: &EventTimelineItem, -) -> String { +pub fn plaintext_body_of_timeline_item(event_tl_item: &EventTimelineItem) -> String { match event_tl_item.content() { - TimelineItemContent::MsgLike(msg_likecontent) => { - match &msg_likecontent.kind { - MsgLikeKind::Message(msg) => { - msg.body().into() - } - MsgLikeKind::Sticker(sticker) => { - sticker.content().body.clone() - } - MsgLikeKind::Poll(poll_state) => { - format!("[Poll]: {}", - poll_state.fallback_text().unwrap_or_else(|| poll_state.results().question) - ) - } - MsgLikeKind::Redacted => { - let sender_username = utils::get_or_fetch_event_sender(event_tl_item, None); - text_preview_of_redacted_message( - event_tl_item.latest_json(), - event_tl_item.sender(), - &sender_username, - ).format_with(&sender_username, false) - } - MsgLikeKind::UnableToDecrypt(em) => { - text_preview_of_encrypted_message(em) - .format_with(&utils::get_or_fetch_event_sender(event_tl_item, None), false) - } - MsgLikeKind::Other(other_msg_like) => { - text_preview_of_other_message_like(other_msg_like) - .format_with(&utils::get_or_fetch_event_sender(event_tl_item, None), false)} + TimelineItemContent::MsgLike(msg_likecontent) => match &msg_likecontent.kind { + MsgLikeKind::Message(msg) => msg.body().into(), + MsgLikeKind::Sticker(sticker) => sticker.content().body.clone(), + MsgLikeKind::Poll(poll_state) => { + format!( + "[Poll]: {}", + poll_state + .fallback_text() + .unwrap_or_else(|| poll_state.results().question) + ) } - } + MsgLikeKind::Redacted => { + let sender_username = utils::get_or_fetch_event_sender(event_tl_item, None); + text_preview_of_redacted_message( + event_tl_item.latest_json(), + event_tl_item.sender(), + &sender_username, + ) + .format_with(&sender_username, false) + } + MsgLikeKind::UnableToDecrypt(em) => text_preview_of_encrypted_message(em).format_with( + &utils::get_or_fetch_event_sender(event_tl_item, None), + false, + ), + MsgLikeKind::Other(other_msg_like) => { + text_preview_of_other_message_like(other_msg_like).format_with( + &utils::get_or_fetch_event_sender(event_tl_item, None), + false, + ) + } + }, TimelineItemContent::MembershipChange(membership_change) => { text_preview_of_room_membership_change(membership_change, false) - .unwrap_or_else(|| TextPreview::from(( - String::from("underwent a membership change."), - BeforeText::UsernameWithoutColon, - ))) - .format_with(&utils::get_or_fetch_event_sender(event_tl_item, None), false) + .unwrap_or_else(|| { + TextPreview::from(( + String::from("underwent a membership change."), + BeforeText::UsernameWithoutColon, + )) + }) + .format_with( + &utils::get_or_fetch_event_sender(event_tl_item, None), + false, + ) } TimelineItemContent::ProfileChange(profile_change) => { text_preview_of_member_profile_change( profile_change, &utils::get_or_fetch_event_sender(event_tl_item, None), false, - ).text + ) + .text } TimelineItemContent::OtherState(other_state) => { text_preview_of_other_state(other_state, false) - .unwrap_or_else(|| TextPreview::from(( - String::from("initiated another state change."), - BeforeText::UsernameWithoutColon, - ))) - .format_with(&utils::get_or_fetch_event_sender(event_tl_item, None), false) + .unwrap_or_else(|| { + TextPreview::from(( + String::from("initiated another state change."), + BeforeText::UsernameWithoutColon, + )) + }) + .format_with( + &utils::get_or_fetch_event_sender(event_tl_item, None), + false, + ) } TimelineItemContent::FailedToParseMessageLike { event_type, error } => { format!("Failed to parse {} message. Error: {}", event_type, error) } - TimelineItemContent::FailedToParseState { event_type, error, state_key } => { - format!("Failed to parse {} state; key: {}. Error: {}", event_type, state_key, error) + TimelineItemContent::FailedToParseState { + event_type, + error, + state_key, + } => { + format!( + "Failed to parse {} state; key: {}. Error: {}", + event_type, state_key, error + ) } TimelineItemContent::CallInvite => String::from("[Call Invitation]"), TimelineItemContent::RtcNotification => String::from("[RTC Call Notification]"), } } - /// Returns a text preview of the given message as an Html-formatted string. -fn text_preview_of_message( - msg: &MessageType, - sender_username: &str, -) -> TextPreview { +fn text_preview_of_message(msg: &MessageType, sender_username: &str) -> TextPreview { let text = match msg { MessageType::Audio(audio) => format!( "[Audio]: {}", @@ -248,7 +281,8 @@ fn text_preview_of_message( "[Location]: {}", htmlize::escape_text(&location.body), ), - MessageType::Notice(notice) => format!("{}", + MessageType::Notice(notice) => format!( + "{}", if let Some(formatted_body) = notice.formatted.as_ref() { utils::trim_start_html_whitespace(&formatted_body.body).into() } else { @@ -260,38 +294,32 @@ fn text_preview_of_message( notice.server_notice_type.as_str(), notice.body, ), - MessageType::Text(text) => { - text.formatted - .as_ref() - .and_then(|fb| - (fb.format == MessageFormat::Html).then(|| { - let filtered_and_trimmed = utils::trim_start_html_whitespace( - utils::remove_mx_reply(&fb.body) - ); - utils::linkify(filtered_and_trimmed, true).to_string() - }) - ) - .unwrap_or_else(|| match utils::linkify(&text.body, false) { - Cow::Borrowed(plaintext) => htmlize::escape_text(plaintext).to_string(), - Cow::Owned(linkified) => linkified, + MessageType::Text(text) => text + .formatted + .as_ref() + .and_then(|fb| { + (fb.format == MessageFormat::Html).then(|| { + let filtered_and_trimmed = + utils::trim_start_html_whitespace(utils::remove_mx_reply(&fb.body)); + utils::linkify(filtered_and_trimmed, true).to_string() }) + }) + .unwrap_or_else(|| match utils::linkify(&text.body, false) { + Cow::Borrowed(plaintext) => htmlize::escape_text(plaintext).to_string(), + Cow::Owned(linkified) => linkified, + }), + MessageType::VerificationRequest(verification) => { + format!("[Verification Request] to user {}", verification.to,) } - MessageType::VerificationRequest(verification) => format!( - "[Verification Request] to user {}", - verification.to, - ), MessageType::Video(video) => format!( "[Video]: {}", if let Some(formatted_body) = video.formatted.as_ref() { - Cow::Borrowed(formatted_body.body.as_str()) + Cow::Borrowed(formatted_body.body.as_str()) } else { htmlize::escape_text(&video.body) } ), - MessageType::_Custom(custom) => format!( - "[Custom message]: {:?}", - custom, - ), + MessageType::_Custom(custom) => format!("[Custom message]: {:?}", custom,), other => format!( "[Unknown message type]: {}", htmlize::escape_text(other.body()), @@ -306,20 +334,19 @@ pub fn text_preview_of_raw_timeline_event( sender_username: &str, ) -> Option { match raw_event.deserialize().ok()? { - AnySyncTimelineEvent::MessageLike( - AnySyncMessageLikeEvent::RoomMessage( - SyncMessageLikeEvent::Original(ev) - ) - ) => Some(text_preview_of_message( + AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage( + SyncMessageLikeEvent::Original(ev), + )) => Some(text_preview_of_message( &ev.content.msgtype, sender_username, )), - AnySyncTimelineEvent::MessageLike( - AnySyncMessageLikeEvent::RoomMessage( - SyncMessageLikeEvent::Redacted(_) - ) - ) => { - let sender_user_id = raw_event.get_field::("sender").ok().flatten()?; + AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage( + SyncMessageLikeEvent::Redacted(_), + )) => { + let sender_user_id = raw_event + .get_field::("sender") + .ok() + .flatten()?; Some(text_preview_of_redacted_message( Some(raw_event), sender_user_id.as_ref(), @@ -330,7 +357,6 @@ pub fn text_preview_of_raw_timeline_event( } } - /// Returns a plaintext preview of the given redacted message. /// /// Note: this function accepts the component parts of an [`EventTimelineItem`] @@ -345,32 +371,38 @@ pub fn text_preview_of_redacted_message( ) -> TextPreview { let mut redactor_and_reason = None; if let Some(redacted_msg) = latest_json { - if let Ok(AnySyncTimelineEvent::MessageLike( - AnySyncMessageLikeEvent::RoomMessage( - SyncMessageLikeEvent::Redacted(redaction) - ) - )) = redacted_msg.deserialize() { + if let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage( + SyncMessageLikeEvent::Redacted(redaction), + ))) = redacted_msg.deserialize() + { if let Ok(redacted_because) = redaction.unsigned.redacted_because.deserialize() { - redactor_and_reason = Some(( - redacted_because.sender, - redacted_because.content.reason, - )); + redactor_and_reason = + Some((redacted_because.sender, redacted_because.content.reason)); } } } let text = match redactor_and_reason { Some((redactor, Some(reason))) => { if redactor == sender_user_id { - format!("{} deleted their own message: \"{}\".", original_sender_username, reason) + format!( + "{} deleted their own message: \"{}\".", + original_sender_username, reason + ) } else { - format!("{} deleted {}'s message: \"{}\".", redactor, original_sender_username, reason) + format!( + "{} deleted {}'s message: \"{}\".", + redactor, original_sender_username, reason + ) } } Some((redactor, None)) => { if redactor == sender_user_id { format!("{} deleted their own message.", original_sender_username) } else { - format!("{} deleted {}'s message.", redactor, original_sender_username) + format!( + "{} deleted {}'s message.", + redactor, original_sender_username + ) } } None => { @@ -380,42 +412,31 @@ pub fn text_preview_of_redacted_message( TextPreview::from((text, BeforeText::Nothing)) } - /// Returns a plaintext preview of the given encrypted message that could not be decrypted. /// /// This is used for "Unable to decrypt" messages, which may have a known cause /// for why they could not be decrypted. -pub fn text_preview_of_encrypted_message( - encrypted_message: &EncryptedMessage, -) -> TextPreview { +pub fn text_preview_of_encrypted_message(encrypted_message: &EncryptedMessage) -> TextPreview { let cause_str = match encrypted_message { EncryptedMessage::MegolmV1AesSha2 { cause, .. } => match cause { UtdCause::Unknown => None, - UtdCause::SentBeforeWeJoined => Some( - "this message was sent before you joined the room." - ), - UtdCause::VerificationViolation => Some( - "this message was sent by an unverified user." - ), - UtdCause::UnsignedDevice => Some( - "the sending device wasn't signed by its owner." - ), - UtdCause::UnknownDevice => Some( - "the sending device's signature was not found." - ), + UtdCause::SentBeforeWeJoined => { + Some("this message was sent before you joined the room.") + } + UtdCause::VerificationViolation => Some("this message was sent by an unverified user."), + UtdCause::UnsignedDevice => Some("the sending device wasn't signed by its owner."), + UtdCause::UnknownDevice => Some("the sending device's signature was not found."), UtdCause::HistoricalMessageAndBackupIsDisabled => Some( - "historical messages are not available on this device because server-side key backup was disabled." - ), - UtdCause::WithheldForUnverifiedOrInsecureDevice => Some( - "your device doesn't meet the sender's security requirements." + "historical messages are not available on this device because server-side key backup was disabled.", ), - UtdCause::WithheldBySender => Some( - "the sender withheld this message from you." - ), - UtdCause::HistoricalMessageAndDeviceIsUnverified => Some( - "historical messages are not available; you must verify this device." - ), - } + UtdCause::WithheldForUnverifiedOrInsecureDevice => { + Some("your device doesn't meet the sender's security requirements.") + } + UtdCause::WithheldBySender => Some("the sender withheld this message from you."), + UtdCause::HistoricalMessageAndDeviceIsUnverified => { + Some("historical messages are not available; you must verify this device.") + } + }, _ => None, }; let text = if let Some(cause) = cause_str { @@ -427,9 +448,7 @@ pub fn text_preview_of_encrypted_message( } /// Returns a plaintext preview of the given other message-like event. -pub fn text_preview_of_other_message_like( - other_msg_like: &OtherMessageLike, -) -> TextPreview { +pub fn text_preview_of_other_message_like(other_msg_like: &OtherMessageLike) -> TextPreview { TextPreview::from(( format!("[Other message type: {}]", other_msg_like.event_type()), BeforeText::UsernameWithColon, @@ -442,7 +461,10 @@ pub fn text_preview_of_other_state( format_as_html: bool, ) -> Option { let text = match other_state.content() { - AnyOtherFullStateEventContent::RoomAliases(FullStateEventContent::Original { content, .. }) => { + AnyOtherFullStateEventContent::RoomAliases(FullStateEventContent::Original { + content, + .. + }) => { let mut s = String::from("set this room's aliases to "); let last_alias = content.aliases.len() - 1; for (i, alias) in content.aliases.iter().enumerate() { @@ -457,50 +479,76 @@ pub fn text_preview_of_other_state( AnyOtherFullStateEventContent::RoomAvatar(_) => { Some(String::from("set this room's avatar picture.")) } - AnyOtherFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original { content, .. }) => { - Some(format!("set the main address of this room to {}.", - content.alias.as_ref().map(|a| a.as_str()).unwrap_or("none") - )) - } - AnyOtherFullStateEventContent::RoomCreate(FullStateEventContent::Original { content, .. }) => { - Some(format!("created this room (v{}).", content.room_version.as_str())) - } + AnyOtherFullStateEventContent::RoomCanonicalAlias(FullStateEventContent::Original { + content, + .. + }) => Some(format!( + "set the main address of this room to {}.", + content.alias.as_ref().map(|a| a.as_str()).unwrap_or("none") + )), + AnyOtherFullStateEventContent::RoomCreate(FullStateEventContent::Original { + content, + .. + }) => Some(format!( + "created this room (v{}).", + content.room_version.as_str() + )), AnyOtherFullStateEventContent::RoomEncryption(_) => { Some(String::from("enabled encryption in this room.")) } - AnyOtherFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original { content, .. }) => { - Some(match &content.guest_access { - GuestAccess::CanJoin => String::from("has allowed guests to join this room."), - GuestAccess::Forbidden => String::from("has forbidden guests from joining this room."), - custom => format!("has set custom guest access rules for this room: {}", custom.as_str()), - }) - } - AnyOtherFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original { content, .. }) => { - Some(format!("set this room's history to be visible by {}", - match &content.history_visibility { - HistoryVisibility::Invited => "invited users, since they were invited.", - HistoryVisibility::Joined => "joined users, since they joined.", - HistoryVisibility::Shared => "joined users, for all of time.", - HistoryVisibility::WorldReadable => "anyone for all time.", - custom => custom.as_str(), - }, - )) - } - AnyOtherFullStateEventContent::RoomJoinRules(FullStateEventContent::Original { content, .. }) => { - Some(match &content.join_rule { - JoinRule::Public => String::from("set this room to be joinable by anyone."), - JoinRule::Knock => String::from("set this room to be joinable by invite only or by request."), - JoinRule::Private => String::from("set this room to be private."), - JoinRule::Restricted(_) => String::from("set this room to be joinable by invite only or with restrictions."), - JoinRule::KnockRestricted(_) => String::from("set this room to be joinable by invite only or requestable with restrictions."), - JoinRule::Invite => String::from("set this room to be joinable by invite only."), - custom => format!("set custom join rules for this room: {}", custom.as_str()), - }) - } - AnyOtherFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { content, .. }) => { - Some(format!("pinned {} events in this room.", content.pinned.len())) - } - AnyOtherFullStateEventContent::RoomName(FullStateEventContent::Original { content, .. }) => { + AnyOtherFullStateEventContent::RoomGuestAccess(FullStateEventContent::Original { + content, + .. + }) => Some(match &content.guest_access { + GuestAccess::CanJoin => String::from("has allowed guests to join this room."), + GuestAccess::Forbidden => String::from("has forbidden guests from joining this room."), + custom => format!( + "has set custom guest access rules for this room: {}", + custom.as_str() + ), + }), + AnyOtherFullStateEventContent::RoomHistoryVisibility(FullStateEventContent::Original { + content, + .. + }) => Some(format!( + "set this room's history to be visible by {}", + match &content.history_visibility { + HistoryVisibility::Invited => "invited users, since they were invited.", + HistoryVisibility::Joined => "joined users, since they joined.", + HistoryVisibility::Shared => "joined users, for all of time.", + HistoryVisibility::WorldReadable => "anyone for all time.", + custom => custom.as_str(), + }, + )), + AnyOtherFullStateEventContent::RoomJoinRules(FullStateEventContent::Original { + content, + .. + }) => Some(match &content.join_rule { + JoinRule::Public => String::from("set this room to be joinable by anyone."), + JoinRule::Knock => { + String::from("set this room to be joinable by invite only or by request.") + } + JoinRule::Private => String::from("set this room to be private."), + JoinRule::Restricted(_) => { + String::from("set this room to be joinable by invite only or with restrictions.") + } + JoinRule::KnockRestricted(_) => String::from( + "set this room to be joinable by invite only or requestable with restrictions.", + ), + JoinRule::Invite => String::from("set this room to be joinable by invite only."), + custom => format!("set custom join rules for this room: {}", custom.as_str()), + }), + AnyOtherFullStateEventContent::RoomPinnedEvents(FullStateEventContent::Original { + content, + .. + }) => Some(format!( + "pinned {} events in this room.", + content.pinned.len() + )), + AnyOtherFullStateEventContent::RoomName(FullStateEventContent::Original { + content, + .. + }) => { let name = if format_as_html { htmlize::escape_text(&content.name) } else { @@ -511,13 +559,20 @@ pub fn text_preview_of_other_state( AnyOtherFullStateEventContent::RoomPowerLevels(_) => { Some(String::from("set the power levels for this room.")) } - AnyOtherFullStateEventContent::RoomServerAcl(_) => { - Some(String::from("set the server access control list for this room.")) - } - AnyOtherFullStateEventContent::RoomTombstone(FullStateEventContent::Original { content, .. }) => { - Some(format!("closed this room and upgraded it to {}", content.replacement_room.matrix_to_uri())) - } - AnyOtherFullStateEventContent::RoomTopic(FullStateEventContent::Original { content, .. }) => { + AnyOtherFullStateEventContent::RoomServerAcl(_) => Some(String::from( + "set the server access control list for this room.", + )), + AnyOtherFullStateEventContent::RoomTombstone(FullStateEventContent::Original { + content, + .. + }) => Some(format!( + "closed this room and upgraded it to {}", + content.replacement_room.matrix_to_uri() + )), + AnyOtherFullStateEventContent::RoomTopic(FullStateEventContent::Original { + content, + .. + }) => { let topic = if format_as_html { htmlize::escape_text(&content.topic) } else { @@ -526,7 +581,7 @@ pub fn text_preview_of_other_state( Some(format!("changed this room's topic to \"{topic}\".")) } AnyOtherFullStateEventContent::SpaceParent(_) => { - let state_key = if format_as_html { + let state_key = if format_as_html { htmlize::escape_text(other_state.state_key()) } else { Cow::Borrowed(other_state.state_key()) @@ -534,7 +589,7 @@ pub fn text_preview_of_other_state( Some(format!("set this room's parent space to \"{state_key}\".")) } AnyOtherFullStateEventContent::SpaceChild(_) => { - let state_key = if format_as_html { + let state_key = if format_as_html { htmlize::escape_text(other_state.state_key()) } else { Cow::Borrowed(other_state.state_key()) @@ -549,7 +604,6 @@ pub fn text_preview_of_other_state( text.map(|t| TextPreview::from((t, BeforeText::UsernameWithoutColon))) } - /// Returns a text preview of the given member profile change /// as a plaintext or HTML-formatted string. pub fn text_preview_of_member_profile_change( @@ -559,9 +613,17 @@ pub fn text_preview_of_member_profile_change( ) -> TextPreview { let name_text = if let Some(name_change) = change.displayname_change() { let old = name_change.old.as_deref().unwrap_or(username); - let old_un = if format_as_html { htmlize::escape_text(old) } else { old.into() }; + let old_un = if format_as_html { + htmlize::escape_text(old) + } else { + old.into() + }; if let Some(new) = name_change.new.as_ref() { - let new_un = if format_as_html { htmlize::escape_text(new) } else { new.into() }; + let new_un = if format_as_html { + htmlize::escape_text(new) + } else { + new.into() + }; format!("{old_un} changed their display name to \"{new_un}\"") } else { format!("{old_un} removed their display name") @@ -590,7 +652,6 @@ pub fn text_preview_of_member_profile_change( )) } - /// Returns a text preview of the given room membership change /// as a plaintext or HTML-formatted string. pub fn text_preview_of_room_membership_change( @@ -598,8 +659,7 @@ pub fn text_preview_of_room_membership_change( format_as_html: bool, ) -> Option { let dn = change.display_name(); - let change_user_id = dn.as_deref() - .unwrap_or_else(|| change.user_id().as_str()); + let change_user_id = dn.as_deref().unwrap_or_else(|| change.user_id().as_str()); let change_user_id = if format_as_html { htmlize::escape_text(change_user_id) } else { @@ -613,34 +673,34 @@ pub fn text_preview_of_room_membership_change( // Don't actually display anything for nonexistent/unimportant membership changes. return None; } - Some(MembershipChange::Joined) => - String::from("joined this room."), - Some(MembershipChange::Left) => - String::from("left this room."), - Some(MembershipChange::Banned) => - format!("banned {} from this room.", change_user_id), - Some(MembershipChange::Unbanned) => - format!("unbanned {} from this room.", change_user_id), - Some(MembershipChange::Kicked) => - format!("kicked {} from this room.", change_user_id), - Some(MembershipChange::Invited) => - format!("invited {} to this room.", change_user_id), - Some(MembershipChange::KickedAndBanned) => - format!("kicked and banned {} from this room.", change_user_id), - Some(MembershipChange::InvitationAccepted) => - String::from("accepted an invitation to this room."), - Some(MembershipChange::InvitationRejected) => - String::from("rejected an invitation to this room."), - Some(MembershipChange::InvitationRevoked) => - format!("revoked {}'s invitation to this room.", change_user_id), - Some(MembershipChange::Knocked) => - String::from("requested to join this room."), - Some(MembershipChange::KnockAccepted) => - format!("accepted {}'s request to join this room.", change_user_id), - Some(MembershipChange::KnockRetracted) => - String::from("retracted their request to join this room."), - Some(MembershipChange::KnockDenied) => - format!("denied {}'s request to join this room.", change_user_id), + Some(MembershipChange::Joined) => String::from("joined this room."), + Some(MembershipChange::Left) => String::from("left this room."), + Some(MembershipChange::Banned) => format!("banned {} from this room.", change_user_id), + Some(MembershipChange::Unbanned) => format!("unbanned {} from this room.", change_user_id), + Some(MembershipChange::Kicked) => format!("kicked {} from this room.", change_user_id), + Some(MembershipChange::Invited) => format!("invited {} to this room.", change_user_id), + Some(MembershipChange::KickedAndBanned) => { + format!("kicked and banned {} from this room.", change_user_id) + } + Some(MembershipChange::InvitationAccepted) => { + String::from("accepted an invitation to this room.") + } + Some(MembershipChange::InvitationRejected) => { + String::from("rejected an invitation to this room.") + } + Some(MembershipChange::InvitationRevoked) => { + format!("revoked {}'s invitation to this room.", change_user_id) + } + Some(MembershipChange::Knocked) => String::from("requested to join this room."), + Some(MembershipChange::KnockAccepted) => { + format!("accepted {}'s request to join this room.", change_user_id) + } + Some(MembershipChange::KnockRetracted) => { + String::from("retracted their request to join this room.") + } + Some(MembershipChange::KnockDenied) => { + format!("denied {}'s request to join this room.", change_user_id) + } }; Some(TextPreview::from((text, BeforeText::UsernameWithoutColon))) } diff --git a/src/home/add_room.rs b/src/home/add_room.rs index 981369897..cc909213e 100644 --- a/src/home/add_room.rs +++ b/src/home/add_room.rs @@ -1,11 +1,24 @@ //! A top-level view for adding (joining) or exploring new rooms and spaces. - use makepad_widgets::*; use matrix_sdk::RoomState; -use ruma::{IdParseError, MatrixToUri, MatrixUri, OwnedRoomOrAliasId, OwnedServerName, matrix_uri::MatrixId, room::{JoinRuleSummary, RoomType}}; - -use crate::{app::AppStateAction, home::invite_screen::JoinRoomResultAction, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{avatar::AvatarWidgetRefExt, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{MatrixRequest, submit_async_request}, utils}; +use ruma::{ + IdParseError, MatrixToUri, MatrixUri, OwnedRoomOrAliasId, OwnedServerName, + matrix_uri::MatrixId, + room::{JoinRuleSummary, RoomType}, +}; + +use crate::{ + app::AppStateAction, + home::invite_screen::JoinRoomResultAction, + room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, + shared::{ + avatar::AvatarWidgetRefExt, + popup_list::{PopupKind, enqueue_popup_notification}, + }, + sliding_sync::{MatrixRequest, submit_async_request}, + utils, +}; script_mod! { use mod.prelude.widgets.* @@ -32,7 +45,7 @@ script_mod! { text_style: theme.font_regular {font_size: 18}, } } - + LineH { padding: 10, margin: Inset{top: 10, right: 2} } SubsectionLabel { @@ -248,16 +261,19 @@ script_mod! { } } } - + } } #[derive(Script, ScriptHook, Widget)] pub struct AddRoomScreen { - #[deref] view: View, - #[rust] state: AddRoomState, + #[deref] + view: View, + #[rust] + state: AddRoomState, /// The function to perform when the user clicks the `join_room_button`. - #[rust(JoinButtonFunction::None)] join_function: JoinButtonFunction, + #[rust(JoinButtonFunction::None)] + join_function: JoinButtonFunction, } #[derive(Default)] @@ -286,20 +302,16 @@ enum AddRoomState { FetchError(String), /// We successfully knocked on the room or space, and are waiting for /// a member of that room/space to acknowledge our knock by inviting us. - Knocked { - frp: FetchedRoomPreview, - }, + Knocked { frp: FetchedRoomPreview }, /// We successfully joined the room or space, and are waiting for it /// to be loaded from the homeserver. - Joined { - frp: FetchedRoomPreview, - }, + Joined { frp: FetchedRoomPreview }, /// The fetched room or space has been loaded from the homeserver, /// so we can allow the user to jump to it via the `join_room_button`. Loaded { frp: FetchedRoomPreview, is_invite: bool, - } + }, } impl AddRoomState { fn fetched_room_preview(&self) -> Option<&FetchedRoomPreview> { @@ -333,9 +345,7 @@ impl AddRoomState { fn transition_to_loaded(&mut self, is_invite: bool) { let prev = std::mem::take(self); match prev { - Self::FetchedRoomPreview { frp, .. } - | Self::Joined { frp } - | Self::Knocked { frp } => { + Self::FetchedRoomPreview { frp, .. } | Self::Joined { frp } | Self::Knocked { frp } => { *self = Self::Loaded { frp, is_invite }; } _ => { @@ -348,12 +358,16 @@ impl AddRoomState { impl Widget for AddRoomScreen { 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 room_alias_id_input = self.view.text_input(cx, ids!(room_alias_id_input)); let search_for_room_button = self.view.button(cx, ids!(search_for_room_button)); - let cancel_button = self.view.button(cx, ids!(fetched_room_summary.buttons_view.cancel_button)); - let join_room_button = self.view.button(cx, ids!(fetched_room_summary.buttons_view.join_room_button)); + let cancel_button = self + .view + .button(cx, ids!(fetched_room_summary.buttons_view.cancel_button)); + let join_room_button = self + .view + .button(cx, ids!(fetched_room_summary.buttons_view.join_room_button)); // Enable or disable the button based on if the text input is empty. if let Some(text) = room_alias_id_input.changed(actions) { @@ -373,7 +387,8 @@ impl Widget for AddRoomScreen { match (&self.join_function, &self.state) { ( JoinButtonFunction::NavigateOrJoin, - AddRoomState::FetchedRoomPreview { frp, .. } | AddRoomState::Loaded { frp, .. } + AddRoomState::FetchedRoomPreview { frp, .. } + | AddRoomState::Loaded { frp, .. }, ) => { cx.action(AppStateAction::NavigateToRoom { room_to_close: None, @@ -382,23 +397,28 @@ impl Widget for AddRoomScreen { } ( JoinButtonFunction::Knock, - AddRoomState::FetchedRoomPreview { frp, room_or_alias_id, via } + AddRoomState::FetchedRoomPreview { + frp, + room_or_alias_id, + via, + }, ) => { submit_async_request(MatrixRequest::Knock { - room_or_alias_id: frp.canonical_alias.clone().map_or_else( - || room_or_alias_id.clone(), - Into::into - ), + room_or_alias_id: frp + .canonical_alias + .clone() + .map_or_else(|| room_or_alias_id.clone(), Into::into), reason: None, server_names: via.clone(), }); } - _ => { } + _ => {} } } // If the button was clicked or enter was pressed, try to parse the room address. - let new_room_query = search_for_room_button.clicked(actions) + let new_room_query = search_for_room_button + .clicked(actions) .then(|| room_alias_id_input.text()) .or_else(|| room_alias_id_input.returned(actions).map(|(t, _)| t)); if let Some(t) = new_room_query { @@ -408,15 +428,16 @@ impl Widget for AddRoomScreen { room_or_alias_id: room_or_alias_id.clone(), via: via.clone(), }; - submit_async_request(MatrixRequest::GetRoomPreview { room_or_alias_id, via }); + submit_async_request(MatrixRequest::GetRoomPreview { + room_or_alias_id, + via, + }); } Err(e) => { - let err_str = format!("Could not parse the text as a valid room address.\nError: {e}."); - enqueue_popup_notification( - err_str.clone(), - PopupKind::Error, - None, + let err_str = format!( + "Could not parse the text as a valid room address.\nError: {e}." ); + enqueue_popup_notification(err_str.clone(), PopupKind::Error, None); self.state = AddRoomState::ParseError(err_str); room_alias_id_input.set_key_focus(cx); } @@ -426,7 +447,11 @@ impl Widget for AddRoomScreen { // If we're waiting for the room preview to be fetched (i.e., in the Parsed state), // then check if we've received it via an action. - if let AddRoomState::Parsed { room_or_alias_id, via } = &self.state { + if let AddRoomState::Parsed { + room_or_alias_id, + via, + } = &self.state + { for action in actions { match action.downcast_ref() { Some(RoomPreviewAction::Fetched(Ok(frp))) => { @@ -445,11 +470,7 @@ impl Widget for AddRoomScreen { } Some(RoomPreviewAction::Fetched(Err(e))) => { let err_str = format!("Failed to fetch room info.\n\nError: {e}."); - enqueue_popup_notification( - err_str.clone(), - PopupKind::Error, - None, - ); + enqueue_popup_notification(err_str.clone(), PopupKind::Error, None); self.state = AddRoomState::FetchError(err_str); self.redraw(cx); break; @@ -459,28 +480,40 @@ impl Widget for AddRoomScreen { } } - // If we've fetched and displayed the room preview, handle any responses to // the user clicking the join button (e.g., knocked on or joined the room/space). let mut transition_to_knocked = false; - let mut transition_to_joined = false; - if let AddRoomState::FetchedRoomPreview { frp, room_or_alias_id, .. } = &self.state { + let mut transition_to_joined = false; + if let AddRoomState::FetchedRoomPreview { + frp, + room_or_alias_id, + .. + } = &self.state + { for action in actions { match action.downcast_ref() { - Some(KnockResultAction::Knocked { room, .. }) if room.room_id() == frp.room_name_id.room_id() => { + Some(KnockResultAction::Knocked { room, .. }) + if room.room_id() == frp.room_name_id.room_id() => + { let room_type = match room.room_type() { Some(RoomType::Space) => "space", _ => "room", }; enqueue_popup_notification( - format!("Successfully knocked on {room_type} {}.", frp.room_name_id), + format!( + "Successfully knocked on {room_type} {}.", + frp.room_name_id + ), PopupKind::Success, Some(4.0), ); transition_to_knocked = true; break; } - Some(KnockResultAction::Failed { error, room_or_alias_id: roai }) if room_or_alias_id == roai => { + Some(KnockResultAction::Failed { + error, + room_or_alias_id: roai, + }) if room_or_alias_id == roai => { enqueue_popup_notification( format!("Failed to knock on room.\n\nError: {error}."), PopupKind::Error, @@ -488,11 +521,13 @@ impl Widget for AddRoomScreen { ); break; } - _ => { } + _ => {} } match action.downcast_ref() { - Some(JoinRoomResultAction::Joined { room_id }) if room_id == frp.room_name_id.room_id() => { + Some(JoinRoomResultAction::Joined { room_id }) + if room_id == frp.room_name_id.room_id() => + { let room_type = match &frp.room_type { Some(RoomType::Space) => "space", _ => "room", @@ -505,7 +540,9 @@ impl Widget for AddRoomScreen { transition_to_joined = true; break; } - Some(JoinRoomResultAction::Failed { room_id, error }) if room_id == frp.room_name_id.room_id() => { + Some(JoinRoomResultAction::Failed { room_id, error }) + if room_id == frp.room_name_id.room_id() => + { enqueue_popup_notification( format!("Failed to join room.\n\nError: {error}."), PopupKind::Error, @@ -529,9 +566,17 @@ impl Widget for AddRoomScreen { for action in actions { // If the room/space the user is searching for has been loaded from the homeserver // (e.g., by getting invited to it, or joining it in another client), - // then update the state of - if let Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, is_invite }) = action.downcast_ref() { - if self.state.fetched_room_preview().is_some_and(|frp| frp.room_name_id.room_id() == room_name_id.room_id()) { + // then update the state of + if let Some(AppStateAction::RoomLoadedSuccessfully { + room_name_id, + is_invite, + }) = action.downcast_ref() + { + if self + .state + .fetched_room_preview() + .is_some_and(|frp| frp.room_name_id.room_id() == room_name_id.room_id()) + { self.state.transition_to_loaded(*is_invite); self.redraw(cx); } @@ -540,7 +585,6 @@ impl Widget for AddRoomScreen { } } - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { let loading_room_view = self.view.view(cx, ids!(loading_room_view)); let fetched_room_summary = self.view.view(cx, ids!(fetched_room_summary)); @@ -554,22 +598,23 @@ impl Widget for AddRoomScreen { } AddRoomState::ParseError(err_str) | AddRoomState::FetchError(err_str) => { loading_room_view.set_visible(cx, false); - fetched_room_summary.set_visible(cx, false); + fetched_room_summary.set_visible(cx, false); error_view.set_visible(cx, true); error_view.label(cx, ids!(error_text)).set_text(cx, err_str); } - AddRoomState::Parsed { room_or_alias_id, .. } => { + AddRoomState::Parsed { + room_or_alias_id, .. + } => { loading_room_view.set_visible(cx, true); - loading_room_view.label(cx, ids!(loading_text)).set_text( - cx, - &format!("Fetching {room_or_alias_id}..."), - ); - fetched_room_summary.set_visible(cx, false); + loading_room_view + .label(cx, ids!(loading_text)) + .set_text(cx, &format!("Fetching {room_or_alias_id}...")); + fetched_room_summary.set_visible(cx, false); error_view.set_visible(cx, false); } - ars @ AddRoomState::FetchedRoomPreview { frp, .. } + ars @ AddRoomState::FetchedRoomPreview { frp, .. } | ars @ AddRoomState::Knocked { frp } - | ars @ AddRoomState::Joined { frp } + | ars @ AddRoomState::Joined { frp } | ars @ AddRoomState::Loaded { frp, .. } => { loading_room_view.set_visible(cx, false); fetched_room_summary.set_visible(cx, true); @@ -582,11 +627,9 @@ impl Widget for AddRoomScreen { room_avatar.show_text(cx, None, None, text); } FetchedRoomAvatar::Image(image_data) => { - let res = room_avatar.show_image( - cx, - None, - |cx, img_ref| utils::load_png_or_jpg(&img_ref, cx, image_data), - ); + let res = room_avatar.show_image(cx, None, |cx, img_ref| { + utils::load_png_or_jpg(&img_ref, cx, image_data) + }); if res.is_err() { room_avatar.show_text( cx, @@ -605,55 +648,75 @@ impl Widget for AddRoomScreen { let room_name = fetched_room_summary.label(cx, ids!(room_name)); match frp.room_name_id.name_for_avatar() { Some(n) => room_name.set_text(cx, n), - _ => room_name.set_text(cx, &format!("Unnamed {room_or_space_uc}, ID: {}", frp.room_name_id.room_id())), + _ => room_name.set_text( + cx, + &format!( + "Unnamed {room_or_space_uc}, ID: {}", + frp.room_name_id.room_id() + ), + ), } - fetched_room_summary.label(cx, ids!(subsection_alias_id)).set_text( - cx, - &format!("Main {room_or_space_uc} Alias and ID"), - ); + fetched_room_summary + .label(cx, ids!(subsection_alias_id)) + .set_text(cx, &format!("Main {room_or_space_uc} Alias and ID")); fetched_room_summary.label(cx, ids!(room_alias)).set_text( cx, - &format!("Alias: {}", frp.canonical_alias.as_ref().map_or("not set", |a| a.as_str())), - ); - fetched_room_summary.label(cx, ids!(room_id)).set_text( - cx, - &format!("ID: {}", frp.room_name_id.room_id().as_str()), - ); - fetched_room_summary.label(cx, ids!(subsection_topic)).set_text( - cx, - &format!("{room_or_space_uc} Topic"), - ); - fetched_room_summary.html(cx, ids!(room_topic)).set_text( - cx, - frp.topic.as_deref().unwrap_or("No topic set"), + &format!( + "Alias: {}", + frp.canonical_alias + .as_ref() + .map_or("not set", |a| a.as_str()) + ), ); + fetched_room_summary + .label(cx, ids!(room_id)) + .set_text(cx, &format!("ID: {}", frp.room_name_id.room_id().as_str())); + fetched_room_summary + .label(cx, ids!(subsection_topic)) + .set_text(cx, &format!("{room_or_space_uc} Topic")); + fetched_room_summary + .html(cx, ids!(room_topic)) + .set_text(cx, frp.topic.as_deref().unwrap_or("No topic set")); let room_summary = fetched_room_summary.label(cx, ids!(room_summary)); let join_room_button = fetched_room_summary.button(cx, ids!(join_room_button)); let join_function = match (&frp.state, &frp.join_rule) { (Some(RoomState::Joined), _) => { - room_summary.set_text(cx, &format!("You have already joined this {room_or_space_lc}.")); + room_summary.set_text( + cx, + &format!("You have already joined this {room_or_space_lc}."), + ); join_room_button.set_text(cx, &format!("Go to {room_or_space_lc}")); JoinButtonFunction::NavigateOrJoin } (Some(RoomState::Banned), _) => { - room_summary.set_text(cx, &format!("You have been banned from this {room_or_space_lc}.")); + room_summary.set_text( + cx, + &format!("You have been banned from this {room_or_space_lc}."), + ); join_room_button.set_text(cx, "Cannot join until un-banned"); JoinButtonFunction::None } (Some(RoomState::Invited), _) => { - room_summary.set_text(cx, &format!("You have already been invited to this {room_or_space_lc}.")); + room_summary.set_text( + cx, + &format!("You have already been invited to this {room_or_space_lc}."), + ); join_room_button.set_text(cx, "Go to invitation"); JoinButtonFunction::NavigateOrJoin } (Some(RoomState::Knocked), _) => { - room_summary.set_text(cx, &format!("You have already knocked on this {room_or_space_lc}.")); + room_summary.set_text( + cx, + &format!("You have already knocked on this {room_or_space_lc}."), + ); join_room_button.set_text(cx, "Knock again (be nice!)"); JoinButtonFunction::Knock } (Some(RoomState::Left), join_rule) => { - room_summary.set_text(cx, &format!("You previously left this {room_or_space_lc}.")); + room_summary + .set_text(cx, &format!("You previously left this {room_or_space_lc}.")); let (join_room_text, join_function) = match join_rule { Some(JoinRuleSummary::Public) => ( format!("Re-join this {room_or_space_lc}"), @@ -669,7 +732,9 @@ impl Widget for AddRoomScreen { ), // TODO: handle this after we update matrix-sdk to the new `JoinRule` enum. Some(JoinRuleSummary::Restricted(_)) => ( - format!("Re-joining {room_or_space_lc} requires an invite or other room membership"), + format!( + "Re-joining {room_or_space_lc} requires an invite or other room membership" + ), JoinButtonFunction::None, ), _ => ( @@ -682,15 +747,22 @@ impl Widget for AddRoomScreen { } // This room is not yet known to the user. (None, join_rule) => { - let direct = if frp.is_direct == Some(true) { "direct" } else { "regular" }; - room_summary.set_text(cx, &format!( - "This is a {direct} {room_or_space_lc} with {} {}.", - frp.num_joined_members, - match frp.num_joined_members { - 1 => "member", - _ => "members", - }, - )); + let direct = if frp.is_direct == Some(true) { + "direct" + } else { + "regular" + }; + room_summary.set_text( + cx, + &format!( + "This is a {direct} {room_or_space_lc} with {} {}.", + frp.num_joined_members, + match frp.num_joined_members { + 1 => "member", + _ => "members", + }, + ), + ); let (join_room_text, join_function) = match join_rule { Some(JoinRuleSummary::Public) => ( @@ -707,10 +779,12 @@ impl Widget for AddRoomScreen { ), // TODO: handle this after we update matrix-sdk to the new `JoinRule` enum. Some(JoinRuleSummary::Restricted(_)) => ( - format!("Joining {room_or_space_lc} requires an invite or other room membership"), + format!( + "Joining {room_or_space_lc} requires an invite or other room membership" + ), JoinButtonFunction::None, ), - _ => ( + _ => ( format!("Not allowed to join this {room_or_space_lc}"), JoinButtonFunction::None, ), @@ -722,7 +796,8 @@ impl Widget for AddRoomScreen { match ars { AddRoomState::FetchedRoomPreview { .. } => { - join_room_button.set_enabled(cx, !matches!(join_function, JoinButtonFunction::None)); + join_room_button + .set_enabled(cx, !matches!(join_function, JoinButtonFunction::None)); self.join_function = join_function; } AddRoomState::Knocked { .. } => { @@ -736,8 +811,13 @@ impl Widget for AddRoomScreen { join_room_button.set_enabled(cx, false); } AddRoomState::Loaded { is_invite, .. } => { - let verb = if *is_invite { "been invited to" } else { "fully joined" }; - room_summary.set_text(cx, &format!("You have {verb} this {room_or_space_lc}.")); + let verb = if *is_invite { + "been invited to" + } else { + "fully joined" + }; + room_summary + .set_text(cx, &format!("You have {verb} this {room_or_space_lc}.")); let adj = if *is_invite { "invited" } else { "joined" }; join_room_button.set_text(cx, &format!("Go to {adj} {room_or_space_lc}")); join_room_button.set_enabled(cx, true); @@ -752,7 +832,6 @@ impl Widget for AddRoomScreen { } } - /// The function to perform when the user clicks the join button in the fetched room preview. enum JoinButtonFunction { None, @@ -761,7 +840,6 @@ enum JoinButtonFunction { /// Knock on (request to join) a room/space. Knock, } - /// Actions sent from the backend task as a result of a [`MatrixRequest::Knock`]. #[derive(Debug)] @@ -778,10 +856,9 @@ pub enum KnockResultAction { /// The room alias/ID that was originally sent with the knock request. room_or_alias_id: OwnedRoomOrAliasId, error: matrix_sdk::Error, - } + }, } - /// Tries to extract a room address (Alias or ID) from the given text. /// /// This function is quite flexible and will attempt to parse `text` as: @@ -795,8 +872,10 @@ fn parse_address(text: &str) -> Result<(OwnedRoomOrAliasId, Vec Err(e) => { let uri_result = MatrixToUri::parse(text) .map(|uri| (uri.id().clone(), uri.via().to_owned())) - .or_else(|_| MatrixUri::parse(text).map(|uri| (uri.id().clone(), uri.via().to_owned()))); - + .or_else(|_| { + MatrixUri::parse(text).map(|uri| (uri.id().clone(), uri.via().to_owned())) + }); + if let Ok((matrix_id, via)) = uri_result { if let Some(room_or_alias_id) = match matrix_id { MatrixId::Room(room_id) => Some(room_id.into()), @@ -809,5 +888,5 @@ fn parse_address(text: &str) -> Result<(OwnedRoomOrAliasId, Vec } Err(e) } - } + } } diff --git a/src/home/edited_indicator.rs b/src/home/edited_indicator.rs index 07fb24f0d..64ae610f3 100644 --- a/src/home/edited_indicator.rs +++ b/src/home/edited_indicator.rs @@ -47,8 +47,10 @@ script_mod! { /// A interactive label that indicates a message has been edited. #[derive(Script, ScriptHook, Widget)] pub struct EditedIndicator { - #[deref] view: View, - #[rust] latest_edit_ts: Option>, + #[deref] + view: View, + #[rust] + latest_edit_ts: Option>, } impl Widget for EditedIndicator { @@ -57,36 +59,35 @@ impl Widget for EditedIndicator { let area = self.view.area(); let should_hover_in = match event.hits(cx, area) { - Hit::FingerLongPress(_) - | Hit::FingerHoverIn(..) => true, + Hit::FingerLongPress(_) | Hit::FingerHoverIn(..) => true, // TODO: show edit history modal on click // Hit::FingerUp(fue) if fue.is_over && fue.is_primary_hit() => { // log!("todo: show edit history."); // false // }, Hit::FingerHoverOut(_) => { - cx.widget_action(self.widget_uid(), TooltipAction::HoverOut); + cx.widget_action(self.widget_uid(), TooltipAction::HoverOut); false } _ => false, }; if should_hover_in { // TODO: use pure_rust_locales crate to format the time based on the chosen Locale. - let locale_extended_fmt_en_us= "%a %b %-d, %Y, %r"; + let locale_extended_fmt_en_us = "%a %b %-d, %Y, %r"; let text = if let Some(ts) = self.latest_edit_ts { format!("Last edited {}", ts.format(locale_extended_fmt_en_us)) } else { "Last edit time unknown".to_string() }; cx.widget_action( - self.widget_uid(), + self.widget_uid(), TooltipAction::HoverIn { text, widget_rect: area.rect(cx), options: CalloutTooltipOptions { position: TooltipPosition::Right, ..Default::default() - } + }, }, ); } @@ -120,7 +121,6 @@ impl EditedIndicatorRef { } } - /// Actions emitted by an `EditedIndicator` widget. #[derive(Clone, Debug, Default)] pub enum EditedIndicatorAction { diff --git a/src/home/editing_pane.rs b/src/home/editing_pane.rs index ae89492b0..56e09ff92 100644 --- a/src/home/editing_pane.rs +++ b/src/home/editing_pane.rs @@ -8,9 +8,13 @@ use matrix_sdk::{ }, }, }; -use matrix_sdk_ui::timeline::{EventTimelineItem, MsgLikeKind, TimelineEventItemId, TimelineItemContent}; +use matrix_sdk_ui::timeline::{ + EventTimelineItem, MsgLikeKind, TimelineEventItemId, TimelineItemContent, +}; -use crate::shared::mentionable_text_input::{MentionableTextInputWidgetExt, MentionableTextInputWidgetRefExt}; +use crate::shared::mentionable_text_input::{ + MentionableTextInputWidgetExt, MentionableTextInputWidgetRefExt, +}; use crate::{ shared::popup_list::{enqueue_popup_notification, PopupKind}, sliding_sync::{submit_async_request, MatrixRequest, TimelineKind}, @@ -142,19 +146,26 @@ struct EditingPaneInfo { /// A view that slides in from the bottom of the screen to allow editing a message. #[derive(Script, ScriptHook, Widget, Animator)] pub struct EditingPane { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, - - #[rust] info: Option, - #[rust] is_animating_out: bool, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, + + #[rust] + info: Option, + #[rust] + is_animating_out: bool, } impl Widget for EditingPane { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); - if !self.visible { return; } + if !self.visible { + return; + } let animator_action = self.animator_handle_event(cx, event); if animator_action.must_redraw() { @@ -170,21 +181,20 @@ impl Widget for EditingPane { (true, false) => { self.visible = false; self.info = None; - cx.widget_action(self.widget_uid(), EditingPaneAction::Hidden); + cx.widget_action(self.widget_uid(), EditingPaneAction::Hidden); cx.revert_key_focus(); self.redraw(cx); return; - }, + } (false, true) => { self.is_animating_out = true; return; - }, - _ => {}, + } + _ => {} } } if let Event::Actions(actions) = event { - let edit_text_input = self .mentionable_text_input(cx, ids!(editing_content.edit_text_input)) .text_input_ref(); @@ -199,10 +209,14 @@ impl Widget for EditingPane { return; } - let Some(info) = self.info.as_ref() else { return }; + let Some(info) = self.info.as_ref() else { + return; + }; if self.button(cx, ids!(accept_button)).clicked(actions) - || edit_text_input.returned(actions).is_some_and(|(_, m)| m.is_primary()) + || edit_text_input + .returned(actions) + .is_some_and(|(_, m)| m.is_primary()) { let edited_text = edit_text_input.text().trim().to_string(); let edited_content = match info.event_tl_item.content() { @@ -217,7 +231,9 @@ impl Widget for EditingPane { // TODO: also handle "/html" or "/plain" prefixes, just like when sending new messages. MessageType::Text(_text) => EditedContent::RoomMessage( - RoomMessageEventContentWithoutRelation::text_markdown(&edited_text), + RoomMessageEventContentWithoutRelation::text_markdown( + &edited_text, + ), ), MessageType::Emote(_emote) => EditedContent::RoomMessage( RoomMessageEventContentWithoutRelation::emote_markdown( @@ -231,7 +247,8 @@ impl Widget for EditingPane { MessageType::Image(image) => { let mut new_image_msg = image.clone(); if image.formatted.is_some() { - new_image_msg.formatted = FormattedBody::markdown(&edited_text); + new_image_msg.formatted = + FormattedBody::markdown(&edited_text); } new_image_msg.body = edited_text.clone(); EditedContent::RoomMessage( @@ -239,11 +256,12 @@ impl Widget for EditingPane { MessageType::Image(new_image_msg), ), ) - }, + } MessageType::Audio(audio) => { let mut new_audio_msg = audio.clone(); if audio.formatted.is_some() { - new_audio_msg.formatted = FormattedBody::markdown(&edited_text); + new_audio_msg.formatted = + FormattedBody::markdown(&edited_text); } new_audio_msg.body = edited_text.clone(); EditedContent::RoomMessage( @@ -251,23 +269,25 @@ impl Widget for EditingPane { MessageType::Audio(new_audio_msg), ), ) - }, + } MessageType::File(file) => { let mut new_file_msg = file.clone(); if file.formatted.is_some() { - new_file_msg.formatted = FormattedBody::markdown(&edited_text); + new_file_msg.formatted = + FormattedBody::markdown(&edited_text); } new_file_msg.body = edited_text.clone(); EditedContent::RoomMessage( - RoomMessageEventContentWithoutRelation::new(MessageType::File( - new_file_msg, - )), + RoomMessageEventContentWithoutRelation::new( + MessageType::File(new_file_msg), + ), ) - }, + } MessageType::Video(video) => { let mut new_video_msg = video.clone(); if video.formatted.is_some() { - new_video_msg.formatted = FormattedBody::markdown(&edited_text); + new_video_msg.formatted = + FormattedBody::markdown(&edited_text); } new_video_msg.body = edited_text.clone(); EditedContent::RoomMessage( @@ -275,7 +295,7 @@ impl Widget for EditingPane { MessageType::Video(new_video_msg), ), ) - }, + } _non_editable => { enqueue_popup_notification( "That message type cannot be edited.", @@ -285,7 +305,7 @@ impl Widget for EditingPane { self.animator_play(cx, ids!(panel.hide)); self.redraw(cx); return; - }, + } }; // TODO: extract mentions out of the new edited text and use them here. @@ -293,7 +313,8 @@ impl Widget for EditingPane { if let EditedContent::RoomMessage(new_message_content) = &mut edited_content { - new_message_content.mentions = Some(existing_mentions.clone()); + new_message_content.mentions = + Some(existing_mentions.clone()); } // TODO: once we update the matrix-sdk dependency, uncomment this. // EditedContent::MediaCaption { mentions, .. }) => { @@ -334,7 +355,6 @@ impl Widget for EditingPane { fallback_text: edited_text, new_content: new_content_block, } - } _ => { enqueue_popup_notification( @@ -353,7 +373,7 @@ impl Widget for EditingPane { None, ); return; - }, + } }; submit_async_request(MatrixRequest::EditMessage { @@ -402,14 +422,14 @@ impl EditingPane { match edit_result { Ok(()) => { self.animator_play(cx, ids!(panel.hide)); - }, + } Err(e) => { enqueue_popup_notification( format!("Failed to edit message: {}", e), PopupKind::Error, None, ); - }, + } } } @@ -421,15 +441,12 @@ impl EditingPane { timeline_kind: TimelineKind, ) { if !event_tl_item.is_editable() { - enqueue_popup_notification( - "That message cannot be edited.", - PopupKind::Error, - None, - ); + enqueue_popup_notification("That message cannot be edited.", PopupKind::Error, None); return; } - let edit_text_input = self.mentionable_text_input(cx, ids!(editing_content.edit_text_input)); + let edit_text_input = + self.mentionable_text_input(cx, ids!(editing_content.edit_text_input)); if let Some(message) = event_tl_item.content().as_message() { edit_text_input.set_text(cx, message.body()); @@ -444,7 +461,6 @@ impl EditingPane { return; } - self.info = Some(EditingPaneInfo { event_tl_item, timeline_kind, @@ -460,7 +476,10 @@ impl EditingPane { let text_len = edit_text_input.text().len(); inner_text_input.set_cursor( cx, - Cursor { index: text_len, prefer_next_row: false }, + Cursor { + index: text_len, + prefer_next_row: false, + }, false, ); // TODO: this doesn't work, likely because of Makepad's bug in which you cannot @@ -473,7 +492,8 @@ impl EditingPane { pub fn save_state(&self) -> Option { self.info.as_ref().map(|info| EditingPaneState { event_tl_item: info.event_tl_item.clone(), - text_input_state: self.child_by_path(ids!(editing_content.edit_text_input)) + text_input_state: self + .child_by_path(ids!(editing_content.edit_text_input)) .as_mentionable_text_input() .text_input_ref() .save_state(), @@ -487,7 +507,10 @@ impl EditingPane { editing_pane_state: EditingPaneState, timeline_kind: TimelineKind, ) { - let EditingPaneState { event_tl_item, text_input_state } = editing_pane_state; + let EditingPaneState { + event_tl_item, + text_input_state, + } = editing_pane_state; self.mentionable_text_input(cx, ids!(editing_content.edit_text_input)) .text_input_ref() .restore_state(cx, text_input_state); @@ -524,7 +547,9 @@ impl EditingPaneRef { timeline_event_item_id: TimelineEventItemId, edit_result: Result<(), matrix_sdk_ui::timeline::Error>, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.handle_edit_result(cx, timeline_event_item_id, edit_result); } @@ -538,13 +563,10 @@ impl EditingPaneRef { } /// See [`EditingPane::show()`]. - pub fn show( - &self, - cx: &mut Cx, - event_tl_item: EventTimelineItem, - timeline_kind: TimelineKind, - ) { - let Some(mut inner) = self.borrow_mut() else { return; }; + pub fn show(&self, cx: &mut Cx, event_tl_item: EventTimelineItem, timeline_kind: TimelineKind) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show(cx, event_tl_item, timeline_kind); } @@ -562,7 +584,9 @@ impl EditingPaneRef { editing_pane_state: EditingPaneState, timeline_kind: TimelineKind, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.restore_state(cx, editing_pane_state, timeline_kind); } @@ -570,7 +594,9 @@ impl EditingPaneRef { /// /// This function *DOES NOT* emit an [`EditingPaneAction::Hidden`] action. pub fn force_reset_hide(&self, cx: &mut Cx) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.visible = false; inner.animator_cut(cx, ids!(panel.hide)); inner.is_animating_out = false; diff --git a/src/home/event_reaction_list.rs b/src/home/event_reaction_list.rs index b47749a77..374e819cd 100644 --- a/src/home/event_reaction_list.rs +++ b/src/home/event_reaction_list.rs @@ -113,15 +113,24 @@ pub struct ReactionData { #[derive(Script, ScriptHook, Widget)] pub struct ReactionList { - #[uid] uid: WidgetUid, - #[redraw] #[rust] area: Area, - #[live] item: Option, - #[rust] children: Vec<(ButtonRef, ReactionData)>, - #[layout] layout: Layout, - #[walk] walk: Walk, + #[uid] + uid: WidgetUid, + #[redraw] + #[rust] + area: Area, + #[live] + item: Option, + #[rust] + children: Vec<(ButtonRef, ReactionData)>, + #[layout] + layout: Layout, + #[walk] + walk: Walk, - #[rust] timeline_kind: Option, - #[rust] timeline_event_id: Option, + #[rust] + timeline_kind: Option, + #[rust] + timeline_event_id: Option, } impl Widget for ReactionList { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { @@ -163,7 +172,9 @@ impl Widget for ReactionList { } // Otherwise, a primary click/press over the button should toggle the reaction. else if fue.is_primary_hit() && fue.was_tap() { - let Some(kind) = &self.timeline_kind else { return }; + let Some(kind) = &self.timeline_kind else { + return; + }; let Some(timeline_event_id) = &self.timeline_event_id else { return; }; @@ -176,7 +187,10 @@ impl Widget for ReactionList { let (bg_color, border_color) = if !reaction_data.includes_user { (EMOJI_BG_COLOR_INCLUDE_SELF, EMOJI_BORDER_COLOR_INCLUDE_SELF) } else { - (EMOJI_BG_COLOR_NOT_INCLUDE_SELF, EMOJI_BORDER_COLOR_NOT_INCLUDE_SELF) + ( + EMOJI_BG_COLOR_NOT_INCLUDE_SELF, + EMOJI_BORDER_COLOR_NOT_INCLUDE_SELF, + ) }; let mut reaction_button = button_ref.clone(); script_apply_eval!(cx, reaction_button, { @@ -206,7 +220,7 @@ impl ReactionList { reaction_data: ReactionData, ) { cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomScreenTooltipActions::HoverInReactionButton { widget_rect: button_ref.area().rect(cx), reaction_data, @@ -218,20 +232,14 @@ impl ReactionList { } /// Deals with to any event/hit that triggers a hover-out action. - fn do_hover_out( - &self, - cx: &mut Cx, - _scope: &mut Scope, - button_ref: &ButtonRef, - ) { - cx.widget_action(self.widget_uid(), RoomScreenTooltipActions::HoverOut); + fn do_hover_out(&self, cx: &mut Cx, _scope: &mut Scope, button_ref: &ButtonRef) { + cx.widget_action(self.widget_uid(), RoomScreenTooltipActions::HoverOut); let mut button_ref = button_ref.clone(); script_apply_eval!(cx, button_ref, { draw_bg +: { hover: 0.0 } }); cx.set_cursor(MouseCursor::Default); } } - impl ReactionListRef { /// Set the list of reactions and their counts to display in the ReactionList widget, /// along with the room ID and event ID that these reactions are for. @@ -278,7 +286,8 @@ impl ReactionListRef { cx, sender.clone(), Some(timeline_kind.room_id()), - true, |_, _| { }, + true, + |_, _| {}, ); } @@ -289,10 +298,10 @@ impl ReactionListRef { room_id: timeline_kind.room_id().clone(), }; let mut button = widget_ref_from_live_ptr(cx, inner.item).as_button(); - button.set_text(cx, &format!("{} {}", - reaction_data.reaction, - reaction_senders.len() - )); + button.set_text( + cx, + &format!("{} {}", reaction_data.reaction, reaction_senders.len()), + ); let (bg_color, border_color) = if reaction_data.includes_user { (EMOJI_BG_COLOR_INCLUDE_SELF, EMOJI_BORDER_COLOR_INCLUDE_SELF) } else { diff --git a/src/home/event_source_modal.rs b/src/home/event_source_modal.rs index 69405d24d..dc3203ed9 100644 --- a/src/home/event_source_modal.rs +++ b/src/home/event_source_modal.rs @@ -6,7 +6,6 @@ use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId}; use crate::shared::popup_list::{PopupKind, enqueue_popup_notification}; - script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -177,7 +176,7 @@ script_mod! { code_block := View { width: Fill, height: Fit, - flow: Overlay + flow: Overlay // align the left side of the border frame with the left side of the room id / event id rows padding: 6 @@ -251,13 +250,16 @@ pub enum EventSourceModalAction { Close, } - #[derive(Script, ScriptHook, Widget)] pub struct EventSourceModal { - #[deref] view: View, - #[rust] room_id: Option, - #[rust] event_id: Option, - #[rust] original_json: Option, + #[deref] + view: View, + #[rust] + room_id: Option, + #[rust] + event_id: Option, + #[rust] + original_json: Option, } impl Widget for EventSourceModal { @@ -268,10 +270,14 @@ impl Widget for EventSourceModal { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { if let Some(room_id) = &self.room_id { - self.view.label(cx, ids!(room_id_value)).set_text(cx, room_id.as_str()); + self.view + .label(cx, ids!(room_id_value)) + .set_text(cx, room_id.as_str()); } if let Some(event_id) = &self.event_id { - self.view.label(cx, ids!(event_id_value)).set_text(cx, event_id.as_str()); + self.view + .label(cx, ids!(event_id_value)) + .set_text(cx, event_id.as_str()); } if let Some(json) = &self.original_json { self.view.code_view(cx, ids!(code_view)).set_text(cx, json); @@ -286,8 +292,10 @@ impl WidgetMatchEvent for EventSourceModal { // Handle canceling/closing the modal. let close_clicked = close_button.clicked(actions); - if close_clicked || - actions.iter().any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) + if close_clicked + || actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) { // If the modal was dismissed by clicking outside of it, we MUST NOT emit // an EventSourceModalAction::Close action, as that would cause @@ -298,7 +306,11 @@ impl WidgetMatchEvent for EventSourceModal { return; } - if self.view.button(cx, ids!(room_id_copy_button)).clicked(actions) { + if self + .view + .button(cx, ids!(room_id_copy_button)) + .clicked(actions) + { if let Some(room_id) = &self.room_id { cx.copy_to_clipboard(room_id.as_str()); enqueue_popup_notification( @@ -309,7 +321,11 @@ impl WidgetMatchEvent for EventSourceModal { } } - if self.view.button(cx, ids!(event_id_copy_button)).clicked(actions) { + if self + .view + .button(cx, ids!(event_id_copy_button)) + .clicked(actions) + { if let Some(event_id) = &self.event_id { cx.copy_to_clipboard(event_id.as_str()); enqueue_popup_notification( @@ -320,7 +336,11 @@ impl WidgetMatchEvent for EventSourceModal { } } - if self.view.button(cx, ids!(copy_source_button)).clicked(actions) { + if self + .view + .button(cx, ids!(copy_source_button)) + .clicked(actions) + { if let Some(json) = &self.original_json { cx.copy_to_clipboard(json); enqueue_popup_notification( @@ -347,9 +367,15 @@ impl EventSourceModal { self.original_json = original_json.clone(); self.view.button(cx, ids!(close_button)).reset_hover(cx); - self.view.button(cx, ids!(room_id_copy_button)).reset_hover(cx); - self.view.button(cx, ids!(event_id_copy_button)).reset_hover(cx); - self.view.button(cx, ids!(copy_source_button)).reset_hover(cx); + self.view + .button(cx, ids!(room_id_copy_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(event_id_copy_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(copy_source_button)) + .reset_hover(cx); self.view.redraw(cx); } } @@ -363,7 +389,9 @@ impl EventSourceModalRef { event_id: Option, original_json: Option, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show(cx, room_id, event_id, original_json); } } diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index 910f817e1..123925f50 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -1,6 +1,10 @@ use makepad_widgets::*; -use crate::{app::AppState, home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, settings::settings_screen::SettingsScreenWidgetRefExt}; +use crate::{ + app::AppState, + home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, + settings::settings_screen::SettingsScreenWidgetRefExt, +}; script_mod! { use mod.prelude.widgets.* @@ -303,7 +307,7 @@ script_mod! { // We wrap it in the SpacesBarWrapper in order to animate it in or out, // and wrap *that* in a CachedWidget in order to maintain its shown/hidden state // across AdaptiveView transitions between Mobile view mode and Desktop view mode. - // + // // ... Then we wrap *that* in a ... CachedWidget { spaces_bar_wrapper := mod.widgets.SpacesBarWrapper {} @@ -353,13 +357,15 @@ script_mod! { } } - /// A simple wrapper around the SpacesBar that allows us to animate showing or hiding it. #[derive(Script, ScriptHook, Widget, Animator)] pub struct SpacesBarWrapper { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, } impl Widget for SpacesBarWrapper { @@ -384,7 +390,9 @@ impl Widget for SpacesBarWrapper { impl SpacesBarWrapperRef { /// Shows or hides the spaces bar by animating it in or out. fn show_or_hide(&self, cx: &mut Cx, show: bool) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; if show { inner.animator_play(cx, ids!(spaces_bar_animator.show)); } else { @@ -394,18 +402,20 @@ impl SpacesBarWrapperRef { } } - #[derive(Script, ScriptHook, Widget)] pub struct HomeScreen { - #[deref] view: View, + #[deref] + view: View, /// The previously-selected navigation tab, used to determine which tab /// and top-level view we return to after closing the settings screen. /// /// Note that the current selected tap is stored in `AppState` so that /// other widgets can easily access it. - #[rust] previous_selection: SelectedTab, - #[rust] is_spaces_bar_shown: bool, + #[rust] + previous_selection: SelectedTab, + #[rust] + is_spaces_bar_shown: bool, } impl Widget for HomeScreen { @@ -418,7 +428,9 @@ impl Widget for HomeScreen { if !matches!(app_state.selected_tab, SelectedTab::Home) { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = SelectedTab::Home; - cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); + cx.action(NavigationBarAction::TabSelected( + app_state.selected_tab.clone(), + )); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } @@ -427,17 +439,23 @@ impl Widget for HomeScreen { if !matches!(app_state.selected_tab, SelectedTab::AddRoom) { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = SelectedTab::AddRoom; - cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); + cx.action(NavigationBarAction::TabSelected( + app_state.selected_tab.clone(), + )); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } } Some(NavigationBarAction::GoToSpace { space_name_id }) => { - let new_space_selection = SelectedTab::Space { space_name_id: space_name_id.clone() }; + let new_space_selection = SelectedTab::Space { + space_name_id: space_name_id.clone(), + }; if app_state.selected_tab != new_space_selection { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = new_space_selection; - cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); + cx.action(NavigationBarAction::TabSelected( + app_state.selected_tab.clone(), + )); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } @@ -447,8 +465,12 @@ impl Widget for HomeScreen { if !matches!(app_state.selected_tab, SelectedTab::Settings) { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = SelectedTab::Settings; - cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); - if let Some(settings_page) = self.update_active_page_from_selection(cx, app_state) { + cx.action(NavigationBarAction::TabSelected( + app_state.selected_tab.clone(), + )); + if let Some(settings_page) = + self.update_active_page_from_selection(cx, app_state) + { settings_page .settings_screen(cx, ids!(settings_screen)) .populate(cx, None); @@ -461,19 +483,21 @@ impl Widget for HomeScreen { Some(NavigationBarAction::CloseSettings) => { if matches!(app_state.selected_tab, SelectedTab::Settings) { app_state.selected_tab = self.previous_selection.clone(); - cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); + cx.action(NavigationBarAction::TabSelected( + app_state.selected_tab.clone(), + )); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } } Some(NavigationBarAction::ToggleSpacesBar) => { self.is_spaces_bar_shown = !self.is_spaces_bar_shown; - self.view.spaces_bar_wrapper(cx, ids!(spaces_bar_wrapper)) + self.view + .spaces_bar_wrapper(cx, ids!(spaces_bar_wrapper)) .show_or_hide(cx, self.is_spaces_bar_shown); } // We're the ones who emitted this action, so we don't need to handle it again. - Some(NavigationBarAction::TabSelected(_)) - | None => { } + Some(NavigationBarAction::TabSelected(_)) | None => {} } } } @@ -504,12 +528,10 @@ impl HomeScreen { .set_active_page( cx, match app_state.selected_tab { - SelectedTab::Space { .. } - | SelectedTab::Home => id!(home_page), + SelectedTab::Space { .. } | SelectedTab::Home => id!(home_page), SelectedTab::Settings => id!(settings_page), SelectedTab::AddRoom => id!(add_room_page), }, ) } } - diff --git a/src/home/invite_modal.rs b/src/home/invite_modal.rs index d644bf542..56bd5a413 100644 --- a/src/home/invite_modal.rs +++ b/src/home/invite_modal.rs @@ -7,7 +7,6 @@ use crate::home::room_screen::InviteResultAction; use crate::sliding_sync::{MatrixRequest, submit_async_request}; use crate::utils::RoomNameId; - script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -138,12 +137,14 @@ enum InviteModalState { InviteError, } - #[derive(Script, ScriptHook, Widget)] pub struct InviteModal { - #[deref] view: View, - #[rust] state: InviteModalState, - #[rust] room_name_id: Option, + #[deref] + view: View, + #[rust] + state: InviteModalState, + #[rust] + room_name_id: Option, } impl Widget for InviteModal { @@ -163,8 +164,10 @@ impl WidgetMatchEvent for InviteModal { // Handle canceling/closing the modal. let cancel_clicked = cancel_button.clicked(actions); - if cancel_clicked || - actions.iter().any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) + if cancel_clicked + || actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) { // If the modal was dismissed by clicking outside of it, we MUST NOT emit // a `InviteModalAction::Close` action, as that would cause @@ -188,7 +191,8 @@ impl WidgetMatchEvent for InviteModal { let mut status_label = self.view.label(cx, ids!(status_label_view.status_label)); // Handle return key or invite button click. - if let Some(user_id_str) = confirm_button.clicked(actions) + if let Some(user_id_str) = confirm_button + .clicked(actions) .then(|| user_id_input.text()) .or_else(|| user_id_input.returned(actions).map(|(t, _)| t)) { @@ -244,9 +248,12 @@ impl WidgetMatchEvent for InviteModal { for action in actions { let new_state = match action.downcast_ref() { Some(InviteResultAction::Sent { room_id, user_id }) - if self.room_name_id.as_ref().is_some_and(|rni| rni.room_id() == room_id) - && invited_user_id == user_id - => { + if self + .room_name_id + .as_ref() + .is_some_and(|rni| rni.room_id() == room_id) + && invited_user_id == user_id => + { let status = format!("Successfully invited {user_id}!"); script_apply_eval!(cx, status_label, { text: #(status), @@ -260,10 +267,16 @@ impl WidgetMatchEvent for InviteModal { okay_button.set_visible(cx, true); Some(InviteModalState::InviteSuccess) } - Some(InviteResultAction::Failed { room_id, user_id, error }) - if self.room_name_id.as_ref().is_some_and(|rni| rni.room_id() == room_id) - && invited_user_id == user_id - => { + Some(InviteResultAction::Failed { + room_id, + user_id, + error, + }) if self + .room_name_id + .as_ref() + .is_some_and(|rni| rni.room_id() == room_id) + && invited_user_id == user_id => + { let status = format!("Failed to send invite: {error}"); script_apply_eval!(cx, status_label, { text: #(status), @@ -291,10 +304,9 @@ impl WidgetMatchEvent for InviteModal { impl InviteModal { pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId) { - self.view.label(cx, ids!(title)).set_text( - cx, - &format!("Invite to {room_name_id}"), - ); + self.view + .label(cx, ids!(title)) + .set_text(cx, &format!("Invite to {room_name_id}")); self.state = InviteModalState::WaitingForUserInput; self.room_name_id = Some(room_name_id); @@ -313,8 +325,12 @@ impl InviteModal { okay_button.reset_hover(cx); user_id_input.set_is_read_only(cx, false); user_id_input.set_text(cx, ""); - self.view.view(cx, ids!(status_label_view)).set_visible(cx, false); - self.view.label(cx, ids!(status_label_view.status_label)).set_text(cx, ""); + self.view + .view(cx, ids!(status_label_view)) + .set_visible(cx, false); + self.view + .label(cx, ids!(status_label_view.status_label)) + .set_text(cx, ""); self.view.redraw(cx); user_id_input.set_key_focus(cx); } @@ -322,7 +338,9 @@ impl InviteModal { impl InviteModalRef { pub fn show(&self, cx: &mut Cx, room_name_id: RoomNameId) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show(cx, room_name_id); } } diff --git a/src/home/invite_screen.rs b/src/home/invite_screen.rs index 672b6d1ba..37531e022 100644 --- a/src/home/invite_screen.rs +++ b/src/home/invite_screen.rs @@ -8,11 +8,22 @@ use std::ops::Deref; use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; -use crate::{app::AppStateAction, home::rooms_list::RoomsListRef, join_leave_room_modal::{JoinLeaveModalKind, JoinLeaveRoomModalAction}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::AvatarWidgetRefExt, popup_list::{enqueue_popup_notification, PopupKind}, restore_status_view::RestoreStatusViewWidgetExt}, sliding_sync::{submit_async_request, MatrixRequest}, utils::{self, RoomNameId}}; +use crate::{ + app::AppStateAction, + home::rooms_list::RoomsListRef, + join_leave_room_modal::{JoinLeaveModalKind, JoinLeaveRoomModalAction}, + room::{BasicRoomDetails, FetchedRoomAvatar}, + shared::{ + avatar::AvatarWidgetRefExt, + popup_list::{enqueue_popup_notification, PopupKind}, + restore_status_view::RestoreStatusViewWidgetExt, + }, + sliding_sync::{submit_async_request, MatrixRequest}, + utils::{self, RoomNameId}, +}; use super::rooms_list::{InviteState, InviterInfo}; - script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -208,14 +219,12 @@ impl Deref for InviteDetails { #[derive(Debug)] pub enum JoinRoomResultAction { /// The user has successfully joined the room. - Joined { - room_id: OwnedRoomId, - }, + Joined { room_id: OwnedRoomId }, /// There was an error attempting to join the room. Failed { room_id: OwnedRoomId, error: matrix_sdk::Error, - } + }, } /// Actions sent from the backend task as a result of a [`MatrixRequest::LeaveRoom`]. @@ -224,33 +233,37 @@ pub enum JoinRoomResultAction { #[derive(Debug)] pub enum LeaveRoomResultAction { /// The user has successfully left the room. - Left { - room_id: OwnedRoomId, - }, + Left { room_id: OwnedRoomId }, /// There was an error attempting to leave the room. Failed { room_id: OwnedRoomId, error: matrix_sdk::Error, - } + }, } - /// A view that shows information about a room that the user has been invited to. #[derive(Script, ScriptHook, Widget)] pub struct InviteScreen { - #[deref] view: View, + #[deref] + view: View, - #[rust] invite_state: InviteState, - #[rust] info: Option, + #[rust] + invite_state: InviteState, + #[rust] + info: Option, /// Whether a JoinLeaveRoomModal dialog has been displayed /// to allow the user to confirm their join/reject action. /// This is used to prevent showing multiple popup notifications /// (one from the JoinLeaveRoomModal, and one from this invite screen). - #[rust] has_shown_confirmation: bool, + #[rust] + has_shown_confirmation: bool, /// The name and ID of the invited room. - #[rust] room_name_id: Option, - #[rust] is_loaded: bool, - #[rust] all_rooms_loaded: bool, + #[rust] + room_name_id: Option, + #[rust] + is_loaded: bool, + #[rust] + all_rooms_loaded: bool, } impl Widget for InviteScreen { @@ -258,7 +271,11 @@ impl Widget for InviteScreen { // Currently, a Signal event is only used to tell this widget // to check if the room has been loaded from the homeserver yet. if let Event::Signal = event { - if let (false, Some(room_name_id), true) = (self.is_loaded, self.room_name_id.as_ref(), cx.has_global::()) { + if let (false, Some(room_name_id), true) = ( + self.is_loaded, + self.room_name_id.as_ref(), + cx.has_global::(), + ) { let rooms_list_ref = cx.get_global::(); if !rooms_list_ref.is_room_loaded(room_name_id.room_id()) { self.all_rooms_loaded = rooms_list_ref.all_rooms_loaded(); @@ -279,16 +296,28 @@ impl Widget for InviteScreen { // First, we quickly loop over the actions up front to handle the case // where this room was restored and has now been successfully loaded from the homeserver. for action in actions { - if let Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) = action.downcast_ref() { - if self.room_name_id.as_ref().is_some_and(|current| current.room_id() == room_name_id.room_id()) { + if let Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) = + action.downcast_ref() + { + if self + .room_name_id + .as_ref() + .is_some_and(|current| current.room_id() == room_name_id.room_id()) + { self.set_displayed_invite(cx, room_name_id); break; } } } - let Some(info) = self.info.as_ref() else { return; }; - if let Some(modifiers) = self.view.button(cx, ids!(cancel_button)).clicked_modifiers(actions) { + let Some(info) = self.info.as_ref() else { + return; + }; + if let Some(modifiers) = self + .view + .button(cx, ids!(cancel_button)) + .clicked_modifiers(actions) + { self.invite_state = InviteState::WaitingForLeaveResult; if modifiers.shift { submit_async_request(MatrixRequest::LeaveRoom { @@ -303,7 +332,11 @@ impl Widget for InviteScreen { self.has_shown_confirmation = true; } } - if let Some(modifiers) = self.view.button(cx, ids!(accept_button)).clicked_modifiers(actions) { + if let Some(modifiers) = self + .view + .button(cx, ids!(accept_button)) + .clicked_modifiers(actions) + { self.invite_state = InviteState::WaitingForJoinResult; if modifiers.shift { submit_async_request(MatrixRequest::JoinRoom { @@ -324,14 +357,25 @@ impl Widget for InviteScreen { Some(JoinRoomResultAction::Joined { room_id }) if room_id == info.room_id() => { self.invite_state = InviteState::WaitingForJoinedRoom; if !self.has_shown_confirmation { - enqueue_popup_notification("Successfully joined room.", PopupKind::Success, Some(5.0)); + enqueue_popup_notification( + "Successfully joined room.", + PopupKind::Success, + Some(5.0), + ); } continue; } - Some(JoinRoomResultAction::Failed { room_id, error }) if room_id == info.room_id() => { + Some(JoinRoomResultAction::Failed { room_id, error }) + if room_id == info.room_id() => + { self.invite_state = InviteState::WaitingOnUserInput; if !self.has_shown_confirmation { - let msg = utils::stringify_join_leave_error(error, info.room_name_id(), true, true); + let msg = utils::stringify_join_leave_error( + error, + info.room_name_id(), + true, + true, + ); enqueue_popup_notification(msg, PopupKind::Error, None); } continue; @@ -343,21 +387,33 @@ impl Widget for InviteScreen { Some(LeaveRoomResultAction::Left { room_id }) if room_id == info.room_id() => { self.invite_state = InviteState::RoomLeft; if !self.has_shown_confirmation { - enqueue_popup_notification("Successfully rejected invite.", PopupKind::Success, Some(5.0)); + enqueue_popup_notification( + "Successfully rejected invite.", + PopupKind::Success, + Some(5.0), + ); } continue; } - Some(LeaveRoomResultAction::Failed { room_id, error }) if room_id == info.room_id() => { + Some(LeaveRoomResultAction::Failed { room_id, error }) + if room_id == info.room_id() => + { self.invite_state = InviteState::WaitingOnUserInput; if !self.has_shown_confirmation { - enqueue_popup_notification(format!("Failed to reject invite: {error}"), PopupKind::Error, None); + enqueue_popup_notification( + format!("Failed to reject invite: {error}"), + PopupKind::Error, + None, + ); } continue; } _ => {} } - if let Some(JoinLeaveRoomModalAction::Close { successful, .. }) = action.downcast_ref() { + if let Some(JoinLeaveRoomModalAction::Close { successful, .. }) = + action.downcast_ref() + { // If the modal didn't result in a successful join/leave, // then we must reset the invite state to waiting for user input. if !*successful { @@ -373,10 +429,10 @@ impl Widget for InviteScreen { } } - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { if !self.is_loaded { - let mut restore_status_view = self.view.restore_status_view(cx, ids!(restore_status_view)); + let mut restore_status_view = + self.view.restore_status_view(cx, ids!(restore_status_view)); if let Some(room_name) = &self.room_name_id { restore_status_view.set_content(cx, self.all_rooms_loaded, room_name); } @@ -393,18 +449,23 @@ impl Widget for InviteScreen { let inviter_avatar = inviter_view.avatar(cx, ids!(inviter_avatar)); let mut drew_avatar = false; if let Some(avatar_bytes) = inviter.avatar.as_ref() { - drew_avatar = inviter_avatar.show_image( - cx, - None, // don't make this avatar clickable. - |cx, img| utils::load_png_or_jpg(&img, cx, avatar_bytes), - ).is_ok(); + drew_avatar = inviter_avatar + .show_image( + cx, + None, // don't make this avatar clickable. + |cx, img| utils::load_png_or_jpg(&img, cx, avatar_bytes), + ) + .is_ok(); } if !drew_avatar { inviter_avatar.show_text( cx, None, None, // don't make this avatar clickable. - inviter.display_name.as_deref().unwrap_or_else(|| inviter.user_id.as_str()), + inviter + .display_name + .as_deref() + .unwrap_or_else(|| inviter.user_id.as_str()), ); } let inviter_name = inviter_view.label(cx, ids!(inviter_name)); @@ -414,20 +475,20 @@ impl Widget for InviteScreen { inviter_name.set_text(cx, inviter_user_name); inviter_user_id.set_visible(cx, true); inviter_user_id.set_text(cx, inviter.user_id.as_str()); - } - else { + } else { // If we only have a user ID, show it in the user_name field, // and hide the user ID field. inviter_name.set_text(cx, inviter.user_id.as_str()); inviter_user_id.set_visible(cx, false); } (true, "has invited you to join:") - } - else { + } else { (false, "You have been invited to join:") }; inviter_view.set_visible(cx, is_visible); - self.view.label(cx, ids!(invite_message)).set_text(cx, invite_text); + self.view + .label(cx, ids!(invite_message)) + .set_text(cx, invite_text); // Second, populate the room info, if we have it. let room_view = self.view.view(cx, ids!(room_view)); @@ -435,9 +496,7 @@ impl Widget for InviteScreen { match &info.room_avatar() { FetchedRoomAvatar::Text(text) => { room_avatar.show_text( - cx, - None, - None, // don't make this avatar clickable. + cx, None, None, // don't make this avatar clickable. text, ); } @@ -450,7 +509,9 @@ 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); + room_view + .label(cx, ids!(room_name)) + .set_text(cx, &invite_room_label); // Third, set the buttons' text based on the invite state. let cancel_button = self.view.button(cx, ids!(cancel_button)); @@ -518,11 +579,7 @@ impl InviteScreen { let restore_status_view = self.view.restore_status_view(cx, ids!(restore_status_view)); if !self.is_loaded { - restore_status_view.set_content( - cx, - self.all_rooms_loaded, - room_name_id, - ); + restore_status_view.set_content(cx, self.all_rooms_loaded, room_name_id); restore_status_view.set_visible(cx, true); } else { restore_status_view.set_visible(cx, false); diff --git a/src/home/link_preview.rs b/src/home/link_preview.rs index 1d605dc3d..ed4b7d32e 100644 --- a/src/home/link_preview.rs +++ b/src/home/link_preview.rs @@ -8,7 +8,10 @@ use std::{ use makepad_widgets::*; use crate::{LivePtr, widget_ref_from_live_ptr}; -use matrix_sdk::ruma::{events::room::{ImageInfo, MediaSource}, OwnedMxcUri, UInt}; +use matrix_sdk::ruma::{ + events::room::{ImageInfo, MediaSource}, + OwnedMxcUri, UInt, +}; use serde::Deserialize; use url::Url; @@ -235,8 +238,12 @@ impl Widget for LinkPreview { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { // Handle collapsible button clicks if let Event::Actions(actions) = event { - let expand_btn = self.view.button(cx, ids!(collapsible_buttons.expand_button)); - let collapse_btn = self.view.button(cx, ids!(collapsible_buttons.collapse_button)); + let expand_btn = self + .view + .button(cx, ids!(collapsible_buttons.expand_button)); + let collapse_btn = self + .view + .button(cx, ids!(collapsible_buttons.collapse_button)); if expand_btn.clicked(actions) || collapse_btn.clicked(actions) { self.is_expanded = !self.is_expanded; self.update_button_and_visibility(cx); @@ -265,10 +272,12 @@ impl Widget for LinkPreview { draw_bg.color: mod.widgets.COLOR_BG_PREVIEW }); if fe.is_over && fe.is_primary_hit() && fe.was_tap() { - if let Some(html_link) = view.link_label(cx, ids!(content_view.title_label)).borrow() { + if let Some(html_link) = + view.link_label(cx, ids!(content_view.title_label)).borrow() + { if !html_link.url.is_empty() { cx.widget_action( - html_link.widget_uid(), + html_link.widget_uid(), HtmlLinkAction::Clicked { url: html_link.url.clone(), key_modifiers: fe.modifiers, @@ -287,7 +296,11 @@ impl Widget for LinkPreview { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // Draw children (link preview items) - let max_visible = if self.is_expanded { self.children.len() } else { 2 }; + let max_visible = if self.is_expanded { + self.children.len() + } else { + 2 + }; for (index, view) in self.children.iter_mut().enumerate() { if index < max_visible { let _ = view.draw(cx, scope); @@ -306,9 +319,15 @@ impl LinkPreview { fn update_button_and_visibility(&mut self, cx: &mut Cx) { if self.show_collapsible_buttons { - self.view.view(cx, ids!(collapsible_buttons)).set_visible(cx, true); - let expand_btn = self.view.button(cx, ids!(collapsible_buttons.expand_button)); - let collapse_btn = self.view.button(cx, ids!(collapsible_buttons.collapse_button)); + self.view + .view(cx, ids!(collapsible_buttons)) + .set_visible(cx, true); + let expand_btn = self + .view + .button(cx, ids!(collapsible_buttons.expand_button)); + let collapse_btn = self + .view + .button(cx, ids!(collapsible_buttons.collapse_button)); if self.is_expanded { expand_btn.set_visible(cx, false); collapse_btn.set_visible(cx, true); @@ -320,7 +339,9 @@ impl LinkPreview { expand_btn.reset_hover(cx); collapse_btn.reset_hover(cx); } else { - self.view.view(cx, ids!(collapsible_buttons)).set_visible(cx, false); + self.view + .view(cx, ids!(collapsible_buttons)) + .set_visible(cx, false); } } } @@ -346,19 +367,27 @@ impl LinkPreviewRef { } /// Shows the collapsible button for the link preview. - /// + /// /// This function is usually called when the link preview is updated. /// If the link preview is updated, and the collapsible button should be shown, /// this function should be called. fn show_collapsible_buttons(&mut self, cx: &mut Cx, hidden_count: usize) { - if let Some(mut inner) = self.borrow_mut() { + if let Some(mut inner) = self.borrow_mut() { inner.show_collapsible_buttons = true; inner.hidden_links_count = hidden_count; - let expand_btn = inner.view.button(cx, ids!(collapsible_buttons.expand_button)); + let expand_btn = inner + .view + .button(cx, ids!(collapsible_buttons.expand_button)); expand_btn.set_text(cx, &format!("Show {} more links", inner.hidden_links_count)); expand_btn.set_visible(cx, true); - inner.view.button(cx, ids!(collapsible_buttons.collapse_button)).set_visible(cx, false); - inner.view.view(cx, ids!(collapsible_buttons)).set_visible(cx, true); + inner + .view + .button(cx, ids!(collapsible_buttons.collapse_button)) + .set_visible(cx, false); + inner + .view + .view(cx, ids!(collapsible_buttons)) + .set_visible(cx, true); } } @@ -373,7 +402,14 @@ impl LinkPreviewRef { image_populate_fn: F, ) -> (ViewRef, bool) where - F: FnOnce(&mut Cx, &TextOrImageRef, Option>, MediaSource, &str, &mut MediaCache) -> bool, + F: FnOnce( + &mut Cx, + &TextOrImageRef, + Option>, + MediaSource, + &str, + &mut MediaCache, + ) -> bool, { let view_ref = widget_ref_from_live_ptr(cx, self.item_template()).as_view(); let mut fully_drawn = true; @@ -450,7 +486,7 @@ impl LinkPreviewRef { /// The given `media_cache` is used to fetch the thumbnails from cache. /// /// The given `link_preview_cache` is used to fetch the link previews from cache. - /// + /// /// Return true when the link preview is fully drawn pub fn populate_below_message( &mut self, @@ -459,9 +495,16 @@ impl LinkPreviewRef { media_cache: &mut MediaCache, link_preview_cache: &mut LinkPreviewCache, populate_image_fn: &F, - ) -> bool + ) -> bool where - F: Fn(&mut Cx, &TextOrImageRef, Option>, MediaSource, &str, &mut MediaCache) -> bool, + F: Fn( + &mut Cx, + &TextOrImageRef, + Option>, + MediaSource, + &str, + &mut MediaCache, + ) -> bool, { const SKIPPED_DOMAINS: &[&str] = &["matrix.to", "matrix.io"]; const MAX_LINK_PREVIEWS_BY_EXPAND: usize = 2; @@ -469,13 +512,13 @@ impl LinkPreviewRef { let mut accepted_link_count = 0; let mut views = Vec::new(); let mut seen_urls = std::collections::HashSet::new(); - + for link in links { let url_string = link.to_string(); if seen_urls.contains(&url_string) { continue; } - + if let Some(domain) = link.host_str() { if SKIPPED_DOMAINS .iter() @@ -484,7 +527,7 @@ impl LinkPreviewRef { continue; } } - + seen_urls.insert(url_string.clone()); accepted_link_count += 1; let (view_ref, was_image_drawn) = self.populate_view( @@ -493,7 +536,14 @@ impl LinkPreviewRef { link, media_cache, |cx, text_or_image_ref, image_info_source, original_source, body, media_cache| { - populate_image_fn(cx, text_or_image_ref, image_info_source, original_source, body, media_cache) + populate_image_fn( + cx, + text_or_image_ref, + image_info_source, + original_source, + body, + media_cache, + ) }, ); fully_drawn_count += was_image_drawn as usize; @@ -679,11 +729,11 @@ fn insert_into_cache( UrlPreviewError::HttpStatus(404) => LinkPreviewError::NotFound, UrlPreviewError::HttpStatus(429) => LinkPreviewError::RateLimited, UrlPreviewError::Json(_) => LinkPreviewError::ParseError(e.to_string()), - UrlPreviewError::Request(_) | - UrlPreviewError::ClientNotAvailable | - UrlPreviewError::AccessTokenNotAvailable | - UrlPreviewError::UrlParse(_) | - UrlPreviewError::HttpStatus(_) => LinkPreviewError::NetworkError(e.to_string()), + UrlPreviewError::Request(_) + | UrlPreviewError::ClientNotAvailable + | UrlPreviewError::AccessTokenNotAvailable + | UrlPreviewError::UrlParse(_) + | UrlPreviewError::HttpStatus(_) => LinkPreviewError::NetworkError(e.to_string()), }; if let LinkPreviewError::RateLimited = error_type { LinkPreviewCacheEntry::Requested @@ -693,12 +743,12 @@ fn insert_into_cache( } } }; - + if let Ok(mut timestamped_entry) = value_ref.lock() { timestamped_entry.entry = new_entry; timestamped_entry.timestamp = Instant::now(); } - + if let Some(sender) = update_sender { // Reuse TimelineUpdate MediaFetched to trigger redraw in the timeline. let _ = sender.send(TimelineUpdate::LinkPreviewFetched); diff --git a/src/home/loading_pane.rs b/src/home/loading_pane.rs index baa975a3d..474dbc78b 100644 --- a/src/home/loading_pane.rs +++ b/src/home/loading_pane.rs @@ -3,7 +3,6 @@ use matrix_sdk::ruma::OwnedEventId; use crate::sliding_sync::TimelineRequestSender; - script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -88,8 +87,6 @@ script_mod! { } } - - /// The state of a LoadingPane: the possible tasks that it may be performing. #[derive(Clone, Default)] pub enum LoadingPaneState { @@ -110,16 +107,25 @@ pub enum LoadingPaneState { None, } - #[derive(Script, ScriptHook, Widget)] pub struct LoadingPane { - #[deref] view: View, - #[rust] state: LoadingPaneState, + #[deref] + view: View, + #[rust] + state: LoadingPaneState, } impl Drop for LoadingPane { fn drop(&mut self) { - if let LoadingPaneState::BackwardsPaginateUntilEvent { target_event_id, request_sender, .. } = &self.state { - warning!("Dropping LoadingPane with target_event_id: {}", target_event_id); + if let LoadingPaneState::BackwardsPaginateUntilEvent { + target_event_id, + request_sender, + .. + } = &self.state + { + warning!( + "Dropping LoadingPane with target_event_id: {}", + target_event_id + ); request_sender.send_if_modified(|requests| { let initial_len = requests.len(); requests.retain(|r| &r.target_event_id != target_event_id); @@ -131,7 +137,6 @@ impl Drop for LoadingPane { } } - impl Widget for LoadingPane { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { self.visible = true; @@ -144,7 +149,9 @@ impl Widget for LoadingPane { } fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - if !self.visible { return; } + if !self.visible { + return; + } self.view.handle_event(cx, event, scope); let area = self.view.area(); @@ -159,23 +166,31 @@ impl Widget for LoadingPane { matches!( event, Event::Actions(actions) if self.button(cx, ids!(cancel_button)).clicked(actions) - ) - || event.back_pressed() - || match event.hits_with_capture_overload(cx, area, true) { - Hit::KeyUp(key) => key.key_code == KeyCode::Escape, - Hit::FingerDown(_fde) => { - cx.set_key_focus(area); - false - } - Hit::FingerUp(fue) if fue.is_over => { - fue.mouse_button().is_some_and(|b| b.is_back()) - || !self.view(cx, ids!(main_content)).area().rect(cx).contains(fue.abs) + ) || event.back_pressed() + || match event.hits_with_capture_overload(cx, area, true) { + Hit::KeyUp(key) => key.key_code == KeyCode::Escape, + Hit::FingerDown(_fde) => { + cx.set_key_focus(area); + false + } + Hit::FingerUp(fue) if fue.is_over => { + fue.mouse_button().is_some_and(|b| b.is_back()) + || !self + .view(cx, ids!(main_content)) + .area() + .rect(cx) + .contains(fue.abs) + } + _ => false, } - _ => false, - } }; if close_pane { - if let LoadingPaneState::BackwardsPaginateUntilEvent { target_event_id, request_sender, .. } = &self.state { + if let LoadingPaneState::BackwardsPaginateUntilEvent { + target_event_id, + request_sender, + .. + } = &self.state + { let _did_send = request_sender.send_if_modified(|requests| { let initial_len = requests.len(); requests.retain(|r| &r.target_event_id != target_event_id); @@ -183,7 +198,8 @@ impl Widget for LoadingPane { // such that they can stop looking for the target event. requests.len() != initial_len }); - log!("LoadingPane: {} cancel request for target_event_id: {target_event_id}", + log!( + "LoadingPane: {} cancel request for target_event_id: {target_event_id}", if _did_send { "Sent" } else { "Did not send" }, ); } @@ -194,7 +210,6 @@ impl Widget for LoadingPane { } } - impl LoadingPane { /// Returns `true` if this pane is currently being shown. pub fn is_currently_shown(&self, _cx: &mut Cx) -> bool { @@ -216,10 +231,13 @@ impl LoadingPane { .. } => { self.set_title(cx, "Searching older messages..."); - self.set_status(cx, &format!( - "Looking for event {target_event_id}\n\n\ + self.set_status( + cx, + &format!( + "Looking for event {target_event_id}\n\n\ Fetched {events_paginated} messages so far...", - )); + ), + ); cancel_button.set_text(cx, "Cancel"); } LoadingPaneState::Error(error_message) => { @@ -227,7 +245,7 @@ impl LoadingPane { self.set_status(cx, error_message); cancel_button.set_text(cx, "Okay"); } - LoadingPaneState::None => { } + LoadingPaneState::None => {} } self.state = state; @@ -246,13 +264,17 @@ impl LoadingPane { impl LoadingPaneRef { /// See [`LoadingPane::is_currently_shown()`] pub fn is_currently_shown(&self, cx: &mut Cx) -> bool { - let Some(inner) = self.borrow() else { return false }; + let Some(inner) = self.borrow() else { + return false; + }; inner.is_currently_shown(cx) } /// See [`LoadingPane::show()`] pub fn show(&self, cx: &mut Cx) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show(cx); } @@ -263,17 +285,23 @@ impl LoadingPaneRef { } pub fn set_state(&self, cx: &mut Cx, state: LoadingPaneState) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_state(cx, state); } pub fn set_status(&self, cx: &mut Cx, status: &str) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_status(cx, status); } pub fn set_title(&self, cx: &mut Cx, title: &str) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_title(cx, title); } } diff --git a/src/home/location_preview.rs b/src/home/location_preview.rs index 958b4416f..f88f5f96c 100644 --- a/src/home/location_preview.rs +++ b/src/home/location_preview.rs @@ -10,7 +10,9 @@ use std::time::SystemTime; use makepad_widgets::*; use robius_location::Coordinates; -use crate::location::{get_latest_location, request_location_update, LocationAction, LocationRequest, LocationUpdate}; +use crate::location::{ + get_latest_location, request_location_update, LocationAction, LocationRequest, LocationUpdate, +}; script_mod! { use mod.prelude.widgets.* @@ -89,12 +91,14 @@ script_mod! { } } - #[derive(Script, ScriptHook, Widget)] struct LocationPreview { - #[deref] view: View, - #[rust] coords: Option>, - #[rust] timestamp: Option, + #[deref] + view: View, + #[rust] + coords: Option>, + #[rust] + timestamp: Option, } impl Widget for LocationPreview { @@ -106,16 +110,18 @@ impl Widget for LocationPreview { Some(LocationAction::Update(LocationUpdate { coordinates, time })) => { self.coords = Some(Ok(*coordinates)); self.timestamp = *time; - self.button(cx, ids!(send_location_button)).set_enabled(cx, true); + self.button(cx, ids!(send_location_button)) + .set_enabled(cx, true); needs_redraw = true; } Some(LocationAction::Error(e)) => { self.coords = Some(Err(*e)); self.timestamp = None; - self.button(cx, ids!(send_location_button)).set_enabled(cx, false); + self.button(cx, ids!(send_location_button)) + .set_enabled(cx, false); needs_redraw = true; } - _ => { } + _ => {} } } @@ -123,7 +129,10 @@ impl Widget for LocationPreview { // in the RoomScreen handle_event function. // Handle the cancel location button being clicked. - if self.button(cx, ids!(cancel_location_button)).clicked(actions) { + if self + .button(cx, ids!(cancel_location_button)) + .clicked(actions) + { self.clear(); needs_redraw = true; } @@ -149,7 +158,6 @@ impl Widget for LocationPreview { } } - impl LocationPreview { fn show(&mut self) { request_location_update(LocationRequest::UpdateOnce); diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 628d477bf..fd95bd753 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -3,8 +3,19 @@ use ruma::OwnedRoomId; use tokio::sync::Notify; use std::{collections::HashMap, sync::Arc}; -use crate::{app::{AppState, AppStateAction, SavedDockState, SelectedRoom}, home::{navigation_tab_bar::{NavigationBarAction, SelectedTab}, rooms_list::RoomsListRef, space_lobby::SpaceLobbyScreenWidgetRefExt}, utils::RoomNameId}; -use super::{invite_screen::InviteScreenWidgetRefExt, room_screen::RoomScreenWidgetRefExt, rooms_list::RoomsListAction}; +use crate::{ + app::{AppState, AppStateAction, SavedDockState, SelectedRoom}, + home::{ + navigation_tab_bar::{NavigationBarAction, SelectedTab}, + rooms_list::RoomsListRef, + space_lobby::SpaceLobbyScreenWidgetRefExt, + }, + utils::RoomNameId, +}; +use super::{ + invite_screen::InviteScreenWidgetRefExt, room_screen::RoomScreenWidgetRefExt, + rooms_list::RoomsListAction, +}; script_mod! { use mod.prelude.widgets.* @@ -75,7 +86,8 @@ pub struct MainDesktopUI { /// The default layout that should be loaded into the dock /// when there is no previously-saved content to restore. /// This is a Rust-level instance of the dock content defined in the above live DSL. - #[rust] default_layout: SavedDockState, + #[rust] + default_layout: SavedDockState, /// The rooms that are currently open, keyed by the LiveId of their tab. #[rust] @@ -99,7 +111,8 @@ pub struct MainDesktopUI { /// /// This determines which set of rooms this dock is currently showing. /// If `None`, we're displaying the main home view of all rooms from any space. - #[rust] selected_space: Option, + #[rust] + selected_space: Option, /// Boolean to indicate if we've drawn the MainDesktopUi previously in the desktop view. /// @@ -142,7 +155,11 @@ impl MainDesktopUI { /// 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. - if self.most_recently_selected_room.as_ref().is_some_and(|sr| sr == &room) { + if self + .most_recently_selected_room + .as_ref() + .is_some_and(|sr| sr == &room) + { return; } @@ -158,15 +175,16 @@ impl MainDesktopUI { // Create a new tab for the room let kind = match &room { - SelectedRoom::JoinedRoom { .. } - | SelectedRoom::Thread { .. } => id!(room_screen), + SelectedRoom::JoinedRoom { .. } | SelectedRoom::Thread { .. } => id!(room_screen), SelectedRoom::InvitedRoom { .. } => id!(invite_screen), SelectedRoom::Space { .. } => id!(space_lobby_screen), }; // 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() + let (tab_bar, insert_after) = self + .most_recently_selected_room + .as_ref() .and_then(|curr_room| dock.find_tab_bar_of_tab(curr_room.tab_id())) .unwrap_or_else(|| dock.find_tab_bar_of_tab(id!(home_tab)).unwrap()); @@ -184,14 +202,15 @@ impl MainDesktopUI { if let Some(new_widget) = new_tab_widget { self.room_order.push(room.clone()); match &room { - SelectedRoom::JoinedRoom { room_name_id } => { - new_widget.as_room_screen().set_displayed_room( - cx, - room_name_id, - None, - ); + SelectedRoom::JoinedRoom { room_name_id } => { + new_widget + .as_room_screen() + .set_displayed_room(cx, room_name_id, None); } - SelectedRoom::Thread { room_name_id, thread_root_event_id } => { + SelectedRoom::Thread { + room_name_id, + thread_root_event_id, + } => { new_widget.as_room_screen().set_displayed_room( cx, room_name_id, @@ -199,16 +218,14 @@ impl MainDesktopUI { ); } SelectedRoom::InvitedRoom { room_name_id } => { - new_widget.as_invite_screen().set_displayed_invite( - cx, - room_name_id, - ); + new_widget + .as_invite_screen() + .set_displayed_invite(cx, room_name_id); } SelectedRoom::Space { space_name_id } => { - new_widget.as_space_lobby_screen().set_displayed_space( - cx, - space_name_id, - ); + new_widget + .as_space_lobby_screen() + .set_displayed_space(cx, space_name_id); } } cx.action(MainDesktopUiAction::SaveDockIntoAppState); @@ -256,7 +273,7 @@ impl MainDesktopUI { /// Closes all tabs pub fn close_all_tabs(&mut self, cx: &mut Cx) { let dock = self.view.dock(cx, ids!(dock)); - for tab_id in self.open_rooms.keys() { + for tab_id in self.open_rooms.keys() { dock.close_tab(cx, *tab_id); } @@ -297,7 +314,9 @@ impl MainDesktopUI { // Go through all existing `SelectedRoom` instances and replace the // `SelectedRoom::InvitedRoom`s with `SelectedRoom::JoinedRoom`s. - for selected_room in self.most_recently_selected_room.iter_mut() + for selected_room in self + .most_recently_selected_room + .iter_mut() .chain(self.room_order.iter_mut()) .chain(self.open_rooms.values_mut()) { @@ -305,7 +324,9 @@ impl MainDesktopUI { } // Finally, emit an action to update the AppState with the new room. - cx.action(AppStateAction::UpgradedInviteToJoinedRoom(room_name_id.room_id().clone())); + cx.action(AppStateAction::UpgradedInviteToJoinedRoom( + room_name_id.room_id().clone(), + )); } /// Saves a copy of the current UI state of the dock into the given app state, @@ -313,19 +334,18 @@ impl MainDesktopUI { fn save_dock_state_to(&mut self, cx: &mut Cx, app_state: &mut AppState) { if self.open_rooms.is_empty() { return; - } + } let saved_dock_state = self.save_dock_state(cx); if let Some(space_id) = self.selected_space.as_ref() { - app_state.saved_dock_state_per_space.insert( - space_id.clone(), - saved_dock_state, - ); + app_state + .saved_dock_state_per_space + .insert(space_id.clone(), saved_dock_state); } else { app_state.saved_dock_state_home = saved_dock_state; } } - /// An inner function that creates a `SavedDockState` from the current contents of this widget. + /// An inner function that creates a `SavedDockState` from the current contents of this widget. fn save_dock_state(&self, cx: &mut Cx) -> SavedDockState { let dock = self.view.dock(cx, ids!(dock)); SavedDockState { @@ -352,7 +372,12 @@ impl MainDesktopUI { Some(sds) if sds.open_rooms.is_empty() => &self.default_layout, Some(sds) => sds, }; - let SavedDockState { dock_items, open_rooms, room_order, selected_room } = to_restore; + let SavedDockState { + dock_items, + open_rooms, + room_order, + selected_room, + } = to_restore; self.room_order = room_order.clone(); self.open_rooms = open_rooms.clone(); @@ -364,37 +389,38 @@ impl MainDesktopUI { for (head_live_id, (_, widget)) in dock.items().iter() { match self.open_rooms.get(head_live_id) { Some(SelectedRoom::JoinedRoom { room_name_id }) => { - widget.as_room_screen().set_displayed_room( - cx, - room_name_id, - None, - ); + widget + .as_room_screen() + .set_displayed_room(cx, room_name_id, None); } Some(SelectedRoom::InvitedRoom { room_name_id }) => { - widget.as_invite_screen().set_displayed_invite( - cx, - room_name_id, - ); + widget + .as_invite_screen() + .set_displayed_invite(cx, room_name_id); } Some(SelectedRoom::Space { space_name_id }) => { - widget.as_space_lobby_screen().set_displayed_space( - cx, - space_name_id, - ); + widget + .as_space_lobby_screen() + .set_displayed_space(cx, space_name_id); } - Some(SelectedRoom::Thread { room_name_id, thread_root_event_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 => { } + None => {} } } } } else { - error!("BUG: failed to borrow dock widget to restore state upon LoadDockFromAppState action."); + error!( + "BUG: failed to borrow dock widget to restore state upon LoadDockFromAppState action." + ); return; } // Note: the borrow of `dock` must end here *before* we call `self.focus_or_create_tab()`. @@ -415,7 +441,8 @@ impl WidgetMatchEvent for MainDesktopUI { for action in actions { let widget_action = action.as_widget_action(); - if let Some(MainDesktopUiAction::CloseAllTabs { on_close_all }) = action.downcast_ref() { + if let Some(MainDesktopUiAction::CloseAllTabs { on_close_all }) = action.downcast_ref() + { self.close_all_tabs(cx); on_close_all.notify_one(); continue; @@ -426,7 +453,7 @@ impl WidgetMatchEvent for MainDesktopUI { if let Some(NavigationBarAction::TabSelected(tab)) = action.downcast_ref() { let new_space = match (tab, self.selected_space.as_ref()) { (SelectedTab::Space { space_name_id }, space_id_opt) - if space_id_opt.is_none_or(|id| id != space_name_id.room_id()) => + if space_id_opt.is_none_or(|id| id != space_name_id.room_id()) => { Some(space_name_id.room_id().clone()) } @@ -448,8 +475,7 @@ impl WidgetMatchEvent for MainDesktopUI { if tab_id == id!(home_tab) { cx.action(AppStateAction::FocusNone); self.most_recently_selected_room = None; - } - else if let Some(selected_room) = self.open_rooms.get(&tab_id) { + } else if let Some(selected_room) = self.open_rooms.get(&tab_id) { cx.action(AppStateAction::RoomFocused(selected_room.clone())); self.most_recently_selected_room = Some(selected_room.clone()); } @@ -475,7 +501,11 @@ impl WidgetMatchEvent for MainDesktopUI { // When dragging a tab, allow it to be dragged DockAction::Drag(drag_event) => { if drag_event.items.len() == 1 { - self.view.dock(cx, ids!(dock)).accept_drag(cx, drag_event, DragResponse::Move); + self.view.dock(cx, ids!(dock)).accept_drag( + cx, + drag_event, + DragResponse::Move, + ); } } // When dropping a tab, move it to the new position @@ -484,8 +514,11 @@ impl WidgetMatchEvent for MainDesktopUI { if let DragItem::FilePath { internal_id: Some(internal_id), .. - } = &drop_event.items[0] { - self.view.dock(cx, ids!(dock)).drop_move(cx, drop_event.abs, *internal_id); + } = &drop_event.items[0] + { + self.view + .dock(cx, ids!(dock)) + .drop_move(cx, drop_event.abs, *internal_id); } should_save_dock_action = true; } @@ -504,7 +537,7 @@ impl WidgetMatchEvent for MainDesktopUI { self.replace_invite_with_joined_room(cx, scope, room_name_id); } RoomsListAction::OpenRoomContextMenu { .. } => {} - RoomsListAction::None => { } + RoomsListAction::None => {} } // Handle our own actions related to dock updates that we have previously emitted. @@ -535,7 +568,5 @@ pub enum MainDesktopUiAction { /// Load the room panel state from the AppState to the dock. LoadDockFromAppState, /// Close all tabs; see [`MainDesktopUI::close_all_tabs()`] - CloseAllTabs { - on_close_all: Arc, - }, + CloseAllTabs { on_close_all: Arc }, } diff --git a/src/home/main_mobile_ui.rs b/src/home/main_mobile_ui.rs index f06118447..2741c4ea7 100644 --- a/src/home/main_mobile_ui.rs +++ b/src/home/main_mobile_ui.rs @@ -1,7 +1,11 @@ use makepad_widgets::*; use crate::{ - app::{AppState, AppStateAction, SelectedRoom}, home::{room_screen::RoomScreenWidgetExt, rooms_list::RoomsListAction, space_lobby::SpaceLobbyScreenWidgetExt} + app::{AppState, AppStateAction, SelectedRoom}, + home::{ + room_screen::RoomScreenWidgetExt, rooms_list::RoomsListAction, + space_lobby::SpaceLobbyScreenWidgetExt, + }, }; use super::invite_screen::InviteScreenWidgetExt; @@ -61,8 +65,12 @@ impl Widget for MainMobileUI { RoomsListAction::Selected(_selected_room) => {} // Because the MainMobileUI is drawn based on the AppState only, // all we need to do is update the AppState here. - RoomsListAction::InviteAccepted { room_name_id: room_name } => { - cx.action(AppStateAction::UpgradedInviteToJoinedRoom(room_name.room_id().clone())); + RoomsListAction::InviteAccepted { + room_name_id: room_name, + } => { + cx.action(AppStateAction::UpgradedInviteToJoinedRoom( + room_name.room_id().clone(), + )); } RoomsListAction::OpenRoomContextMenu { .. } => {} RoomsListAction::None => {} @@ -107,7 +115,10 @@ impl Widget for MainMobileUI { .space_lobby_screen(cx, ids!(space_lobby_screen)) .set_displayed_space(cx, space_name_id); } - Some(SelectedRoom::Thread { room_name_id, thread_root_event_id }) => { + Some(SelectedRoom::Thread { + room_name_id, + thread_root_event_id, + }) => { show_welcome = false; show_room = true; show_invite = false; @@ -124,10 +135,18 @@ impl Widget for MainMobileUI { } } - self.view.view(cx, ids!(welcome)).set_visible(cx, show_welcome); - self.view.view(cx, ids!(room_view)).set_visible(cx, show_room); - self.view.view(cx, ids!(invite_view)).set_visible(cx, show_invite); - self.view.view(cx, ids!(space_lobby_view)).set_visible(cx, show_space_lobby); + self.view + .view(cx, ids!(welcome)) + .set_visible(cx, show_welcome); + self.view + .view(cx, ids!(room_view)) + .set_visible(cx, show_room); + self.view + .view(cx, ids!(invite_view)) + .set_visible(cx, show_invite); + self.view + .view(cx, ids!(space_lobby_view)) + .set_visible(cx, show_space_lobby); self.view.draw_walk(cx, scope, walk) } } diff --git a/src/home/navigation_tab_bar.rs b/src/home/navigation_tab_bar.rs index 95cec1317..371560d93 100644 --- a/src/home/navigation_tab_bar.rs +++ b/src/home/navigation_tab_bar.rs @@ -9,7 +9,7 @@ //! 2. Add Room (plus sign icon): a separate view that allows adding (joining) existing rooms, //! exploring public rooms, or creating new rooms/spaces. //! 3. Spaces: a button that toggles the `SpacesBar` (shows/hides it). -//! * This is NOT a regular radio button, it's a separate toggle. +//! * This is NOT a regular radio button, it's a separate toggle. //! * This is only shown in Mobile view mode, because the `SpacesBar` is always shown //! within the NavigationTabBar itself in Desktop view mode. //! 4. Activity (an inbox, alert bell, or notifications icon): a separate view that shows @@ -31,12 +31,20 @@ use makepad_widgets::*; use serde::{Deserialize, Serialize}; use crate::{ - avatar_cache::{self, AvatarCacheEntry}, login::login_screen::LoginAction, logout::logout_confirm_modal::LogoutAction, profile::{ + avatar_cache::{self, AvatarCacheEntry}, + login::login_screen::LoginAction, + logout::logout_confirm_modal::LogoutAction, + profile::{ user_profile::UserProfile, user_profile_cache::{self, UserProfileUpdate}, - }, shared::{ - avatar::{AvatarState, AvatarWidgetExt}, styles::*, verification_badge::VerificationBadgeWidgetExt - }, sliding_sync::{current_user_id, AccountDataAction}, utils::{self, RoomNameId} + }, + shared::{ + avatar::{AvatarState, AvatarWidgetExt}, + styles::*, + verification_badge::VerificationBadgeWidgetExt, + }, + sliding_sync::{current_user_id, AccountDataAction}, + utils::{self, RoomNameId}, }; script_mod! { @@ -162,7 +170,7 @@ script_mod! { flow: Down, align: Align{x: 0.5} padding: Inset{top: 40., bottom: 8} - width: (NAVIGATION_TAB_BAR_SIZE), + width: (NAVIGATION_TAB_BAR_SIZE), height: Fill draw_bg +: { @@ -228,8 +236,10 @@ script_mod! { /// Clicking on this icon will open the settings screen. #[derive(Script, Widget)] pub struct ProfileIcon { - #[deref] view: View, - #[rust] own_profile: Option, + #[deref] + view: View, + #[rust] + own_profile: Option, } impl ScriptHook for ProfileIcon { @@ -258,13 +268,15 @@ impl Widget for ProfileIcon { needs_redraw = true; } // If we're waiting for an avatar image, process avatar updates. - if let Some(p) = self.own_profile.as_mut() && p.avatar_state.uri().is_some() { + if let Some(p) = self.own_profile.as_mut() + && p.avatar_state.uri().is_some() + { avatar_cache::process_avatar_updates(cx); let new_data = p.avatar_state.update_from_cache(cx); needs_redraw |= new_data.is_some(); if new_data.is_some() { user_profile_cache::enqueue_user_profile_update( - UserProfileUpdate::UserProfileOnly(p.clone()) + UserProfileUpdate::UserProfileOnly(p.clone()), ); } } @@ -296,7 +308,7 @@ impl Widget for ProfileIcon { if let Some(p) = self.own_profile.as_mut() { p.avatar_state = AvatarState::Known(None); user_profile_cache::enqueue_user_profile_update( - UserProfileUpdate::UserProfileOnly(p.clone()) + UserProfileUpdate::UserProfileOnly(p.clone()), ); self.view.redraw(cx); } @@ -307,7 +319,7 @@ impl Widget for ProfileIcon { p.avatar_state = AvatarState::Known(Some(new_uri.clone())); p.avatar_state.update_from_cache(cx); user_profile_cache::enqueue_user_profile_update( - UserProfileUpdate::UserProfileOnly(p.clone()) + UserProfileUpdate::UserProfileOnly(p.clone()), ); self.view.redraw(cx); } @@ -321,7 +333,7 @@ impl Widget for ProfileIcon { if let Some(p) = self.own_profile.as_mut() { p.username = new_display_name.clone(); user_profile_cache::enqueue_user_profile_update( - UserProfileUpdate::UserProfileOnly(p.clone()) + UserProfileUpdate::UserProfileOnly(p.clone()), ); self.view.redraw(cx); } @@ -339,22 +351,33 @@ impl Widget for ProfileIcon { let area = self.view.area(); match event.hits(cx, area) { Hit::FingerLongPress(_) | Hit::FingerHoverIn(_) => { - let (verification_str, bg_color) = self.view + let (verification_str, bg_color) = self + .view .verification_badge(cx, ids!(verification_badge)) .tooltip_content(); let text = self.own_profile.as_ref().map_or_else( || format!("Not logged in.\n\n{}", verification_str), - |p| format!("Logged in as \"{}\".\n\n{}", p.displayable_name(), verification_str) + |p| { + format!( + "Logged in as \"{}\".\n\n{}", + p.displayable_name(), + verification_str + ) + }, ); let mut options = CalloutTooltipOptions { - position: if cx.display_context.is_desktop() { TooltipPosition::Right} else { TooltipPosition::Top}, + position: if cx.display_context.is_desktop() { + TooltipPosition::Right + } else { + TooltipPosition::Top + }, ..Default::default() }; if let Some(c) = bg_color { options.bg_color = c; } cx.widget_action( - self.widget_uid(), + self.widget_uid(), TooltipAction::HoverIn { text, widget_rect: area.rect(cx), @@ -363,9 +386,9 @@ impl Widget for ProfileIcon { ); } Hit::FingerHoverOut(_) => { - cx.widget_action(self.widget_uid(), TooltipAction::HoverOut); + cx.widget_action(self.widget_uid(), TooltipAction::HoverOut); } - _ => { } + _ => {} }; self.view.handle_event(cx, event, scope); @@ -386,11 +409,13 @@ impl Widget for ProfileIcon { let mut drew_avatar = false; if let Some(avatar_img_data) = own_profile.avatar_state.data() { - drew_avatar = our_own_avatar.show_image( - cx, - None, // don't make this avatar clickable; we handle clicks on this ProfileIcon widget directly. - |cx, img| utils::load_png_or_jpg(&img, cx, avatar_img_data), - ).is_ok(); + drew_avatar = our_own_avatar + .show_image( + cx, + None, // don't make this avatar clickable; we handle clicks on this ProfileIcon widget directly. + |cx, img| utils::load_png_or_jpg(&img, cx, avatar_img_data), + ) + .is_ok(); } if !drew_avatar { our_own_avatar.show_text( @@ -405,16 +430,17 @@ impl Widget for ProfileIcon { } } - /// The tab bar with buttons that navigate through top-level app pages. /// /// * In the "desktop" (wide) layout, this is a vertical bar on the left. /// * In the "mobile" (narrow) layout, this is a horizontal bar on the bottom. #[derive(Script, Widget)] pub struct NavigationTabBar { - #[deref] view: AdaptiveView, + #[deref] + view: AdaptiveView, - #[rust] is_spaces_bar_shown: bool, + #[rust] + is_spaces_bar_shown: bool, } impl ScriptHook for NavigationTabBar { @@ -435,19 +461,22 @@ impl Widget for NavigationTabBar { if let Event::Actions(actions) = event { // Handle one of the radio buttons being clicked (selected). - let radio_button_set = self.view.radio_button_set(cx, ids_array!( - home_button, - add_room_button, - settings_button, - )); + let radio_button_set = self.view.radio_button_set( + cx, + ids_array!(home_button, add_room_button, settings_button,), + ); match radio_button_set.selected(cx, actions) { Some(0) => cx.action(NavigationBarAction::GoToHome), Some(1) => cx.action(NavigationBarAction::GoToAddRoom), Some(2) => cx.action(NavigationBarAction::OpenSettings), - _ => { } + _ => {} } - if self.view.button(cx, ids!(toggle_spaces_bar_button)).clicked(actions) { + if self + .view + .button(cx, ids!(toggle_spaces_bar_button)) + .clicked(actions) + { self.is_spaces_bar_shown = !self.is_spaces_bar_shown; cx.action(NavigationBarAction::ToggleSpacesBar); } @@ -457,9 +486,18 @@ impl Widget for NavigationTabBar { // update our radio buttons accordingly. if let Some(NavigationBarAction::TabSelected(tab)) = action.downcast_ref() { match tab { - SelectedTab::Home => self.view.radio_button(cx, ids!(home_button)).select(cx, scope), - SelectedTab::AddRoom => self.view.radio_button(cx, ids!(add_room_button)).select(cx, scope), - SelectedTab::Settings => self.view.radio_button(cx, ids!(settings_button)).select(cx, scope), + SelectedTab::Home => self + .view + .radio_button(cx, ids!(home_button)) + .select(cx, scope), + SelectedTab::AddRoom => self + .view + .radio_button(cx, ids!(add_room_button)) + .select(cx, scope), + SelectedTab::Settings => self + .view + .radio_button(cx, ids!(settings_button)) + .select(cx, scope), SelectedTab::Space { .. } => { for rb in radio_button_set.iter() { if let Some(mut rb_inner) = rb.borrow_mut() { @@ -479,7 +517,6 @@ impl Widget for NavigationTabBar { } } - /// Which top-level view is currently shown, and which navigation tab is selected. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum SelectedTab { @@ -488,10 +525,11 @@ pub enum SelectedTab { AddRoom, Settings, // AlertsInbox, - Space { space_name_id: RoomNameId }, + Space { + space_name_id: RoomNameId, + }, } - /// Actions for navigating through the top-level views of the app, /// e.g., when the user clicks/taps on a button in the NavigationTabBar. /// @@ -534,9 +572,8 @@ pub enum NavigationBarAction { GoToSpace { space_name_id: RoomNameId }, // 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. + /// 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 @@ -544,7 +581,6 @@ pub enum NavigationBarAction { ToggleSpacesBar, } - /// Returns the current user's profile and avatar, if available. pub fn get_own_profile(cx: &mut Cx) -> Option { let mut own_profile = None; @@ -562,12 +598,14 @@ pub fn get_own_profile(cx: &mut Cx) -> Option { ); // If we have an avatar URI to fetch, try to fetch it. if let Some(Some(avatar_uri)) = avatar_uri_to_fetch { - if let AvatarCacheEntry::Loaded(data) = avatar_cache::get_or_fetch_avatar(cx, &avatar_uri) { + if let AvatarCacheEntry::Loaded(data) = + avatar_cache::get_or_fetch_avatar(cx, &avatar_uri) + { if let Some(p) = own_profile.as_mut() { p.avatar_state = AvatarState::Loaded(data); // Update the user profile cache with the new avatar data. user_profile_cache::enqueue_user_profile_update( - UserProfileUpdate::UserProfileOnly(p.clone()) + UserProfileUpdate::UserProfileOnly(p.clone()), ); } } diff --git a/src/home/new_message_context_menu.rs b/src/home/new_message_context_menu.rs index 06c963fb3..9291b07a0 100644 --- a/src/home/new_message_context_menu.rs +++ b/src/home/new_message_context_menu.rs @@ -11,7 +11,7 @@ use crate::sliding_sync::UserPowerLevels; use super::room_screen::MessageAction; const BUTTON_HEIGHT: f64 = 35.0; // KEEP IN SYNC WITH BUTTON_HEIGHT BELOW -const MENU_WIDTH: f64 = 215.0; // KEEP IN SYNC WITH MENU_WIDTH BELOW +const MENU_WIDTH: f64 = 215.0; // KEEP IN SYNC WITH MENU_WIDTH BELOW script_mod! { use mod.prelude.widgets.* @@ -203,7 +203,6 @@ script_mod! { } } - bitflags! { /// Possible actions that the user can perform on a message. /// @@ -243,7 +242,9 @@ impl MessageAbilities { abilities.set(Self::CanDelete, user_power_levels.can_redact_own()); } abilities.set(Self::CanReplyTo, event_tl_item.can_be_replied_to()); - if let Some(event_id) = event_tl_item.event_id() && user_power_levels.can_pin() { + if let Some(event_id) = event_tl_item.event_id() + && user_power_levels.can_pin() + { if pinned_events.iter().any(|ev| ev == event_id) { abilities.set(Self::CanUnpin, true); } else { @@ -254,7 +255,6 @@ impl MessageAbilities { abilities.set(Self::HasHtml, has_html); abilities } - } /// Details about the message that define its context menu content. @@ -290,9 +290,12 @@ impl MessageDetails { #[derive(Script, ScriptHook, Widget)] pub struct NewMessageContextMenu { - #[deref] view: View, - #[source] source: ScriptObjectRef, - #[rust] details: Option, + #[deref] + view: View, + #[source] + source: ScriptObjectRef, + #[rust] + details: Option, } impl Widget for NewMessageContextMenu { @@ -305,7 +308,9 @@ impl Widget for NewMessageContextMenu { } fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - if !self.visible { return; } + if !self.visible { + return; + } self.view.handle_event(cx, event, scope); let area = self.view.area(); @@ -317,23 +322,27 @@ impl Widget for NewMessageContextMenu { // 4. The user scrolls anywhere. let close_menu = { event.back_pressed() - || match event.hits_with_capture_overload(cx, area, true) { - Hit::KeyUp(key) => key.key_code == KeyCode::Escape, - Hit::FingerDown(fde) => { - let reaction_text_input = self.view.text_input(cx, ids!(reaction_input_view.reaction_text_input)); - if reaction_text_input.area().rect(cx).contains(fde.abs) { - reaction_text_input.set_key_focus(cx); - } else { - cx.set_key_focus(area); + || match event.hits_with_capture_overload(cx, area, true) { + Hit::KeyUp(key) => key.key_code == KeyCode::Escape, + Hit::FingerDown(fde) => { + let reaction_text_input = self + .view + .text_input(cx, ids!(reaction_input_view.reaction_text_input)); + if reaction_text_input.area().rect(cx).contains(fde.abs) { + reaction_text_input.set_key_focus(cx); + } else { + cx.set_key_focus(area); + } + false } - false - } - Hit::FingerUp(fue) if fue.is_over => { - !self.view(cx, ids!(main_content)).area().rect(cx).contains(fue.abs) + Hit::FingerUp(fue) if fue.is_over => !self + .view(cx, ids!(main_content)) + .area() + .rect(cx) + .contains(fue.abs), + Hit::FingerScroll(_) => true, + _ => false, } - Hit::FingerScroll(_) => true, - _ => false, - } }; if close_menu { self.close(cx); @@ -346,94 +355,100 @@ impl Widget for NewMessageContextMenu { impl WidgetMatchEvent for NewMessageContextMenu { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { - let Some(details) = self.details.as_ref() else { return }; + let Some(details) = self.details.as_ref() else { + return; + }; let mut close_menu = false; - let reaction_text_input = self.view.text_input(cx, ids!(reaction_input_view.reaction_text_input)); - let reaction_send_button = self.view.button(cx, ids!(reaction_input_view.reaction_send_button)); - if reaction_send_button.clicked(actions) - || reaction_text_input.returned(actions).is_some() + let reaction_text_input = self + .view + .text_input(cx, ids!(reaction_input_view.reaction_text_input)); + let reaction_send_button = self + .view + .button(cx, ids!(reaction_input_view.reaction_send_button)); + if reaction_send_button.clicked(actions) || reaction_text_input.returned(actions).is_some() { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::React { details: details.clone(), reaction: reaction_text_input.text(), }, ); close_menu = true; - } - else if reaction_text_input.escaped(actions) { + } else if reaction_text_input.escaped(actions) { close_menu = true; - } - else if self.button(cx, ids!(react_button)).clicked(actions) { + } else if self.button(cx, ids!(react_button)).clicked(actions) { // Show a box to allow the user to input the reaction. // In the future, we'll show an emoji chooser. - self.view.button(cx, ids!(react_button)).set_visible(cx, false); - self.view.view(cx, ids!(reaction_input_view)).set_visible(cx, true); - self.text_input(cx, ids!(reaction_input_view.reaction_text_input)).set_key_focus(cx); + self.view + .button(cx, ids!(react_button)) + .set_visible(cx, false); + self.view + .view(cx, ids!(reaction_input_view)) + .set_visible(cx, true); + self.text_input(cx, ids!(reaction_input_view.reaction_text_input)) + .set_key_focus(cx); self.redraw(cx); close_menu = false; - } - else if self.button(cx, ids!(reply_button)).clicked(actions) { + } else if self.button(cx, ids!(reply_button)).clicked(actions) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::Reply(details.clone()), ); close_menu = true; - } - else if self.button(cx, ids!(edit_message_button)).clicked(actions) { + } else if self.button(cx, ids!(edit_message_button)).clicked(actions) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::Edit(details.clone()), ); close_menu = true; - } - else if self.button(cx, ids!(pin_button)).clicked(actions) { + } else if self.button(cx, ids!(pin_button)).clicked(actions) { if details.abilities.contains(MessageAbilities::CanPin) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::Pin(details.clone()), ); } else if details.abilities.contains(MessageAbilities::CanUnpin) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::Unpin(details.clone()), ); } close_menu = true; - } - else if self.button(cx, ids!(copy_text_button)).clicked(actions) { + } else if self.button(cx, ids!(copy_text_button)).clicked(actions) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::CopyText(details.clone()), ); close_menu = true; - } - else if self.button(cx, ids!(copy_html_button)).clicked(actions) { + } else if self.button(cx, ids!(copy_html_button)).clicked(actions) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::CopyHtml(details.clone()), ); close_menu = true; - } - else if self.button(cx, ids!(copy_link_to_message_button)).clicked(actions) { + } else if self + .button(cx, ids!(copy_link_to_message_button)) + .clicked(actions) + { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::CopyLink(details.clone()), ); close_menu = true; - } - else if self.button(cx, ids!(view_source_button)).clicked(actions) { + } else if self.button(cx, ids!(view_source_button)).clicked(actions) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::ViewSource(details.clone()), ); close_menu = true; - } - else if self.button(cx, ids!(jump_to_related_button)).clicked(actions) { + } else if self + .button(cx, ids!(jump_to_related_button)) + .clicked(actions) + { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::JumpToRelated(details.clone()), ); close_menu = true; @@ -452,7 +467,7 @@ impl WidgetMatchEvent for NewMessageContextMenu { // } else if self.button(cx, ids!(delete_button)).clicked(actions) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::Redact { details: details.clone(), // TODO: show a Modal to confirm deletion, and get the reason. @@ -493,7 +508,9 @@ impl NewMessageContextMenu { /// /// Returns the total height of all visible items. fn set_button_visibility(&mut self, cx: &mut Cx) -> f64 { - let Some(details) = self.details.as_ref() else { return 0.0 }; + let Some(details) = self.details.as_ref() else { + return 0.0; + }; let react_button = self.view.button(cx, ids!(react_button)); let reply_button = self.view.button(cx, ids!(reply_button)); @@ -525,10 +542,14 @@ impl NewMessageContextMenu { let show_divider_before_report_delete = show_delete; // || show_report; // Actually set the buttons' visibility. - self.view.view(cx, ids!(react_view)).set_visible(cx, show_react); + self.view + .view(cx, ids!(react_view)) + .set_visible(cx, show_react); react_button.set_visible(cx, show_react); reply_button.set_visible(cx, show_reply_to); - self.view.view(cx, ids!(divider_after_react_reply)).set_visible(cx, show_divider_after_react_reply); + self.view + .view(cx, ids!(divider_after_react_reply)) + .set_visible(cx, show_divider_after_react_reply); edit_button.set_visible(cx, show_edit); if details.abilities.contains(MessageAbilities::CanPin) { pin_button.set_text(cx, "Pin Message"); @@ -542,7 +563,9 @@ impl NewMessageContextMenu { pin_button.set_visible(cx, show_pin); copy_html_button.set_visible(cx, show_copy_html); jump_to_related_button.set_visible(cx, show_jump_to_related); - self.view.view(cx, ids!(divider_before_report_delete)).set_visible(cx, show_divider_before_report_delete); + self.view + .view(cx, ids!(divider_before_report_delete)) + .set_visible(cx, show_divider_before_report_delete); // report_button.set_visible(cx, show_report); delete_button.set_visible(cx, show_delete); @@ -560,13 +583,15 @@ impl NewMessageContextMenu { delete_button.reset_hover(cx); // Reset reaction input view stuff. - self.view.view(cx, ids!(reaction_input_view)).set_visible(cx, false); // hide until the react_button is clicked - self.text_input(cx, ids!(reaction_input_view.reaction_text_input)).set_text(cx, ""); + self.view + .view(cx, ids!(reaction_input_view)) + .set_visible(cx, false); // hide until the react_button is clicked + self.text_input(cx, ids!(reaction_input_view.reaction_text_input)) + .set_text(cx, ""); self.redraw(cx); - let num_visible_buttons = - show_react as u8 + let num_visible_buttons = show_react as u8 + show_reply_to as u8 + show_edit as u8 + show_pin as u8 @@ -583,7 +608,7 @@ impl NewMessageContextMenu { + if show_divider_after_react_reply { 10.0 } else { 0.0 } + if show_divider_before_report_delete { 10.0 } else { 0.0 } + 20.0 // top and bottom padding - + 1.0 // top and bottom border + + 1.0 // top and bottom border } fn close(&mut self, cx: &mut Cx) { @@ -597,13 +622,17 @@ impl NewMessageContextMenu { impl NewMessageContextMenuRef { /// See [`NewMessageContextMenu::is_currently_shown()`]. pub fn is_currently_shown(&self, cx: &mut Cx) -> bool { - let Some(inner) = self.borrow() else { return false }; + let Some(inner) = self.borrow() else { + return false; + }; inner.is_currently_shown(cx) } /// See [`NewMessageContextMenu::show()`]. pub fn show(&self, cx: &mut Cx, details: MessageDetails) -> DVec2 { - let Some(mut inner) = self.borrow_mut() else { return DVec2::default()}; + let Some(mut inner) = self.borrow_mut() else { + return DVec2::default(); + }; inner.show(cx, details) } } diff --git a/src/home/room_context_menu.rs b/src/home/room_context_menu.rs index c55b7fa54..4020ca502 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -3,7 +3,12 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; -use crate::{home::invite_modal::InviteModalAction, shared::popup_list::{PopupKind, enqueue_popup_notification}, sliding_sync::{MatrixRequest, submit_async_request}, utils::RoomNameId}; +use crate::{ + home::invite_modal::InviteModalAction, + shared::popup_list::{PopupKind, enqueue_popup_notification}, + sliding_sync::{MatrixRequest, submit_async_request}, + utils::RoomNameId, +}; const BUTTON_HEIGHT: f64 = 35.0; const MENU_WIDTH: f64 = 215.0; @@ -69,7 +74,7 @@ script_mod! { } priority_button := mod.widgets.RoomContextMenuButton { - draw_icon +: { svg: (ICON_TOMBSTONE) } + draw_icon +: { svg: (ICON_TOMBSTONE) } text: "Set Low Priority" } @@ -77,7 +82,7 @@ script_mod! { draw_icon +: { svg: (ICON_LINK) } text: "Copy Link to Room" } - + divider1 := LineH { margin: Inset{top: 3, bottom: 3} width: Fill, @@ -137,9 +142,12 @@ pub enum RoomContextMenuAction { #[derive(Script, ScriptHook, Widget)] pub struct RoomContextMenu { - #[deref] view: View, - #[source] source: ScriptObjectRef, - #[rust] details: Option, + #[deref] + view: View, + #[source] + source: ScriptObjectRef, + #[rust] + details: Option, } impl Widget for RoomContextMenu { @@ -151,21 +159,25 @@ impl Widget for RoomContextMenu { } fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - if !self.visible { return; } + if !self.visible { + return; + } self.view.handle_event(cx, event, scope); // Close logic similar to NewMessageContextMenu let area = self.view.area(); let close_menu = { event.back_pressed() - || match event.hits_with_capture_overload(cx, area, true) { - Hit::KeyUp(key) => key.key_code == KeyCode::Escape, - Hit::FingerUp(fue) if fue.is_over => { - !self.view(cx, ids!(main_content)).area().rect(cx).contains(fue.abs) + || match event.hits_with_capture_overload(cx, area, true) { + Hit::KeyUp(key) => key.key_code == KeyCode::Escape, + Hit::FingerUp(fue) if fue.is_over => !self + .view(cx, ids!(main_content)) + .area() + .rect(cx) + .contains(fue.abs), + Hit::FingerScroll(_) => true, + _ => false, } - Hit::FingerScroll(_) => true, - _ => false, - } }; if close_menu { @@ -179,31 +191,30 @@ impl Widget for RoomContextMenu { impl WidgetMatchEvent for RoomContextMenu { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { - let Some(details) = self.details.as_ref() else { return }; + let Some(details) = self.details.as_ref() else { + return; + }; let mut close_menu = false; - + if self.button(cx, ids!(mark_unread_button)).clicked(actions) { submit_async_request(MatrixRequest::SetUnreadFlag { room_id: details.room_name_id.room_id().clone(), mark_as_unread: !details.is_marked_unread, }); close_menu = true; - } - else if self.button(cx, ids!(favorite_button)).clicked(actions) { + } else if self.button(cx, ids!(favorite_button)).clicked(actions) { submit_async_request(MatrixRequest::SetIsFavorite { room_id: details.room_name_id.room_id().clone(), is_favorite: !details.is_favorite, }); close_menu = true; - } - else if self.button(cx, ids!(priority_button)).clicked(actions) { + } else if self.button(cx, ids!(priority_button)).clicked(actions) { submit_async_request(MatrixRequest::SetIsLowPriority { room_id: details.room_name_id.room_id().clone(), is_low_priority: !details.is_low_priority, }); close_menu = true; - } - else if self.button(cx, ids!(copy_link_button)).clicked(actions) { + } else if self.button(cx, ids!(copy_link_button)).clicked(actions) { submit_async_request(MatrixRequest::GenerateMatrixLink { room_id: details.room_name_id.room_id().clone(), event_id: None, @@ -211,8 +222,7 @@ impl WidgetMatchEvent for RoomContextMenu { join_on_click: false, }); close_menu = true; - } - else if self.button(cx, ids!(room_settings_button)).clicked(actions) { + } else if self.button(cx, ids!(room_settings_button)).clicked(actions) { // TODO: handle/implement this enqueue_popup_notification( "The room settings page is not yet implemented.", @@ -220,8 +230,7 @@ impl WidgetMatchEvent for RoomContextMenu { Some(5.0), ); close_menu = true; - } - else if self.button(cx, ids!(notifications_button)).clicked(actions) { + } else if self.button(cx, ids!(notifications_button)).clicked(actions) { // TODO: handle/implement this enqueue_popup_notification( "The room notifications page is not yet implemented.", @@ -229,12 +238,10 @@ impl WidgetMatchEvent for RoomContextMenu { Some(5.0), ); close_menu = true; - } - else if self.button(cx, ids!(invite_button)).clicked(actions) { + } else if self.button(cx, ids!(invite_button)).clicked(actions) { cx.action(InviteModalAction::Open(details.room_name_id.clone())); close_menu = true; - } - else if self.button(cx, ids!(leave_button)).clicked(actions) { + } else if self.button(cx, ids!(leave_button)).clicked(actions) { use crate::join_leave_room_modal::{JoinLeaveRoomModalAction, JoinLeaveModalKind}; use crate::room::BasicRoomDetails; let room_details = BasicRoomDetails::Name(details.room_name_id.clone()); @@ -263,7 +270,7 @@ impl RoomContextMenu { cx.set_key_focus(self.view.area()); dvec2(MENU_WIDTH, height) } - + fn update_buttons(&mut self, cx: &mut Cx, details: &RoomContextMenuDetails) -> f64 { let mark_unread_button = self.button(cx, ids!(mark_unread_button)); if details.is_marked_unread { @@ -271,12 +278,12 @@ impl RoomContextMenu { } else { mark_unread_button.set_text(cx, "Mark as Unread"); } - + let favorite_button = self.button(cx, ids!(favorite_button)); if details.is_favorite { favorite_button.set_text(cx, "Un-favorite"); } else { - favorite_button.set_text(cx, "Favorite"); + favorite_button.set_text(cx, "Favorite"); } let priority_button = self.button(cx, ids!(priority_button)); @@ -285,7 +292,7 @@ impl RoomContextMenu { } else { priority_button.set_text(cx, "Set Low Priority"); } - + // Reset hover states mark_unread_button.reset_hover(cx); favorite_button.reset_hover(cx); @@ -295,9 +302,9 @@ impl RoomContextMenu { self.button(cx, ids!(notifications_button)).reset_hover(cx); self.button(cx, ids!(invite_button)).reset_hover(cx); self.button(cx, ids!(leave_button)).reset_hover(cx); - + self.redraw(cx); - + // Calculate height (rudimentary) - sum of visible buttons + padding // 8 buttons * 35.0 + 2 dividers * ~10.0 + padding (8.0 * BUTTON_HEIGHT) + 20.0 + 10.0 // approx @@ -313,12 +320,16 @@ impl RoomContextMenu { impl RoomContextMenuRef { pub fn is_currently_shown(&self, cx: &mut Cx) -> bool { - let Some(inner) = self.borrow() else { return false }; + let Some(inner) = self.borrow() else { + return false; + }; inner.is_currently_shown(cx) } pub fn show(&self, cx: &mut Cx, details: RoomContextMenuDetails) -> DVec2 { - let Some(mut inner) = self.borrow_mut() else { return DVec2::default()}; + let Some(mut inner) = self.borrow_mut() else { + return DVec2::default(); + }; inner.show(cx, details) } } diff --git a/src/home/room_image_viewer.rs b/src/home/room_image_viewer.rs index 9bc11b6c4..9199d63e7 100644 --- a/src/home/room_image_viewer.rs +++ b/src/home/room_image_viewer.rs @@ -6,7 +6,10 @@ use matrix_sdk::{ }; use reqwest::StatusCode; -use crate::{media_cache::{MediaCache, MediaCacheEntry}, shared::image_viewer::{ImageViewerAction, ImageViewerError, LoadState}}; +use crate::{ + media_cache::{MediaCache, MediaCacheEntry}, + shared::image_viewer::{ImageViewerAction, ImageViewerError, LoadState}, +}; /// Populates the image viewer modal with the given media content. /// diff --git a/src/home/room_read_receipt.rs b/src/home/room_read_receipt.rs index d2bad9726..b85841b41 100644 --- a/src/home/room_read_receipt.rs +++ b/src/home/room_read_receipt.rs @@ -11,7 +11,6 @@ use matrix_sdk_ui::timeline::EventTimelineItem; use std::cmp; - /// The maximum number of items to display in the read receipts AvatarRow /// and its accompanying tooltip. pub const MAX_VISIBLE_AVATARS_IN_READ_RECEIPT: usize = 3; @@ -96,11 +95,10 @@ impl Widget for AvatarRow { let widget_rect = self.area.rect(cx); let should_hover_in = match event.hits(cx, self.area) { - Hit::FingerLongPress(_) - | Hit::FingerHoverIn(..) => true, + Hit::FingerLongPress(_) | Hit::FingerHoverIn(..) => true, Hit::FingerUp(fue) if fue.is_over && fue.is_primary_hit() => true, Hit::FingerHoverOut(_) => { - cx.widget_action(uid, RoomScreenTooltipActions::HoverOut); + cx.widget_action(uid, RoomScreenTooltipActions::HoverOut); false } _ => false, @@ -108,7 +106,7 @@ impl Widget for AvatarRow { if should_hover_in { if let Some(read_receipts) = &self.read_receipts { cx.widget_action( - uid, + uid, RoomScreenTooltipActions::HoverInReadReceipt { widget_rect, read_receipts: read_receipts.clone(), diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 61f20ced9..b4be33658 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1,40 +1,103 @@ //! The `RoomScreen` widget is the UI view that displays a single room or thread's timeline //! of events (messages,state changes, etc.), along with an input bar at the bottom. -use std::{borrow::Cow, cell::RefCell, ops::{DerefMut, Range}, sync::Arc}; +use std::{ + borrow::Cow, + cell::RefCell, + ops::{DerefMut, Range}, + sync::Arc, +}; use bytesize::ByteSize; use hashbrown::{HashMap, HashSet}; use imbl::Vector; use makepad_widgets::{image_cache::ImageBuffer, *}; use matrix_sdk::{ - OwnedServerName, RoomDisplayName, media::{MediaFormat, MediaRequestParameters}, room::RoomMember, ruma::{ - EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, events::{ + OwnedServerName, RoomDisplayName, + media::{MediaFormat, MediaRequestParameters}, + room::RoomMember, + ruma::{ + EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, + events::{ receipt::Receipt, room::{ - ImageInfo, MediaSource, message::{ - AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent - } + ImageInfo, MediaSource, + message::{ + AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, + FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, + LocationMessageEventContent, MessageFormat, MessageType, + NoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent, + }, }, sticker::{StickerEventContent, StickerMediaSource}, - }, matrix_uri::MatrixId, uint - } + }, + matrix_uri::MatrixId, + uint, + }, }; use matrix_sdk_ui::timeline::{ - self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, MemberProfileChange, MembershipChange, MsgLikeContent, MsgLikeKind, OtherMessageLike, PollState, RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem + self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, + MemberProfileChange, MembershipChange, MsgLikeContent, MsgLikeKind, OtherMessageLike, + PollState, RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, + TimelineItemContent, TimelineItemKind, VirtualTimelineItem, +}; +use ruma::{ + OwnedUserId, + api::client::receipt::create_receipt::v3::ReceiptType, + events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent}, + owned_room_id, }; -use ruma::{OwnedUserId, api::client::receipt::create_receipt::v3::ReceiptType, events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent}, owned_room_id}; use crate::{ - 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}, + 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, }, - room::{BasicRoomDetails, room_input_bar::{RoomInputBarState, RoomInputBarWidgetRefExt}, typing_notice::TypingNoticeWidgetExt}, + room::{ + BasicRoomDetails, + room_input_bar::{RoomInputBarState, RoomInputBarWidgetRefExt}, + typing_notice::TypingNoticeWidgetExt, + }, shared::{ - avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::ConfirmationModalContent, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt + avatar::{AvatarState, AvatarWidgetRefExt}, + confirmation_modal::ConfirmationModalContent, + html_or_plaintext::{ + HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction, + }, + image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, + jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, + popup_list::{PopupKind, enqueue_popup_notification}, + restore_status_view::RestoreStatusViewWidgetExt, + styles::*, + text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, + timestamp::TimestampWidgetRefExt, + }, + sliding_sync::{ + BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, + TimelineKind, TimelineRequestSender, UserPowerLevels, get_client, submit_async_request, + take_timeline_endpoints, }, - sliding_sync::{BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, get_client, submit_async_request, take_timeline_endpoints}, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime} + utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime}, }; use crate::home::event_reaction_list::ReactionListWidgetRefExt; use crate::home::room_read_receipt::AvatarRowWidgetRefExt; @@ -43,7 +106,12 @@ use crate::shared::mentionable_text_input::MentionableTextInputAction; use rangemap::RangeSet; -use super::{event_reaction_list::ReactionData, loading_pane::LoadingPaneRef, new_message_context_menu::{MessageAbilities, MessageDetails}, room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}}; +use super::{ + event_reaction_list::ReactionData, + loading_pane::LoadingPaneRef, + new_message_context_menu::{MessageAbilities, MessageDetails}, + room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}, +}; /// The maximum number of timeline items to search through /// when looking for a particular event. @@ -62,7 +130,6 @@ const COLOR_THREAD_SUMMARY_BG: Vec4 = vec4(1.0, 0.957, 0.898, 1.0); /// #FFEACC const COLOR_THREAD_SUMMARY_BG_HOVER: Vec4 = vec4(1.0, 0.918, 0.8, 1.0); - script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -608,20 +675,27 @@ script_mod! { /// The main widget that displays a single Matrix room. #[derive(Script, Widget)] pub struct RoomScreen { - #[deref] view: View, + #[deref] + view: View, /// The name and ID of the currently-shown room, if any. - #[rust] room_name_id: Option, + #[rust] + room_name_id: Option, /// The timeline currently displayed by this RoomScreen, if any. - #[rust] timeline_kind: Option, + #[rust] + timeline_kind: Option, /// The persistent UI-relevant states for the room that this widget is currently displaying. - #[rust] tl_state: Option, + #[rust] + tl_state: Option, /// The set of pinned events in this room. - #[rust] pinned_events: Vec, + #[rust] + pinned_events: Vec, /// Whether this room has been successfully loaded (received from the homeserver). - #[rust] is_loaded: bool, + #[rust] + is_loaded: bool, /// Whether or not all rooms have been loaded (received from the homeserver). - #[rust] all_rooms_loaded: bool, + #[rust] + all_rooms_loaded: bool, } impl Drop for RoomScreen { @@ -653,7 +727,8 @@ impl Widget for RoomScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { let room_screen_widget_uid = self.widget_uid(); let portal_list = self.portal_list(cx, ids!(timeline.list)); - let user_profile_sliding_pane = self.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)); + let user_profile_sliding_pane = + self.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)); let loading_pane = self.loading_pane(cx, ids!(loading_pane)); // Handle actions here before processing timeline updates. @@ -668,9 +743,13 @@ impl Widget for RoomScreen { if let RoomScreenTooltipActions::HoverInReactionButton { widget_rect, reaction_data, - } = reaction_list.hovered_in(actions) { - let Some(_tl_state) = self.tl_state.as_ref() else { continue }; - let tooltip_text_arr: Vec = reaction_data.reaction_senders + } = reaction_list.hovered_in(actions) + { + let Some(_tl_state) = self.tl_state.as_ref() else { + continue; + }; + let tooltip_text_arr: Vec = reaction_data + .reaction_senders .iter() .map(|(sender, _react_info)| { user_profile_cache::get_user_display_name_for_room( @@ -684,10 +763,13 @@ impl Widget for RoomScreen { }) .collect(); - let mut tooltip_text = utils::human_readable_list(&tooltip_text_arr, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT); + let mut tooltip_text = utils::human_readable_list( + &tooltip_text_arr, + MAX_VISIBLE_AVATARS_IN_READ_RECEIPT, + ); tooltip_text.push_str(&format!(" reacted with: {}", reaction_data.reaction)); cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, TooltipAction::HoverIn { text: tooltip_text, widget_rect, @@ -701,24 +783,23 @@ impl Widget for RoomScreen { // Handle a hover-out action on the reaction list or avatar row. let avatar_row_ref = wr.avatar_row(cx, ids!(avatar_row)); - if reaction_list.hovered_out(actions) - || avatar_row_ref.hover_out(actions) - { - cx.widget_action( - room_screen_widget_uid, - TooltipAction::HoverOut, - ); + if reaction_list.hovered_out(actions) || avatar_row_ref.hover_out(actions) { + cx.widget_action(room_screen_widget_uid, TooltipAction::HoverOut); } // Handle a hover-in action on the avatar row: show a read receipts summary. if let RoomScreenTooltipActions::HoverInReadReceipt { widget_rect, - read_receipts - } = avatar_row_ref.hover_in(actions) { - let Some(room_id) = self.room_id() else { return; }; - let tooltip_text= room_read_receipt::populate_tooltip(cx, read_receipts, room_id); + read_receipts, + } = avatar_row_ref.hover_in(actions) + { + let Some(room_id) = self.room_id() else { + return; + }; + let tooltip_text = + room_read_receipt::populate_tooltip(cx, read_receipts, room_id); cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, TooltipAction::HoverIn { text: tooltip_text, widget_rect, @@ -732,23 +813,27 @@ impl Widget for RoomScreen { // Handle an image within the message being clicked. let content_message = wr.text_or_image(cx, ids!(content.message)); - if let TextOrImageAction::Clicked(mxc_uri) = actions.find_widget_action(content_message.widget_uid()).cast() { + if let TextOrImageAction::Clicked(mxc_uri) = actions + .find_widget_action(content_message.widget_uid()) + .cast() + { let texture = content_message.get_texture(cx); - self.handle_image_click( - cx, - mxc_uri, - texture, - index, - ); + self.handle_image_click(cx, mxc_uri, texture, index); continue; } // Handle the invite_user_button (in a SmallStateEvent) being clicked. if wr.button(cx, ids!(invite_user_button)).clicked(actions) { - let Some(tl) = self.tl_state.as_ref() else { continue }; - if let Some(event_tl_item) = tl.items.get(index).and_then(|item| item.as_event()) { + let Some(tl) = self.tl_state.as_ref() else { + continue; + }; + if let Some(event_tl_item) = + tl.items.get(index).and_then(|item| item.as_event()) + { let user_id = event_tl_item.sender().to_owned(); - let username = if let TimelineDetails::Ready(profile) = event_tl_item.sender_profile() { + let username = if let TimelineDetails::Ready(profile) = + event_tl_item.sender_profile() + { profile.display_name.as_deref().unwrap_or(user_id.as_str()) } else { user_id.as_str() @@ -756,14 +841,22 @@ impl Widget for RoomScreen { let room_id = tl.kind.room_id().clone(); let content = ConfirmationModalContent { title_text: "Send Invitation".into(), - body_text: format!("Are you sure you want to invite {username} to this room?").into(), + body_text: format!( + "Are you sure you want to invite {username} to this room?" + ) + .into(), accept_button_text: Some("Invite".into()), on_accept_clicked: Some(Box::new(move |_cx| { - submit_async_request(MatrixRequest::InviteUser { room_id, user_id }); + submit_async_request(MatrixRequest::InviteUser { + room_id, + user_id, + }); })), ..Default::default() }; - cx.action(InviteAction::ShowInviteConfirmationModal(RefCell::new(Some(content)))); + cx.action(InviteAction::ShowInviteConfirmationModal(RefCell::new( + Some(content), + ))); } } } @@ -772,11 +865,19 @@ impl Widget for RoomScreen { for action in actions { // Handle actions related to restoring the previously-saved state of rooms. - if let Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, ..}) = action.downcast_ref() { - if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_name_id.room_id()) { + if let Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) = + action.downcast_ref() + { + if self + .room_name_id + .as_ref() + .is_some_and(|rn| rn.room_id() == room_name_id.room_id()) + { // `set_displayed_room()` does nothing if the room_name_id is unchanged, so we clear it first. self.room_name_id = None; - let thread_root_event_id = self.timeline_kind.as_ref() + let thread_root_event_id = self + .timeline_kind + .as_ref() .and_then(|k| k.thread_root_event_id().cloned()); self.set_displayed_room(cx, room_name_id, thread_root_event_id); return; @@ -786,7 +887,11 @@ impl Widget for RoomScreen { // Handle InviteResultAction to show popup notifications. if let Some(InviteResultAction::Sent { room_id, .. }) = action.downcast_ref() { // Only handle if this is for the current room. - if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_id) { + if self + .room_name_id + .as_ref() + .is_some_and(|rn| rn.room_id() == room_id) + { enqueue_popup_notification( "Sent invite successfully.", PopupKind::Success, @@ -794,9 +899,15 @@ impl Widget for RoomScreen { ); } } - if let Some(InviteResultAction::Failed { room_id, error, .. }) = action.downcast_ref() { + if let Some(InviteResultAction::Failed { room_id, error, .. }) = + action.downcast_ref() + { // Only handle if this is for the current room. - if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_id) { + if self + .room_name_id + .as_ref() + .is_some_and(|rn| rn.room_id() == room_id) + { enqueue_popup_notification( format!("Failed to send invite.\n\nError: {error}"), PopupKind::Error, @@ -806,11 +917,15 @@ impl Widget for RoomScreen { } // Handle the highlight animation for a message. - let Some(tl) = self.tl_state.as_mut() else { continue }; - if let MessageHighlightAnimationState::Pending { item_id } = tl.message_highlight_animation_state { + let Some(tl) = self.tl_state.as_mut() else { + continue; + }; + if let MessageHighlightAnimationState::Pending { item_id } = + tl.message_highlight_animation_state + { if portal_list.smooth_scroll_reached(actions) { cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, MessageAction::HighlightMessage(item_id), ); tl.message_highlight_animation_state = MessageHighlightAnimationState::Off; @@ -834,22 +949,25 @@ impl Widget for RoomScreen { self.send_user_read_receipts_based_on_scroll_pos(cx, actions, &portal_list); // Handle the jump to bottom button: update its visibility, and handle clicks. - self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)).update_from_actions( - cx, - &portal_list, - actions, - ); + self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)) + .update_from_actions(cx, &portal_list, actions); } // Currently, a Signal event is only used to tell this widget: // 1. to check if the room has been loaded from the homeserver yet, or // 2. that its timeline events have been updated in the background. if let Event::Signal = event { - if let (false, Some(room_name_id), true) = (self.is_loaded, self.room_name_id.as_ref(), cx.has_global::()) { + if let (false, Some(room_name_id), true) = ( + self.is_loaded, + self.room_name_id.as_ref(), + cx.has_global::(), + ) { let rooms_list_ref = cx.get_global::(); if rooms_list_ref.is_room_loaded(room_name_id.room_id()) { let room_name_clone = room_name_id.clone(); - let thread_root_event_id = self.timeline_kind.as_ref() + let thread_root_event_id = self + .timeline_kind + .as_ref() .and_then(|k| k.thread_root_event_id().cloned()); // This room has been loaded now, so we call `set_displayed_room()`. // We first clear the `room_name_id`, otherwise that function will do nothing. @@ -891,14 +1009,12 @@ impl Widget for RoomScreen { if is_interactive_hit { loading_pane.handle_event(cx, event, scope); } - } - else if user_profile_sliding_pane.is_currently_shown(cx) { + } else if user_profile_sliding_pane.is_currently_shown(cx) { is_pane_shown = true; if is_interactive_hit { user_profile_sliding_pane.handle_event(cx, event, scope); } - } - else { + } else { is_pane_shown = false; } @@ -917,10 +1033,12 @@ impl Widget for RoomScreen { // Fetch room data once to avoid duplicate expensive lookups let (room_display_name, room_avatar_url) = get_client() .and_then(|client| client.get_room(&room_id)) - .map(|room| ( - room.cached_display_name().unwrap_or(RoomDisplayName::Empty), - room.avatar_url() - )) + .map(|room| { + ( + room.cached_display_name().unwrap_or(RoomDisplayName::Empty), + room.avatar_url(), + ) + }) .unwrap_or((RoomDisplayName::Empty, None)); RoomScreenProps { @@ -935,7 +1053,9 @@ impl Widget for RoomScreen { RoomScreenProps { room_screen_widget_uid, room_name_id: room_name.clone(), - timeline_kind: self.timeline_kind.clone() + timeline_kind: self + .timeline_kind + .clone() .expect("BUG: room_name_id was set but timeline_kind was missing"), room_members: None, room_avatar_url: None, @@ -945,7 +1065,9 @@ impl Widget for RoomScreen { if !is_pane_shown || !is_interactive_hit { return; } - log!("RoomScreen handling event with no room_name_id and no tl_state, skipping room-dependent event handling"); + log!( + "RoomScreen handling event with no room_name_id and no tl_state, skipping room-dependent event handling" + ); // Use a dummy room props for non-room-specific events let room_id = owned_room_id!("!dummy:matrix.org"); RoomScreenProps { @@ -958,13 +1080,11 @@ impl Widget for RoomScreen { }; let mut room_scope = Scope::with_props(&room_props); - // Forward the event to the inner timeline view, but capture any actions it produces // such that we can handle the ones relevant to only THIS RoomScreen widget right here and now, // ensuring they are not mistakenly handled by other RoomScreen widget instances. - let mut actions_generated_within_this_room_screen = cx.capture_actions(|cx| - self.view.handle_event(cx, event, &mut room_scope) - ); + let mut actions_generated_within_this_room_screen = + cx.capture_actions(|cx| self.view.handle_event(cx, event, &mut room_scope)); // Here, we handle and remove any general actions that are relevant to only this RoomScreen. // Removing the handled actions ensures they are not mistakenly handled by other RoomScreen widget instances. actions_generated_within_this_room_screen.retain(|action| { @@ -973,16 +1093,18 @@ impl Widget for RoomScreen { } // Handle the action that requests to show the user profile sliding pane. - if let ShowUserProfileAction::ShowUserProfile(profile_and_room_id) = action.as_widget_action().cast() { + if let ShowUserProfileAction::ShowUserProfile(profile_and_room_id) = + action.as_widget_action().cast() + { self.show_user_profile( cx, &user_profile_sliding_pane, UserProfilePaneInfo { profile_and_room_id, - room_name: self.room_name_id.as_ref().map_or_else( - || UNNAMED_ROOM.to_string(), - |r| r.to_string(), - ), + room_name: self + .room_name_id + .as_ref() + .map_or_else(|| UNNAMED_ROOM.to_string(), |r| r.to_string()), room_member: None, }, ); @@ -1033,7 +1155,6 @@ impl Widget for RoomScreen { } } - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // If the room isn't loaded yet, we show the restore status label only. if !self.is_loaded { @@ -1041,7 +1162,8 @@ impl Widget for RoomScreen { // No room selected yet, nothing to show. return DrawStep::done(); }; - let mut restore_status_view = self.view.restore_status_view(cx, ids!(restore_status_view)); + let mut restore_status_view = + self.view.restore_status_view(cx, ids!(restore_status_view)); restore_status_view.set_content(cx, self.all_rooms_loaded, room_name); return restore_status_view.draw(cx, scope); } @@ -1051,13 +1173,14 @@ impl Widget for RoomScreen { return DrawStep::done(); } - let room_screen_widget_uid = self.widget_uid(); while let Some(subview) = self.view.draw_walk(cx, scope, walk).step() { // Here, we only need to handle drawing the portal list. let portal_list_ref = subview.as_portal_list(); let Some(mut list_ref) = portal_list_ref.borrow_mut() else { - error!("!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else"); + error!( + "!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else" + ); continue; }; let Some(tl_state) = self.tl_state.as_mut() else { @@ -1095,13 +1218,17 @@ impl Widget for RoomScreen { && msg_like_content.thread_root.is_some() { // Hide threaded replies from the main room timeline UI. - (list.item(cx, item_id, id!(Empty)), ItemDrawnStatus::both_drawn()) + ( + list.item(cx, item_id, id!(Empty)), + ItemDrawnStatus::both_drawn(), + ) } else { match &msg_like_content.kind { MsgLikeKind::Message(_) | MsgLikeKind::Sticker(_) | MsgLikeKind::Redacted => { - let prev_event = tl_idx.checked_sub(1).and_then(|i| tl_items.get(i)); + let prev_event = + tl_idx.checked_sub(1).and_then(|i| tl_items.get(i)); populate_message_view( cx, list, @@ -1119,26 +1246,30 @@ impl Widget for RoomScreen { item_drawn_status, room_screen_widget_uid, ) - }, + } // TODO: properly implement `Poll` as a regular Message-like timeline item. - MsgLikeKind::Poll(poll_state) => populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - poll_state, - item_drawn_status, - ), - MsgLikeKind::UnableToDecrypt(utd) => populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - utd, - item_drawn_status, - ), + MsgLikeKind::Poll(poll_state) => { + populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + poll_state, + item_drawn_status, + ) + } + MsgLikeKind::UnableToDecrypt(utd) => { + populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + utd, + item_drawn_status, + ) + } MsgLikeKind::Other(other) => populate_small_state_event( cx, list, @@ -1150,25 +1281,29 @@ impl Widget for RoomScreen { ), } } - }, - TimelineItemContent::MembershipChange(membership_change) => populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - membership_change, - item_drawn_status, - ), - TimelineItemContent::ProfileChange(profile_change) => populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - profile_change, - item_drawn_status, - ), + } + TimelineItemContent::MembershipChange(membership_change) => { + populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + membership_change, + item_drawn_status, + ) + } + TimelineItemContent::ProfileChange(profile_change) => { + populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + profile_change, + item_drawn_status, + ) + } TimelineItemContent::OtherState(other) => populate_small_state_event( cx, list, @@ -1180,10 +1315,11 @@ impl Widget for RoomScreen { ), unhandled => { let item = list.item(cx, item_id, id!(SmallStateEvent)); - item.label(cx, ids!(content)).set_text(cx, &format!("[Unsupported] {:?}", unhandled)); + item.label(cx, ids!(content)) + .set_text(cx, &format!("[Unsupported] {:?}", unhandled)); (item, ItemDrawnStatus::both_drawn()) } - } + }, TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(millis)) => { let item = list.item(cx, item_id, id!(DateDivider)); let text = unix_time_millis_to_datetime(*millis) @@ -1205,10 +1341,14 @@ impl Widget for RoomScreen { // Now that we've drawn the item, add its index to the set of drawn items. if item_new_draw_status.content_drawn { - tl_state.content_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); + tl_state + .content_drawn_since_last_update + .insert(tl_idx..tl_idx + 1); } if item_new_draw_status.profile_drawn { - tl_state.profile_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); + tl_state + .profile_drawn_since_last_update + .insert(tl_idx..tl_idx + 1); } item }; @@ -1218,7 +1358,10 @@ impl Widget for RoomScreen { // If the list is not filling the viewport, we need to back paginate the timeline // until we have enough events items to fill the viewport. if !tl_state.fully_paginated && !list.is_filling_viewport() { - log!("Automatically paginating timeline to fill viewport for room {:?}", self.room_name_id); + log!( + "Automatically paginating timeline to fill viewport for room {:?}", + self.room_name_id + ); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl_state.kind.clone(), num_events: 50, @@ -1243,7 +1386,9 @@ impl RoomScreen { let jump_to_bottom_button = self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)); let curr_first_id = portal_list.first_id(); let ui = self.widget_uid(); - let Some(tl) = self.tl_state.as_mut() else { return }; + let Some(tl) = self.tl_state.as_mut() else { + return; + }; let mut done_loading = false; let mut should_continue_backwards_pagination = false; @@ -1264,10 +1409,19 @@ impl RoomScreen { tl.items = initial_items; done_loading = true; } - TimelineUpdate::NewItems { new_items, changed_indices, is_append, clear_cache } => { + TimelineUpdate::NewItems { + new_items, + changed_indices, + is_append, + clear_cache, + } => { if new_items.is_empty() { if !tl.items.is_empty() { - log!("process_timeline_updates(): timeline (had {} items) was cleared for room {}", tl.items.len(), tl.kind.room_id()); + log!( + "process_timeline_updates(): timeline (had {} items) was cleared for room {}", + tl.items.len(), + tl.kind.room_id() + ); // For now, we paginate a cleared timeline in order to be able to show something at least. // A proper solution would be what's described below, which would be to save a few event IDs // and then either focus on them (if we're not close to the end of the timeline) @@ -1301,9 +1455,12 @@ impl RoomScreen { if new_items.len() == tl.items.len() { // log!("process_timeline_updates(): no jump necessary for updated timeline of same length: {}", items.len()); - } - else if curr_first_id > new_items.len() { - log!("process_timeline_updates(): jumping to bottom: curr_first_id {} is out of bounds for {} new items", curr_first_id, new_items.len()); + } else if curr_first_id > new_items.len() { + log!( + "process_timeline_updates(): jumping to bottom: curr_first_id {} is out of bounds for {} new items", + curr_first_id, + new_items.len() + ); portal_list.set_first_id_and_scroll(new_items.len().saturating_sub(1), 0.0); portal_list.set_tail_range(true); jump_to_bottom_button.update_visibility(cx, true); @@ -1312,19 +1469,28 @@ impl RoomScreen { // in the timeline viewport so that we can maintain the scroll position of that item, // which ensures that the timeline doesn't jump around unexpectedly and ruin the user's experience. else if let Some((curr_item_idx, new_item_idx, new_item_scroll, _event_id)) = - prior_items_changed.then(|| - find_new_item_matching_current_item(cx, portal_list, curr_first_id, &tl.items, &new_items) - ) - .flatten() + prior_items_changed + .then(|| { + find_new_item_matching_current_item( + cx, + portal_list, + curr_first_id, + &tl.items, + &new_items, + ) + }) + .flatten() { if curr_item_idx != new_item_idx { - log!("process_timeline_updates(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}"); + log!( + "process_timeline_updates(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}" + ); portal_list.set_first_id_and_scroll(new_item_idx, new_item_scroll); tl.prev_first_index = Some(new_item_idx); // Set scrolled_past_read_marker false when we jump to a new event tl.scrolled_past_read_marker = false; // Hide the tooltip when the timeline jumps, as a hover-out event won't occur. - cx.widget_action(ui, RoomScreenTooltipActions::HoverOut); + cx.widget_action(ui, RoomScreenTooltipActions::HoverOut); } } // @@ -1340,8 +1506,9 @@ impl RoomScreen { // because the matrix SDK doesn't currently support querying unread message counts for threads. if matches!(tl.kind, TimelineKind::MainRoom { .. }) { // Immediately show the unread badge with no count while we fetch the actual count in the background. - jump_to_bottom_button.show_unread_message_badge(cx, UnreadMessageCount::Unknown); - submit_async_request(MatrixRequest::GetNumberUnreadMessages{ + jump_to_bottom_button + .show_unread_message_badge(cx, UnreadMessageCount::Unknown); + submit_async_request(MatrixRequest::GetNumberUnreadMessages { timeline_kind: tl.kind.clone(), }); } @@ -1355,10 +1522,15 @@ impl RoomScreen { let loading_pane = self.view.loading_pane(cx, ids!(loading_pane)); let mut loading_pane_state = loading_pane.take_state(); if let LoadingPaneState::BackwardsPaginateUntilEvent { - events_paginated, target_event_id, .. - } = &mut loading_pane_state { + events_paginated, + target_event_id, + .. + } = &mut loading_pane_state + { *events_paginated += new_items.len().saturating_sub(tl.items.len()); - log!("While finding target event {target_event_id}, we have now loaded {events_paginated} messages..."); + log!( + "While finding target event {target_event_id}, we have now loaded {events_paginated} messages..." + ); // Here, we assume that we have not yet found the target event, // so we need to continue paginating backwards. // If the target event has already been found, it will be handled @@ -1375,8 +1547,10 @@ impl RoomScreen { tl.profile_drawn_since_last_update.clear(); tl.fully_paginated = false; } else { - tl.content_drawn_since_last_update.remove(changed_indices.clone()); - tl.profile_drawn_since_last_update.remove(changed_indices.clone()); + tl.content_drawn_since_last_update + .remove(changed_indices.clone()); + tl.profile_drawn_since_last_update + .remove(changed_indices.clone()); // log!("process_timeline_updates(): changed_indices: {changed_indices:?}, items len: {}\ncontent drawn: {:#?}\nprofile drawn: {:#?}", items.len(), tl.content_drawn_since_last_update, tl.profile_drawn_since_last_update); } tl.items = new_items; @@ -1389,7 +1563,10 @@ impl RoomScreen { jump_to_bottom_button.show_unread_message_badge(cx, unread_messages_count); } } - TimelineUpdate::TargetEventFound { target_event_id, index } => { + TimelineUpdate::TargetEventFound { + target_event_id, + index, + } => { // log!("Target event found in room {}: {target_event_id}, index: {index}", tl.kind.room_id()); tl.request_sender.send_if_modified(|requests| { requests.retain(|r| &r.room_id != tl.kind.room_id()); @@ -1399,10 +1576,10 @@ impl RoomScreen { // sanity check: ensure the target event is in the timeline at the given `index`. let item = tl.items.get(index); - let is_valid = item.is_some_and(|item| + let is_valid = item.is_some_and(|item| { item.as_event() .is_some_and(|ev| ev.event_id() == Some(&target_event_id)) - ); + }); let loading_pane = self.view.loading_pane(cx, ids!(loading_pane)); // log!("TargetEventFound: is_valid? {is_valid}. room {}, event {target_event_id}, index {index} of {}\n --> item: {item:?}", tl.kind.room_id(), tl.items.len()); @@ -1421,19 +1598,24 @@ impl RoomScreen { // appear beneath the top of the viewport. portal_list.smooth_scroll_to(cx, index.saturating_sub(1), speed, None); // start highlight animation. - tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { - item_id: index - }; - } - else { + tl.message_highlight_animation_state = + MessageHighlightAnimationState::Pending { item_id: index }; + } else { // Here, the target event was not found in the current timeline, // or we found it previously but it is no longer in the timeline (or has moved), // which means we encountered an error and are unable to jump to the target event. - error!("Target event index {index} of {} is out of bounds for room {}", tl.items.len(), tl.kind.room_id()); + error!( + "Target event index {index} of {} is out of bounds for room {}", + tl.items.len(), + tl.kind.room_id() + ); // Show this error in the loading pane, which should already be open. - loading_pane.set_state(cx, LoadingPaneState::Error( - String::from("Unable to find related message; it may have been deleted.") - )); + loading_pane.set_state( + cx, + LoadingPaneState::Error(String::from( + "Unable to find related message; it may have been deleted.", + )), + ); } should_continue_backwards_pagination = false; @@ -1450,16 +1632,25 @@ impl RoomScreen { } } TimelineUpdate::PaginationError { error, direction } => { - error!("Pagination error ({direction}) in {:?}: {error:?}", self.room_name_id); + error!( + "Pagination error ({direction}) in {:?}: {error:?}", + self.room_name_id + ); let room_name = self.room_name_id.as_ref().map(|r| r.to_string()); enqueue_popup_notification( - utils::stringify_pagination_error(&error, room_name.as_deref().unwrap_or(UNNAMED_ROOM)), + utils::stringify_pagination_error( + &error, + room_name.as_deref().unwrap_or(UNNAMED_ROOM), + ), PopupKind::Error, Some(10.0), ); done_loading = true; } - TimelineUpdate::PaginationIdle { fully_paginated, direction } => { + TimelineUpdate::PaginationIdle { + fully_paginated, + direction, + } => { if direction == PaginationDirection::Backwards { // Don't set `done_loading` to `true` here, because we want to keep the top space visible // (with the "loading" message) until the corresponding `NewItems` update is received. @@ -1471,9 +1662,12 @@ impl RoomScreen { error!("Unexpected PaginationIdle update in the Forwards direction"); } } - TimelineUpdate::EventDetailsFetched {event_id, result } => { + TimelineUpdate::EventDetailsFetched { event_id, result } => { if let Err(_e) = result { - error!("Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}", tl.kind.room_id()); + error!( + "Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}", + tl.kind.room_id() + ); } // Here, to be most efficient, we could redraw only the updated event, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. @@ -1484,7 +1678,8 @@ impl RoomScreen { num_replies, latest_reply_preview_text, } => { - tl.pending_thread_summary_fetches.remove(&thread_root_event_id); + tl.pending_thread_summary_fetches + .remove(&thread_root_event_id); tl.fetched_thread_summaries.insert( thread_root_event_id.clone(), FetchedThreadSummary { @@ -1492,14 +1687,15 @@ impl RoomScreen { latest_reply_preview_text, }, ); - let event_id_matches_at_index = tl.items + let event_id_matches_at_index = tl + .items .get(timeline_item_index) .and_then(|item| item.as_event()) .and_then(|ev| ev.event_id()) .is_some_and(|id| id == thread_root_event_id); if event_id_matches_at_index { tl.content_drawn_since_last_update - .remove(timeline_item_index .. timeline_item_index + 1); + .remove(timeline_item_index..timeline_item_index + 1); } else { tl.content_drawn_since_last_update.clear(); } @@ -1512,9 +1708,12 @@ impl RoomScreen { TimelineUpdate::RoomMembersListFetched { members } => { // Store room members directly in TimelineUiState tl.room_members = Some(Arc::new(members)); - }, + } TimelineUpdate::MediaFetched(request) => { - log!("process_timeline_updates(): media fetched for room {}", tl.kind.room_id()); + log!( + "process_timeline_updates(): media fetched for room {}", + tl.kind.room_id() + ); // Set Image to image viewer modal if the media is not a thumbnail. if let (MediaFormat::File, media_source) = (request.format, request.source) { populate_matrix_image_modal(cx, media_source, &mut tl.media_cache); @@ -1522,26 +1721,39 @@ impl RoomScreen { // Here, to be most efficient, we could redraw only the media items in the timeline, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. } - TimelineUpdate::MessageEdited { timeline_event_item_id: timeline_event_id, result } => { - self.view.room_input_bar(cx, ids!(room_input_bar)) + TimelineUpdate::MessageEdited { + timeline_event_item_id: timeline_event_id, + result, + } => { + self.view + .room_input_bar(cx, ids!(room_input_bar)) .handle_edit_result(cx, timeline_event_id, result); } TimelineUpdate::PinResult { result, pin, .. } => { let (message, auto_dismissal_duration, kind) = match &result { Ok(true) => ( - format!("Successfully {} event.", if pin { "pinned" } else { "unpinned" }), + format!( + "Successfully {} event.", + if pin { "pinned" } else { "unpinned" } + ), Some(4.0), - PopupKind::Success + PopupKind::Success, ), Ok(false) => ( - format!("Message was already {}.", if pin { "pinned" } else { "unpinned" }), + format!( + "Message was already {}.", + if pin { "pinned" } else { "unpinned" } + ), Some(4.0), - PopupKind::Info + PopupKind::Info, ), Err(e) => ( - format!("Failed to {} event. Error: {e}", if pin { "pin" } else { "unpin" }), + format!( + "Failed to {} event. Error: {e}", + if pin { "pin" } else { "unpin" } + ), None, - PopupKind::Error + PopupKind::Error, ), }; enqueue_popup_notification(message, kind, auto_dismissal_duration); @@ -1565,7 +1777,8 @@ impl RoomScreen { } TimelineUpdate::UserPowerLevels(user_power_levels) => { tl.user_power = user_power_levels; - self.view.room_input_bar(cx, ids!(room_input_bar)) + self.view + .room_input_bar(cx, ids!(room_input_bar)) .update_user_power_levels(cx, user_power_levels); // Update the @room mention capability based on the user's power level cx.action(MentionableTextInputAction::PowerLevelsUpdated { @@ -1581,8 +1794,13 @@ impl RoomScreen { tl.latest_own_user_receipt = Some(receipt); } TimelineUpdate::Tombstoned(successor_room_details) => { - self.view.room_input_bar(cx, ids!(room_input_bar)) - .update_tombstone_footer(cx, tl.kind.room_id(), Some(&successor_room_details)); + self.view + .room_input_bar(cx, ids!(room_input_bar)) + .update_tombstone_footer( + cx, + tl.kind.room_id(), + Some(&successor_room_details), + ); tl.tombstone_info = Some(successor_room_details); } TimelineUpdate::LinkPreviewFetched => {} @@ -1613,7 +1831,6 @@ impl RoomScreen { } } - /// Handles a link being clicked in any child widgets of this RoomScreen. /// /// Returns `true` if the given `action` was handled as a link click. @@ -1657,7 +1874,11 @@ impl RoomScreen { true } MatrixId::Room(room_id) => { - if self.room_name_id.as_ref().is_some_and(|r| r.room_id() == room_id) { + if self + .room_name_id + .as_ref() + .is_some_and(|r| r.room_id() == room_id) + { enqueue_popup_notification( "You are already viewing that room.", PopupKind::Info, @@ -1665,7 +1886,9 @@ impl RoomScreen { ); return true; } - if let Some(room_name_id) = cx.get_global::().get_room_name(room_id) { + if let Some(room_name_id) = + cx.get_global::().get_room_name(room_id) + { cx.action(AppStateAction::NavigateToRoom { room_to_close: None, destination_room: BasicRoomDetails::Name(room_name_id), @@ -1699,8 +1922,7 @@ impl RoomScreen { let mut link_was_handled = false; if let Ok(matrix_to_uri) = MatrixToUri::parse(&url) { link_was_handled |= handle_matrix_link(matrix_to_uri.id(), matrix_to_uri.via()); - } - else if let Ok(matrix_uri) = MatrixUri::parse(&url) { + } else if let Ok(matrix_uri) = MatrixUri::parse(&url) { link_was_handled |= handle_matrix_link(matrix_uri.id(), matrix_uri.via()); } @@ -1716,8 +1938,13 @@ impl RoomScreen { } } true - } - else if let RobrixHtmlLinkAction::ClickedMatrixLink { url, matrix_id, via, .. } = action.as_widget_action().cast() { + } else if let RobrixHtmlLinkAction::ClickedMatrixLink { + url, + matrix_id, + via, + .. + } = action.as_widget_action().cast() + { let link_was_handled = handle_matrix_link(&matrix_id, &via); if !link_was_handled { log!("Opening URL \"{}\"", url); @@ -1731,8 +1958,7 @@ impl RoomScreen { } } true - } - else { + } else { false } } @@ -1748,8 +1974,13 @@ impl RoomScreen { let Some(media_source) = mxc_uri else { return; }; - let Some(tl_state) = self.tl_state.as_mut() else { return }; - let Some(event_tl_item) = tl_state.items.get(item_id).and_then(|item| item.as_event()) else { return }; + let Some(tl_state) = self.tl_state.as_mut() else { + return; + }; + let Some(event_tl_item) = tl_state.items.get(item_id).and_then(|item| item.as_event()) + else { + return; + }; let timestamp_millis = event_tl_item.timestamp(); let (image_name, image_file_size) = get_image_name_and_filesize(event_tl_item); @@ -1759,10 +1990,7 @@ impl RoomScreen { image_name, image_file_size, timestamp: unix_time_millis_to_datetime(timestamp_millis), - avatar_parameter: Some(( - tl_state.kind.clone(), - event_tl_item.clone(), - )), + avatar_parameter: Some((tl_state.kind.clone(), event_tl_item.clone())), }), ))); @@ -1783,13 +2011,15 @@ impl RoomScreen { details: &MessageDetails, ) -> Option<&'a EventTimelineItem> { let target_event_id = details.event_id()?; - if let Some(event) = items.get(details.item_id) + if let Some(event) = items + .get(details.item_id) .and_then(|item| item.as_event()) .filter(|ev| ev.event_id().is_some_and(|id| id == target_event_id)) { return Some(event); } - items.iter() + items + .iter() .rev() .take(MAX_ITEMS_TO_SEARCH_THROUGH) .filter_map(|item| item.as_event()) @@ -1806,9 +2036,15 @@ impl RoomScreen { ) { let room_screen_widget_uid = self.widget_uid(); for action in actions { - match action.as_widget_action().widget_uid_eq(room_screen_widget_uid).cast_ref() { + match action + .as_widget_action() + .widget_uid_eq(room_screen_widget_uid) + .cast_ref() + { MessageAction::React { details, reaction } => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; submit_async_request(MatrixRequest::ToggleReaction { timeline_kind: tl.kind.clone(), timeline_event_id: details.timeline_event_id.clone(), @@ -1816,19 +2052,24 @@ impl RoomScreen { }); } MessageAction::Reply(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details).cloned() { + let Some(tl) = self.tl_state.as_ref() else { + return; + }; + if let Some(event_tl_item) = + Self::find_event_in_timeline(&tl.items, details).cloned() + { let replied_to_info = EmbeddedEvent::from_timeline_item(&event_tl_item); - self.view.room_input_bar(cx, ids!(room_input_bar)) + self.view + .room_input_bar(cx, ids!(room_input_bar)) .show_replying_to(cx, (event_tl_item, replied_to_info), &tl.kind); - } - else { + } else { enqueue_popup_notification( "Could not find message in timeline to reply to. Please try again.", PopupKind::Error, Some(5.0), ); - error!("MessageAction::Reply: couldn't find event [{}] {:?} to reply to in room {:?}", + error!( + "MessageAction::Reply: couldn't find event [{}] {:?} to reply to in room {:?}", details.item_id, details.timeline_event_id, self.room_id(), @@ -1836,22 +2077,21 @@ impl RoomScreen { } } MessageAction::Edit(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { - self.view.room_input_bar(cx, ids!(room_input_bar)) - .show_editing_pane( - cx, - event_tl_item.clone(), - tl.kind.clone(), - ); - } - else { + self.view + .room_input_bar(cx, ids!(room_input_bar)) + .show_editing_pane(cx, event_tl_item.clone(), tl.kind.clone()); + } else { enqueue_popup_notification( "Could not find message in timeline to edit. Please try again.", PopupKind::Error, Some(5.0), ); - error!("MessageAction::Edit: couldn't find event [{}] {:?} to edit in room {:?}", + error!( + "MessageAction::Edit: couldn't find event [{}] {:?} to edit in room {:?}", details.item_id, details.timeline_event_id, self.room_id(), @@ -1859,21 +2099,20 @@ impl RoomScreen { } } MessageAction::EditLatest => { - let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(latest_sent_msg) = tl.items + let Some(tl) = self.tl_state.as_ref() else { + return; + }; + if let Some(latest_sent_msg) = tl + .items .iter() .rev() .take(MAX_ITEMS_TO_SEARCH_THROUGH) .find_map(|item| item.as_event().filter(|ev| ev.is_editable()).cloned()) { - self.view.room_input_bar(cx, ids!(room_input_bar)) - .show_editing_pane( - cx, - latest_sent_msg, - tl.kind.clone(), - ); - } - else { + self.view + .room_input_bar(cx, ids!(room_input_bar)) + .show_editing_pane(cx, latest_sent_msg, tl.kind.clone()); + } else { enqueue_popup_notification( "No recent message available to edit. Please manually select a message to edit.", PopupKind::Warning, @@ -1882,7 +2121,9 @@ impl RoomScreen { } } MessageAction::Pin(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_id) = details.event_id() { submit_async_request(MatrixRequest::PinEvent { timeline_kind: tl.kind.clone(), @@ -1898,7 +2139,9 @@ impl RoomScreen { } } MessageAction::Unpin(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_id) = details.event_id() { submit_async_request(MatrixRequest::PinEvent { timeline_kind: tl.kind.clone(), @@ -1914,17 +2157,19 @@ impl RoomScreen { } } MessageAction::CopyText(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { cx.copy_to_clipboard(&plaintext_body_of_timeline_item(event_tl_item)); - } - else { + } else { enqueue_popup_notification( "Could not find message in timeline to copy text from. Please try again.", PopupKind::Error, Some(5.0), ); - error!("MessageAction::CopyText: couldn't find event [{}] {:?} to copy text from in room {}", + error!( + "MessageAction::CopyText: couldn't find event [{}] {:?} to copy text from in room {}", details.item_id, details.timeline_event_id, tl.kind.room_id(), @@ -1932,22 +2177,49 @@ impl RoomScreen { } } MessageAction::CopyHtml(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; // The logic for getting the formatted body of a message is the same // as the logic used in `populate_message_view()`. let mut success = false; if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { if let Some(message) = event_tl_item.content().as_message() { match message.msgtype() { - MessageType::Text(TextMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Notice(NoticeMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Emote(EmoteMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Image(ImageMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::File(FileMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Audio(AudioMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Video(VideoMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::VerificationRequest(KeyVerificationRequestEventContent { formatted: Some(FormattedBody { body, .. }), .. }) => - { + MessageType::Text(TextMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Notice(NoticeMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Emote(EmoteMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Image(ImageMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::File(FileMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Audio(AudioMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Video(VideoMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::VerificationRequest( + KeyVerificationRequestEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }, + ) => { cx.copy_to_clipboard(body); success = true; } @@ -1961,7 +2233,8 @@ impl RoomScreen { PopupKind::Error, Some(5.0), ); - error!("MessageAction::CopyHtml: couldn't find event [{}] {:?} to copy HTML from in room {}", + error!( + "MessageAction::CopyHtml: couldn't find event [{}] {:?} to copy HTML from in room {}", details.item_id, details.timeline_event_id, tl.kind.room_id(), @@ -1969,7 +2242,9 @@ impl RoomScreen { } } MessageAction::CopyLink(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_id) = details.event_id() { let matrix_to_uri = tl.kind.room_id().matrix_to_event_uri(event_id.clone()); cx.copy_to_clipboard(&matrix_to_uri.to_string()); @@ -1979,7 +2254,8 @@ impl RoomScreen { PopupKind::Error, Some(5.0), ); - error!("MessageAction::CopyLink: no `event_id`: [{}] {:?} in room {}", + error!( + "MessageAction::CopyLink: no `event_id`: [{}] {:?} in room {}", details.item_id, details.timeline_event_id, tl.kind.room_id(), @@ -1987,8 +2263,11 @@ impl RoomScreen { } } MessageAction::ViewSource(details) => { - let Some(tl) = self.tl_state.as_ref() else { continue }; - let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) else { + let Some(tl) = self.tl_state.as_ref() else { + continue; + }; + let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) + else { enqueue_popup_notification( "Could not find message in timeline to view source.", PopupKind::Error, @@ -2012,7 +2291,9 @@ impl RoomScreen { } MessageAction::JumpToRelated(details) => { let Some(related_event_id) = details.related_event_id.as_ref() else { - error!("BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}"); + error!( + "BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}" + ); enqueue_popup_notification( "Could not find related message or event in timeline.", PopupKind::Error, @@ -2025,25 +2306,21 @@ impl RoomScreen { related_event_id, Some(details.item_id), portal_list, - loading_pane + loading_pane, ); } MessageAction::JumpToEvent(event_id) => { - self.jump_to_event( - cx, - event_id, - None, - portal_list, - loading_pane - ); + self.jump_to_event(cx, event_id, None, portal_list, loading_pane); } MessageAction::OpenThread(thread_root_event_id) => { let Some(room_name_id) = self.room_name_id.as_ref().cloned() else { - error!("### ERROR: MessageAction::OpenThread: thread_root_event_id: {thread_root_event_id}, but room_name_id was None!"); - continue + error!( + "### ERROR: MessageAction::OpenThread: thread_root_event_id: {thread_root_event_id}, but room_name_id was None!" + ); + continue; }; cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, RoomsListAction::Selected(SelectedRoom::Thread { room_name_id, thread_root_event_id: thread_root_event_id.clone(), @@ -2051,13 +2328,17 @@ impl RoomScreen { ); } MessageAction::Redact { details, reason } => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; let timeline_event_id = details.timeline_event_id.clone(); let timeline_kind = tl.kind.clone(); let reason = reason.clone(); let content = ConfirmationModalContent { title_text: "Delete Message".into(), - body_text: "Are you sure you want to delete this message? This cannot be undone.".into(), + body_text: + "Are you sure you want to delete this message? This cannot be undone." + .into(), accept_button_text: Some("Delete".into()), on_accept_clicked: Some(Box::new(move |_cx| { submit_async_request(MatrixRequest::RedactMessage { @@ -2075,14 +2356,14 @@ impl RoomScreen { // } // This is handled within the Message widget itself. - MessageAction::HighlightMessage(..) => { } + MessageAction::HighlightMessage(..) => {} // This is handled by the top-level App itself. - MessageAction::OpenMessageContextMenu { .. } => { } + MessageAction::OpenMessageContextMenu { .. } => {} // This isn't yet handled, as we need to completely redesign it. - MessageAction::ActionBarOpen { .. } => { } + MessageAction::ActionBarOpen { .. } => {} // This isn't yet handled, as we need to completely redesign it. - MessageAction::ActionBarClose => { } - MessageAction::None => { } + MessageAction::ActionBarClose => {} + MessageAction::None => {} } } } @@ -2100,14 +2381,17 @@ impl RoomScreen { portal_list: &PortalListRef, loading_pane: &LoadingPaneRef, ) { - let Some(tl) = self.tl_state.as_mut() else { return }; + let Some(tl) = self.tl_state.as_mut() else { + return; + }; let max_tl_idx = max_tl_idx.unwrap_or_else(|| tl.items.len()); // Attempt to find the index of replied-to message in the timeline. // Start from the current item's index (`tl_idx`) and search backwards, // since we know the related message must come before the current item. let mut num_items_searched = 0; - let related_msg_tl_index = tl.items + let related_msg_tl_index = tl + .items .focus() .narrow(..max_tl_idx) .into_iter() @@ -2130,11 +2414,13 @@ impl RoomScreen { // appear beneath the top of the viewport. portal_list.smooth_scroll_to(cx, index.saturating_sub(1), speed, None); // start highlight animation. - tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { - item_id: index - }; + tl.message_highlight_animation_state = + MessageHighlightAnimationState::Pending { item_id: index }; } else { - log!("The related event {target_event_id} wasn't immediately available in room {}, searching for it in the background...", tl.kind.room_id()); + log!( + "The related event {target_event_id} wasn't immediately available in room {}, searching for it in the background...", + tl.kind.room_id() + ); // Here, we set the state of the loading pane and display it to the user. // The main logic will be handled in `process_timeline_updates()`, which is the only // place where we can receive updates to the timeline from the background tasks. @@ -2187,7 +2473,9 @@ impl RoomScreen { /// Invoke this when this timeline is being shown, /// e.g., when the user navigates to this timeline. fn show_timeline(&mut self, cx: &mut Cx) { - let kind = self.timeline_kind.clone() + let kind = self + .timeline_kind + .clone() .expect("BUG: Timeline::show_timeline(): no timeline_kind was set."); let room_id = kind.room_id().clone(); @@ -2204,8 +2492,10 @@ impl RoomScreen { return; } if !self.is_loaded && self.all_rooms_loaded { - panic!("BUG: timeline {kind} is not loaded, but its RoomScreen \ - was not waiting for its timeline to be loaded either."); + panic!( + "BUG: timeline {kind} is not loaded, but its RoomScreen \ + was not waiting for its timeline to be loaded either." + ); } return; }; @@ -2278,14 +2568,19 @@ impl RoomScreen { self.is_loaded = is_loaded_now; } - self.view.restore_status_view(cx, ids!(restore_status_view)).set_visible(cx, !self.is_loaded); + self.view + .restore_status_view(cx, ids!(restore_status_view)) + .set_visible(cx, !self.is_loaded); // Kick off a back pagination request if it's the first time loading this room, // because we want to show the user some messages as soon as possible // when they first open the room, and there might not be any messages yet. if is_first_time_being_loaded { if !tl_state.fully_paginated { - log!("Sending a first-time backwards pagination request for {}", tl_state.kind); + log!( + "Sending a first-time backwards pagination request for {}", + tl_state.kind + ); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl_state.kind.clone(), num_events: 50, @@ -2354,7 +2649,9 @@ impl RoomScreen { /// Invoke this when this RoomScreen/timeline is being hidden or no longer being shown. fn hide_timeline(&mut self) { - let Some(timeline_kind) = self.timeline_kind.clone() else { return }; + let Some(timeline_kind) = self.timeline_kind.clone() else { + return; + }; self.save_state(); @@ -2417,7 +2714,12 @@ impl RoomScreen { // 1. Restore the position of the timeline. let portal_list = self.portal_list(cx, ids!(timeline.list)); if let Some((first_index, scroll_from_first_id)) = first_index_and_scroll { - log!("Restoring state for room {:?}: first_id: {:?}, scroll: {}", self.room_name_id, first_index, scroll_from_first_id); + log!( + "Restoring state for room {:?}: first_id: {:?}, scroll: {}", + self.room_name_id, + first_index, + scroll_from_first_id + ); portal_list.set_first_id_and_scroll(*first_index, *scroll_from_first_id); portal_list.set_tail_range(false); } else { @@ -2463,7 +2765,11 @@ impl RoomScreen { // If this timeline is already displayed, we don't need to do anything major, // but we do need update the `room_name_id` in case it has changed, or it has been cleared. - if self.timeline_kind.as_ref().is_some_and(|kind| kind == &timeline_kind) { + if self + .timeline_kind + .as_ref() + .is_some_and(|kind| kind == &timeline_kind) + { self.room_name_id = Some(room_name_id.clone()); return; } @@ -2498,7 +2804,9 @@ impl RoomScreen { return; } let first_index = portal_list.first_id(); - let Some(tl_state) = self.tl_state.as_mut() else { return }; + let Some(tl_state) = self.tl_state.as_mut() else { + return; + }; if let Some(ref mut index) = tl_state.prev_first_index { // to detect change of scroll when scroll ends @@ -2509,7 +2817,7 @@ impl RoomScreen { .items .get(std::cmp::min( first_index + portal_list.visible_items(), - tl_state.items.len().saturating_sub(1) + tl_state.items.len().saturating_sub(1), )) .and_then(|f| f.as_event()) .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) @@ -2529,17 +2837,20 @@ impl RoomScreen { receipt_type: ReceiptType::FullyRead, }); } else { - if let Some(own_user_receipt_timestamp) = &tl_state.latest_own_user_receipt.clone() - .and_then(|receipt| receipt.ts) { + if let Some(own_user_receipt_timestamp) = &tl_state + .latest_own_user_receipt + .clone() + .and_then(|receipt| receipt.ts) + { let Some((_first_event_id, first_timestamp)) = tl_state .items .get(first_index) .and_then(|f| f.as_event()) .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) - else { - *index = first_index; - return; - }; + else { + *index = first_index; + return; + }; if own_user_receipt_timestamp >= &first_timestamp && own_user_receipt_timestamp <= &last_timestamp { @@ -2550,7 +2861,6 @@ impl RoomScreen { receipt_type: ReceiptType::FullyRead, }); } - } } } @@ -2569,14 +2879,22 @@ impl RoomScreen { actions: &ActionsBuf, portal_list: &PortalListRef, ) { - let Some(tl) = self.tl_state.as_mut() else { return }; - if tl.fully_paginated { return }; - if !portal_list.scrolled(actions) { return }; + let Some(tl) = self.tl_state.as_mut() else { + return; + }; + if tl.fully_paginated { + return; + }; + if !portal_list.scrolled(actions) { + return; + }; let first_index = portal_list.first_id(); if first_index == 0 && tl.last_scrolled_index > 0 { - log!("Scrolled up from item {} --> 0, sending back pagination request for room {}", - tl.last_scrolled_index, tl.kind, + log!( + "Scrolled up from item {} --> 0, sending back pagination request for room {}", + tl.last_scrolled_index, + tl.kind, ); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl.kind.clone(), @@ -2596,7 +2914,9 @@ impl RoomScreenRef { room_name_id: &RoomNameId, thread_root_event_id: Option, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_displayed_room(cx, room_name_id, thread_root_event_id); } } @@ -2611,7 +2931,6 @@ pub struct RoomScreenProps { pub room_avatar_url: Option, } - /// Actions for the room screen's tooltip. #[derive(Clone, Debug, Default)] pub enum RoomScreenTooltipActions { @@ -2710,9 +3029,7 @@ pub enum TimelineUpdate { /// includes a complete list of room members that can be shared across components. /// This is different from RoomMembersSynced which only indicates members were fetched /// but doesn't provide the actual data. - RoomMembersListFetched { - members: Vec, - }, + RoomMembersListFetched { members: Vec }, /// A notice with an option of Media Request Parameters that one or more requested media items (images, videos, etc.) /// that should be displayed in this timeline have now been fetched and are available. MediaFetched(MediaRequestParameters), @@ -2744,7 +3061,7 @@ thread_local! { /// The global set of all timeline states, one entry per room. /// /// This is only useful when accessed from the main UI thread. - static TIMELINE_STATES: RefCell> = + static TIMELINE_STATES: RefCell> = RefCell::new(HashMap::new()); } @@ -2860,7 +3177,9 @@ struct TimelineUiState { #[derive(Default, Debug)] enum MessageHighlightAnimationState { - Pending { item_id: usize }, + Pending { + item_id: usize, + }, #[default] Off, } @@ -2897,9 +3216,8 @@ fn find_new_item_matching_current_item( ) -> Option<(usize, usize, f64, OwnedEventId)> { let mut curr_item_focus = curr_items.focus(); let mut idx_curr = starting_at_curr_idx; - let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = Vec::with_capacity( - portal_list.visible_items() - ); + let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = + Vec::with_capacity(portal_list.visible_items()); // Find all items with real event IDs that are currently visible in the portal list. // TODO: if this is slow, we could limit it to 3-5 events at the most. @@ -2928,7 +3246,9 @@ fn find_new_item_matching_current_item( // some may be zeroed-out, so we need to account for that possibility by only // using events that have a real non-zero area if let Some(pos_offset) = portal_list.position_of_item(cx, *idx_curr) { - log!("Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}"); + log!( + "Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}" + ); return Some((*idx_curr, idx_new, pos_offset, event_id.to_owned())); } } @@ -3002,7 +3322,8 @@ fn populate_message_view( TimelineItemContent::MsgLike(_msg_like_content) => { let prev_msg_sender = prev_event_tl_item.sender(); prev_msg_sender == event_tl_item.sender() - && ts_millis.0 + && ts_millis + .0 .checked_sub(prev_event_tl_item.timestamp().0) .is_some_and(|d| d < uint!(600000)) // 10 mins in millis } @@ -3019,8 +3340,12 @@ fn populate_message_view( let (item, used_cached_item) = match &msg_like_content.kind { MsgLikeKind::Message(msg) => { match msg.msgtype() { - MessageType::Text(TextMessageEventContent { body, formatted, .. }) => { - has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + MessageType::Text(TextMessageEventContent { + body, formatted, .. + }) => { + has_html_body = formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3048,9 +3373,13 @@ fn populate_message_view( } // A notice message is just a message sent by an automated bot, // so we treat it just like a message but use a different font color. - MessageType::Notice(NoticeMessageEventContent{body, formatted, ..}) => { + MessageType::Notice(NoticeMessageEventContent { + body, formatted, .. + }) => { is_notice = true; - has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3060,7 +3389,8 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); + let html_or_plaintext_ref = + item.html_or_plaintext(cx, ids!(content.message)); // Apply gray color to all text styles for notice messages. let mut html_widget = html_or_plaintext_ref.html(cx, ids!(html_view.html)); script_apply_eval!(cx, html_widget, { @@ -3090,7 +3420,8 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); + let html_or_plaintext_ref = + item.html_or_plaintext(cx, ids!(content.message)); // Apply red color to all text styles for server notices. let mut html_widget = html_or_plaintext_ref.html(cx, ids!(html_view.html)); script_apply_eval!(cx, html_widget, { @@ -3105,10 +3436,12 @@ fn populate_message_view( "Server notice: {}\n\nNotice type:: {}{}{}", sn.body, sn.server_notice_type.as_str(), - sn.limit_type.as_ref() + sn.limit_type + .as_ref() .map(|l| format!("\nLimit type: {}", l.as_str())) .unwrap_or_default(), - sn.admin_contact.as_ref() + sn.admin_contact + .as_ref() .map(|c| format!("\nAdmin contact: {}", c)) .unwrap_or_default(), ); @@ -3131,8 +3464,12 @@ fn populate_message_view( } // An emote is just like a message but is prepended with the user's name // to indicate that it's an "action" that the user is performing. - MessageType::Emote(EmoteMessageEventContent { body, formatted, .. }) => { - has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + MessageType::Emote(EmoteMessageEventContent { + body, formatted, .. + }) => { + has_html_body = formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3143,14 +3480,16 @@ fn populate_message_view( (item, true) } else { // Draw the profile up front here because we need the username for the emote body. - let (username, profile_drawn) = item.avatar(cx, ids!(profile.avatar)).set_avatar_and_get_username( - cx, - timeline_kind, - event_tl_item.sender(), - Some(event_tl_item.sender_profile()), - event_tl_item.event_id(), - true, - ); + let (username, profile_drawn) = item + .avatar(cx, ids!(profile.avatar)) + .set_avatar_and_get_username( + cx, + timeline_kind, + event_tl_item.sender(), + Some(event_tl_item.sender_profile()), + event_tl_item.event_id(), + true, + ); // Prepend a "* " to the emote body, as suggested by the Matrix spec. let (body, formatted) = if let Some(fb) = formatted.as_ref() { @@ -3159,7 +3498,7 @@ fn populate_message_view( Some(FormattedBody { format: fb.format.clone(), body: format!("* {} {}", &username, &fb.body), - }) + }), ) } else { (Cow::from(format!("* {} {}", &username, body)), None) @@ -3183,7 +3522,9 @@ fn populate_message_view( } } MessageType::Image(image) => { - has_html_body = image.formatted.as_ref() + has_html_body = image + .formatted + .as_ref() .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedImageMessage) @@ -3221,17 +3562,17 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - let is_location_fully_drawn = populate_location_message_content( - cx, - &html_or_plaintext_ref, - location, - ); + let is_location_fully_drawn = + populate_location_message_content(cx, &html_or_plaintext_ref, location); new_drawn_status.content_drawn = is_location_fully_drawn; (item, false) } } MessageType::File(file_content) => { - has_html_body = file_content.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = file_content + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3243,16 +3584,16 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - new_drawn_status.content_drawn = populate_file_message_content( - cx, - &html_or_plaintext_ref, - file_content, - ); + new_drawn_status.content_drawn = + populate_file_message_content(cx, &html_or_plaintext_ref, file_content); (item, false) } } MessageType::Audio(audio) => { - has_html_body = audio.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = audio + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3264,16 +3605,16 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - new_drawn_status.content_drawn = populate_audio_message_content( - cx, - &html_or_plaintext_ref, - audio, - ); + new_drawn_status.content_drawn = + populate_audio_message_content(cx, &html_or_plaintext_ref, audio); (item, false) } } MessageType::Video(video) => { - has_html_body = video.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = video + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3285,16 +3626,16 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - new_drawn_status.content_drawn = populate_video_message_content( - cx, - &html_or_plaintext_ref, - video, - ); + new_drawn_status.content_drawn = + populate_video_message_content(cx, &html_or_plaintext_ref, video); (item, false) } } MessageType::VerificationRequest(verification) => { - has_html_body = verification.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = verification + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = id!(Message); let (item, existed) = list.item_with_existed(cx, item_id, template); if existed && item_drawn_status.content_drawn { @@ -3306,7 +3647,8 @@ fn populate_message_view( body: format!( "Sent a verification request to {}.
(Supported methods: {})
", verification.to, - verification.methods + verification + .methods .iter() .map(|m| m.as_str()) .collect::>() @@ -3336,10 +3678,8 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - item.label(cx, ids!(content.message)).set_text( - cx, - &format!("[Unsupported {:?}]", msg_like_content.kind), - ); + item.label(cx, ids!(content.message)) + .set_text(cx, &format!("[Unsupported {:?}]", msg_like_content.kind)); new_drawn_status.content_drawn = true; (item, false) } @@ -3349,7 +3689,9 @@ fn populate_message_view( // Handle sticker messages that are static images. MsgLikeKind::Sticker(sticker) => { has_html_body = false; - let StickerEventContent { body, info, source, .. } = sticker.content(); + let StickerEventContent { + body, info, source, .. + } = sticker.content(); let template = if use_compact_view { id!(CondensedImageMessage) @@ -3378,7 +3720,7 @@ fn populate_message_view( (item, true) } } - } + } // Handle messages that have been redacted (deleted). MsgLikeKind::Redacted => { has_html_body = false; @@ -3417,10 +3759,8 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - item.label(cx, ids!(content.message)).set_text( - cx, - &format!("[Unsupported {:?}] ", other), - ); + item.label(cx, ids!(content.message)) + .set_text(cx, &format!("[Unsupported {:?}] ", other)); new_drawn_status.content_drawn = true; (item, false) } @@ -3432,13 +3772,14 @@ fn populate_message_view( // If we didn't use a cached item, we need to draw all other message content: // the reactions, the read receipts avatar row, the reply preview. if !used_cached_item { - item.reaction_list(cx, ids!(content.reaction_list)).set_list( - cx, - event_tl_item.content().reactions(), - timeline_kind.clone(), - timeline_event_id.clone(), - item_id, - ); + item.reaction_list(cx, ids!(content.reaction_list)) + .set_list( + cx, + event_tl_item.content().reactions(), + timeline_kind.clone(), + timeline_event_id.clone(), + item_id, + ); populate_read_receipts(&item, cx, timeline_kind, event_tl_item); let is_reply_fully_drawn = draw_replied_to_message( cx, @@ -3465,17 +3806,21 @@ fn populate_message_view( new_drawn_status.content_drawn &= is_thread_summary_fully_drawn; } - // We must always re-set the message details, even when re-using a cached portallist item, // because the item type might be the same but for a different message entirely. let message_details = MessageDetails { thread_root_event_id: msg_like_content.thread_root.clone().or_else(|| { - msg_like_content.thread_summary.as_ref() + msg_like_content + .thread_summary + .as_ref() .and_then(|_| event_tl_item.event_id().map(|id| id.to_owned())) }), timeline_event_id, item_id, - related_event_id: msg_like_content.in_reply_to.as_ref().map(|r| r.event_id.clone()), + related_event_id: msg_like_content + .in_reply_to + .as_ref() + .map(|r| r.event_id.clone()), room_screen_widget_uid, abilities: MessageAbilities::from_user_power_and_event( user_power_levels, @@ -3488,7 +3833,6 @@ fn populate_message_view( }; item.as_message().set_data(message_details); - // If `used_cached_item` is false, we should always redraw the profile, even if profile_drawn is true. let skip_draw_profile = use_compact_view || (used_cached_item && item_drawn_status.profile_drawn); @@ -3499,17 +3843,20 @@ fn populate_message_view( // log!("\t --> populate_message_view(): DRAWING profile draw for item_id: {item_id}"); let mut username_label = item.label(cx, ids!(content.username)); - if !is_server_notice { // the normal case - let (username, profile_drawn) = set_username_and_get_avatar_retval.unwrap_or_else(|| - item.avatar(cx, ids!(profile.avatar)).set_avatar_and_get_username( - cx, - timeline_kind, - event_tl_item.sender(), - Some(event_tl_item.sender_profile()), - event_tl_item.event_id(), - true, - ) - ); + if !is_server_notice { + // the normal case + let (username, profile_drawn) = + set_username_and_get_avatar_retval.unwrap_or_else(|| { + item.avatar(cx, ids!(profile.avatar)) + .set_avatar_and_get_username( + cx, + timeline_kind, + event_tl_item.sender(), + Some(event_tl_item.sender_profile()), + event_tl_item.event_id(), + true, + ) + }); if is_notice { script_apply_eval!(cx, username_label, { draw_text +: { @@ -3519,8 +3866,7 @@ fn populate_message_view( } username_label.set_text(cx, &username); new_drawn_status.profile_drawn = profile_drawn; - } - else { + } else { // Server notices are drawn with a red color avatar background and username. let avatar = item.avatar(cx, ids!(profile.avatar)); avatar.show_text(cx, Some(COLOR_FG_DANGER_RED), None, "⚠"); @@ -3541,33 +3887,46 @@ fn populate_message_view( // Set the timestamp. if let Some(dt) = unix_time_millis_to_datetime(ts_millis) { - item.timestamp(cx, ids!(profile.timestamp)).set_date_time(cx, dt); + item.timestamp(cx, ids!(profile.timestamp)) + .set_date_time(cx, dt); } // Set the "edited" indicator if this message was edited. if msg_like_content.as_message().is_some_and(|m| m.is_edited()) { - item.edited_indicator(cx, ids!(profile.edited_indicator)).set_latest_edit( - cx, - event_tl_item, - ); + item.edited_indicator(cx, ids!(profile.edited_indicator)) + .set_latest_edit(cx, event_tl_item); } - #[cfg(feature = "tsp")] { + #[cfg(feature = "tsp")] + { use matrix_sdk::ruma::serde::Base64; - use crate::tsp::{self, tsp_sign_indicator::{TspSignState, TspSignIndicatorWidgetRefExt}}; + use crate::tsp::{ + self, + tsp_sign_indicator::{TspSignState, TspSignIndicatorWidgetRefExt}, + }; - if let Some(mut tsp_sig) = event_tl_item.latest_json() + if let Some(mut tsp_sig) = event_tl_item + .latest_json() .and_then(|raw| raw.get_field::("content").ok()) .flatten() .and_then(|content_obj| content_obj.get("org.robius.tsp_signature").cloned()) .and_then(|tsp_sig_value| serde_json::from_value::(tsp_sig_value).ok()) .map(|b64| b64.into_inner()) { - log!("Found event {:?} with TSP signature.", event_tl_item.event_id()); - let tsp_sign_state = if let Some(sender_vid) = tsp::tsp_state_ref().lock().unwrap() + log!( + "Found event {:?} with TSP signature.", + event_tl_item.event_id() + ); + let tsp_sign_state = if let Some(sender_vid) = tsp::tsp_state_ref() + .lock() + .unwrap() .get_verified_vid_for(event_tl_item.sender()) { - log!("Found verified VID for sender {}: \"{}\"", event_tl_item.sender(), sender_vid.identifier()); + log!( + "Found verified VID for sender {}: \"{}\"", + event_tl_item.sender(), + sender_vid.identifier() + ); tsp_sdk::crypto::verify(&*sender_vid, &mut tsp_sig).map_or( TspSignState::WrongSignature, |(msg, msg_type)| { @@ -3579,7 +3938,11 @@ fn populate_message_view( TspSignState::Unknown }; - log!("TSP signature state for event {:?} is {:?}", event_tl_item.event_id(), tsp_sign_state); + log!( + "TSP signature state for event {:?} is {:?}", + event_tl_item.event_id(), + tsp_sign_state + ); item.tsp_sign_indicator(cx, ids!(profile.tsp_sign_indicator)) .show_with_state(cx, tsp_sign_state); } @@ -3602,7 +3965,8 @@ fn populate_text_message_content( ) -> bool { // The message was HTML-formatted rich text. let mut links = Vec::new(); - if let Some(fb) = formatted_body.as_ref() + if let Some(fb) = formatted_body + .as_ref() .and_then(|fb| (fb.format == MessageFormat::Html).then_some(fb)) { let linkified_html = utils::linkify_get_urls( @@ -3622,7 +3986,7 @@ fn populate_text_message_content( }; // Populate link previews if all required parameters are provided - if let (Some(link_preview_ref), Some(media_cache), Some(link_preview_cache)) = + if let (Some(link_preview_ref), Some(media_cache), Some(link_preview_cache)) = (link_preview_ref, media_cache, link_preview_cache) { link_preview_ref.populate_below_message( @@ -3650,7 +4014,8 @@ fn populate_image_message_content( ) -> bool { // We don't use thumbnails, as their resolution is too low to be visually useful. // We also don't trust the provided mimetype, as it can be incorrect. - let (mimetype, _width, _height) = image_info_source.as_ref() + let (mimetype, _width, _height) = image_info_source + .as_ref() .map(|info| (info.mimetype.as_deref(), info.width, info.height)) .unwrap_or_default(); @@ -3658,10 +4023,7 @@ fn populate_image_message_content( // then show a message about it being unsupported (e.g., for animated gifs). if let Some(mime) = mimetype.as_ref() { if ImageFormat::from_mimetype(mime).is_none() { - text_or_image_ref.show_text( - cx, - format!("{body}\n\nUnsupported type {mime:?}"), - ); + text_or_image_ref.show_text(cx, format!("{body}\n\nUnsupported type {mime:?}")); return true; // consider this as fully drawn } } @@ -3670,102 +4032,132 @@ fn populate_image_message_content( // A closure that fetches and shows the image from the given `mxc_uri`, // marking it as fully drawn if the image was available. - let mut fetch_and_show_image_uri = |cx: &mut Cx, mxc_uri: OwnedMxcUri, image_info: Box| { - match media_cache.try_get_media_or_fetch(&mxc_uri, MEDIA_THUMBNAIL_FORMAT.into()) { - (MediaCacheEntry::Loaded(data), _media_format) => { - let show_image_result = text_or_image_ref.show_image(cx, Some(MediaSource::Plain(mxc_uri)),|cx, img| { - utils::load_png_or_jpg(&img, cx, &data) - .map(|()| img.size_in_pixels(cx).unwrap_or_default()) - }); - if let Err(e) = show_image_result { - let err_str = format!("{body}\n\nFailed to display image: {e:?}"); - error!("{err_str}"); - text_or_image_ref.show_text(cx, &err_str); - } - - // We're done drawing the image, so mark it as fully drawn. - fully_drawn = true; - } - (MediaCacheEntry::Requested, _media_format) => { - // If the image is being fetched, we try to show its blurhash. - if let (Some(ref blurhash), Some(width), Some(height)) = (image_info.blurhash.clone(), image_info.width, image_info.height) { - let show_image_result = text_or_image_ref.show_image(cx, Some(MediaSource::Plain(mxc_uri)), |cx, img| { - let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) else { - return Err(image_cache::ImageError::EmptyData) - }; - let (width, height): (u32, u32) = (width, height); - if width == 0 || height == 0 { - warning!("Image had an invalid aspect ratio (width or height of 0)."); - return Err(image_cache::ImageError::EmptyData); - } - let aspect_ratio: f32 = width as f32 / height as f32; - // Cap the blurhash to a max size of 500 pixels in each dimension - // because the `blurhash::decode()` function can be rather expensive. - let (mut capped_width, mut capped_height) = (width, height); - if capped_height > BLURHASH_IMAGE_MAX_SIZE { - capped_height = BLURHASH_IMAGE_MAX_SIZE; - capped_width = (capped_height as f32 * aspect_ratio).floor() as u32; - } - if capped_width > BLURHASH_IMAGE_MAX_SIZE { - capped_width = BLURHASH_IMAGE_MAX_SIZE; - capped_height = (capped_width as f32 / aspect_ratio).floor() as u32; - } - - match blurhash::decode(blurhash, capped_width, capped_height, 1.0) { - Ok(data) => { - ImageBuffer::new(&data, capped_width as usize, capped_height as usize).map(|img_buff| { - let texture = Some(img_buff.into_new_texture(cx)); - img.set_texture(cx, texture); - img.size_in_pixels(cx).unwrap_or_default() - }) - } - Err(e) => { - error!("Failed to decode blurhash {e:?}"); - Err(image_cache::ImageError::EmptyData) - } - } - }); + let mut fetch_and_show_image_uri = + |cx: &mut Cx, mxc_uri: OwnedMxcUri, image_info: Box| { + match media_cache.try_get_media_or_fetch(&mxc_uri, MEDIA_THUMBNAIL_FORMAT.into()) { + (MediaCacheEntry::Loaded(data), _media_format) => { + let show_image_result = text_or_image_ref.show_image( + cx, + Some(MediaSource::Plain(mxc_uri)), + |cx, img| { + utils::load_png_or_jpg(&img, cx, &data) + .map(|()| img.size_in_pixels(cx).unwrap_or_default()) + }, + ); if let Err(e) = show_image_result { let err_str = format!("{body}\n\nFailed to display image: {e:?}"); error!("{err_str}"); text_or_image_ref.show_text(cx, &err_str); } + + // We're done drawing the image, so mark it as fully drawn. + fully_drawn = true; } - fully_drawn = false; - } - (MediaCacheEntry::Failed(_status_code), _media_format) => { - if text_or_image_ref.view(cx, ids!(default_image_view)).visible() { + (MediaCacheEntry::Requested, _media_format) => { + // If the image is being fetched, we try to show its blurhash. + if let (Some(ref blurhash), Some(width), Some(height)) = ( + image_info.blurhash.clone(), + image_info.width, + image_info.height, + ) { + let show_image_result = text_or_image_ref.show_image( + cx, + Some(MediaSource::Plain(mxc_uri)), + |cx, img| { + let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) + else { + return Err(image_cache::ImageError::EmptyData); + }; + let (width, height): (u32, u32) = (width, height); + if width == 0 || height == 0 { + warning!( + "Image had an invalid aspect ratio (width or height of 0)." + ); + return Err(image_cache::ImageError::EmptyData); + } + let aspect_ratio: f32 = width as f32 / height as f32; + // Cap the blurhash to a max size of 500 pixels in each dimension + // because the `blurhash::decode()` function can be rather expensive. + let (mut capped_width, mut capped_height) = (width, height); + if capped_height > BLURHASH_IMAGE_MAX_SIZE { + capped_height = BLURHASH_IMAGE_MAX_SIZE; + capped_width = + (capped_height as f32 * aspect_ratio).floor() as u32; + } + if capped_width > BLURHASH_IMAGE_MAX_SIZE { + capped_width = BLURHASH_IMAGE_MAX_SIZE; + capped_height = + (capped_width as f32 / aspect_ratio).floor() as u32; + } + + match blurhash::decode(blurhash, capped_width, capped_height, 1.0) { + Ok(data) => ImageBuffer::new( + &data, + capped_width as usize, + capped_height as usize, + ) + .map(|img_buff| { + let texture = Some(img_buff.into_new_texture(cx)); + img.set_texture(cx, texture); + img.size_in_pixels(cx).unwrap_or_default() + }), + Err(e) => { + error!("Failed to decode blurhash {e:?}"); + Err(image_cache::ImageError::EmptyData) + } + } + }, + ); + if let Err(e) = show_image_result { + let err_str = format!("{body}\n\nFailed to display image: {e:?}"); + error!("{err_str}"); + text_or_image_ref.show_text(cx, &err_str); + } + } + fully_drawn = false; + } + (MediaCacheEntry::Failed(_status_code), _media_format) => { + if text_or_image_ref + .view(cx, ids!(default_image_view)) + .visible() + { + fully_drawn = true; + return; + } + text_or_image_ref.show_text( + cx, + format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri), + ); + // For now, we consider this as being "complete". In the future, we could support + // retrying to fetch thumbnail of the image on a user click/tap. fully_drawn = true; - return; } - text_or_image_ref - .show_text(cx, format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri)); - // For now, we consider this as being "complete". In the future, we could support - // retrying to fetch thumbnail of the image on a user click/tap. - fully_drawn = true; } - } - }; + }; - let mut fetch_and_show_media_source = |cx: &mut Cx, media_source: MediaSource, image_info: Box| { - match media_source { - MediaSource::Encrypted(encrypted) => { - // We consider this as "fully drawn" since we don't yet support encryption. - text_or_image_ref.show_text( - cx, - format!("{body}\n\n[TODO] fetch encrypted image at {:?}", encrypted.url) - ); - }, - MediaSource::Plain(mxc_uri) => { - fetch_and_show_image_uri(cx, mxc_uri, image_info) + let mut fetch_and_show_media_source = + |cx: &mut Cx, media_source: MediaSource, image_info: Box| { + match media_source { + MediaSource::Encrypted(encrypted) => { + // We consider this as "fully drawn" since we don't yet support encryption. + text_or_image_ref.show_text( + cx, + format!( + "{body}\n\n[TODO] fetch encrypted image at {:?}", + encrypted.url + ), + ); + } + MediaSource::Plain(mxc_uri) => fetch_and_show_image_uri(cx, mxc_uri, image_info), } - } - }; + }; match image_info_source { Some(image_info) => { // Use the provided thumbnail URI if it exists; otherwise use the original URI. - let media_source = image_info.thumbnail_source.clone() + let media_source = image_info + .thumbnail_source + .clone() .unwrap_or(original_source); fetch_and_show_media_source(cx, media_source, image_info); } @@ -3778,7 +4170,6 @@ fn populate_image_message_content( fully_drawn } - /// Draws a file message's content into the given `message_content_widget`. /// /// Returns whether the file message content was fully drawn. @@ -3795,7 +4186,8 @@ fn populate_file_message_content( .and_then(|info| info.size) .map(|bytes| format!(" ({})", ByteSize::b(bytes.into()))) .unwrap_or_default(); - let caption = file_content.formatted_caption() + let caption = file_content + .formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| file_content.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -3822,20 +4214,23 @@ fn populate_audio_message_content( let (duration, mime, size) = audio .info .as_ref() - .map(|info| ( - info.duration - .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) - .unwrap_or_default(), - info.mimetype - .as_ref() - .map(|m| format!(" {m},")) - .unwrap_or_default(), - info.size - .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) - .unwrap_or_default(), - )) + .map(|info| { + ( + info.duration + .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) + .unwrap_or_default(), + info.mimetype + .as_ref() + .map(|m| format!(" {m},")) + .unwrap_or_default(), + info.size + .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) + .unwrap_or_default(), + ) + }) .unwrap_or_default(); - let caption = audio.formatted_caption() + let caption = audio + .formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| audio.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -3849,7 +4244,6 @@ fn populate_audio_message_content( true } - /// Draws a video message's content into the given `message_content_widget`. /// /// Returns whether the video message content was fully drawn. @@ -3863,23 +4257,26 @@ fn populate_video_message_content( let (duration, mime, size, dimensions) = video .info .as_ref() - .map(|info| ( - info.duration - .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) - .unwrap_or_default(), - info.mimetype - .as_ref() - .map(|m| format!(" {m},")) - .unwrap_or_default(), - info.size - .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) - .unwrap_or_default(), - info.width.and_then(|width| - info.height.map(|height| format!(" {width}x{height},")) - ).unwrap_or_default(), - )) + .map(|info| { + ( + info.duration + .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) + .unwrap_or_default(), + info.mimetype + .as_ref() + .map(|m| format!(" {m},")) + .unwrap_or_default(), + info.size + .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) + .unwrap_or_default(), + info.width + .and_then(|width| info.height.map(|height| format!(" {width}x{height},"))) + .unwrap_or_default(), + ) + }) .unwrap_or_default(); - let caption = video.formatted_caption() + let caption = video + .formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| video.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -3893,8 +4290,6 @@ fn populate_video_message_content( true } - - /// Draws the given location message's content into the `message_content_widget`. /// /// Returns whether the location message content was fully drawn. @@ -3903,8 +4298,9 @@ fn populate_location_message_content( message_content_widget: &HtmlOrPlaintextRef, location: &LocationMessageEventContent, ) -> bool { - let coords = location.geo_uri - .get(utils::GEO_URI_SCHEME.len() ..) + let coords = location + .geo_uri + .get(utils::GEO_URI_SCHEME.len()..) .and_then(|s| { let mut iter = s.split(','); if let (Some(lat), Some(long)) = (iter.next(), iter.next()) { @@ -3914,8 +4310,14 @@ fn populate_location_message_content( } }); if let Some((lat, long)) = coords { - let short_lat = lat.find('.').and_then(|dot| lat.get(..dot + 7)).unwrap_or(lat); - let short_long = long.find('.').and_then(|dot| long.get(..dot + 7)).unwrap_or(long); + let short_lat = lat + .find('.') + .and_then(|dot| lat.get(..dot + 7)) + .unwrap_or(lat); + let short_long = long + .find('.') + .and_then(|dot| long.get(..dot + 7)) + .unwrap_or(long); let safe_lat = htmlize::escape_attribute(lat); let safe_long = htmlize::escape_attribute(long); let safe_geo_uri = htmlize::escape_attribute(&location.geo_uri); @@ -3934,7 +4336,10 @@ fn populate_location_message_content( } else { message_content_widget.show_html( cx, - format!("[Location invalid] {}", htmlize::escape_text(&location.body)) + format!( + "[Location invalid] {}", + htmlize::escape_text(&location.body) + ), ); } @@ -3944,7 +4349,6 @@ fn populate_location_message_content( true } - /// Draws the given redacted message's content into the `message_content_widget`. /// /// Returns whether the redacted message content was fully drawn. @@ -3957,16 +4361,13 @@ fn populate_redacted_message_content( let fully_drawn: bool; let mut redactor_id_and_reason = None; if let Some(redacted_msg) = event_tl_item.latest_json() { - if let Ok(AnySyncTimelineEvent::MessageLike( - AnySyncMessageLikeEvent::RoomMessage( - SyncMessageLikeEvent::Redacted(redaction) - ) - )) = redacted_msg.deserialize() { + if let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage( + SyncMessageLikeEvent::Redacted(redaction), + ))) = redacted_msg.deserialize() + { if let Ok(redacted_because) = redaction.unsigned.redacted_because.deserialize() { - redactor_id_and_reason = Some(( - redacted_because.sender, - redacted_because.content.reason, - )); + redactor_id_and_reason = + Some((redacted_because.sender, redacted_because.content.reason)); } } } @@ -3975,7 +4376,10 @@ fn populate_redacted_message_content( if redactor == event_tl_item.sender() { fully_drawn = true; match reason { - Some(r) => format!("⛔ Deleted their own message. Reason: \"{}\".", htmlize::escape_text(r)), + Some(r) => format!( + "⛔ Deleted their own message. Reason: \"{}\".", + htmlize::escape_text(r) + ), None => String::from("⛔ Deleted their own message."), } } else { @@ -3987,9 +4391,11 @@ fn populate_redacted_message_content( true, ); fully_drawn = redactor_name.was_found(); - let redactor_name_esc = htmlize::escape_text(redactor_name.as_deref().unwrap_or(redactor.as_str())); + let redactor_name_esc = + htmlize::escape_text(redactor_name.as_deref().unwrap_or(redactor.as_str())); match reason { - Some(r) => format!("⛔ {} deleted this message. Reason: \"{}\".", + Some(r) => format!( + "⛔ {} deleted this message. Reason: \"{}\".", redactor_name_esc, htmlize::escape_text(r), ), @@ -4004,7 +4410,6 @@ fn populate_redacted_message_content( fully_drawn } - /// Draws a ReplyPreview above a message if it was in-reply to another message. /// /// ## Arguments @@ -4031,24 +4436,24 @@ fn draw_replied_to_message( show_reply = true; match &in_reply_to_details.event { TimelineDetails::Ready(replied_to_event) => { - let (in_reply_to_username, is_avatar_fully_drawn) = - replied_to_message_view - .avatar(cx, ids!(replied_to_message_content.reply_preview_avatar)) - .set_avatar_and_get_username( - cx, - timeline_kind, - &replied_to_event.sender, - Some(&replied_to_event.sender_profile), - Some(in_reply_to_details.event_id.as_ref()), - true, - ); + let (in_reply_to_username, is_avatar_fully_drawn) = replied_to_message_view + .avatar(cx, ids!(replied_to_message_content.reply_preview_avatar)) + .set_avatar_and_get_username( + cx, + timeline_kind, + &replied_to_event.sender, + Some(&replied_to_event.sender_profile), + Some(in_reply_to_details.event_id.as_ref()), + true, + ); fully_drawn = is_avatar_fully_drawn; replied_to_message_view .label(cx, ids!(replied_to_message_content.reply_preview_username)) .set_text(cx, in_reply_to_username.as_str()); - let msg_body = replied_to_message_view.html_or_plaintext(cx, ids!(reply_preview_body)); + let msg_body = + replied_to_message_view.html_or_plaintext(cx, ids!(reply_preview_body)); populate_preview_of_timeline_item( cx, &msg_body, @@ -4160,7 +4565,8 @@ fn populate_thread_root_summary( &embedded_event.content, &embedded_event.sender, sender_username, - ).format_with(sender_username, true); + ) + .format_with(sender_username, true); match utils::replace_linebreaks_separators(&preview, true) { Cow::Borrowed(_) => Cow::Owned(preview), Cow::Owned(replaced) => Cow::Owned(replaced), @@ -4171,9 +4577,11 @@ fn populate_thread_root_summary( if td.is_unavailable() && let Some(thread_root_event_id) = thread_root_event_id.clone() { - let needs_refresh = fetched_summary - .is_none_or(|fs| fs.latest_reply_preview_text.is_none()); - if needs_refresh && pending_thread_summary_fetches.insert(thread_root_event_id.clone()) { + let needs_refresh = + fetched_summary.is_none_or(|fs| fs.latest_reply_preview_text.is_none()); + if needs_refresh + && pending_thread_summary_fetches.insert(thread_root_event_id.clone()) + { submit_async_request(MatrixRequest::FetchThreadSummaryDetails { timeline_kind: timeline_kind.clone(), thread_root_event_id, @@ -4181,7 +4589,8 @@ fn populate_thread_root_summary( }); } } - fetched_summary.and_then(|fs| fs.latest_reply_preview_text.as_deref()) + fetched_summary + .and_then(|fs| fs.latest_reply_preview_text.as_deref()) .unwrap_or("Loading latest reply...") .into() } @@ -4193,7 +4602,7 @@ fn populate_thread_root_summary( let replies_count_text = match replies_count { 1 => Cow::Borrowed("1 reply"), - n => Cow::Owned(format!("{n} replies")) + n => Cow::Owned(format!("{n} replies")), }; item.label(cx, ids!(thread_summary_count)) .set_text(cx, &replies_count_text); @@ -4213,23 +4622,32 @@ pub fn populate_preview_of_timeline_item( ) { if let Some(m) = timeline_item_content.as_message() { match m.msgtype() { - MessageType::Text(TextMessageEventContent { body, formatted, .. }) - | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. }) => { - let _ = populate_text_message_content(cx, widget_out, body, formatted.as_ref(), None, None, None); + MessageType::Text(TextMessageEventContent { + body, formatted, .. + }) + | MessageType::Notice(NoticeMessageEventContent { + body, formatted, .. + }) => { + let _ = populate_text_message_content( + cx, + widget_out, + body, + formatted.as_ref(), + None, + None, + None, + ); return; } - _ => { } // fall through to the general case for all timeline items below. + _ => {} // fall through to the general case for all timeline items below. } } - let html = text_preview_of_timeline_item( - timeline_item_content, - sender_user_id, - sender_username, - ).format_with(sender_username, true); + let html = + text_preview_of_timeline_item(timeline_item_content, sender_user_id, sender_username) + .format_with(sender_username, true); widget_out.show_html(cx, html); } - /// A trait for abstracting over the different types of timeline events /// that can be displayed in a `SmallStateEvent` widget. trait SmallStateEventContent { @@ -4320,7 +4738,9 @@ impl SmallStateEventContent for PollState { ) -> (WidgetRef, ItemDrawnStatus) { item.label(cx, ids!(content)).set_text( cx, - self.fallback_text().unwrap_or_else(|| self.results().question).as_str(), + self.fallback_text() + .unwrap_or_else(|| self.results().question) + .as_str(), ); new_drawn_status.content_drawn = true; (item, new_drawn_status) @@ -4389,20 +4809,15 @@ impl SmallStateEventContent for RoomMembershipChange { ) -> (WidgetRef, ItemDrawnStatus) { let Some(preview) = text_preview_of_room_membership_change(self, false) else { // Don't actually display anything for nonexistent/unimportant membership changes. - return ( - list.item(cx, item_id, id!(Empty)), - ItemDrawnStatus::new(), - ); + return (list.item(cx, item_id, id!(Empty)), ItemDrawnStatus::new()); }; item.label(cx, ids!(content)) .set_text(cx, &preview.format_with(username, false)); // The invite_user_button is only used for "Knocked" membership change events. - item.button(cx, ids!(invite_user_button)).set_visible( - cx, - matches!(self.change(), Some(MembershipChange::Knocked)), - ); + item.button(cx, ids!(invite_user_button)) + .set_visible(cx, matches!(self.change(), Some(MembershipChange::Knocked))); new_drawn_status.content_drawn = true; (item, new_drawn_status) @@ -4454,7 +4869,8 @@ fn populate_small_state_event( ); // Draw the timestamp as part of the profile. if let Some(dt) = unix_time_millis_to_datetime(event_tl_item.timestamp()) { - item.timestamp(cx, ids!(left_container.timestamp)).set_date_time(cx, dt); + item.timestamp(cx, ids!(left_container.timestamp)) + .set_date_time(cx, dt); } new_drawn_status.profile_drawn = profile_drawn; username @@ -4473,7 +4889,6 @@ fn populate_small_state_event( ) } - /// Returns the display name of the sender of the given `event_tl_item`, if available. fn get_profile_display_name(event_tl_item: &EventTimelineItem) -> Option { if let TimelineDetails::Ready(profile) = event_tl_item.sender_profile() { @@ -4483,7 +4898,6 @@ fn get_profile_display_name(event_tl_item: &EventTimelineItem) -> Option } } - /// Actions related to invites within a room. /// /// These are NOT widget actions, just regular actions. @@ -4518,7 +4932,6 @@ pub enum InviteResultAction { }, } - /// Actions related to a specific message within a room timeline. #[derive(Clone, Default, Debug)] pub enum MessageAction { @@ -4563,7 +4976,6 @@ pub enum MessageAction { // /// The user clicked the "report" button on a message. // Report(MessageDetails), - /// The message at the given item index in the timeline should be highlighted. HighlightMessage(usize), /// The user requested that we show a context menu with actions @@ -4597,11 +5009,15 @@ impl ActionDefaultRef for MessageAction { /// A widget representing a single message of any kind within a room timeline. #[derive(Script, ScriptHook, Widget, Animator)] pub struct Message { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, - - #[rust] details: Option, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, + + #[rust] + details: Option, } impl Widget for Message { @@ -4616,7 +5032,9 @@ impl Widget for Message { self.animator_play(cx, ids!(highlight.off)); } - let Some(details) = self.details.clone() else { return }; + let Some(details) = self.details.clone() else { + return; + }; // We first handle a click on the replied-to message preview, if present, // because we don't want any widgets within the replied-to message to be @@ -4625,31 +5043,31 @@ impl Widget for Message { Hit::FingerDown(fe) => { if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - } + }, ); } } Hit::FingerLongPress(lp) => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - } + }, ); } // If the hit occurred on the replied-to message preview, jump to it. Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::JumpToRelated(details.clone()), ); } - _ => { } + _ => {} } // Handle clicks on the thread summary shown beneath a thread-root message. @@ -4666,11 +5084,11 @@ impl Widget for Message { apply_hover(cx, COLOR_THREAD_SUMMARY_BG_HOVER); if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - } + }, ); } } @@ -4682,23 +5100,23 @@ impl Widget for Message { } Hit::FingerLongPress(lp) => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - } + }, ); } Hit::FingerUp(fe) => { apply_hover(cx, COLOR_THREAD_SUMMARY_BG); if fe.is_over && fe.is_primary_hit() && fe.was_tap() { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenThread(thread_root_event_id.clone()), ); } } - _ => { } + _ => {} } } @@ -4717,21 +5135,21 @@ impl Widget for Message { // A right click means we should display the context menu. if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - } + }, ); } } Hit::FingerLongPress(lp) => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - } + }, ); } Hit::FingerHoverIn(..) => { @@ -4742,12 +5160,16 @@ impl Widget for Message { self.animator_play(cx, ids!(hover.off)); // TODO: here, hide the "action bar" buttons upon hover-out } - _ => { } + _ => {} } if let Event::Actions(actions) = event { for action in actions { - match action.as_widget_action().widget_uid_eq(details.room_screen_widget_uid).cast_ref() { + match action + .as_widget_action() + .widget_uid_eq(details.room_screen_widget_uid) + .cast_ref() + { MessageAction::HighlightMessage(id) if id == &details.item_id => { self.animator_play(cx, ids!(highlight.on)); self.redraw(cx); @@ -4759,7 +5181,11 @@ impl Widget for Message { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - if self.details.as_ref().is_some_and(|d| d.should_be_highlighted) { + if self + .details + .as_ref() + .is_some_and(|d| d.should_be_highlighted) + { script_apply_eval!(cx, self, { draw_bg +: { color: #ffffd1, @@ -4780,7 +5206,9 @@ impl Message { impl MessageRef { fn set_data(&self, details: MessageDetails) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_data(details); } } @@ -4789,7 +5217,7 @@ impl MessageRef { /// /// This function requires passing in a reference to `Cx`, /// which isn't used, but acts as a guarantee that this function -/// must only be called by the main UI thread. +/// must only be called by the main UI thread. pub fn clear_timeline_states(_cx: &mut Cx) { // Clear timeline states cache TIMELINE_STATES.with_borrow_mut(|states| { diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 7cf7d5106..0d08156fd 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -16,30 +16,50 @@ //! so you can use it from other widgets or functions on the main UI thread //! that need to query basic info about a particular room or space. -use std::{cell::RefCell, collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, rc::Rc, sync::Arc}; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, + rc::Rc, + sync::Arc, +}; use crossbeam_queue::SegQueue; 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, OwnedRoomAliasId, OwnedRoomId, OwnedUserId}}; +use matrix_sdk::{ + RoomState, + ruma::{ + events::tag::Tags, MilliSecondsSinceUnixEpoch, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, + }, +}; use crate::{ app::{AppState, SelectedRoom}, home::{ - navigation_tab_bar::{NavigationBarAction, SelectedTab}, room_context_menu::RoomContextMenuDetails, rooms_list_entry::RoomsListEntryAction, space_lobby::{SpaceLobbyAction, SpaceLobbyEntryWidgetExt} + navigation_tab_bar::{NavigationBarAction, SelectedTab}, + room_context_menu::RoomContextMenuDetails, + rooms_list_entry::RoomsListEntryAction, + space_lobby::{SpaceLobbyAction, SpaceLobbyEntryWidgetExt}, }, room::{ FetchedRoomAvatar, - room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria, SortFn}, + room_display_filter::{ + RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria, SortFn, + }, }, shared::{ - collapsible_header::{CollapsibleHeaderAction, CollapsibleHeaderWidgetRefExt, HeaderCategory}, + collapsible_header::{ + CollapsibleHeaderAction, CollapsibleHeaderWidgetRefExt, HeaderCategory, + }, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction, }, - sliding_sync::{MatrixLinkAction, MatrixRequest, PaginationDirection, TimelineKind, submit_async_request}, - space_service_sync::{ParentChain, SpaceRequest, SpaceRoomListAction}, utils::{RoomNameId, VecDiff}, + sliding_sync::{ + MatrixLinkAction, MatrixRequest, PaginationDirection, TimelineKind, submit_async_request, + }, + space_service_sync::{ParentChain, SpaceRequest, SpaceRoomListAction}, + utils::{RoomNameId, VecDiff}, }; /// Whether to pre-paginate visible rooms at least once in order to @@ -71,11 +91,10 @@ pub fn get_invited_rooms(_cx: &mut Cx) -> Rc }, + LoadedRooms { max_rooms: Option }, /// Add a new room to the list of rooms the user has been invited to. /// This will be maintained and displayed separately from joined rooms. AddInvitedRoom(InvitedRoomInfo), @@ -171,9 +189,7 @@ pub enum RoomsListUpdate { unread_mentions: u64, }, /// Update the displayable name for the given room. - UpdateRoomName { - new_room_name: RoomNameId, - }, + UpdateRoomName { new_room_name: RoomNameId }, /// Update the avatar (image) for the given room. UpdateRoomAvatar { room_id: OwnedRoomId, @@ -196,21 +212,15 @@ pub enum RoomsListUpdate { new_tags: Tags, }, /// Update the status label at the bottom of the list of all rooms. - Status { - status: String, - }, + Status { status: String }, /// Mark the given room as tombstoned. - TombstonedRoom { - room_id: OwnedRoomId - }, + TombstonedRoom { room_id: OwnedRoomId }, /// Hide the given room from being displayed. /// /// This is useful for temporarily preventing a room from being shown, /// e.g., after a room has been left but before the homeserver has registered /// that we left it and removed it via the RoomListService. - HideRoom { - room_id: OwnedRoomId, - }, + HideRoom { room_id: OwnedRoomId }, /// Scroll to the given room. ScrollToRoom(OwnedRoomId), /// The background space service is now listening for requests, @@ -237,9 +247,7 @@ pub enum RoomsListAction { /// A new room was joined from an accepted invite, /// meaning that the existing `InviteScreen` should be converted /// to a `RoomScreen` to display the now-joined room. - InviteAccepted { - room_name_id: RoomNameId, - }, + InviteAccepted { room_name_id: RoomNameId }, /// Instructs the top-level app to show the context menu for the given room. /// /// Emitted by the RoomsList when the user right-clicks or long-presses @@ -259,7 +267,6 @@ impl ActionDefaultRef for RoomsListAction { } } - /// UI-related info about a joined room. /// /// This includes info needed display a preview of that room in the RoomsList @@ -298,7 +305,6 @@ pub struct JoinedRoomInfo { pub is_direct: bool, /// Whether this room is tombstoned (shut down and replaced with a successor room). pub is_tombstoned: bool, - // TODO: we could store the parent chain(s) of this room, i.e., which spaces // they are children of. One room can be in multiple spaces. } @@ -390,28 +396,34 @@ struct SpaceMapValue { #[derive(Script, Widget)] pub struct RoomsList { - #[deref] view: View, + #[deref] + view: View, /// The list of all rooms that the user has been invited to. /// /// This is a shared reference to the thread-local [`ALL_INVITED_ROOMS`] variable. - #[rust] invited_rooms: Rc>>, + #[rust] + invited_rooms: Rc>>, /// The set of all joined rooms and their cached info. /// This includes both direct rooms and regular rooms, but not invited rooms. - #[rust] all_joined_rooms: HashMap, + #[rust] + all_joined_rooms: HashMap, /// The list of all room IDs in display order, matching the order from the room list service. - #[rust] all_known_rooms_order: VecDeque, + #[rust] + all_known_rooms_order: VecDeque, /// 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. - #[rust] selected_space: Option, + #[rust] + selected_space: Option, /// The sender used to send Space-related requests to the background service. - #[rust] space_request_sender: Option>, + #[rust] + space_request_sender: Option>, /// A flattened map of all spaces known to the client. /// @@ -419,50 +431,66 @@ pub struct RoomsList { /// and nested subspaces *directly* within that space. /// /// This can include both joined and non-joined spaces. - #[rust] space_map: HashMap, + #[rust] + space_map: HashMap, /// Rooms that are explicitly hidden and should never be shown in the rooms list. - #[rust] hidden_rooms: HashSet, + #[rust] + hidden_rooms: HashSet, /// The currently-active filter function for the list of rooms. /// /// ## Important Notes /// 1. Do not use this directly. Instead, use the `should_display_room!()` macro. /// 2. This does *not* get auto-applied when it changes, for performance reasons. - #[rust] display_filter: RoomDisplayFilter, + #[rust] + display_filter: RoomDisplayFilter, /// The currently-active sort function for the list of rooms. - #[rust] sort_fn: Option>, + #[rust] + sort_fn: Option>, /// The list of invited rooms currently displayed in the UI. - #[rust] displayed_invited_rooms: Vec, - #[rust(false)] is_invited_rooms_header_expanded: bool, - #[rust] invited_rooms_indexes: RoomCategoryIndexes, + #[rust] + displayed_invited_rooms: Vec, + #[rust(false)] + is_invited_rooms_header_expanded: bool, + #[rust] + invited_rooms_indexes: RoomCategoryIndexes, /// The list of direct rooms currently displayed in the UI. - #[rust] displayed_direct_rooms: Vec, - #[rust(false)] is_direct_rooms_header_expanded: bool, - #[rust] direct_rooms_indexes: RoomCategoryIndexes, + #[rust] + displayed_direct_rooms: Vec, + #[rust(false)] + is_direct_rooms_header_expanded: bool, + #[rust] + direct_rooms_indexes: RoomCategoryIndexes, /// The list of regular (non-direct) joined rooms currently displayed in the UI. /// /// **Direct rooms are excluded** from this; they are in `displayed_direct_rooms`. - #[rust] displayed_regular_rooms: Vec, - #[rust(true)] is_regular_rooms_header_expanded: bool, - #[rust] regular_rooms_indexes: RoomCategoryIndexes, + #[rust] + displayed_regular_rooms: Vec, + #[rust(true)] + is_regular_rooms_header_expanded: bool, + #[rust] + regular_rooms_indexes: RoomCategoryIndexes, /// The latest status message that should be displayed in the bottom status label. - #[rust] status: String, + #[rust] + status: String, /// The currently-selected room. - #[rust] current_active_room: Option, + #[rust] + current_active_room: Option, /// The maximum number of rooms that will ever be loaded. /// /// This should not be used to determine whether all requested rooms have been loaded, /// because we will likely never receive this many rooms due to the room list service /// excluding rooms that we have filtered out (e.g., left or tombstoned rooms, spaces, etc). - #[rust] max_known_rooms: Option, + #[rust] + max_known_rooms: Option, // /// Whether the room list service has loaded all requested rooms from the homeserver. // #[rust] all_rooms_loaded: bool, } @@ -485,15 +513,16 @@ macro_rules! should_display_room { ($self:expr, $room_id:expr, $room:expr) => { !$self.hidden_rooms.contains($room_id) && ($self.display_filter)($room) - && $self.selected_space.as_ref() + && $self + .selected_space + .as_ref() .is_none_or(|space| $self.is_room_indirectly_in_space(space.room_id(), $room_id)) }; } - impl RoomsList { /// Returns whether the homeserver has finished syncing all of the rooms - /// that should be synced to our client based on the currently-specified room list filter. + /// that should be synced to our client based on the currently-specified room list filter. pub fn all_rooms_loaded(&self) -> bool { // TODO: fix this: figure out a way to determine if // all requested rooms have been received from the homeserver. @@ -522,7 +551,10 @@ impl RoomsList { RoomsListUpdate::AddInvitedRoom(invited_room) => { let room_id = invited_room.room_name_id.room_id().clone(); let should_display = should_display_room!(self, &room_id, &invited_room); - let _replaced = self.invited_rooms.borrow_mut().insert(room_id.clone(), invited_room); + let _replaced = self + .invited_rooms + .borrow_mut() + .insert(room_id.clone(), invited_room); if should_display { self.displayed_invited_rooms.push(room_id); } @@ -548,24 +580,29 @@ impl RoomsList { // 3. Emit an action to inform other widgets that the InviteScreen // displaying the invite to this room should be converted to a // RoomScreen displaying the now-joined room. - if let Some(_accepted_invite) = self.invited_rooms.borrow_mut().remove(&room_id) { + if let Some(_accepted_invite) = self.invited_rooms.borrow_mut().remove(&room_id) + { log!("Removed room {room_id} from the list of invited rooms"); - self.displayed_invited_rooms.iter() + self.displayed_invited_rooms + .iter() .position(|r| r == &room_id) .map(|index| self.displayed_invited_rooms.remove(index)); if let Some(room) = self.all_joined_rooms.get(&room_id) { cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::InviteAccepted { room_name_id: room.room_name_id.clone(), - } + }, ); } } self.update_status(); SignalToUI::set_ui_signal(); // signal the RoomScreen to update itself } - RoomsListUpdate::UpdateRoomAvatar { room_id, room_avatar } => { + RoomsListUpdate::UpdateRoomAvatar { + room_id, + room_avatar, + } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.room_avatar = room_avatar; } else if let Some(room) = self.invited_rooms.borrow_mut().get_mut(&room_id) { @@ -574,14 +611,23 @@ impl RoomsList { error!("Error: couldn't find room {room_id} to update avatar"); } } - RoomsListUpdate::UpdateLatestEvent { room_id, timestamp, latest_message_text } => { + RoomsListUpdate::UpdateLatestEvent { + room_id, + timestamp, + latest_message_text, + } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.latest = Some((timestamp, latest_message_text)); } else { error!("Error: couldn't find room {room_id} to update latest event"); } } - RoomsListUpdate::UpdateNumUnreadMessages { room_id, is_marked_unread, unread_messages, unread_mentions } => { + RoomsListUpdate::UpdateNumUnreadMessages { + room_id, + is_marked_unread, + unread_messages, + unread_mentions, + } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.num_unread_messages = match unread_messages { UnreadMessageCount::Unknown => 0, @@ -590,11 +636,13 @@ impl RoomsList { room.num_unread_mentions = unread_mentions; room.is_marked_unread = is_marked_unread; } else { - warning!("Warning: couldn't find room {} to update unread messages count", room_id); + warning!( + "Warning: couldn't find room {} to update unread messages count", + room_id + ); } } RoomsListUpdate::UpdateRoomName { new_room_name } => { - // TODO: broadcast a new AppState action to ensure that this room's or space's new name // gets updated in all of the `SelectedRoom` instances throughout Robrix, // e.g., the name of the room in the Dock Tab or the StackNav header. @@ -607,12 +655,16 @@ impl RoomsList { let should_display = should_display_room!(self, &room_id, room); let (pos_in_list, displayed_list) = if is_direct { ( - self.displayed_direct_rooms.iter().position(|r| r == &room_id), + self.displayed_direct_rooms + .iter() + .position(|r| r == &room_id), &mut self.displayed_direct_rooms, ) } else { ( - self.displayed_regular_rooms.iter().position(|r| r == &room_id), + self.displayed_regular_rooms + .iter() + .position(|r| r == &room_id), &mut self.displayed_regular_rooms, ) }; @@ -630,7 +682,9 @@ impl RoomsList { if let Some(invited_room) = invited_rooms.get_mut(&room_id) { invited_room.room_name_id = new_room_name; let should_display = should_display_room!(self, &room_id, invited_room); - let pos_in_list = self.displayed_invited_rooms.iter() + let pos_in_list = self + .displayed_invited_rooms + .iter() .position(|r| r == &room_id); if should_display { if pos_in_list.is_none() { @@ -640,7 +694,9 @@ impl RoomsList { pos_in_list.map(|i| self.displayed_invited_rooms.remove(i)); } } else { - warning!("Warning: couldn't find room {new_room_name} to update its name."); + warning!( + "Warning: couldn't find room {new_room_name} to update its name." + ); } } } @@ -651,7 +707,8 @@ impl RoomsList { continue; } enqueue_popup_notification( - format!("{} was changed from {} to {}.", + format!( + "{} was changed from {} to {}.", room.room_name_id, if room.is_direct { "direct" } else { "regular" }, if is_direct { "direct" } else { "regular" } @@ -666,7 +723,8 @@ impl RoomsList { } else { &mut self.displayed_regular_rooms }; - list_to_remove_from.iter() + list_to_remove_from + .iter() .position(|r| r == &room_id) .map(|index| list_to_remove_from.remove(index)); @@ -690,19 +748,23 @@ impl RoomsList { // and then options/buttons for the user to re-join it if desired. if let Some(removed) = self.all_joined_rooms.remove(&room_id) { - log!("Removed room {room_id} from the list of all joined rooms, now has state {new_state:?}"); + log!( + "Removed room {room_id} from the list of all joined rooms, now has state {new_state:?}" + ); let list_to_remove_from = if removed.is_direct { &mut self.displayed_direct_rooms } else { &mut self.displayed_regular_rooms }; - list_to_remove_from.iter() + list_to_remove_from + .iter() .position(|r| r == &room_id) .map(|index| list_to_remove_from.remove(index)); - } - else if let Some(_removed) = self.invited_rooms.borrow_mut().remove(&room_id) { + } else if let Some(_removed) = self.invited_rooms.borrow_mut().remove(&room_id) + { log!("Removed room {room_id} from the list of all invited rooms"); - self.displayed_invited_rooms.iter() + self.displayed_invited_rooms + .iter() .position(|r| r == &room_id) .map(|index| self.displayed_invited_rooms.remove(index)); } @@ -723,7 +785,7 @@ impl RoomsList { } RoomsListUpdate::LoadedRooms { max_rooms } => { self.max_known_rooms = max_rooms; - }, + } RoomsListUpdate::Tags { room_id, new_tags } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.tags = new_tags; @@ -743,12 +805,16 @@ impl RoomsList { let should_display = should_display_room!(self, &room_id, room); let (pos_in_list, displayed_list) = if is_direct { ( - self.displayed_direct_rooms.iter().position(|r| r == &room_id), + self.displayed_direct_rooms + .iter() + .position(|r| r == &room_id), &mut self.displayed_direct_rooms, ) } else { ( - self.displayed_regular_rooms.iter().position(|r| r == &room_id), + self.displayed_regular_rooms + .iter() + .position(|r| r == &room_id), &mut self.displayed_regular_rooms, ) }; @@ -760,20 +826,32 @@ impl RoomsList { pos_in_list.map(|i| displayed_list.remove(i)); } } else { - warning!("Warning: couldn't find room {room_id} to update the tombstone status"); + warning!( + "Warning: couldn't find room {room_id} to update the tombstone status" + ); } } RoomsListUpdate::HideRoom { room_id } => { self.hidden_rooms.insert(room_id.clone()); // Hiding a regular room is the most common case (e.g., after its successor is joined), // so we check that list first. - if let Some(i) = self.displayed_regular_rooms.iter().position(|r| r == &room_id) { + if let Some(i) = self + .displayed_regular_rooms + .iter() + .position(|r| r == &room_id) + { self.displayed_regular_rooms.remove(i); - } - else if let Some(i) = self.displayed_direct_rooms.iter().position(|r| r == &room_id) { + } else if let Some(i) = self + .displayed_direct_rooms + .iter() + .position(|r| r == &room_id) + { self.displayed_direct_rooms.remove(i); - } - else if let Some(i) = self.displayed_invited_rooms.iter().position(|r| r == &room_id) { + } else if let Some(i) = self + .displayed_invited_rooms + .iter() + .position(|r| r == &room_id) + { self.displayed_invited_rooms.remove(i); } } @@ -782,75 +860,89 @@ impl RoomsList { self.recalculate_indexes(); let portal_list = self.view.portal_list(cx, ids!(list)); let speed = 50.0; - let portal_list_index = if let Some(regular_index) = self.displayed_regular_rooms.iter().position(|r| r == &room_id) { + let portal_list_index = if let Some(regular_index) = self + .displayed_regular_rooms + .iter() + .position(|r| r == &room_id) + { self.regular_rooms_indexes.first_room_index + regular_index - } - else if let Some(direct_index) = self.displayed_direct_rooms.iter().position(|r| r == &room_id) { + } else if let Some(direct_index) = self + .displayed_direct_rooms + .iter() + .position(|r| r == &room_id) + { self.direct_rooms_indexes.first_room_index + direct_index - } - else if let Some(invited_index) = self.displayed_invited_rooms.iter().position(|r| r == &room_id) { + } else if let Some(invited_index) = self + .displayed_invited_rooms + .iter() + .position(|r| r == &room_id) + { self.invited_rooms_indexes.first_room_index + invited_index - } - else { continue }; + } else { + continue; + }; // 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)); + 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. + num_updates -= 1; // this does not require a redraw. } - RoomsListUpdate::RoomOrderUpdate(diff) => { - match diff { - VecDiff::Append { values } => { - self.all_known_rooms_order.extend(values); - needs_sort = true; - } - VecDiff::Clear => { - self.all_known_rooms_order.clear(); - needs_sort = true; - } - VecDiff::PushFront { value } => { - self.all_known_rooms_order.push_front(value); - needs_sort = true; - } - VecDiff::PushBack { value } => { - self.all_known_rooms_order.push_back(value); - needs_sort = true; - } - VecDiff::PopFront => { - self.all_known_rooms_order.pop_front(); - needs_sort = true; - } - VecDiff::PopBack => { - self.all_known_rooms_order.pop_back(); + RoomsListUpdate::RoomOrderUpdate(diff) => match diff { + VecDiff::Append { values } => { + self.all_known_rooms_order.extend(values); + needs_sort = true; + } + VecDiff::Clear => { + self.all_known_rooms_order.clear(); + needs_sort = true; + } + VecDiff::PushFront { value } => { + self.all_known_rooms_order.push_front(value); + needs_sort = true; + } + VecDiff::PushBack { value } => { + self.all_known_rooms_order.push_back(value); + needs_sort = true; + } + VecDiff::PopFront => { + self.all_known_rooms_order.pop_front(); + needs_sort = true; + } + VecDiff::PopBack => { + self.all_known_rooms_order.pop_back(); + needs_sort = true; + } + VecDiff::Insert { index, value } => { + if index <= self.all_known_rooms_order.len() { + self.all_known_rooms_order.insert(index, value); needs_sort = true; } - VecDiff::Insert { index, value } => { - if index <= self.all_known_rooms_order.len() { - self.all_known_rooms_order.insert(index, value); - needs_sort = true; - } - } - VecDiff::Set { index, value } => { - if let Some(existing) = self.all_known_rooms_order.get_mut(index) { - if *existing != value { - *existing = value; - needs_sort = true; - } - } - } - VecDiff::Remove { index } => { - if index < self.all_known_rooms_order.len() { - self.all_known_rooms_order.remove(index); + } + VecDiff::Set { index, value } => { + if let Some(existing) = self.all_known_rooms_order.get_mut(index) { + if *existing != value { + *existing = value; needs_sort = true; } } - VecDiff::Truncate { length } => { - self.all_known_rooms_order.truncate(length); + } + VecDiff::Remove { index } => { + if index < self.all_known_rooms_order.len() { + self.all_known_rooms_order.remove(index); needs_sort = true; } } - } + VecDiff::Truncate { length } => { + self.all_known_rooms_order.truncate(length); + needs_sort = true; + } + }, } } if needs_sort { @@ -875,9 +967,9 @@ impl RoomsList { + self.displayed_regular_rooms.len(); let mut text = match (self.display_filter.is_none(), num_rooms) { - (true, 0) => "No joined or invited rooms found".to_string(), - (true, 1) => "Loaded 1 room".to_string(), - (true, n) => format!("Loaded {n} rooms"), + (true, 0) => "No joined or invited rooms found".to_string(), + (true, 1) => "Loaded 1 room".to_string(), + (true, n) => format!("Loaded {n} rooms"), (false, 0) => "No matching rooms found".to_string(), (false, 1) => "Found 1 matching room".to_string(), (false, n) => format!("Found {n} matching rooms"), @@ -926,7 +1018,6 @@ impl RoomsList { self.redraw(cx); } - /// Generates a tuple of three kinds of displayed rooms (accounting for the current `display_filter`): /// 1. displayed_invited_rooms /// 2. displayed_regular_rooms @@ -934,7 +1025,7 @@ impl RoomsList { /// /// If `self.sort_fn` is `Some`, the rooms are ordered based on that function. /// Otherwise, the rooms are ordered based on `self.all_known_rooms_order` (the default). - fn generate_displayed_rooms(&self) -> (Vec,Vec, Vec) { + fn generate_displayed_rooms(&self) -> (Vec, Vec, Vec) { let mut new_displayed_invited_rooms = Vec::new(); let mut new_displayed_regular_rooms = Vec::new(); let mut new_displayed_direct_rooms = Vec::new(); @@ -952,7 +1043,9 @@ impl RoomsList { // If a sort function was provided, use it. if let Some(sort_fn) = self.sort_fn.as_deref() { - let mut filtered_joined_rooms = self.all_joined_rooms.iter() + let mut filtered_joined_rooms = self + .all_joined_rooms + .iter() .filter(|&(room_id, room)| should_display_room!(self, room_id, room)) .collect::>(); filtered_joined_rooms.sort_by(|(_, room_a), (_, room_b)| sort_fn(*room_a, *room_b)); @@ -960,7 +1053,8 @@ impl RoomsList { push_joined_room(room_id, jr) } - let mut filtered_invited_rooms = invited_rooms_ref.iter() + let mut filtered_invited_rooms = invited_rooms_ref + .iter() .filter(|&(room_id, room)| should_display_room!(self, room_id, room)) .collect::>(); filtered_invited_rooms.sort_by(|(_, room_a), (_, room_b)| sort_fn(*room_a, *room_b)); @@ -983,7 +1077,11 @@ impl RoomsList { } } - (new_displayed_invited_rooms, new_displayed_regular_rooms, new_displayed_direct_rooms) + ( + new_displayed_invited_rooms, + new_displayed_regular_rooms, + new_displayed_direct_rooms, + ) } /// Calculates the indexes in the PortalList where the headers and rooms should be drawn. @@ -996,35 +1094,35 @@ impl RoomsList { // Based on the various displayed room lists and is_expanded state of each room header, // calculate the indexes in the PortalList where the headers and rooms should be drawn. let should_show_invited_rooms_header = !self.displayed_invited_rooms.is_empty(); - let should_show_direct_rooms_header = !self.displayed_direct_rooms.is_empty(); + let should_show_direct_rooms_header = !self.displayed_direct_rooms.is_empty(); let should_show_regular_rooms_header = !self.displayed_regular_rooms.is_empty(); let index_of_invited_rooms_header = should_show_invited_rooms_header.then_some(0); let index_of_first_invited_room = should_show_invited_rooms_header as usize; - let index_after_invited_rooms = index_of_first_invited_room + - if self.is_invited_rooms_header_expanded { + let index_after_invited_rooms = index_of_first_invited_room + + if self.is_invited_rooms_header_expanded { self.displayed_invited_rooms.len() } else { 0 }; - let index_of_direct_rooms_header = should_show_direct_rooms_header - .then_some(index_after_invited_rooms); - let index_of_first_direct_room = index_after_invited_rooms + - should_show_direct_rooms_header as usize; - let index_after_direct_rooms = index_of_first_direct_room + - if self.is_direct_rooms_header_expanded { + let index_of_direct_rooms_header = + should_show_direct_rooms_header.then_some(index_after_invited_rooms); + let index_of_first_direct_room = + index_after_invited_rooms + should_show_direct_rooms_header as usize; + let index_after_direct_rooms = index_of_first_direct_room + + if self.is_direct_rooms_header_expanded { self.displayed_direct_rooms.len() } else { 0 }; - let index_of_regular_rooms_header = should_show_regular_rooms_header - .then_some(index_after_direct_rooms); - let index_of_first_regular_room = index_after_direct_rooms + - should_show_regular_rooms_header as usize; - let index_after_regular_rooms = index_of_first_regular_room + - if self.is_regular_rooms_header_expanded { + let index_of_regular_rooms_header = + should_show_regular_rooms_header.then_some(index_after_direct_rooms); + let index_of_first_regular_room = + index_after_direct_rooms + should_show_regular_rooms_header as usize; + let index_after_regular_rooms = index_of_first_regular_room + + if self.is_regular_rooms_header_expanded { self.displayed_regular_rooms.len() } else { 0 @@ -1050,32 +1148,43 @@ impl RoomsList { /// Handle any incoming updates to spaces' room lists and pagination state. fn handle_space_room_list_action(&mut self, cx: &mut Cx, action: &SpaceRoomListAction) { match action { - SpaceRoomListAction::UpdatedChildren { space_id, parent_chain, direct_child_rooms, direct_subspaces } => { + SpaceRoomListAction::UpdatedChildren { + space_id, + parent_chain, + direct_child_rooms, + direct_subspaces, + } => { match self.space_map.entry(space_id.clone()) { Entry::Occupied(mut occ) => { let occ_mut = occ.get_mut(); occ_mut.parent_chain = parent_chain.clone(); occ_mut.direct_child_rooms = Arc::clone(direct_child_rooms); - occ_mut.direct_subspaces = Arc::clone(direct_subspaces); + occ_mut.direct_subspaces = Arc::clone(direct_subspaces); } Entry::Vacant(vac) => { vac.insert_entry(SpaceMapValue { is_fully_paginated: false, parent_chain: parent_chain.clone(), direct_child_rooms: Arc::clone(direct_child_rooms), - direct_subspaces: Arc::clone(direct_subspaces), + direct_subspaces: Arc::clone(direct_subspaces), }); } } - if self.selected_space.as_ref().is_some_and(|sel_space| - sel_space.room_id() == space_id - || parent_chain.contains(sel_space.room_id()) - ) { + if self.selected_space.as_ref().is_some_and(|sel_space| { + sel_space.room_id() == space_id || parent_chain.contains(sel_space.room_id()) + }) { self.update_displayed_rooms(cx, false); } } - SpaceRoomListAction::PaginationState { space_id, parent_chain, state } => { - let is_fully_paginated = matches!(state, SpaceRoomListPaginationState::Idle { end_reached: true }); + SpaceRoomListAction::PaginationState { + space_id, + parent_chain, + state, + } => { + let is_fully_paginated = matches!( + state, + SpaceRoomListPaginationState::Idle { end_reached: true } + ); // Only re-fetch the list of rooms in this space if it was not already fully paginated. let should_fetch_rooms: bool; match self.space_map.entry(space_id.clone()) { @@ -1094,15 +1203,22 @@ impl RoomsList { } } let Some(sender) = self.space_request_sender.as_ref() else { - error!("BUG: RoomsList: no space request sender was available after pagination state update."); + error!( + "BUG: RoomsList: no space request sender was available after pagination state update." + ); return; }; if should_fetch_rooms { - if sender.send(SpaceRequest::GetChildren { - space_id: space_id.clone(), - parent_chain: parent_chain.clone(), - }).is_err() { - error!("BUG: RoomsList: failed to send GetRooms request for space {space_id}."); + if sender + .send(SpaceRequest::GetChildren { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + }) + .is_err() + { + error!( + "BUG: RoomsList: failed to send GetRooms request for space {space_id}." + ); } } @@ -1112,11 +1228,16 @@ impl RoomsList { // all of its children, such that we can see if any of them are subspaces, // and then we'll paginate those as well. if !is_fully_paginated { - if sender.send(SpaceRequest::PaginateSpaceRoomList { - space_id: space_id.clone(), - parent_chain: parent_chain.clone(), - }).is_err() { - error!("BUG: RoomsList: failed to send pagination request for space {space_id}."); + if sender + .send(SpaceRequest::PaginateSpaceRoomList { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + }) + .is_err() + { + error!( + "BUG: RoomsList: failed to send pagination request for space {space_id}." + ); } } } @@ -1128,7 +1249,10 @@ impl RoomsList { None, ); } - SpaceRoomListAction::LeaveSpaceResult { space_name_id, result } => match result { + SpaceRoomListAction::LeaveSpaceResult { + space_name_id, + result, + } => match result { Ok(()) => { enqueue_popup_notification( format!("Successfully left space \"{}\".", space_name_id), @@ -1136,7 +1260,11 @@ impl RoomsList { Some(4.0), ); // If the space we left was the currently-selected one, go back to the main Home view. - if self.selected_space.as_ref().is_some_and(|s| s.room_id() == space_name_id.room_id()) { + if self + .selected_space + .as_ref() + .is_some_and(|s| s.room_id() == space_name_id.room_id()) + { cx.action(NavigationBarAction::GoToHome); } } @@ -1151,14 +1279,18 @@ impl RoomsList { }, // Details-related space actions are handled by SpaceLobbyScreen, not RoomsList. SpaceRoomListAction::DetailedChildren { .. } - | SpaceRoomListAction::TopLevelSpaceDetails(_) => { } + | SpaceRoomListAction::TopLevelSpaceDetails(_) => {} } } /// Returns whether the given target room or space is indirectly within the given parent space. /// /// This will recursively search all nested spaces within the given `parent_space`. - fn is_room_indirectly_in_space(&self, parent_space: &OwnedRoomId, target: &OwnedRoomId) -> bool { + fn is_room_indirectly_in_space( + &self, + parent_space: &OwnedRoomId, + target: &OwnedRoomId, + ) -> bool { if let Some(smv) = self.space_map.get(parent_space) { if smv.direct_child_rooms.contains(target) { return true; @@ -1186,12 +1318,14 @@ impl Widget for RoomsList { let props = RoomsListScopeProps { was_scrolling: self.view.portal_list(cx, ids!(list)).was_scrolling(), }; - let rooms_list_actions = cx.capture_actions( - |cx| self.view.handle_event(cx, event, &mut Scope::with_props(&props)) - ); + let rooms_list_actions = cx.capture_actions(|cx| { + self.view + .handle_event(cx, event, &mut Scope::with_props(&props)) + }); for action in rooms_list_actions { // Handle a regular room (joined or invited) being clicked. - if let RoomsListEntryAction::PrimaryClicked(room_id) = action.as_widget_action().cast() { + if let RoomsListEntryAction::PrimaryClicked(room_id) = action.as_widget_action().cast() + { let new_selected_room = if let Some(jr) = self.all_joined_rooms.get(&room_id) { SelectedRoom::JoinedRoom { room_name_id: jr.room_name_id.clone(), @@ -1207,13 +1341,15 @@ impl Widget for RoomsList { self.current_active_room = Some(new_selected_room.clone()); cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::Selected(new_selected_room), ); self.redraw(cx); } // Handle a room being right-clicked or long-pressed by opening the room context menu. - else if let RoomsListEntryAction::SecondaryClicked(room_id, pos) = action.as_widget_action().cast() { + else if let RoomsListEntryAction::SecondaryClicked(room_id, pos) = + action.as_widget_action().cast() + { // Determine details for the context menu let Some(jr) = self.all_joined_rooms.get(&room_id) else { error!("BUG: couldn't find right-clicked room details for room {room_id}"); @@ -1226,29 +1362,35 @@ impl Widget for RoomsList { is_marked_unread: jr.is_marked_unread, }; cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::OpenRoomContextMenu { details, pos }, ); } // Handle the space lobby being clicked. else if let Some(SpaceLobbyAction::SpaceLobbyEntryClicked) = action.downcast_ref() { - let Some(space_name_id) = self.selected_space.clone() else { continue }; + let Some(space_name_id) = self.selected_space.clone() else { + continue; + }; let new_selected_space = SelectedRoom::Space { space_name_id }; self.current_active_room = Some(new_selected_space.clone()); cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::Selected(new_selected_space), ); self.redraw(cx); } // Handle a collapsible header being clicked. - else if let CollapsibleHeaderAction::Toggled { category } = 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; + self.is_invited_rooms_header_expanded = + !self.is_invited_rooms_header_expanded; } HeaderCategory::RegularRooms => { - self.is_regular_rooms_header_expanded = !self.is_regular_rooms_header_expanded; + self.is_regular_rooms_header_expanded = + !self.is_regular_rooms_header_expanded; } HeaderCategory::DirectRooms => { self.is_direct_rooms_header_expanded = @@ -1273,47 +1415,73 @@ impl Widget for RoomsList { if let Some(NavigationBarAction::TabSelected(tab)) = action.downcast_ref() { match tab { SelectedTab::Space { space_name_id } => { - if self.selected_space.as_ref().is_some_and(|s| s.room_id() == space_name_id.room_id()) { + if self + .selected_space + .as_ref() + .is_some_and(|s| s.room_id() == space_name_id.room_id()) + { continue; } self.selected_space = Some(space_name_id.clone()); - self.view.space_lobby_entry(cx, ids!(space_lobby_entry)).set_visible(cx, true); + self.view + .space_lobby_entry(cx, ids!(space_lobby_entry)) + .set_visible(cx, true); // If we don't have the full list of children in this newly-selected space, then fetch it. - let (is_fully_paginated, parent_chain) = self.space_map + let (is_fully_paginated, parent_chain) = self + .space_map .get(space_name_id.room_id()) .map(|smv| (smv.is_fully_paginated, smv.parent_chain.clone())) .unwrap_or_default(); if !is_fully_paginated { let Some(sender) = self.space_request_sender.as_ref() else { - error!("BUG: RoomsList: no space request sender was available."); + error!( + "BUG: RoomsList: no space request sender was available." + ); continue; }; - if sender.send(SpaceRequest::SubscribeToSpaceRoomList { - space_id: space_name_id.room_id().clone(), - parent_chain: parent_chain.clone(), - }).is_err() { - error!("BUG: RoomsList: failed to send SubscribeToSpaceRoomList request for space {space_name_id}."); + if sender + .send(SpaceRequest::SubscribeToSpaceRoomList { + space_id: space_name_id.room_id().clone(), + parent_chain: parent_chain.clone(), + }) + .is_err() + { + error!( + "BUG: RoomsList: failed to send SubscribeToSpaceRoomList request for space {space_name_id}." + ); } - if sender.send(SpaceRequest::PaginateSpaceRoomList { - space_id: space_name_id.room_id().clone(), - parent_chain: parent_chain.clone(), - }).is_err() { - error!("BUG: RoomsList: failed to send PaginateSpaceRoomList request for space {space_name_id}."); + if sender + .send(SpaceRequest::PaginateSpaceRoomList { + space_id: space_name_id.room_id().clone(), + parent_chain: parent_chain.clone(), + }) + .is_err() + { + error!( + "BUG: RoomsList: failed to send PaginateSpaceRoomList request for space {space_name_id}." + ); } - if sender.send(SpaceRequest::GetChildren { - space_id: space_name_id.room_id().clone(), - parent_chain, - }).is_err() { - error!("BUG: RoomsList: failed to send GetRooms request for space {space_name_id}."); + if sender + .send(SpaceRequest::GetChildren { + space_id: space_name_id.room_id().clone(), + parent_chain, + }) + .is_err() + { + error!( + "BUG: RoomsList: failed to send GetRooms request for space {space_name_id}." + ); } } } _ => { self.selected_space = None; - self.view.space_lobby_entry(cx, ids!(space_lobby_entry)).set_visible(cx, false); + self.view + .space_lobby_entry(cx, ids!(space_lobby_entry)) + .set_visible(cx, false); } } @@ -1372,25 +1540,31 @@ impl Widget for RoomsList { let total_count = status_label_id + 1; let get_invited_room_id = |portal_list_index: usize| { - portal_list_index.checked_sub(self.invited_rooms_indexes.first_room_index) - .and_then(|index| self.is_invited_rooms_header_expanded - .then(|| self.displayed_invited_rooms.get(index)) - ) + portal_list_index + .checked_sub(self.invited_rooms_indexes.first_room_index) + .and_then(|index| { + self.is_invited_rooms_header_expanded + .then(|| self.displayed_invited_rooms.get(index)) + }) .flatten() }; let get_direct_room_id = |portal_list_index: usize| { - portal_list_index.checked_sub(self.direct_rooms_indexes.first_room_index) - .and_then(|index| self.is_direct_rooms_header_expanded - .then(|| self.displayed_direct_rooms.get(index)) - ) + portal_list_index + .checked_sub(self.direct_rooms_indexes.first_room_index) + .and_then(|index| { + self.is_direct_rooms_header_expanded + .then(|| self.displayed_direct_rooms.get(index)) + }) .flatten() }; let get_regular_room_id = |portal_list_index: usize| { - portal_list_index.checked_sub(self.regular_rooms_indexes.first_room_index) - .and_then(|index| self.is_regular_rooms_header_expanded - .then(|| self.displayed_regular_rooms.get(index)) - ) + portal_list_index + .checked_sub(self.regular_rooms_indexes.first_room_index) + .and_then(|index| { + self.is_regular_rooms_header_expanded + .then(|| self.displayed_regular_rooms.get(index)) + }) .flatten() }; @@ -1402,7 +1576,9 @@ impl Widget for RoomsList { portal_list_ref.set_first_id_and_scroll(status_label_id, 0.0); } // We only care about drawing the portal list. - let Some(mut list) = portal_list_ref.borrow_mut() else { continue }; + let Some(mut list) = portal_list_ref.borrow_mut() else { + continue; + }; list.set_item_range(cx, 0, total_count); @@ -1418,12 +1594,13 @@ impl Widget for RoomsList { self.displayed_invited_rooms.len() as u64, ); item.draw_all(cx, &mut scope); - } - else if let Some(invited_room_id) = get_invited_room_id(portal_list_index) { + } else if let Some(invited_room_id) = get_invited_room_id(portal_list_index) { let mut invited_rooms_mut = self.invited_rooms.borrow_mut(); if let Some(invited_room) = invited_rooms_mut.get_mut(invited_room_id) { let item = list.item(cx, portal_list_index, id!(rooms_list_entry)); - invited_room.is_selected = self.current_active_room.as_ref() + invited_room.is_selected = self + .current_active_room + .as_ref() .is_some_and(|sel_room| sel_room.room_id() == invited_room_id); // Pass the room info down to the RoomsListEntry widget via Scope. scope = Scope::with_props(&*invited_room); @@ -1432,8 +1609,7 @@ impl Widget for RoomsList { list.item(cx, portal_list_index, id!(empty)) .draw_all(cx, &mut scope); } - } - else if self.direct_rooms_indexes.header_index == Some(portal_list_index) { + } else if self.direct_rooms_indexes.header_index == Some(portal_list_index) { let item = list.item(cx, portal_list_index, id!(collapsible_header)); item.as_collapsible_header().set_details( cx, @@ -1444,11 +1620,12 @@ impl Widget for RoomsList { // NOTE: this might be really slow, so we should maintain a running total of mentions in this struct ); item.draw_all(cx, &mut scope); - } - else if let Some(direct_room_id) = get_direct_room_id(portal_list_index) { + } else if let Some(direct_room_id) = get_direct_room_id(portal_list_index) { if let Some(direct_room) = self.all_joined_rooms.get_mut(direct_room_id) { let item = list.item(cx, portal_list_index, id!(rooms_list_entry)); - direct_room.is_selected = self.current_active_room.as_ref() + direct_room.is_selected = self + .current_active_room + .as_ref() .is_some_and(|sel_room| sel_room.room_id() == direct_room_id); // Paginate the room if it hasn't been paginated yet. @@ -1469,8 +1646,7 @@ impl Widget for RoomsList { list.item(cx, portal_list_index, id!(empty)) .draw_all(cx, &mut scope); } - } - else if self.regular_rooms_indexes.header_index == Some(portal_list_index) { + } else if self.regular_rooms_indexes.header_index == Some(portal_list_index) { let item = list.item(cx, portal_list_index, id!(collapsible_header)); item.as_collapsible_header().set_details( cx, @@ -1481,11 +1657,12 @@ impl Widget for RoomsList { // NOTE: this might be really slow, so we should maintain a running total of mentions in this struct ); item.draw_all(cx, &mut scope); - } - else if let Some(regular_room_id) = get_regular_room_id(portal_list_index) { + } else if let Some(regular_room_id) = get_regular_room_id(portal_list_index) { if let Some(regular_room) = self.all_joined_rooms.get_mut(regular_room_id) { let item = list.item(cx, portal_list_index, id!(rooms_list_entry)); - regular_room.is_selected = self.current_active_room.as_ref() + regular_room.is_selected = self + .current_active_room + .as_ref() .is_some_and(|sel_room| sel_room.room_id() == regular_room_id); // Paginate the room if it hasn't been paginated yet. @@ -1503,7 +1680,8 @@ impl Widget for RoomsList { scope = Scope::with_props(&*regular_room); item.draw_all(cx, &mut scope); } else { - list.item(cx, portal_list_index, id!(empty)).draw_all(cx, &mut scope); + list.item(cx, portal_list_index, id!(empty)) + .draw_all(cx, &mut scope); } } // Draw the status label as the bottom entry. @@ -1527,7 +1705,9 @@ impl Widget for RoomsList { impl RoomsListRef { /// See [`RoomsList::all_rooms_loaded()`]. pub fn all_rooms_loaded(&self) -> bool { - let Some(inner) = self.borrow() else { return false; }; + let Some(inner) = self.borrow() else { + return false; + }; inner.all_rooms_loaded() } @@ -1544,14 +1724,17 @@ impl RoomsListRef { /// Returns the name of the given room, if it is known and loaded. pub fn get_room_name(&self, room_id: &OwnedRoomId) -> Option { let inner = self.borrow()?; - inner.all_joined_rooms + inner + .all_joined_rooms .get(room_id) .map(|jr| jr.room_name_id.clone()) - .or_else(|| - inner.invited_rooms.borrow() + .or_else(|| { + inner + .invited_rooms + .borrow() .get(room_id) .map(|ir| ir.room_name_id.clone()) - ) + }) } /// Returns the currently-selected space (the one selected in the SpacesBar). @@ -1561,7 +1744,10 @@ impl RoomsListRef { /// Same as [`Self::get_selected_space()`], but only returns the space ID. pub fn get_selected_space_id(&self) -> Option { - self.borrow()?.selected_space.as_ref().map(|ss| ss.room_id().clone()) + self.borrow()? + .selected_space + .as_ref() + .map(|ss| ss.room_id().clone()) } /// Returns a clone of the space request sender channel, if available. diff --git a/src/home/rooms_list_entry.rs b/src/home/rooms_list_entry.rs index d421a12ac..d8eef8139 100644 --- a/src/home/rooms_list_entry.rs +++ b/src/home/rooms_list_entry.rs @@ -2,10 +2,12 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; use crate::{ - room::FetchedRoomAvatar, shared::{ - avatar::AvatarWidgetExt, - html_or_plaintext::HtmlOrPlaintextWidgetExt, unread_badge::UnreadBadgeWidgetExt as _, - }, utils::{self, relative_format} + room::FetchedRoomAvatar, + shared::{ + avatar::AvatarWidgetExt, html_or_plaintext::HtmlOrPlaintextWidgetExt, + unread_badge::UnreadBadgeWidgetExt as _, + }, + utils::{self, relative_format}, }; use super::rooms_list::{InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListScopeProps}; @@ -197,8 +199,10 @@ script_mod! { /// An entry in the rooms list. #[derive(Script, Widget)] pub struct RoomsListEntry { - #[deref] view: View, - #[rust] room_id: Option, + #[deref] + view: View, + #[rust] + room_id: Option, } impl ScriptHook for RoomsListEntry { @@ -247,21 +251,26 @@ impl Widget for RoomsListEntry { cx.set_key_focus(area); if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { cx.widget_action( - uid, + uid, RoomsListEntryAction::SecondaryClicked(room_id.clone(), fe.abs), ); } } Hit::FingerLongPress(fe) => { cx.widget_action( - uid, + uid, RoomsListEntryAction::SecondaryClicked(room_id.clone(), fe.abs), ); } - Hit::FingerUp(fe) if !rooms_list_props.was_scrolling && fe.is_over && fe.is_primary_hit() && fe.was_tap() => { - cx.widget_action(uid, RoomsListEntryAction::PrimaryClicked(room_id.clone())); + Hit::FingerUp(fe) + if !rooms_list_props.was_scrolling + && fe.is_over + && fe.is_primary_hit() + && fe.was_tap() => + { + cx.widget_action(uid, RoomsListEntryAction::PrimaryClicked(room_id.clone())); } - _ => { } + _ => {} } } @@ -271,8 +280,7 @@ impl Widget for RoomsListEntry { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { if let Some(room_info) = scope.props.get::() { self.room_id = Some(room_info.room_name_id.room_id().clone()); - } - else if let Some(room_info) = scope.props.get::() { + } else if let Some(room_info) = scope.props.get::() { self.room_id = Some(room_info.room_name_id.room_id().clone()); } @@ -282,9 +290,12 @@ impl Widget for RoomsListEntry { #[derive(Script, ScriptHook, Widget, Animator)] pub struct RoomsListEntryContent { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, } impl Widget for RoomsListEntryContent { @@ -308,12 +319,10 @@ impl Widget for RoomsListEntryContent { impl RoomsListEntryContent { /// Populates this RoomsListEntry with info about a joined room. - pub fn draw_joined_room( - &mut self, - cx: &mut Cx, - room_info: &JoinedRoomInfo, - ) { - self.view.label(cx, ids!(room_name)).set_text(cx, &room_info.room_name_id.to_string()); + pub fn draw_joined_room(&mut self, cx: &mut Cx, room_info: &JoinedRoomInfo) { + self.view + .label(cx, ids!(room_name)) + .set_text(cx, &room_info.room_name_id.to_string()); if let Some((ts, msg)) = room_info.latest.as_ref() { if let Some(human_readable_date) = relative_format(*ts) { self.view @@ -325,35 +334,51 @@ impl RoomsListEntryContent { .show_html(cx, msg); } - self.view.unread_badge(cx, ids!(unread_badge)).update_counts( - room_info.is_marked_unread, - room_info.num_unread_mentions, - room_info.num_unread_messages, - ); + self.view + .unread_badge(cx, ids!(unread_badge)) + .update_counts( + room_info.is_marked_unread, + room_info.num_unread_mentions, + room_info.num_unread_messages, + ); self.draw_common(cx, &room_info.room_avatar, room_info.is_selected); // Show tombstone icon if the room is tombstoned - self.view.view(cx, ids!(tombstone_icon)).set_visible(cx, room_info.is_tombstoned); + self.view + .view(cx, ids!(tombstone_icon)) + .set_visible(cx, room_info.is_tombstoned); } /// Populates this RoomsListEntry with info about an invited room. - pub fn draw_invited_room( - &mut self, - cx: &mut Cx, - room_info: &InvitedRoomInfo, - ) { - self.view.label(cx, ids!(room_name)).set_text(cx, &room_info.room_name_id.to_string()); + pub fn draw_invited_room(&mut self, cx: &mut Cx, room_info: &InvitedRoomInfo) { + self.view + .label(cx, ids!(room_name)) + .set_text(cx, &room_info.room_name_id.to_string()); // Hide the timestamp field, and use the latest message field to show the inviter. self.view.label(cx, ids!(timestamp)).set_text(cx, ""); let inviter_string = match &room_info.inviter_info { - Some(InviterInfo { user_id, display_name: Some(dn), .. }) => format!("Invited by {} ({})", htmlize::escape_text(dn), htmlize::escape_text(user_id.as_str())), - Some(InviterInfo { user_id, .. }) => format!("Invited by {}", htmlize::escape_text(user_id.as_str())), + Some(InviterInfo { + user_id, + display_name: Some(dn), + .. + }) => format!( + "Invited by {} ({})", + htmlize::escape_text(dn), + htmlize::escape_text(user_id.as_str()) + ), + Some(InviterInfo { user_id, .. }) => { + format!("Invited by {}", htmlize::escape_text(user_id.as_str())) + } None => String::from("You were invited"), }; - self.view.html_or_plaintext(cx, ids!(latest_message)).show_html(cx, &inviter_string); + self.view + .html_or_plaintext(cx, ids!(latest_message)) + .show_html(cx, &inviter_string); match room_info.room_avatar { FetchedRoomAvatar::Text(ref text) => { - self.view.avatar(cx, ids!(avatar)).show_text(cx, None, None, text); + self.view + .avatar(cx, ids!(avatar)) + .show_text(cx, None, None, text); } FetchedRoomAvatar::Image(ref img_bytes) => { let _ = self.view.avatar(cx, ids!(avatar)).show_image( @@ -372,15 +397,12 @@ impl RoomsListEntryContent { } /// Populates the widgets common to both invited and joined rooms list entries. - pub fn draw_common( - &mut self, - cx: &mut Cx, - room_avatar: &FetchedRoomAvatar, - is_selected: bool, - ) { + pub fn draw_common(&mut self, cx: &mut Cx, room_avatar: &FetchedRoomAvatar, is_selected: bool) { match room_avatar { FetchedRoomAvatar::Text(text) => { - self.view.avatar(cx, ids!(avatar)).show_text(cx, None, None, text); + self.view + .avatar(cx, ids!(avatar)) + .show_text(cx, None, None, text); } FetchedRoomAvatar::Image(img_bytes) => { let _ = self.view.avatar(cx, ids!(avatar)).show_image( @@ -422,7 +444,13 @@ impl RoomsListEntryContent { } // Toggle the background color via the animator (handles selected/deselected bg). - self.animator_toggle(cx, is_selected, Animate::No, ids!(selected.on), ids!(selected.off)); + self.animator_toggle( + cx, + is_selected, + Animate::No, + ids!(selected.on), + ids!(selected.off), + ); // Update text colors for room name. let mut room_name_label = self.view.label(cx, ids!(room_name)); @@ -456,13 +484,18 @@ impl RoomsListEntryContent { // When not selected, restore the default blue link color. self.view .html_or_plaintext(cx, ids!(latest_message)) - .set_link_color(cx, if is_selected { - None - } else { - Some(vec4(0., 0., 0.933, 1.0)) // #0000EE, default HtmlLink color - }); - - let mut pt_label = self.view.label(cx, ids!(latest_message.plaintext_view.pt_label)); + .set_link_color( + cx, + if is_selected { + None + } else { + Some(vec4(0., 0., 0.933, 1.0)) // #0000EE, default HtmlLink color + }, + ); + + let mut pt_label = self + .view + .label(cx, ids!(latest_message.plaintext_view.pt_label)); script_apply_eval!(cx, pt_label, { draw_text +: { color: #(message_text_color) diff --git a/src/home/rooms_list_header.rs b/src/home/rooms_list_header.rs index eac4372a4..3b02c58de 100644 --- a/src/home/rooms_list_header.rs +++ b/src/home/rooms_list_header.rs @@ -1,6 +1,6 @@ //! The RoomsListHeader contains the title label and loading spinner for rooms list. //! -//! This widget is designed to be reused across both Desktop and Mobile variants +//! This widget is designed to be reused across both Desktop and Mobile variants //! of the RoomsSideBar to avoid code duplication. use std::mem::discriminant; @@ -85,9 +85,11 @@ script_mod! { #[derive(Script, ScriptHook, Widget)] pub struct RoomsListHeader { - #[deref] view: View, + #[deref] + view: View, - #[rust(State::Idle)] sync_state: State, + #[rust(State::Idle)] + sync_state: State, } impl Widget for RoomsListHeader { @@ -101,9 +103,15 @@ impl Widget for RoomsListHeader { if matches!(self.sync_state, State::Offline) { continue; } - self.view.view(cx, ids!(loading_spinner)).set_visible(cx, *is_syncing); - self.view.view(cx, ids!(synced_icon)).set_visible(cx, !*is_syncing); - self.view.view(cx, ids!(offline_icon)).set_visible(cx, false); + self.view + .view(cx, ids!(loading_spinner)) + .set_visible(cx, *is_syncing); + self.view + .view(cx, ids!(synced_icon)) + .set_visible(cx, !*is_syncing); + self.view + .view(cx, ids!(offline_icon)) + .set_visible(cx, false); self.redraw(cx); continue; } @@ -112,7 +120,9 @@ impl Widget for RoomsListHeader { continue; } if matches!(new_state, State::Offline) { - self.view.view(cx, ids!(loading_spinner)).set_visible(cx, false); + self.view + .view(cx, ids!(loading_spinner)) + .set_visible(cx, false); self.view.view(cx, ids!(synced_icon)).set_visible(cx, false); self.view.view(cx, ids!(offline_icon)).set_visible(cx, true); enqueue_popup_notification( @@ -121,7 +131,9 @@ impl Widget for RoomsListHeader { None, ); // Since there is no timeout for fetching media, send an action to ImageViewer when syncing is offline. - cx.action(ImageViewerAction::Show(LoadState::Error(ImageViewerError::Offline))); + cx.action(ImageViewerAction::Show(LoadState::Error( + ImageViewerError::Offline, + ))); } self.sync_state = new_state.clone(); self.redraw(cx); @@ -145,9 +157,21 @@ impl Widget for RoomsListHeader { // Show tooltips for the sync status icons. for (view, text, bg_color) in [ - (self.view.view(cx, ids!(loading_spinner)), "Syncing...", vec4(0.059, 0.533, 0.996, 1.0)), // COLOR_ACTIVE_PRIMARY #0f88fe - (self.view.view(cx, ids!(offline_icon)), "Offline", vec4(0.863, 0.0, 0.020, 1.0)), // COLOR_FG_DANGER_RED #DC0005 - (self.view.view(cx, ids!(synced_icon)), "Fully synced", vec4(0.075, 0.533, 0.031, 1.0)), // COLOR_FG_ACCEPT_GREEN #138808 + ( + self.view.view(cx, ids!(loading_spinner)), + "Syncing...", + vec4(0.059, 0.533, 0.996, 1.0), + ), // COLOR_ACTIVE_PRIMARY #0f88fe + ( + self.view.view(cx, ids!(offline_icon)), + "Offline", + vec4(0.863, 0.0, 0.020, 1.0), + ), // COLOR_FG_DANGER_RED #DC0005 + ( + self.view.view(cx, ids!(synced_icon)), + "Fully synced", + vec4(0.075, 0.533, 0.031, 1.0), + ), // COLOR_FG_ACCEPT_GREEN #138808 ] { if !view.visible() { continue; diff --git a/src/home/rooms_sidebar.rs b/src/home/rooms_sidebar.rs index c50ca5695..ee4fa6087 100644 --- a/src/home/rooms_sidebar.rs +++ b/src/home/rooms_sidebar.rs @@ -35,7 +35,7 @@ script_mod! { Mobile := View { width: Fill, height: Fill flow: Down, - + RoundedShadowView { width: Fill, height: Fit padding: Inset{top: 15, left: 15, right: 15, bottom: 10} @@ -62,7 +62,7 @@ script_mod! { height: 45, flow: Right padding: Inset{top: 5, bottom: 2} - spacing: 5 + spacing: 5 align: Align{y: 0.5} CachedWidget { @@ -93,7 +93,8 @@ script_mod! { /// (because the search bar is at the top of the HomeScreen). #[derive(Script, Widget)] pub struct RoomsSideBar { - #[deref] view: AdaptiveView, + #[deref] + view: AdaptiveView, } impl ScriptHook for RoomsSideBar { diff --git a/src/home/search_messages.rs b/src/home/search_messages.rs index 5228ca129..dd9fe0a0a 100644 --- a/src/home/search_messages.rs +++ b/src/home/search_messages.rs @@ -1,4 +1,3 @@ - //! UI widgets for searching messages in one or more rooms. use makepad_widgets::*; @@ -41,12 +40,13 @@ script_mod! { } } - + } #[derive(Script, ScriptHook, Widget)] pub struct SearchMessagesButton { - #[deref] button: Button, + #[deref] + button: Button, } impl Widget for SearchMessagesButton { diff --git a/src/home/space_lobby.rs b/src/home/space_lobby.rs index 42bca8635..5690d5c68 100644 --- a/src/home/space_lobby.rs +++ b/src/home/space_lobby.rs @@ -21,10 +21,7 @@ use crate::utils::replace_linebreaks_separators; use crate::{ app::AppStateAction, avatar_cache::{self, AvatarCacheEntry}, - home::{ - invite_modal::InviteModalAction, - rooms_list::RoomsListRef, - }, + home::{invite_modal::InviteModalAction, rooms_list::RoomsListRef}, join_leave_room_modal::{JoinLeaveModalKind, JoinLeaveRoomModalAction}, room::BasicRoomDetails, shared::avatar::{AvatarWidgetExt, AvatarWidgetRefExt}, @@ -32,7 +29,6 @@ use crate::{ utils::{self, RoomNameId}, }; - script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -213,7 +209,7 @@ script_mod! { // Dumb approach, but it works. for i in 0..20 { if f32(i) > self.level { break; } - + if f32(i) < self.level { // Check mask for parent levels let mask_bit = modf(floor(self.parent_mask / pow(2.0, f32(i))), 2.0); @@ -236,7 +232,7 @@ script_mod! { c = vec4(0.8, 0.8, 0.8, 1.0); break; } - + // Vertical line (L shape) if abs(pos.x - (f32(i) * indent + half_indent)) < half_line && pos.y < (self.rect_size.y * (1.0 - 0.5 * self.is_last)) { c = vec4(0.8, 0.8, 0.8, 1.0); @@ -456,20 +452,20 @@ script_mod! { } text: "Welcome to the space:" } - + parent_space_row := View { width: Fill, height: Fit, flow: Right, align: Align{ y: 0.5 } padding: Inset{ top: 8 } - + parent_avatar := Avatar { width: 36, height: 36, margin: Inset{ right: 12 } } - + parent_name := Label { width: Fill, height: Fit, @@ -515,7 +511,6 @@ script_mod! { } } - thread_local! { /// A cache of UI states for each SpaceLobbyScreen, keyed by the space's room ID. /// This allows preserving the expanded/collapsed state of subspaces across screen changes. @@ -531,13 +526,15 @@ struct SpaceLobbyUiState { expanded_spaces: HashSet, } - /// A clickable entry shown in the RoomsList that will show the space lobby when clicked. #[derive(Script, ScriptHook, Widget, Animator)] pub struct SpaceLobbyEntry { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, } impl Widget for SpaceLobbyEntry { @@ -567,7 +564,7 @@ impl Widget for SpaceLobbyEntry { Hit::FingerUp(fe) if !fe.is_over => { self.animator_play(cx, ids!(hover.off)); } - Hit::FingerMove(_fe) => { } + Hit::FingerMove(_fe) => {} _ => {} } } @@ -577,7 +574,6 @@ impl Widget for SpaceLobbyEntry { } } - #[derive(Debug)] pub enum SpaceLobbyAction { SpaceLobbyEntryClicked, @@ -586,44 +582,59 @@ pub enum SpaceLobbyAction { #[derive(Script, ScriptHook)] #[repr(C)] pub struct DrawTreeLine { - #[deref] draw_super: DrawQuad, - #[live] indent_width: f32, - #[live] level: f32, - #[live] is_last: f32, - #[live] parent_mask: f32, + #[deref] + draw_super: DrawQuad, + #[live] + indent_width: f32, + #[live] + level: f32, + #[live] + is_last: f32, + #[live] + parent_mask: f32, } #[derive(Script, ScriptHook, Widget)] pub struct TreeLines { - #[uid] uid: WidgetUid, - #[redraw] #[live] draw_bg: DrawTreeLine, - #[walk] walk: Walk, + #[uid] + uid: WidgetUid, + #[redraw] + #[live] + draw_bg: DrawTreeLine, + #[walk] + walk: Walk, } impl Widget for TreeLines { - fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) { } + fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {} fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { let indent_pixel = (self.draw_bg.level + 1.0) * self.draw_bg.indent_width; let mut walk = walk; walk.width = Size::Fixed(indent_pixel as f64); - + self.draw_bg.draw_walk(cx, walk); DrawStep::done() } } - /// A clickable entry for a child subspace. #[derive(Script, ScriptHook, Widget, Animator)] pub struct SubspaceEntry { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, - #[rust] room_id: Option, - #[rust] is_space: bool, - #[rust] show_buttons_view: bool, - #[rust] is_expanded: bool, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, + #[rust] + room_id: Option, + #[rust] + is_space: bool, + #[rust] + show_buttons_view: bool, + #[rust] + is_expanded: bool, } /// Actions emitted when a `SubspaceEntry` or its buttons are clicked. @@ -631,11 +642,23 @@ pub struct SubspaceEntry { /// These *are* all widget actions. #[derive(Clone, Debug, Default)] pub enum SubspaceEntryAction { - SpaceClicked { space_id: OwnedRoomId }, - RoomClicked { room_id: OwnedRoomId }, - JoinClicked { room_id: OwnedRoomId, is_space: bool }, - LeaveClicked { room_id: OwnedRoomId, is_space: bool }, - ViewClicked { room_id: OwnedRoomId }, + SpaceClicked { + space_id: OwnedRoomId, + }, + RoomClicked { + room_id: OwnedRoomId, + }, + JoinClicked { + room_id: OwnedRoomId, + is_space: bool, + }, + LeaveClicked { + room_id: OwnedRoomId, + is_space: bool, + }, + ViewClicked { + room_id: OwnedRoomId, + }, #[default] None, } @@ -666,7 +689,9 @@ impl Widget for SubspaceEntry { self.animator_play(cx, ids!(hover.on)); if !self.show_buttons_view { self.show_buttons_view = true; - self.view.child_by_path(ids!(buttons_view)).set_visible(cx, true); + self.view + .child_by_path(ids!(buttons_view)) + .set_visible(cx, true); self.redraw(cx); } } @@ -675,7 +700,9 @@ impl Widget for SubspaceEntry { Hit::FingerHoverOver(_) if !self.show_buttons_view => { self.animator_play(cx, ids!(hover.on)); self.show_buttons_view = true; - self.view.child_by_path(ids!(buttons_view)).set_visible(cx, true); + self.view + .child_by_path(ids!(buttons_view)) + .set_visible(cx, true); self.redraw(cx); } Hit::FingerHoverOut(fe) => { @@ -683,11 +710,14 @@ impl Widget for SubspaceEntry { // Makepad emits a HoverOut hit, but we don't want that to actually count as a hover-out // because the mouse is still hovering over the buttons_view. let entry_rect = self.view.area().rect(cx); - let is_over_buttons_view = self.show_buttons_view && buttons_view_rect.contains(fe.abs); + let is_over_buttons_view = + self.show_buttons_view && buttons_view_rect.contains(fe.abs); if !entry_rect.contains(fe.abs) && !is_over_buttons_view { self.animator_play(cx, ids!(hover.off)); self.show_buttons_view = false; - self.view.child_by_path(ids!(buttons_view)).set_visible(cx, false); + self.view + .child_by_path(ids!(buttons_view)) + .set_visible(cx, false); self.redraw(cx); } } @@ -696,23 +726,36 @@ impl Widget for SubspaceEntry { } Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { let is_within_buttons_view = self.show_buttons_view - && self.view.child_by_path(ids!(buttons_view)).area().rect(cx).contains(fe.abs); + && self + .view + .child_by_path(ids!(buttons_view)) + .area() + .rect(cx) + .contains(fe.abs); if !is_within_buttons_view { if let Some(room_id) = self.room_id.as_ref() { if self.is_space { // Toggle expansion and animate the arrow self.is_expanded = !self.is_expanded; - if let Some(mut arrow) = self.view.child_by_path(ids!(expand_icon)).borrow_mut::() { + if let Some(mut arrow) = self + .view + .child_by_path(ids!(expand_icon)) + .borrow_mut::() + { arrow.set_is_open(cx, self.is_expanded, Animate::Yes); } cx.widget_action( self.widget_uid(), - SubspaceEntryAction::SpaceClicked { space_id: room_id.clone() }, + SubspaceEntryAction::SpaceClicked { + space_id: room_id.clone(), + }, ); } else { cx.widget_action( self.widget_uid(), - SubspaceEntryAction::RoomClicked { room_id: room_id.clone() }, + SubspaceEntryAction::RoomClicked { + room_id: room_id.clone(), + }, ); } } @@ -724,16 +767,28 @@ impl Widget for SubspaceEntry { self.view.handle_event(cx, event, scope); if let Event::Actions(actions) = event { - let join_button = self.view.child_by_path(ids!(buttons_view.join_button)).as_button(); - let leave_button = self.view.child_by_path(ids!(buttons_view.leave_button)).as_button(); - let view_button = self.view.child_by_path(ids!(buttons_view.view_button)).as_button(); + let join_button = self + .view + .child_by_path(ids!(buttons_view.join_button)) + .as_button(); + let leave_button = self + .view + .child_by_path(ids!(buttons_view.leave_button)) + .as_button(); + let view_button = self + .view + .child_by_path(ids!(buttons_view.view_button)) + .as_button(); if join_button.clicked(actions) { if let Some(room_id) = self.room_id.clone() { join_button.reset_hover(cx); cx.widget_action( self.widget_uid(), - SubspaceEntryAction::JoinClicked { room_id, is_space: self.is_space }, + SubspaceEntryAction::JoinClicked { + room_id, + is_space: self.is_space, + }, ); } } @@ -742,7 +797,10 @@ impl Widget for SubspaceEntry { leave_button.reset_hover(cx); cx.widget_action( self.widget_uid(), - SubspaceEntryAction::LeaveClicked { room_id, is_space: self.is_space }, + SubspaceEntryAction::LeaveClicked { + room_id, + is_space: self.is_space, + }, ); } } @@ -787,9 +845,10 @@ impl From<&SpaceRoom> for SpaceRoomInfo { SpaceRoomInfo { id: space_room.room_id.clone(), name: space_room.display_name.clone(), - topic: space_room.topic.as_ref().map(|t| { - replace_linebreaks_separators(t.trim(), false).into_owned() - }), + topic: space_room + .topic + .as_ref() + .map(|t| replace_linebreaks_separators(t.trim(), false).into_owned()), avatar: AvatarState::Known(space_room.avatar_url.clone()), num_joined_members: space_room.num_joined_members, state: space_room.state, @@ -804,9 +863,9 @@ impl From for SpaceRoomInfo { children_count: space_room.is_space().then_some(space_room.children_count), id: space_room.room_id, name: space_room.display_name, - topic: space_room.topic.map(|t| { - replace_linebreaks_separators(t.trim(), false).into_owned() - }), + topic: space_room + .topic + .map(|t| replace_linebreaks_separators(t.trim(), false).into_owned()), avatar: AvatarState::Known(space_room.avatar_url), num_joined_members: space_room.num_joined_members, state: space_room.state, @@ -841,31 +900,41 @@ enum TreeEntry { /// The view showing the lobby/homepage for a given space. #[derive(Script, ScriptHook, Widget)] pub struct SpaceLobbyScreen { - #[source] source: ScriptObjectRef, - #[deref] view: View, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, /// The space that is currently being displayed. - #[rust] space_name_id: Option, - #[rust] space_avatar_state: AvatarState, + #[rust] + space_name_id: Option, + #[rust] + space_avatar_state: AvatarState, /// The sender channel to submit space requests to the background service. - #[rust] space_request_sender: Option>, + #[rust] + space_request_sender: Option>, /// Cache of detailed children for each space we've fetched. /// Key is the space_id, value is the list of its direct children. - #[rust] children_cache: HashMap>, + #[rust] + children_cache: HashMap>, /// The set of space IDs that are currently expanded (showing their children). - #[rust] expanded_spaces: HashSet, + #[rust] + expanded_spaces: HashSet, /// The ordered list of children to display in the space tree. - #[rust] tree_entries: Vec, + #[rust] + tree_entries: Vec, /// The set of space IDs that are currently loading their children. - #[rust] loading_subspaces: HashSet, + #[rust] + loading_subspaces: HashSet, /// Whether we are currently loading the initial data. - #[rust] is_loading: bool, + #[rust] + is_loading: bool, } impl Widget for SpaceLobbyScreen { @@ -882,34 +951,53 @@ impl Widget for SpaceLobbyScreen { if let Event::Actions(actions) = event { for action in actions { match action.downcast_ref() { - Some(SpaceRoomListAction::DetailedChildren { space_id, children, .. }) => { + Some(SpaceRoomListAction::DetailedChildren { + space_id, children, .. + }) => { self.update_children_in_space(cx, space_id, children); } // Handle receiving top-level space details (join rule, member count). Some(SpaceRoomListAction::TopLevelSpaceDetails(sr)) => { - if self.space_name_id.as_ref().is_some_and(|sni| sni.room_id() == &sr.room_id) { + if self + .space_name_id + .as_ref() + .is_some_and(|sni| sni.room_id() == &sr.room_id) + { self.space_avatar_state = AvatarState::Known(sr.avatar_url.clone()); self.space_avatar_state.update_from_cache(cx); // prefetch the avatar image - self.view.label(cx, ids!(header.space_info_label)).set_text(cx, &format!( - "{} · {} {}", - match sr.join_rule { - Some(JoinRuleSummary::Public) => "🌐 Public space", - _ => "🔒 Private space", - }, - sr.num_joined_members, - if sr.num_joined_members == 1 { "member" } else { "members" } - )); + self.view.label(cx, ids!(header.space_info_label)).set_text( + cx, + &format!( + "{} · {} {}", + match sr.join_rule { + Some(JoinRuleSummary::Public) => "🌐 Public space", + _ => "🔒 Private space", + }, + sr.num_joined_members, + if sr.num_joined_members == 1 { + "member" + } else { + "members" + } + ), + ); self.redraw(cx); } } // Handle a change to the set of children in this space or any of its child subspaces. - Some(SpaceRoomListAction::UpdatedChildren { space_id, parent_chain, .. }) => { - if self.space_name_id.as_ref().is_some_and(|sni| + Some(SpaceRoomListAction::UpdatedChildren { + space_id, + parent_chain, + .. + }) => { + if self.space_name_id.as_ref().is_some_and(|sni| { sni.room_id() == space_id - || parent_chain.iter().any(|ancestor_id| sni.room_id() == ancestor_id) - ) { + || parent_chain + .iter() + .any(|ancestor_id| sni.room_id() == ancestor_id) + }) { if let Some(sender) = &self.space_request_sender { let _ = sender.send(SpaceRequest::GetDetailedChildren { space_id: space_id.clone(), @@ -918,7 +1006,7 @@ impl Widget for SpaceLobbyScreen { } } } - _ => { } + _ => {} } // Handle SubspaceEntry clicks @@ -953,7 +1041,7 @@ impl Widget for SpaceLobbyScreen { } else { cx.action(JoinLeaveRoomModalAction::Open { kind: JoinLeaveModalKind::LeaveRoom( - self.basic_room_details_for(room_id) + self.basic_room_details_for(room_id), ), show_tip: false, }); @@ -965,12 +1053,16 @@ impl Widget for SpaceLobbyScreen { destination_room: self.basic_room_details_for(room_id), }); } - SubspaceEntryAction::None => { } + SubspaceEntryAction::None => {} } } // Handle the invite button being clicked in the header. - if self.view.button(cx, ids!(header.parent_space_row.invite_button)).clicked(actions) { + if self + .view + .button(cx, ids!(header.parent_space_row.invite_button)) + .clicked(actions) + { if let Some(space_name_id) = self.space_name_id.as_ref() { cx.action(InviteModalAction::Open(space_name_id.clone())); } @@ -981,21 +1073,28 @@ impl Widget for SpaceLobbyScreen { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // Draw parent avatar from the SpaceRoom's avatar URL, or show initials. let parent_avatar_ref = self.view.avatar(cx, ids!(parent_avatar)); - if self.space_avatar_state.update_from_cache(cx).is_none_or(|data| { - parent_avatar_ref.show_image( - cx, - None, - |cx, img| utils::load_png_or_jpg(&img, cx, data), - ).is_err() - }) { - let first_char = self.space_name_id.as_ref().and_then(|sni| sni.name_for_avatar()) + if self + .space_avatar_state + .update_from_cache(cx) + .is_none_or(|data| { + parent_avatar_ref + .show_image(cx, None, |cx, img| utils::load_png_or_jpg(&img, cx, data)) + .is_err() + }) + { + let first_char = self + .space_name_id + .as_ref() + .and_then(|sni| sni.name_for_avatar()) .and_then(|name| utils::user_name_first_letter(name)); parent_avatar_ref.show_text(cx, None, None, first_char.unwrap_or("S")); } - + while let Some(widget_to_draw) = self.view.draw_walk(cx, scope, walk).step() { let portal_list_ref = widget_to_draw.as_portal_list(); - let Some(mut list) = portal_list_ref.borrow_mut() else { continue }; + let Some(mut list) = portal_list_ref.borrow_mut() else { + continue; + }; let entry_count = self.tree_entries.len(); let total_count = if self.is_loading || entry_count == 0 { @@ -1014,20 +1113,30 @@ impl Widget for SpaceLobbyScreen { // Draw loading indicator let item = if self.is_loading && item_id == 0 { let item = list.item(cx, item_id, id!(status_label)); - item.child_by_path(ids!(label)).as_label().set_text(cx, "Loading rooms and spaces..."); + item.child_by_path(ids!(label)) + .as_label() + .set_text(cx, "Loading rooms and spaces..."); item } // No entries found else if entry_count == 0 && item_id == 0 { let item = list.item(cx, item_id, id!(status_label)); - item.child_by_path(ids!(label)).as_label().set_text(cx, "No rooms or spaces found."); - item.child_by_path(ids!(loading_spinner)).set_visible(cx, false); + item.child_by_path(ids!(label)) + .as_label() + .set_text(cx, "No rooms or spaces found."); + item.child_by_path(ids!(loading_spinner)) + .set_visible(cx, false); item } // Draw a regular entry else if let Some(entry) = self.tree_entries.get_mut(item_id) { match entry { - TreeEntry::Item { info, level, is_last, parent_mask } => { + TreeEntry::Item { + info, + level, + is_last, + parent_mask, + } => { let show_join_button = !matches!(info.state, Some(RoomState::Joined)); let show_leave_button = !show_join_button; let show_view_button = show_leave_button && !info.is_space(); @@ -1047,11 +1156,15 @@ impl Widget for SpaceLobbyScreen { } show_buttons_view = inner.show_buttons_view; } - item.child_by_path(ids!(buttons_view)).set_visible(cx, show_buttons_view); + item.child_by_path(ids!(buttons_view)) + .set_visible(cx, show_buttons_view); // Snap expand arrow to correct state without animation // when item is reused or state changed externally if need_snap { - if let Some(mut arrow) = item.child_by_path(ids!(expand_icon)).borrow_mut::() { + if let Some(mut arrow) = item + .child_by_path(ids!(expand_icon)) + .borrow_mut::() + { arrow.set_is_open(cx, is_expanded, Animate::No); } } @@ -1068,16 +1181,22 @@ impl Widget for SpaceLobbyScreen { } show_buttons_view = inner.show_buttons_view; } - item.child_by_path(ids!(buttons_view)).set_visible(cx, show_buttons_view); + item.child_by_path(ids!(buttons_view)) + .set_visible(cx, show_buttons_view); item }; - item.child_by_path(ids!(buttons_view.join_button)).set_visible(cx, show_join_button); - item.child_by_path(ids!(buttons_view.leave_button)).set_visible(cx, show_leave_button); - item.child_by_path(ids!(buttons_view.view_button)).set_visible(cx, show_view_button); + item.child_by_path(ids!(buttons_view.join_button)) + .set_visible(cx, show_join_button); + item.child_by_path(ids!(buttons_view.leave_button)) + .set_visible(cx, show_leave_button); + item.child_by_path(ids!(buttons_view.view_button)) + .set_visible(cx, show_view_button); // Below, draw things that are common to child rooms and subspaces. - item.child_by_path(ids!(content.name_label)).as_label().set_text(cx, &info.name); + item.child_by_path(ids!(content.name_label)) + .as_label() + .set_text(cx, &info.name); // Display avatar from stored data, or fetch from cache, or show initials let avatar_ref = item.child_by_path(ids!(avatar)).as_avatar(); @@ -1086,36 +1205,39 @@ impl Widget for SpaceLobbyScreen { match &info.avatar { AvatarState::Loaded(data) => { - drew_avatar = avatar_ref.show_image( - cx, - None, - |cx, img| utils::load_png_or_jpg(&img, cx, data), - ).is_ok(); + drew_avatar = avatar_ref + .show_image(cx, None, |cx, img| { + utils::load_png_or_jpg(&img, cx, data) + }) + .is_ok(); } AvatarState::Known(Some(uri)) => { match avatar_cache::get_or_fetch_avatar(cx, uri) { AvatarCacheEntry::Loaded(data) => { - drew_avatar = avatar_ref.show_image( - cx, - None, - |cx, img| utils::load_png_or_jpg(&img, cx, &data), - ).is_ok(); + drew_avatar = avatar_ref + .show_image(cx, None, |cx, img| { + utils::load_png_or_jpg(&img, cx, &data) + }) + .is_ok(); info.avatar = AvatarState::Loaded(data); } AvatarCacheEntry::Failed => { info.avatar = AvatarState::Failed; } - AvatarCacheEntry::Requested => { } + AvatarCacheEntry::Requested => {} } } - _ => { } + _ => {} }; // Fallback to text initials. if !drew_avatar { avatar_ref.show_text(cx, None, None, first_char.unwrap_or("#")); } - if let Some(mut lines) = item.child_by_path(ids!(tree_lines)).borrow_mut::() { + if let Some(mut lines) = item + .child_by_path(ids!(tree_lines)) + .borrow_mut::() + { lines.draw_bg.level = *level as f32; lines.draw_bg.is_last = if *is_last { 1.0 } else { 0.0 }; lines.draw_bg.parent_mask = *parent_mask as f32; @@ -1124,7 +1246,8 @@ impl Widget for SpaceLobbyScreen { // Build the info label with join status, member count, and topic // Note: Public/Private is intentionally not shown per-item to reduce clutter - let info_label = item.child_by_path(ids!(content.info_label)).as_label(); + let info_label = + item.child_by_path(ids!(content.info_label)).as_label(); let mut info_parts = Vec::new(); // Add join status for rooms we haven't joined @@ -1142,7 +1265,11 @@ impl Widget for SpaceLobbyScreen { info_parts.push(format!( "{} {}", info.num_joined_members, - if info.num_joined_members == 1 { "member" } else { "members" } + if info.num_joined_members == 1 { + "member" + } else { + "members" + } )); // Add children count for spaces @@ -1169,7 +1296,10 @@ impl Widget for SpaceLobbyScreen { // Draw loading indicator for subspace let item = list.item(cx, item_id, id!(subspace_loading)); // Configure tree lines - if let Some(mut lines) = item.child_by_path(ids!(tree_lines)).borrow_mut::() { + if let Some(mut lines) = item + .child_by_path(ids!(tree_lines)) + .borrow_mut::() + { lines.draw_bg.level = *level as f32; lines.draw_bg.is_last = 1.0; lines.draw_bg.parent_mask = *parent_mask as f32; @@ -1203,12 +1333,22 @@ impl SpaceLobbyScreen { } /// Handle receiving detailed children for a space. - fn update_children_in_space(&mut self, cx: &mut Cx, space_id: &OwnedRoomId, children: &Vector) { - self.children_cache.insert(space_id.clone(), children.clone()); + fn update_children_in_space( + &mut self, + cx: &mut Cx, + space_id: &OwnedRoomId, + children: &Vector, + ) { + self.children_cache + .insert(space_id.clone(), children.clone()); self.loading_subspaces.remove(space_id); // If this is for our displayed space, mark as loaded and rebuild tree - if self.space_name_id.as_ref().is_some_and(|sni| sni.room_id() == space_id) { + if self + .space_name_id + .as_ref() + .is_some_and(|sni| sni.room_id() == space_id) + { self.is_loading = false; // Auto-expand the top-level space (we don't show it, just its children) self.expanded_spaces.insert(space_id.clone()); @@ -1230,7 +1370,8 @@ impl SpaceLobbyScreen { if !self.children_cache.contains_key(space_id) { self.loading_subspaces.insert(space_id.clone()); if let Some(sender) = &self.space_request_sender { - let parent_chain = cx.get_global::() + let parent_chain = cx + .get_global::() .get_space_parent_chain(space_id) .unwrap_or_default(); let _ = sender.send(SpaceRequest::GetDetailedChildren { @@ -1247,7 +1388,9 @@ impl SpaceLobbyScreen { /// Rebuild the flattened tree entries based on the current expansion state. fn rebuild_tree_entries(&mut self) { - let Some(space_name_id) = &self.space_name_id else { return }; + let Some(space_name_id) = &self.space_name_id else { + return; + }; let root_space_id = space_name_id.room_id().clone(); // Build tree starting from root let mut new_tree_entries = Vec::new(); @@ -1278,23 +1421,25 @@ impl SpaceLobbyScreen { level: usize, parent_mask: u32, ) { - let Some(children) = children_cache.get(space_id) else { return }; + let Some(children) = children_cache.get(space_id) else { + return; + }; // Sort: spaces first, then rooms, both alphabetically let mut sorted_children: Vec<_> = children.iter().collect(); - sorted_children.sort_by(|a, b| { - match (a.is_space(), b.is_space()) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase()), - } + sorted_children.sort_by(|a, b| match (a.is_space(), b.is_space()) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a + .display_name + .to_lowercase() + .cmp(&b.display_name.to_lowercase()), }); - let count = sorted_children.len(); for (i, child) in sorted_children.into_iter().enumerate() { let is_last = i == count - 1; - + tree_entries.push(TreeEntry::Item { info: SpaceRoomInfo::from(child), level, @@ -1326,7 +1471,7 @@ impl SpaceLobbyScreen { ); } else if loading_subspaces.contains(&child.room_id) { // Show loading indicator - tree_entries.push(TreeEntry::Loading { + tree_entries.push(TreeEntry::Loading { level: level + 1, parent_mask: child_mask, }); @@ -1351,12 +1496,18 @@ impl SpaceLobbyScreen { pub fn set_displayed_space(&mut self, cx: &mut Cx, space_name_id: &RoomNameId) { let space_name = space_name_id.to_string(); - let parent_name = self.view.label(cx, ids!(header.parent_space_row.parent_name)); + let parent_name = self + .view + .label(cx, ids!(header.parent_space_row.parent_name)); parent_name.set_text(cx, &space_name); // If this space is already being displayed, then the only thing we may need to do // is update its name in the top-level header (already done above). - if self.space_name_id.as_ref().is_some_and(|sni| sni.room_id() == space_name_id.room_id()) { + if self + .space_name_id + .as_ref() + .is_some_and(|sni| sni.room_id() == space_name_id.room_id()) + { return; } @@ -1380,7 +1531,9 @@ impl SpaceLobbyScreen { // Clear the main content until we receive the async space info responses. self.tree_entries.clear(); - self.view.label(cx, ids!(header.space_info_label)).set_text(cx, ""); + self.view + .label(cx, ids!(header.space_info_label)) + .set_text(cx, ""); self.is_loading = true; // Restore UI state if we've viewed this space before, otherwise start fresh @@ -1393,7 +1546,9 @@ impl SpaceLobbyScreen { // TODO: move avatar setting to `draw_walk()` // Set parent avatar - let avatar_ref = self.view.avatar(cx, ids!(header.parent_space_row.parent_avatar)); + let avatar_ref = self + .view + .avatar(cx, ids!(header.parent_space_row.parent_avatar)); let first_char = utils::user_name_first_letter(&space_name); avatar_ref.show_text(cx, None, None, first_char.unwrap_or("#")); @@ -1403,13 +1558,17 @@ impl SpaceLobbyScreen { impl SpaceLobbyScreenRef { pub fn set_displayed_space(&self, cx: &mut Cx, space_name_id: &RoomNameId) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_displayed_space(cx, space_name_id); } /// Saves the current UI state. Call this when the screen is being hidden or destroyed. pub fn save_current_state(&self) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.save_current_state(); } } diff --git a/src/home/spaces_bar.rs b/src/home/spaces_bar.rs index 8f613dc93..201491054 100644 --- a/src/home/spaces_bar.rs +++ b/src/home/spaces_bar.rs @@ -13,7 +13,13 @@ use matrix_sdk::{RoomDisplayName, RoomState}; use ruma::{OwnedRoomAliasId, OwnedRoomId, room::JoinRuleSummary}; use crate::{ - home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, room::{FetchedRoomAvatar, room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria}}, shared::{avatar::AvatarWidgetRefExt, room_filter_input_bar::RoomFilterAction}, utils::{self, RoomNameId} + home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, + room::{ + FetchedRoomAvatar, + room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria}, + }, + shared::{avatar::AvatarWidgetRefExt, room_filter_input_bar::RoomFilterAction}, + utils::{self, RoomNameId}, }; script_mod! { @@ -197,7 +203,7 @@ script_mod! { width: Fill, spacing: 0.0 - auto_tail: false, + auto_tail: false, max_pull_down: 0.0, scroll_bar: ScrollBar { // hide the scroll bar bar_size: 0.0, @@ -216,7 +222,7 @@ script_mod! { Desktop := View { align: Align{x: 0.5, y: 0.5} padding: 0, - width: (NAVIGATION_TAB_BAR_SIZE), + width: (NAVIGATION_TAB_BAR_SIZE), height: Fill CachedWidget { @@ -237,7 +243,6 @@ script_mod! { } } - /// Actions emitted by and handled by the SpacesBar widget (and its children). #[derive(Clone, Debug, Default)] pub enum SpacesBarAction { @@ -249,14 +254,17 @@ pub enum SpacesBarAction { None, } - #[derive(Script, ScriptHook, Widget, Animator)] pub struct SpacesBarEntry { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, - - #[rust] space_name_id: Option, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, + + #[rust] + space_name_id: Option, } impl Widget for SpacesBarEntry { @@ -269,13 +277,13 @@ impl Widget for SpacesBarEntry { let emit_hover_in_action = |this: &Self, cx: &mut Cx| { let is_desktop = cx.display_context.is_desktop(); cx.widget_action( - this.widget_uid(), + this.widget_uid(), TooltipAction::HoverIn { widget_rect: area.rect(cx), - text: this.space_name_id.as_ref().map_or( - String::from("Unknown Space Name"), - |sni| sni.to_string(), - ), + text: this + .space_name_id + .as_ref() + .map_or(String::from("Unknown Space Name"), |sni| sni.to_string()), options: CalloutTooltipOptions { position: if is_desktop { TooltipPosition::Right @@ -295,17 +303,14 @@ impl Widget for SpacesBarEntry { } Hit::FingerHoverOut(_) => { self.animator_play(cx, ids!(hover.off)); - cx.widget_action( - self.widget_uid(), - TooltipAction::HoverOut, - ); + cx.widget_action(self.widget_uid(), 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_name_id) = self.space_name_id.clone() { cx.widget_action( - self.widget_uid(), + self.widget_uid(), SpacesBarAction::ButtonSecondaryClicked { space_name_id }, ); } @@ -316,7 +321,7 @@ impl Widget for SpacesBarEntry { emit_hover_in_action(self, cx); if let Some(space_name_id) = self.space_name_id.clone() { cx.widget_action( - self.widget_uid(), + self.widget_uid(), SpacesBarAction::ButtonSecondaryClicked { space_name_id }, ); } @@ -325,7 +330,7 @@ impl Widget for SpacesBarEntry { self.animator_play(cx, ids!(hover.on)); if let Some(space_name_id) = self.space_name_id.clone() { cx.widget_action( - self.widget_uid(), + self.widget_uid(), SpacesBarAction::ButtonClicked { space_name_id }, ); } @@ -336,7 +341,7 @@ impl Widget for SpacesBarEntry { _ => {} } } - + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { self.view.draw_walk(cx, scope, walk) } @@ -345,12 +350,20 @@ impl Widget for SpacesBarEntry { impl SpacesBarEntry { fn set_metadata(&mut self, cx: &mut Cx, space_name_id: RoomNameId, is_selected: bool) { self.space_name_id = Some(space_name_id); - self.animator_toggle(cx, is_selected, Animate::No, ids!(active.on), ids!(active.off)); + self.animator_toggle( + cx, + is_selected, + Animate::No, + ids!(active.on), + ids!(active.off), + ); } } impl SpacesBarEntryRef { pub fn set_metadata(&self, cx: &mut Cx, space_name_id: RoomNameId, is_selected: bool) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_metadata(cx, space_name_id, is_selected); } } @@ -376,8 +389,6 @@ pub struct JoinedSpaceInfo { pub children_count: u64, } - - /// The possible updates that should be displayed by the single list of all spaces. /// /// These updates are enqueued by the `enqueue_spaces_list_update` function @@ -443,7 +454,6 @@ pub enum SpacesListUpdate { ScrollToSpace(OwnedRoomId), } - static PENDING_SPACE_UPDATES: SegQueue = SegQueue::new(); /// Enqueue a new room update for the list of all spaces @@ -453,37 +463,42 @@ pub fn enqueue_spaces_list_update(update: SpacesListUpdate) { SignalToUI::set_ui_signal(); } - /// The tab bar with buttons that navigate through top-level app pages. /// /// * In the "desktop" (wide) layout, this is a vertical bar on the left. /// * In the "mobile" (narrow) layout, this is a horizontal bar on the bottom. #[derive(Script, ScriptHook, Widget)] pub struct SpacesBar { - #[deref] view: AdaptiveView, + #[deref] + view: AdaptiveView, /// The set of all joined spaces, keyed by the space ID. - #[rust] all_joined_spaces: HashMap, + #[rust] + all_joined_spaces: HashMap, /// The currently-active filter function for the list of spaces. /// /// Note: for performance reasons, this does not get automatically applied /// when its value changes. Instead, you must manually invoke it on the set of `all_joined_spaces` /// in order to update the set of `displayed_spaces` accordingly. - #[rust] display_filter: RoomDisplayFilter, + #[rust] + display_filter: RoomDisplayFilter, /// The list of spaces currently displayed in the UI, in order from top to bottom. /// This is a strict subset of the rooms in `all_joined_spaces`, and should be determined /// by applying the `display_filter` to the set of `all_joined_spaces`. - #[rust] displayed_spaces: Vec, + #[rust] + displayed_spaces: Vec, /// Whether the list of `displayed_spaces` is currently filtered: /// `true` if filtered, `false` if showing everything. - #[rust] is_filtered: bool, + #[rust] + is_filtered: bool, /// The ID of the currently-selected space in this SpacesBar. /// Only one space can be selected at once. - #[rust] selected_space: Option, + #[rust] + selected_space: Option, } impl Widget for SpacesBar { @@ -504,7 +519,9 @@ impl Widget for SpacesBar { } // Update which space is currently selected. - if let SpacesBarAction::ButtonClicked { space_name_id } = action.as_widget_action().cast() { + if let SpacesBarAction::ButtonClicked { space_name_id } = + action.as_widget_action().cast() + { self.selected_space = Some(space_name_id.room_id().clone()); self.redraw(cx); cx.action(NavigationBarAction::GoToSpace { space_name_id }); @@ -534,7 +551,9 @@ impl Widget for SpacesBar { while let Some(widget_to_draw) = self.view.draw_walk(cx, scope, walk).step() { // We only care about drawing the portal list. let portal_list_ref = widget_to_draw.as_portal_list(); - let Some(mut list) = portal_list_ref.borrow_mut() else { continue }; + let Some(mut list) = portal_list_ref.borrow_mut() else { + continue; + }; // AdaptiveView + CachedWidget does not properly handle DSL-level style overrides, // so we must manually apply the different style choices here when drawing it. @@ -560,7 +579,7 @@ impl Widget for SpacesBar { "Found no\nmatching spaces." } else { "Found no\njoined spaces." - } + }, ); item } else { @@ -568,11 +587,11 @@ impl Widget for SpacesBar { }; item.draw_all(cx, scope); } - } - else { + } else { list.set_item_range(cx, 0, len + 1); while let Some(portal_list_index) = list.next_visible_item(cx) { - let item = if let Some(space) = self.displayed_spaces + let item = if let Some(space) = self + .displayed_spaces .get(portal_list_index) .and_then(|space_id| self.all_joined_spaces.get(space_id)) { @@ -586,41 +605,38 @@ impl Widget for SpacesBar { avatar_ref.show_text(cx, None, None, text); } FetchedRoomAvatar::Image(image_data) => { - let res = avatar_ref.show_image( - cx, - None, - |cx, img_ref| utils::load_png_or_jpg(&img_ref, cx, image_data), - ); + let res = avatar_ref.show_image(cx, None, |cx, img_ref| { + utils::load_png_or_jpg(&img_ref, cx, image_data) + }); if res.is_err() { - avatar_ref.show_text( - cx, - None, - None, - &space_name, - ); + avatar_ref.show_text(cx, None, None, &space_name); } } } item.as_spaces_bar_entry().set_metadata( cx, space.space_name_id.clone(), - self.selected_space.as_ref().is_some_and(|id| id == space.space_name_id.room_id()), + self.selected_space + .as_ref() + .is_some_and(|id| id == space.space_name_id.room_id()), ); item - } - else if portal_list_index == len { + } else if portal_list_index == len { let item = list.item(cx, portal_list_index, id!(StatusLabel)); - let descriptor = if self.is_filtered { "matching" } else { "joined" }; + let descriptor = if self.is_filtered { + "matching" + } else { + "joined" + }; let text = match len { - 0 => format!("Found no\n{descriptor} spaces."), - 1 => format!("Found 1\n{descriptor} space."), + 0 => format!("Found no\n{descriptor} spaces."), + 1 => format!("Found 1\n{descriptor} space."), 2..100 => format!("Found {len}\n{descriptor} spaces."), - 100.. => format!("Found 99+\n{descriptor} spaces."), + 100.. => format!("Found 99+\n{descriptor} spaces."), }; item.label(cx, ids!(label)).set_text(cx, &text); item - } - else { + } else { list.item(cx, portal_list_index, id!(BottomFiller)) }; item.draw_all(cx, scope); @@ -633,9 +649,8 @@ impl Widget for SpacesBar { } impl SpacesBar { - /// Handle all pending updates to the spaces list. + /// Handle all pending updates to the spaces list. fn handle_spaces_list_updates(&mut self, cx: &mut Cx, _event: &Event, _scope: &mut Scope) { - fn adjust_displayed_spaces( was_displayed: bool, should_display: bool, @@ -644,10 +659,11 @@ impl SpacesBar { ) { match (was_displayed, should_display) { // No need to update anything - (true, true) | (false, false) => { } + (true, true) | (false, false) => {} // Space was displayed but should no longer be displayed. (true, false) => { - displayed_spaces.iter() + displayed_spaces + .iter() .position(|s| s == &space_id) .map(|index| displayed_spaces.remove(index)); } @@ -658,7 +674,6 @@ impl SpacesBar { } } - let mut num_updates: usize = 0; while let Some(update) = PENDING_SPACE_UPDATES.pop() { num_updates += 1; @@ -666,26 +681,46 @@ impl SpacesBar { SpacesListUpdate::AddJoinedSpace(joined_space) => { let space_id = joined_space.space_name_id.room_id().clone(); let should_display = (self.display_filter)(&joined_space); - let replaced = self.all_joined_spaces.insert(space_id.clone(), joined_space); + let replaced = self + .all_joined_spaces + .insert(space_id.clone(), joined_space); if replaced.is_none() { - adjust_displayed_spaces(false, should_display, space_id, &mut self.displayed_spaces); + adjust_displayed_spaces( + false, + should_display, + space_id, + &mut self.displayed_spaces, + ); } else { error!("BUG: Added joined space {space_id} that already existed"); } } - SpacesListUpdate::UpdateCanonicalAlias { space_id, new_canonical_alias } => { + SpacesListUpdate::UpdateCanonicalAlias { + space_id, + new_canonical_alias, + } => { if let Some(space) = self.all_joined_spaces.get_mut(&space_id) { let was_displayed = (self.display_filter)(space); space.canonical_alias = new_canonical_alias; let should_display = (self.display_filter)(space); - adjust_displayed_spaces(was_displayed, should_display, space_id, &mut self.displayed_spaces); + adjust_displayed_spaces( + was_displayed, + should_display, + space_id, + &mut self.displayed_spaces, + ); } else { - error!("Error: couldn't find space {space_id} to update space canonical alias"); + error!( + "Error: couldn't find space {space_id} to update space canonical alias" + ); } } - SpacesListUpdate::UpdateSpaceName { space_id, new_space_name } => { + SpacesListUpdate::UpdateSpaceName { + space_id, + new_space_name, + } => { if let Some(space) = self.all_joined_spaces.get_mut(&space_id) { let was_displayed = (self.display_filter)(space); space.space_name_id = RoomNameId::new( @@ -693,7 +728,12 @@ impl SpacesBar { space_id.clone(), ); let should_display = (self.display_filter)(space); - adjust_displayed_spaces(was_displayed, should_display, space_id, &mut self.displayed_spaces); + adjust_displayed_spaces( + was_displayed, + should_display, + space_id, + &mut self.displayed_spaces, + ); } else { error!("Error: couldn't find space {space_id} to update space name"); } @@ -719,15 +759,23 @@ impl SpacesBar { } } - SpacesListUpdate::UpdateNumJoinedMembers { space_id, num_joined_members } => { + SpacesListUpdate::UpdateNumJoinedMembers { + space_id, + num_joined_members, + } => { if let Some(space) = self.all_joined_spaces.get_mut(&space_id) { space.num_joined_members = num_joined_members; } else { - error!("Error: couldn't find space {space_id} to update space num_joined_members"); + error!( + "Error: couldn't find space {space_id} to update space num_joined_members" + ); } } - SpacesListUpdate::UpdateJoinRule { space_id, join_rule } => { + SpacesListUpdate::UpdateJoinRule { + space_id, + join_rule, + } => { if let Some(space) = self.all_joined_spaces.get_mut(&space_id) { space.join_rule = join_rule; } else { @@ -735,27 +783,42 @@ impl SpacesBar { } } - SpacesListUpdate::UpdateWorldReadable { space_id, world_readable } => { + SpacesListUpdate::UpdateWorldReadable { + space_id, + world_readable, + } => { if let Some(space) = self.all_joined_spaces.get_mut(&space_id) { space.world_readable = world_readable; } else { - error!("Error: couldn't find space {space_id} to update space world_readable"); + error!( + "Error: couldn't find space {space_id} to update space world_readable" + ); } } - SpacesListUpdate::UpdateGuestCanJoin { space_id, guest_can_join } => { + SpacesListUpdate::UpdateGuestCanJoin { + space_id, + guest_can_join, + } => { if let Some(space) = self.all_joined_spaces.get_mut(&space_id) { space.guest_can_join = guest_can_join; } else { - error!("Error: couldn't find space {space_id} to update space guest_can_join"); + error!( + "Error: couldn't find space {space_id} to update space guest_can_join" + ); } } - SpacesListUpdate::UpdateChildrenCount { space_id, children_count } => { + SpacesListUpdate::UpdateChildrenCount { + space_id, + children_count, + } => { if let Some(space) = self.all_joined_spaces.get_mut(&space_id) { space.children_count = children_count; } else { - error!("Error: couldn't find space {space_id} to update space children_count"); + error!( + "Error: couldn't find space {space_id} to update space children_count" + ); } } @@ -784,7 +847,6 @@ impl SpacesBar { } } - /// Updates the lists of displayed spaces based on the current search filter. fn update_displayed_spaces(&mut self, cx: &mut Cx, keywords: &str) { let portal_list = self.view.portal_list(cx, ids!(spaces_list)); @@ -807,18 +869,22 @@ impl SpacesBar { self.display_filter = filter; self.is_filtered = true; - let filtered_spaces_iter = self.all_joined_spaces.iter() + let filtered_spaces_iter = self + .all_joined_spaces + .iter() .filter(|(_, space)| (self.display_filter)(*space)); self.displayed_spaces = if let Some(sort_fn) = sort_fn { - let mut filtered_spaces = filtered_spaces_iter - .collect::>(); + let mut filtered_spaces = filtered_spaces_iter.collect::>(); filtered_spaces.sort_by(|(_, space_a), (_, space_b)| sort_fn(*space_a, *space_b)); filtered_spaces .into_iter() - .map(|(space_id, _)| space_id.clone()).collect() + .map(|(space_id, _)| space_id.clone()) + .collect() } else { - filtered_spaces_iter.map(|(space_id, _)| space_id.clone()).collect() + filtered_spaces_iter + .map(|(space_id, _)| space_id.clone()) + .collect() }; portal_list.set_first_id_and_scroll(0, 0.0); diff --git a/src/home/tombstone_footer.rs b/src/home/tombstone_footer.rs index 2383cb180..12823a950 100644 --- a/src/home/tombstone_footer.rs +++ b/src/home/tombstone_footer.rs @@ -5,11 +5,14 @@ //! the option to join the successor room or stay in the current tombstoned room. use makepad_widgets::*; -use matrix_sdk::{ - ruma::OwnedRoomId, RoomState, SuccessorRoom -}; +use matrix_sdk::{ruma::OwnedRoomId, RoomState, SuccessorRoom}; -use crate::{app::AppStateAction, room::{BasicRoomDetails, FetchedRoomAvatar, FetchedRoomPreview}, shared::avatar::AvatarWidgetExt, utils}; +use crate::{ + app::AppStateAction, + room::{BasicRoomDetails, FetchedRoomAvatar, FetchedRoomPreview}, + shared::avatar::AvatarWidgetExt, + utils, +}; const DEFAULT_TOMBSTONE_REASON: &str = "This room has been replaced and is no longer active."; const DEFAULT_JOIN_BUTTON_TEXT: &str = "Go to the replacement room"; @@ -95,26 +98,34 @@ pub enum SuccessorRoomDetails { Full { room_preview: FetchedRoomPreview, reason: Option, - } + }, } - /// A view that shows information about a tombstoned room and its successor. #[derive(Script, ScriptHook, Widget)] pub struct TombstoneFooter { - #[deref] view: View, + #[deref] + view: View, /// The ID of the current tombstoned room. - #[rust] room_id: Option, + #[rust] + room_id: Option, /// The details of the successor room. - #[rust] successor_info: Option, + #[rust] + successor_info: Option, } impl Widget for TombstoneFooter { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { if let Event::Actions(actions) = event { - if self.view.button(cx, ids!(join_successor_button)).clicked(actions) { + if self + .view + .button(cx, ids!(join_successor_button)) + .clicked(actions) + { let Some(destination_room) = self.successor_info.clone() else { - error!("BUG: cannot navigate to replacement room: no successor room information."); + error!( + "BUG: cannot navigate to replacement room: no successor room information." + ); return; }; cx.action(AppStateAction::NavigateToRoom { @@ -144,7 +155,9 @@ impl TombstoneFooter { let successor_room_avatar = self.view.avatar(cx, ids!(successor_room_avatar)); let successor_room_name = self.view.label(cx, ids!(successor_room_name)); - log!("Showing TombstoneFooter for room {tombstoned_room_id}, Successor: {successor_room_details:?}"); + log!( + "Showing TombstoneFooter for room {tombstoned_room_id}, Successor: {successor_room_details:?}" + ); match successor_room_details { SuccessorRoomDetails::None => { replacement_reason.set_text(cx, DEFAULT_TOMBSTONE_REASON); @@ -154,36 +167,33 @@ impl TombstoneFooter { self.successor_info = None; } SuccessorRoomDetails::Basic(sr) => { - replacement_reason.set_text( - cx, - sr.reason.as_deref().unwrap_or(DEFAULT_TOMBSTONE_REASON) - ); + replacement_reason + .set_text(cx, sr.reason.as_deref().unwrap_or(DEFAULT_TOMBSTONE_REASON)); join_successor_button.set_text(cx, DEFAULT_JOIN_BUTTON_TEXT); successor_room_avatar.show_text(cx, None, None, "#"); successor_room_name.set_text(cx, &format!("Room ID {}", sr.room_id)); self.successor_info = Some(sr.into()); - }, - SuccessorRoomDetails::Full { room_preview, reason } => { - replacement_reason.set_text( - cx, - reason.as_deref().unwrap_or(DEFAULT_TOMBSTONE_REASON) - ); + } + SuccessorRoomDetails::Full { + room_preview, + reason, + } => { + replacement_reason + .set_text(cx, reason.as_deref().unwrap_or(DEFAULT_TOMBSTONE_REASON)); join_successor_button.set_text( cx, matches!(room_preview.state, Some(RoomState::Joined)) .then_some(DEFAULT_JOIN_BUTTON_TEXT) - .unwrap_or("Join the replacement room") + .unwrap_or("Join the replacement room"), ); match &room_preview.room_avatar { FetchedRoomAvatar::Text(text) => { successor_room_avatar.show_text(cx, None, None, text); } FetchedRoomAvatar::Image(image_data) => { - let res = successor_room_avatar.show_image( - cx, - None, - |cx, img_ref| utils::load_png_or_jpg(&img_ref, cx, image_data), - ); + let res = successor_room_avatar.show_image(cx, None, |cx, img_ref| { + utils::load_png_or_jpg(&img_ref, cx, image_data) + }); if res.is_err() { successor_room_avatar.show_text( cx, @@ -196,7 +206,10 @@ impl TombstoneFooter { } match room_preview.room_name_id.name_for_avatar() { Some(n) => successor_room_name.set_text(cx, n), - _ => successor_room_name.set_text(cx, &format!("Unnamed Room, ID: {}", room_preview.room_name_id.room_id())), + _ => successor_room_name.set_text( + cx, + &format!("Unnamed Room, ID: {}", room_preview.room_name_id.room_id()), + ), } self.successor_info = Some(room_preview.clone().into()); } @@ -222,13 +235,17 @@ impl TombstoneFooterRef { tombstoned_room_id: &OwnedRoomId, successor_room_details: &SuccessorRoomDetails, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show(cx, tombstoned_room_id, successor_room_details); } /// See [`TombstoneFooter::hide()`]. pub fn hide(&self, cx: &mut Cx) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.hide(cx); } } diff --git a/src/join_leave_room_modal.rs b/src/join_leave_room_modal.rs index eb8f5632c..66365fb58 100644 --- a/src/join_leave_room_modal.rs +++ b/src/join_leave_room_modal.rs @@ -8,7 +8,20 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; use tokio::sync::mpsc::UnboundedSender; -use crate::{home::invite_screen::{InviteDetails, JoinRoomResultAction, LeaveRoomResultAction}, room::BasicRoomDetails, shared::{popup_list::{PopupKind, enqueue_popup_notification}, styles::{apply_negative_button_style, apply_neutral_button_style, apply_positive_button_style, apply_primary_button_style}}, sliding_sync::{MatrixRequest, submit_async_request}, space_service_sync::{SpaceRequest, SpaceRoomListAction}, utils::{self, RoomNameId}}; +use crate::{ + home::invite_screen::{InviteDetails, JoinRoomResultAction, LeaveRoomResultAction}, + room::BasicRoomDetails, + shared::{ + popup_list::{PopupKind, enqueue_popup_notification}, + styles::{ + apply_negative_button_style, apply_neutral_button_style, apply_positive_button_style, + apply_primary_button_style, + }, + }, + sliding_sync::{MatrixRequest, submit_async_request}, + space_service_sync::{SpaceRequest, SpaceRoomListAction}, + utils::{self, RoomNameId}, +}; script_mod! { use mod.prelude.widgets.* @@ -114,14 +127,17 @@ script_mod! { #[derive(Script, ScriptHook, Widget)] pub struct JoinLeaveRoomModal { - #[deref] view: View, - #[rust] kind: Option, + #[deref] + view: View, + #[rust] + kind: Option, /// Whether the modal is in a final state, meaning the user can only click "Okay" to close it. /// /// * Set to `Some(true)` after a successful action (e.g., joining or leaving a room). /// * Set to `Some(false)` after a join/leave error occurs. /// * Set to `None` when the user is still able to interact with the modal. - #[rust] final_success: Option, + #[rust] + final_success: Option, } /// Kinds of content that can be shown and handled by the [`JoinLeaveRoomModal`]. @@ -151,8 +167,9 @@ pub enum JoinLeaveModalKind { impl JoinLeaveModalKind { pub fn room_id(&self) -> &OwnedRoomId { match self { - JoinLeaveModalKind::AcceptInvite(invite) - | JoinLeaveModalKind::RejectInvite(invite) => invite.room_id(), + JoinLeaveModalKind::AcceptInvite(invite) | JoinLeaveModalKind::RejectInvite(invite) => { + invite.room_id() + } JoinLeaveModalKind::JoinRoom { details, .. } | JoinLeaveModalKind::LeaveRoom(details) | JoinLeaveModalKind::LeaveSpace { details, .. } => details.room_id(), @@ -161,8 +178,9 @@ impl JoinLeaveModalKind { pub fn room_name(&self) -> &RoomNameId { match self { - JoinLeaveModalKind::AcceptInvite(invite) - | JoinLeaveModalKind::RejectInvite(invite) => invite.room_name_id(), + JoinLeaveModalKind::AcceptInvite(invite) | JoinLeaveModalKind::RejectInvite(invite) => { + invite.room_name_id() + } JoinLeaveModalKind::JoinRoom { details, .. } | JoinLeaveModalKind::LeaveRoom(details) | JoinLeaveModalKind::LeaveSpace { details, .. } => details.room_name_id(), @@ -172,8 +190,9 @@ impl JoinLeaveModalKind { #[allow(unused)] // remove when we use it in navigate_to_room pub fn basic_room_details(&self) -> &BasicRoomDetails { match self { - JoinLeaveModalKind::AcceptInvite(invite) - | JoinLeaveModalKind::RejectInvite(invite) => &invite.room_info, + JoinLeaveModalKind::AcceptInvite(invite) | JoinLeaveModalKind::RejectInvite(invite) => { + &invite.room_info + } JoinLeaveModalKind::JoinRoom { details, .. } | JoinLeaveModalKind::LeaveRoom(details) | JoinLeaveModalKind::LeaveSpace { details, .. } => details, @@ -202,7 +221,6 @@ pub enum JoinLeaveRoomModalAction { }, } - impl Widget for JoinLeaveRoomModal { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); @@ -220,25 +238,34 @@ impl WidgetMatchEvent for JoinLeaveRoomModal { let cancel_button = self.view.button(cx, ids!(cancel_button)); let cancel_clicked = cancel_button.clicked(actions); - if cancel_clicked || - actions.iter().any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) + if cancel_clicked + || actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) { // Inform other widgets that this modal has been closed. - cx.action(JoinLeaveRoomModalAction::Close { successful: false, was_internal: cancel_clicked }); + cx.action(JoinLeaveRoomModalAction::Close { + successful: false, + was_internal: cancel_clicked, + }); self.reset_state(); return; } - let Some(kind) = self.kind.as_ref() else { return }; + let Some(kind) = self.kind.as_ref() else { + return; + }; let mut needs_redraw = false; if accept_button.clicked(actions) { if let Some(successful) = self.final_success { - cx.action(JoinLeaveRoomModalAction::Close { successful, was_internal: true }); + cx.action(JoinLeaveRoomModalAction::Close { + successful, + was_internal: true, + }); self.reset_state(); return; - } - else { + } else { let title: Cow; let description: String; let accept_button_text: &str; @@ -268,7 +295,11 @@ impl WidgetMatchEvent for JoinLeaveRoomModal { }); } JoinLeaveModalKind::JoinRoom { details, is_space } => { - title = format!("Joining this {}...", if *is_space { "space" } else { "room" }).into(); + title = format!( + "Joining this {}...", + if *is_space { "space" } else { "room" } + ) + .into(); description = format!( "Joining \"{}\".\n\n\ Waiting for confirmation from the homeserver...", @@ -291,7 +322,10 @@ impl WidgetMatchEvent for JoinLeaveRoomModal { room_id: room.room_id().clone(), }); } - JoinLeaveModalKind::LeaveSpace { details, space_request_sender } => { + JoinLeaveModalKind::LeaveSpace { + details, + space_request_sender, + } => { title = "Leaving this space...".into(); description = format!( "Leaving \"{}\".\n\n\ @@ -299,9 +333,12 @@ impl WidgetMatchEvent for JoinLeaveRoomModal { details.room_name_id(), ); accept_button_text = "Leaving..."; - if space_request_sender.send( - SpaceRequest::LeaveSpace { space_name_id: details.room_name_id().clone() } - ).is_err() { + if space_request_sender + .send(SpaceRequest::LeaveSpace { + space_name_id: details.room_name_id().clone(), + }) + .is_err() + { enqueue_popup_notification( "Failed to send leave space request.\n\nPlease restart Robrix.", PopupKind::Error, @@ -312,7 +349,9 @@ impl WidgetMatchEvent for JoinLeaveRoomModal { } self.view.label(cx, ids!(title)).set_text(cx, &title); - self.view.label(cx, ids!(description)).set_text(cx, &description); + self.view + .label(cx, ids!(description)) + .set_text(cx, &description); self.view.view(cx, ids!(tip_view)).set_visible(cx, false); accept_button.set_text(cx, accept_button_text); accept_button.set_enabled(cx, false); @@ -329,23 +368,33 @@ impl WidgetMatchEvent for JoinLeaveRoomModal { PopupKind::Success, Some(3.0), ); - self.view.label(cx, ids!(title)).set_text(cx, "Joined room!"); - self.view.label(cx, ids!(description)).set_text(cx, &format!( - "Successfully joined \"{}\".", - kind.room_name(), - )); + self.view + .label(cx, ids!(title)) + .set_text(cx, "Joined room!"); + self.view.label(cx, ids!(description)).set_text( + cx, + &format!("Successfully joined \"{}\".", kind.room_name(),), + ); new_final_success = Some(true); } - Some(JoinRoomResultAction::Failed { room_id, error }) if room_id == kind.room_id() => { - self.view.label(cx, ids!(title)).set_text(cx, "Error joining room!"); - let was_invite = matches!(kind, JoinLeaveModalKind::AcceptInvite(_) | JoinLeaveModalKind::RejectInvite(_)); - let msg = utils::stringify_join_leave_error(error, kind.room_name(), true, was_invite); - self.view.label(cx, ids!(description)).set_text(cx, &msg); - enqueue_popup_notification( - msg, - PopupKind::Error, - None, + Some(JoinRoomResultAction::Failed { room_id, error }) + if room_id == kind.room_id() => + { + self.view + .label(cx, ids!(title)) + .set_text(cx, "Error joining room!"); + let was_invite = matches!( + kind, + JoinLeaveModalKind::AcceptInvite(_) | JoinLeaveModalKind::RejectInvite(_) ); + let msg = utils::stringify_join_leave_error( + error, + kind.room_name(), + true, + was_invite, + ); + self.view.label(cx, ids!(description)).set_text(cx, &msg); + enqueue_popup_notification(msg, PopupKind::Error, None); new_final_success = Some(false); } _ => {} @@ -356,49 +405,66 @@ impl WidgetMatchEvent for JoinLeaveRoomModal { let title: &str; let description: String; let popup_msg: Cow<'static, str>; - if matches!(kind, JoinLeaveModalKind::AcceptInvite(_) | JoinLeaveModalKind::RejectInvite(_)) { + if matches!( + kind, + JoinLeaveModalKind::AcceptInvite(_) | JoinLeaveModalKind::RejectInvite(_) + ) { title = "Rejected invite!"; - description = format!( - "Successfully rejected invite to \"{}\".", - kind.room_name(), - ); + description = + format!("Successfully rejected invite to \"{}\".", kind.room_name(),); popup_msg = "Successfully rejected invite.".into(); } else { title = "Left room!"; - description = format!( - "Successfully left \"{}\".", - kind.room_name(), - ); + description = format!("Successfully left \"{}\".", kind.room_name(),); popup_msg = "Successfully left room.".into(); } self.view.label(cx, ids!(title)).set_text(cx, title); - self.view.label(cx, ids!(description)).set_text(cx, &description); + self.view + .label(cx, ids!(description)) + .set_text(cx, &description); enqueue_popup_notification(popup_msg, PopupKind::Success, Some(5.0)); new_final_success = Some(true); } - Some(LeaveRoomResultAction::Failed { room_id, error }) if room_id == kind.room_id() => { + Some(LeaveRoomResultAction::Failed { room_id, error }) + if room_id == kind.room_id() => + { let title: &str; let description: String; let popup_msg: Cow<'static, str>; - if matches!(kind, JoinLeaveModalKind::AcceptInvite(_) | JoinLeaveModalKind::RejectInvite(_)) { + if matches!( + kind, + JoinLeaveModalKind::AcceptInvite(_) | JoinLeaveModalKind::RejectInvite(_) + ) { title = "Error rejecting invite!"; - description = utils::stringify_join_leave_error(error, kind.room_name(), false, true); + description = + utils::stringify_join_leave_error(error, kind.room_name(), false, true); popup_msg = "Failed to reject invite.".into(); } else { title = "Error leaving room!"; - description = utils::stringify_join_leave_error(error, kind.room_name(), false, false); + description = utils::stringify_join_leave_error( + error, + kind.room_name(), + false, + false, + ); popup_msg = "Failed to leave room.".into(); } self.view.label(cx, ids!(title)).set_text(cx, title); - self.view.label(cx, ids!(description)).set_text(cx, &description); + self.view + .label(cx, ids!(description)) + .set_text(cx, &description); enqueue_popup_notification(popup_msg, PopupKind::Error, None); new_final_success = Some(false); } _ => {} } - if let Some(SpaceRoomListAction::LeaveSpaceResult { space_name_id, result }) = action.downcast_ref() { + if let Some(SpaceRoomListAction::LeaveSpaceResult { + space_name_id, + result, + }) = action.downcast_ref() + { if space_name_id.room_id() == kind.room_id() { let title: &str; let description: String; @@ -410,12 +476,15 @@ impl WidgetMatchEvent for JoinLeaveRoomModal { } Err(e) => { title = "Error leaving space!"; - description = format!("Failed to leave space \"{space_name_id}\".\n\nError: {e}"); + description = + format!("Failed to leave space \"{space_name_id}\".\n\nError: {e}"); new_final_success = Some(false); } } self.view.label(cx, ids!(title)).set_text(cx, title); - self.view.label(cx, ids!(description)).set_text(cx, &description); + self.view + .label(cx, ids!(description)) + .set_text(cx, &description); } } } @@ -441,14 +510,9 @@ impl JoinLeaveRoomModal { self.final_success = None; } - /// Populates this modal with the proper info based on + /// Populates this modal with the proper info based on /// the given `kind of join or leave action. - fn set_kind( - &mut self, - cx: &mut Cx, - kind: JoinLeaveModalKind, - show_tip: bool, - ) { + fn set_kind(&mut self, cx: &mut Cx, kind: JoinLeaveModalKind, show_tip: bool) { log!("Showing JoinLeaveRoomModal for {kind:?}"); let title: &str; let description: String; @@ -509,7 +573,9 @@ impl JoinLeaveRoomModal { } self.view.label(cx, ids!(title)).set_text(cx, title); - self.view.label(cx, ids!(description)).set_text(cx, &description); + self.view + .label(cx, ids!(description)) + .set_text(cx, &description); if show_tip { self.view.view(cx, ids!(tip_view)).set_visible(cx, true); self.view.label(cx, ids!(tip)).set_text(cx, &format!( @@ -523,10 +589,11 @@ impl JoinLeaveRoomModal { let mut cancel_button = self.button(cx, ids!(cancel_button)); accept_button.set_text(cx, "Yes"); - let is_negative = matches!(kind, + let is_negative = matches!( + kind, JoinLeaveModalKind::RejectInvite(_) - | JoinLeaveModalKind::LeaveRoom(_) - | JoinLeaveModalKind::LeaveSpace { .. } + | JoinLeaveModalKind::LeaveRoom(_) + | JoinLeaveModalKind::LeaveSpace { .. } ); if is_negative { @@ -554,13 +621,10 @@ impl JoinLeaveRoomModal { impl JoinLeaveRoomModalRef { /// Sets the details of this join/leave modal. - pub fn set_kind( - &self, - cx: &mut Cx, - kind: JoinLeaveModalKind, - show_tip: bool, - ) { - let Some(mut inner) = self.borrow_mut() else { return }; + pub fn set_kind(&self, cx: &mut Cx, kind: JoinLeaveModalKind, show_tip: bool) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_kind(cx, kind, show_tip); } } diff --git a/src/lib.rs b/src/lib.rs index 346c0314b..f26e0c117 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,6 @@ macro_rules! live { pub type LivePtr = makepad_widgets::ScriptValue; - pub fn widget_ref_from_live_ptr( cx: &mut makepad_widgets::Cx, ptr: Option, @@ -61,7 +60,6 @@ pub mod shared; mod event_preview; pub mod room; - /// All content related to TSP (Trust Spanning Protocol) wallets/identities. #[cfg(feature = "tsp")] pub mod tsp; @@ -69,7 +67,6 @@ pub mod tsp; #[cfg(not(feature = "tsp"))] pub mod tsp_dummy; - // Matrix stuff pub mod sliding_sync; pub mod space_service_sync; diff --git a/src/location.rs b/src/location.rs index 515d00322..446ca9008 100644 --- a/src/location.rs +++ b/src/location.rs @@ -1,6 +1,12 @@ //! Functions for querying the device's current location. -use std::{sync::{mpsc::{self, Receiver, Sender}, Mutex}, time::SystemTime}; +use std::{ + sync::{ + mpsc::{self, Receiver, Sender}, + Mutex, + }, + time::SystemTime, +}; use makepad_widgets::{Cx, error, log}; use robius_location::{Access, Accuracy, Coordinates, Location, Manager}; @@ -12,7 +18,7 @@ pub enum LocationAction { Update(LocationUpdate), /// The location handler encountered an error. Error(robius_location::Error), - None + None, } /// An updated location sample, including coordinates and a system timestamp. @@ -32,7 +38,6 @@ pub fn get_latest_location() -> Option { *(LATEST_LOCATION.lock().unwrap()) } - struct LocationHandler; impl robius_location::Handler for LocationHandler { @@ -61,12 +66,10 @@ impl robius_location::Handler for LocationHandler { } } - fn location_request_loop( request_receiver: Receiver, mut manager: ManagerWrapper, ) -> Result<(), robius_location::Error> { - manager.update_once()?; while let Ok(request) = request_receiver.recv() { @@ -87,7 +90,6 @@ fn location_request_loop( Err(robius_location::Error::Unknown) } - pub enum LocationRequest { UpdateOnce, StartUpdates, diff --git a/src/login/login_status_modal.rs b/src/login/login_status_modal.rs index ee92a87cc..da1cf0637 100644 --- a/src/login/login_status_modal.rs +++ b/src/login/login_status_modal.rs @@ -75,7 +75,8 @@ script_mod! { /// A modal dialog that displays the status of a login attempt. #[derive(Script, ScriptHook, Widget)] pub struct LoginStatusModal { - #[deref] view: View, + #[deref] + view: View, } #[derive(Clone, Debug, Default)] @@ -113,7 +114,7 @@ impl WidgetMatchEvent for LoginStatusModal { // a `LoginStatusModalAction::Close` action, as that would cause // an infinite action feedback loop. if !modal_dismissed { - cx.widget_action(widget_uid, LoginStatusModalAction::Close); + cx.widget_action(widget_uid, LoginStatusModalAction::Close); } } } diff --git a/src/logout/logout_confirm_modal.rs b/src/logout/logout_confirm_modal.rs index 506162acf..5be332933 100644 --- a/src/logout/logout_confirm_modal.rs +++ b/src/logout/logout_confirm_modal.rs @@ -85,13 +85,15 @@ script_mod! { /// A modal dialog that displays logout confirmation. #[derive(Script, ScriptHook, Widget)] pub struct LogoutConfirmModal { - #[deref] view: View, + #[deref] + view: View, /// Whether the modal is in a final state, meaning the user can only click "Okay" to close it. /// /// * Set to `Some(true)` after a successful logout Action /// * Set to `Some(false)` after a logout error occurs. /// * Set to `None` when the user is still able to interact with the modal. - #[rust] final_success: Option, + #[rust] + final_success: Option, } /// Actions handled by the parent widget of the [`LogoutConfirmModal`]. @@ -111,16 +113,14 @@ pub enum LogoutConfirmModalAction { None, } -/// Actions related to logout process +/// Actions related to logout process pub enum LogoutAction { /// A positive response to a logout request from the Matrix homeserver. LogoutSuccess, /// A negative response to a logout request from the Matrix homeserver. LogoutFailure(String), /// A request from the background task to the main UI thread to clear all app state. - ClearAppState { - on_clear_appstate: Arc, - }, + ClearAppState { on_clear_appstate: Arc }, /// Signal that the application is in an invalid state and needs to be restarted. /// This happens when critical components have been cleaned up during a previous /// logout attempt that reached the point of no return, but the app wasn't restarted. @@ -129,10 +129,7 @@ pub enum LogoutAction { cleared_component: ClearedComponentType, }, /// Progress update from the logout state machine - ProgressUpdate { - message: String, - percentage: u8, - }, + ProgressUpdate { message: String, percentage: u8 }, /// Indicates logout is in progress or not InProgress(bool), } @@ -146,7 +143,10 @@ impl std::fmt::Debug for LogoutAction { LogoutAction::ApplicationRequiresRestart { cleared_component } => { write!(f, "ApplicationRequiresRestart({:?})", cleared_component) } - LogoutAction::ProgressUpdate { message, percentage } => { + LogoutAction::ProgressUpdate { + message, + percentage, + } => { write!(f, "ProgressUpdate({}, {}%)", message, percentage) } LogoutAction::InProgress(value) => write!(f, "InProgress({})", value), @@ -182,11 +182,16 @@ impl WidgetMatchEvent for LogoutConfirmModal { let cancel_button = self.button(cx, ids!(cancel_button)); let mut confirm_button = self.button(cx, ids!(confirm_button)); - let modal_dismissed = actions.iter().any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))); + let modal_dismissed = actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))); let cancel_clicked = cancel_button.clicked(actions); if cancel_clicked || modal_dismissed { - cx.action(LogoutConfirmModalAction::Close { successful: false, was_internal: cancel_clicked }); + cx.action(LogoutConfirmModalAction::Close { + successful: false, + was_internal: cancel_clicked, + }); self.reset_state(cx); return; } @@ -199,7 +204,10 @@ impl WidgetMatchEvent for LogoutConfirmModal { cx.quit(); } - cx.action(LogoutConfirmModalAction::Close { successful, was_internal: true }); + cx.action(LogoutConfirmModalAction::Close { + successful, + was_internal: true, + }); self.reset_state(cx); return; } else { @@ -210,7 +218,9 @@ impl WidgetMatchEvent for LogoutConfirmModal { cancel_button.set_text(cx, "Abort"); cancel_button.set_enabled(cx, true); - submit_async_request(MatrixRequest::Logout { is_desktop: cx.display_context.is_desktop() }); + submit_async_request(MatrixRequest::Logout { + is_desktop: cx.display_context.is_desktop(), + }); needs_redraw = true; } } @@ -230,7 +240,8 @@ impl WidgetMatchEvent for LogoutConfirmModal { Some(LogoutAction::LogoutFailure(error)) => { if is_logout_past_point_of_no_return() { - self.label(cx, ids!(title)).set_text(cx, "Logout error, please restart Robrix."); + self.label(cx, ids!(title)) + .set_text(cx, "Logout error, please restart Robrix."); self.set_message(cx, "The logout process encountered an error when communicating with the homeserver. Since your login session has been partially invalidated, Robrix must restart in order to continue to properly function."); confirm_button.set_text(cx, "Restart now"); @@ -242,7 +253,6 @@ impl WidgetMatchEvent for LogoutConfirmModal { confirm_button.set_enabled(cx, true); cancel_button.set_visible(cx, false); - } else { self.set_message(cx, &format!("Logout failed: {}", error)); confirm_button.set_text(cx, "Okay"); @@ -255,7 +265,8 @@ impl WidgetMatchEvent for LogoutConfirmModal { } Some(LogoutAction::ApplicationRequiresRestart { .. }) => { - self.label(cx, ids!(title)).set_text(cx, "Logout error, please restart Robrix."); + self.label(cx, ids!(title)) + .set_text(cx, "Logout error, please restart Robrix."); self.set_message(cx, "Application is in an inconsistent state and needs to be restarted to continue."); confirm_button.set_text(cx, "Restart now"); @@ -271,7 +282,10 @@ impl WidgetMatchEvent for LogoutConfirmModal { needs_redraw = true; } - Some(LogoutAction::ProgressUpdate { message, percentage }) => { + Some(LogoutAction::ProgressUpdate { + message, + percentage, + }) => { // Just update the message text to show progress self.set_message(cx, &format!("{} ({}%)", message, percentage)); // Disable confirm button during logout, but keep cancel/abort enabled @@ -288,7 +302,6 @@ impl WidgetMatchEvent for LogoutConfirmModal { if needs_redraw { self.redraw(cx); } - } } @@ -312,7 +325,6 @@ impl LogoutConfirmModal { confirm_button.reset_hover(cx); self.redraw(cx); } - } impl LogoutConfirmModalRef { @@ -323,10 +335,9 @@ impl LogoutConfirmModalRef { } } - pub fn reset_state(&self,cx: &mut Cx) { + pub fn reset_state(&self, cx: &mut Cx) { if let Some(mut inner) = self.borrow_mut() { inner.reset_state(cx); } } - } diff --git a/src/logout/logout_errors.rs b/src/logout/logout_errors.rs index c09719d01..973e069f4 100644 --- a/src/logout/logout_errors.rs +++ b/src/logout/logout_errors.rs @@ -42,4 +42,4 @@ impl fmt::Display for LogoutError { } } -impl std::error::Error for LogoutError {} \ No newline at end of file +impl std::error::Error for LogoutError {} diff --git a/src/logout/logout_state_machine.rs b/src/logout/logout_state_machine.rs index 3ccb922ca..d26b38a6a 100644 --- a/src/logout/logout_state_machine.rs +++ b/src/logout/logout_state_machine.rs @@ -147,7 +147,7 @@ impl LogoutProgress { step_started_at: now, } } - + fn update(&mut self, state: LogoutState, message: String, percentage: u8) { self.state = state; self.message = message; @@ -194,12 +194,9 @@ pub struct LogoutStateMachine { impl LogoutStateMachine { pub fn new(config: LogoutConfig) -> Self { - let initial_progress = LogoutProgress::new( - LogoutState::Idle, - "Ready to logout".to_string(), - 0 - ); - + let initial_progress = + LogoutProgress::new(LogoutState::Idle, "Ready to logout".to_string(), 0); + Self { current_state: Arc::new(Mutex::new(LogoutState::Idle)), progress: Arc::new(Mutex::new(initial_progress)), @@ -208,113 +205,136 @@ impl LogoutStateMachine { cancellation_requested: Arc::new(AtomicBool::new(false)), } } - + /// Get current state pub async fn current_state(&self) -> LogoutState { self.current_state.lock().await.clone() } - + /// Get current progress pub async fn progress(&self) -> LogoutProgress { self.progress.lock().await.clone() } - + /// Request cancellation (only works before point of no return) pub fn request_cancellation(&self) { if !self.point_of_no_return.load(Ordering::Acquire) { self.cancellation_requested.store(true, Ordering::Release); } } - + /// Check if cancellation was requested fn is_cancelled(&self) -> bool { self.cancellation_requested.load(Ordering::Acquire) } - + /// Transition to a new state - async fn transition_to(&self, new_state: LogoutState, message: String, percentage: u8) -> Result<()> { + async fn transition_to( + &self, + new_state: LogoutState, + message: String, + percentage: u8, + ) -> Result<()> { // Check for cancellation before transitioning - if self.is_cancelled() && !matches!(new_state, LogoutState::PointOfNoReturn | LogoutState::Failed(_)) { + if self.is_cancelled() + && !matches!( + new_state, + LogoutState::PointOfNoReturn | LogoutState::Failed(_) + ) + { let mut state = self.current_state.lock().await; *state = LogoutState::Failed(LogoutError::Recoverable(RecoverableError::Cancelled)); return Err(anyhow!("Logout cancelled by user")); } - - log!("Logout state transition: {:?} -> {:?}", self.current_state.lock().await.clone(), new_state); - + + log!( + "Logout state transition: {:?} -> {:?}", + self.current_state.lock().await.clone(), + new_state + ); + // Update state and progress, then extract values for UI update let mut state = self.current_state.lock().await; *state = new_state.clone(); drop(state); - + let mut progress = self.progress.lock().await; progress.update(new_state, message.clone(), percentage); let progress_message = progress.message.clone(); let progress_percentage = progress.percentage; drop(progress); - + // Send progress update to UI - log!("Sending progress update: {} ({}%)", progress_message, progress_percentage); - Cx::post_action(LogoutAction::ProgressUpdate { + log!( + "Sending progress update: {} ({}%)", + progress_message, + progress_percentage + ); + Cx::post_action(LogoutAction::ProgressUpdate { message: progress_message, - percentage: progress_percentage + percentage: progress_percentage, }); - + Ok(()) } - + /// Execute the logout process pub async fn execute(&self) -> Result<()> { log!("LogoutStateMachine::execute() started"); - + // Set logout in progress flag set_logout_in_progress(true); - + // Reset global point of no return flag set_logout_point_of_no_return(false); - + // Start from Idle state self.transition_to( LogoutState::PreChecking, "Checking prerequisites...".to_string(), - 10 - ).await?; - + 10, + ) + .await?; + // Pre-checks if let Err(e) = self.perform_prechecks().await { self.transition_to( LogoutState::Failed(e.clone()), format!("Precheck failed: {}", e), - 0 - ).await?; + 0, + ) + .await?; self.handle_error(&e).await; return Err(anyhow!(e)); } - + // Stop sync service self.transition_to( LogoutState::StoppingSyncService, "Stopping sync service...".to_string(), - 20 - ).await?; - + 20, + ) + .await?; + if let Err(e) = self.stop_sync_service().await { self.transition_to( LogoutState::Failed(e.clone()), format!("Failed to stop sync service: {}", e), - 0 - ).await?; + 0, + ) + .await?; self.handle_error(&e).await; return Err(anyhow!(e)); } - + // Server logout self.transition_to( LogoutState::LoggingOutFromServer, "Logging out from server...".to_string(), - 30 - ).await?; - + 30, + ) + .await?; + match self.perform_server_logout().await { Ok(_) => { self.point_of_no_return.store(true, Ordering::Release); @@ -322,9 +342,10 @@ impl LogoutStateMachine { self.transition_to( LogoutState::PointOfNoReturn, "Point of no return reached".to_string(), - 50 - ).await?; - + 50, + ) + .await?; + // We delete latest_user_id after reaching LOGOUT_POINT_OF_NO_RETURN: // 1. To prevent auto-login with invalid session on next start // 2. While keeping session file intact for potential future login @@ -334,16 +355,18 @@ impl LogoutStateMachine { } Err(e) => { // Check if it's an M_UNKNOWN_TOKEN error - if matches!(&e, LogoutError::Recoverable(RecoverableError::ServerLogoutFailed(msg)) if msg.contains("M_UNKNOWN_TOKEN")) { + if matches!(&e, LogoutError::Recoverable(RecoverableError::ServerLogoutFailed(msg)) if msg.contains("M_UNKNOWN_TOKEN")) + { log!("Token already invalidated, continuing with logout"); self.point_of_no_return.store(true, Ordering::Release); set_logout_point_of_no_return(true); self.transition_to( LogoutState::PointOfNoReturn, "Token already invalidated".to_string(), - 50 - ).await?; - + 50, + ) + .await?; + // Same delete operation as in the success case above if let Err(e) = delete_latest_user_id().await { log!("Warning: Failed to delete latest user ID: {}", e); @@ -353,94 +376,107 @@ impl LogoutStateMachine { if let Some(sync_service) = get_sync_service() { sync_service.start().await; } - + self.transition_to( LogoutState::Failed(e.clone()), format!("Server logout failed: {}", e), - 0 - ).await?; + 0, + ) + .await?; self.handle_error(&e).await; return Err(anyhow!(e)); } } } - + // From here on, all failures are unrecoverable - + // Close tabs (desktop only) if self.config.is_desktop { self.transition_to( LogoutState::ClosingTabs, "Closing all tabs...".to_string(), - 60 - ).await?; - + 60, + ) + .await?; + if let Err(e) = self.close_all_tabs().await { - let error = LogoutError::Unrecoverable(UnrecoverableError::PostPointOfNoReturnFailure(e.to_string())); + let error = LogoutError::Unrecoverable( + UnrecoverableError::PostPointOfNoReturnFailure(e.to_string()), + ); self.transition_to( LogoutState::Failed(error.clone()), "Failed to close tabs".to_string(), - 0 - ).await?; + 0, + ) + .await?; self.handle_error(&error).await; return Err(anyhow!(error)); } } - + // Clean app state self.transition_to( LogoutState::CleaningAppState, "Cleaning up application state...".to_string(), - 70 - ).await?; - + 70, + ) + .await?; + // All static resources (CLIENT, SYNC_SERVICE, etc.) are defined in the sliding_sync module, // so the state machine delegates the cleanup operation to sliding_sync's clear_app_state function // rather than accessing these static variables directly from outside the module. if let Err(e) = clear_app_state(&self.config).await { - let error = LogoutError::Unrecoverable(UnrecoverableError::PostPointOfNoReturnFailure(e.to_string())); + let error = LogoutError::Unrecoverable(UnrecoverableError::PostPointOfNoReturnFailure( + e.to_string(), + )); self.transition_to( LogoutState::Failed(error.clone()), "Failed to clean app state".to_string(), - 0 - ).await?; + 0, + ) + .await?; self.handle_error(&error).await; return Err(anyhow!(error)); } - + // Shutdown tasks self.transition_to( LogoutState::ShuttingDownTasks, "Shutting down background tasks...".to_string(), - 80 - ).await?; - + 80, + ) + .await?; + self.shutdown_background_tasks(); - + // Restart runtime self.transition_to( LogoutState::RestartingRuntime, "Restarting Matrix runtime...".to_string(), - 90 - ).await?; - - if let Err(e) = self.restart_runtime(){ + 90, + ) + .await?; + + if let Err(e) = self.restart_runtime() { let error = LogoutError::Unrecoverable(UnrecoverableError::RuntimeRestartFailed); self.transition_to( LogoutState::Failed(error.clone()), format!("Failed to restart runtime: {}", e), - 0 - ).await?; + 0, + ) + .await?; self.handle_error(&error).await; return Err(anyhow!(error)); } - + // Success! self.transition_to( LogoutState::Completed, "Logout completed successfully".to_string(), - 100 - ).await?; + 100, + ) + .await?; // Close the settings screen after logout, since its content // is specific to the currently-logged-in user's account. @@ -451,24 +487,28 @@ impl LogoutStateMachine { Cx::post_action(LogoutAction::LogoutSuccess); Ok(()) } - + // Individual step implementations async fn perform_prechecks(&self) -> Result<(), LogoutError> { log!("perform_prechecks started"); - + // Check client existence if get_client().is_none() { log!("perform_prechecks: client cleared"); - return Err(LogoutError::Unrecoverable(UnrecoverableError::ComponentsCleared)); + return Err(LogoutError::Unrecoverable( + UnrecoverableError::ComponentsCleared, + )); } - + // Check sync service if get_sync_service().is_none() { log!("perform_prechecks: sync service cleared"); - return Err(LogoutError::Unrecoverable(UnrecoverableError::ComponentsCleared)); + return Err(LogoutError::Unrecoverable( + UnrecoverableError::ComponentsCleared, + )); } log!("perform_prechecks: sync service exists"); - + // Check access token if let Some(client) = get_client() { if client.access_token().is_none() { @@ -477,39 +517,51 @@ impl LogoutStateMachine { } log!("perform_prechecks: access token exists"); } - + log!("perform_prechecks completed successfully"); Ok(()) } - + async fn stop_sync_service(&self) -> Result<(), LogoutError> { if let Some(sync_service) = get_sync_service() { sync_service.stop().await; Ok(()) } else { - Err(LogoutError::Unrecoverable(UnrecoverableError::ComponentsCleared)) + Err(LogoutError::Unrecoverable( + UnrecoverableError::ComponentsCleared, + )) } } - + async fn perform_server_logout(&self) -> Result<(), LogoutError> { let Some(client) = get_client() else { - return Err(LogoutError::Unrecoverable(UnrecoverableError::ComponentsCleared)); + return Err(LogoutError::Unrecoverable( + UnrecoverableError::ComponentsCleared, + )); }; - + match tokio::time::timeout( self.config.server_logout_timeout, - client.matrix_auth().logout() - ).await { + client.matrix_auth().logout(), + ) + .await + { Ok(Ok(_)) => Ok(()), - Ok(Err(e)) => Err(LogoutError::Recoverable(RecoverableError::ServerLogoutFailed(e.to_string()))), - Err(_) => Err(LogoutError::Recoverable(RecoverableError::Timeout("Server logout timed out".to_string()))), + Ok(Err(e)) => Err(LogoutError::Recoverable( + RecoverableError::ServerLogoutFailed(e.to_string()), + )), + Err(_) => Err(LogoutError::Recoverable(RecoverableError::Timeout( + "Server logout timed out".to_string(), + ))), } } - + async fn close_all_tabs(&self) -> Result<()> { let on_close_all = Arc::new(Notify::new()); - Cx::post_action(MainDesktopUiAction::CloseAllTabs { on_close_all: on_close_all.clone() }); - + Cx::post_action(MainDesktopUiAction::CloseAllTabs { + on_close_all: on_close_all.clone(), + }); + match tokio::time::timeout(self.config.tab_close_timeout, on_close_all.notified()).await { Ok(_) => { log!("Received signal that all tabs were closed successfully"); @@ -518,28 +570,28 @@ impl LogoutStateMachine { Err(_) => Err(anyhow!("Timed out waiting for tabs to close")), } } - + fn shutdown_background_tasks(&self) { shutdown_background_tasks(); } - + fn restart_runtime(&self) -> Result<()> { start_matrix_tokio() .map(|_| ()) .map_err(|e| anyhow!("Failed to restart runtime: {}", e)) } - + /// Handle errors by posting appropriate actions async fn handle_error(&self, error: &LogoutError) { // Reset logout in progress flag on error (unless we've reached point of no return) if !is_logout_past_point_of_no_return() { set_logout_in_progress(false); } - + match error { LogoutError::Unrecoverable(UnrecoverableError::ComponentsCleared) => { - Cx::post_action(LogoutAction::ApplicationRequiresRestart { - cleared_component: ClearedComponentType::Client + Cx::post_action(LogoutAction::ApplicationRequiresRestart { + cleared_component: ClearedComponentType::Client, }); } LogoutError::Recoverable(RecoverableError::Cancelled) => { @@ -582,16 +634,22 @@ fn set_logout_in_progress(value: bool) { /// Execute logout using the state machine pub async fn logout_with_state_machine(is_desktop: bool) -> Result<()> { - log!("logout_with_state_machine called with is_desktop: {}", is_desktop); - + log!( + "logout_with_state_machine called with is_desktop: {}", + is_desktop + ); + let config = LogoutConfig { is_desktop, ..Default::default() }; - + let state_machine = LogoutStateMachine::new(config); let result = state_machine.execute().await; - - log!("logout_with_state_machine finished with result: {:?}", result.is_ok()); + + log!( + "logout_with_state_machine finished with result: {:?}", + result.is_ok() + ); result } diff --git a/src/main.rs b/src/main.rs index 3de0885e8..dc8875f93 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,10 @@ // This cfg option hides the command prompt console window on Windows. // TODO: move this into Makepad itself as an addition to the `MAKEPAD` env var. -#![cfg_attr(all(feature = "hide_windows_console", target_os = "windows"), windows_subsystem = "windows")] +#![cfg_attr( + all(feature = "hide_windows_console", target_os = "windows"), + windows_subsystem = "windows" +)] fn main() { robrix::app::app_main() diff --git a/src/media_cache.rs b/src/media_cache.rs index f87ae36da..547f21d82 100644 --- a/src/media_cache.rs +++ b/src/media_cache.rs @@ -1,9 +1,20 @@ -use std::{ops::{Deref, DerefMut}, sync::{Arc, Mutex}, time::SystemTime}; +use std::{ + ops::{Deref, DerefMut}, + sync::{Arc, Mutex}, + time::SystemTime, +}; use hashbrown::{hash_map::RawEntryMut, HashMap}; use makepad_widgets::{error, log, SignalToUI}; -use matrix_sdk::{media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings}, ruma::{events::room::MediaSource, OwnedMxcUri}, Error, HttpError}; +use matrix_sdk::{ + media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings}, + ruma::{events::room::MediaSource, OwnedMxcUri}, + Error, HttpError, +}; use reqwest::StatusCode; -use crate::{home::room_screen::TimelineUpdate, sliding_sync::{self, MatrixRequest}}; +use crate::{ + home::room_screen::TimelineUpdate, + sliding_sync::{self, MatrixRequest}, +}; /// The value type in the media cache, one per Matrix URI. #[derive(Debug, Clone)] @@ -26,7 +37,6 @@ pub enum MediaCacheEntry { /// A reference to a media cache entry and its associated format. pub type MediaCacheEntryRef = Arc>; - /// A cache of fetched media, indexed by Matrix URI. /// /// A single Matrix URI may have multiple media formats associated with it, @@ -57,9 +67,7 @@ impl MediaCache { /// /// It will also optionally send updates to the given timeline update sender /// when a media request has completed. - pub fn new( - timeline_update_sender: Option>, - ) -> Self { + pub fn new(timeline_update_sender: Option>) -> Self { Self { cache: HashMap::new(), timeline_update_sender, @@ -104,11 +112,11 @@ impl MediaCache { value.thumbnail = Some((Arc::clone(&entry_ref), requested_mts.clone())); // If a full-size image is already loaded, return it. if let Some(existing_file) = value.full_file.as_ref() { - if let MediaCacheEntry::Loaded(d) = existing_file.lock().unwrap().deref() { - post_request_retval = ( - MediaCacheEntry::Loaded(Arc::clone(d)), - MediaFormat::File, - ); + if let MediaCacheEntry::Loaded(d) = + existing_file.lock().unwrap().deref() + { + post_request_retval = + (MediaCacheEntry::Loaded(Arc::clone(d)), MediaFormat::File); } } entry_ref_to_fetch = entry_ref; @@ -116,17 +124,18 @@ impl MediaCache { } MediaFormat::File => { if let Some(entry_ref) = value.full_file.as_ref() { - return ( - entry_ref.lock().unwrap().deref().clone(), - MediaFormat::File, - ); + return (entry_ref.lock().unwrap().deref().clone(), MediaFormat::File); } else { // Here, a full-size image was requested but not found, so fetch it. let entry_ref = Arc::new(Mutex::new(MediaCacheEntry::Requested)); value.full_file = Some(entry_ref.clone()); // If a thumbnail is already loaded, return it. - if let Some((existing_thumbnail, existing_mts)) = value.thumbnail.as_ref() { - if let MediaCacheEntry::Loaded(d) = existing_thumbnail.lock().unwrap().deref() { + if let Some((existing_thumbnail, existing_mts)) = + value.thumbnail.as_ref() + { + if let MediaCacheEntry::Loaded(d) = + existing_thumbnail.lock().unwrap().deref() + { post_request_retval = ( MediaCacheEntry::Loaded(Arc::clone(d)), MediaFormat::Thumbnail(existing_mts.clone()), @@ -170,7 +179,11 @@ impl MediaCache { /// Removes a specific media format from the cache for the given MXC URI. /// If `format` is None, removes the entire cache entry for the URI. /// Returns the removed cache entry if found, None otherwise. - pub fn remove_cache_entry(&mut self, mxc_uri: &OwnedMxcUri, format: Option) -> Option { + pub fn remove_cache_entry( + &mut self, + mxc_uri: &OwnedMxcUri, + format: Option, + ) -> Option { match format { Some(MediaFormat::Thumbnail(_)) => { if let Some(cache_value) = self.cache.get_mut(mxc_uri) { @@ -200,7 +213,8 @@ impl MediaCache { // Remove the entire entry for this MXC URI self.cache.remove(mxc_uri).map(|cache_value| { // Return the full_file entry if it exists, otherwise the thumbnail entry - cache_value.full_file + cache_value + .full_file .or_else(|| cache_value.thumbnail.map(|(entry, _)| entry)) .unwrap_or_else(|| Arc::new(Mutex::new(MediaCacheEntry::Requested))) }) @@ -214,7 +228,10 @@ fn error_to_media_cache_entry(error: Error, request: &MediaRequestParameters) -> match error { Error::Http(http_error) => { if let Some(client_error) = http_error.as_client_api_error() { - error!("Client error for media cache: {client_error} for request: {:?}", request); + error!( + "Client error for media cache: {client_error} for request: {:?}", + request + ); MediaCacheEntry::Failed(client_error.status_code) } else { match *http_error { @@ -223,9 +240,11 @@ fn error_to_media_cache_entry(error: Error, request: &MediaRequestParameters) -> if !reqwest_error.is_connect() { MediaCacheEntry::Failed(StatusCode::INTERNAL_SERVER_ERROR) } else if reqwest_error.is_status() { - MediaCacheEntry::Failed(reqwest_error - .status() - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)) + MediaCacheEntry::Failed( + reqwest_error + .status() + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + ) } else { MediaCacheEntry::Failed(StatusCode::INTERNAL_SERVER_ERROR) } @@ -236,7 +255,7 @@ fn error_to_media_cache_entry(error: Error, request: &MediaRequestParameters) -> } Error::InsufficientData => MediaCacheEntry::Failed(StatusCode::PARTIAL_CONTENT), Error::AuthenticationRequired => MediaCacheEntry::Failed(StatusCode::UNAUTHORIZED), - _ => MediaCacheEntry::Failed(StatusCode::INTERNAL_SERVER_ERROR) + _ => MediaCacheEntry::Failed(StatusCode::INTERNAL_SERVER_ERROR), } } @@ -256,20 +275,24 @@ fn insert_into_cache>>( if let MediaSource::Plain(mxc_uri) = &request.source { log!("Fetched media for {mxc_uri}"); let mut path = crate::temp_storage::get_temp_dir_path().clone(); - let filename = format!("{}_{}_{}", - SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis(), - mxc_uri.server_name().unwrap(), mxc_uri.media_id().unwrap(), + let filename = format!( + "{}_{}_{}", + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_millis(), + mxc_uri.server_name().unwrap(), + mxc_uri.media_id().unwrap(), ); path.push(filename); path.set_extension("png"); log!("Writing user media image to disk: {:?}", path); - std::fs::write(path, &data) - .expect("Failed to write user media image to disk"); + std::fs::write(path, &data).expect("Failed to write user media image to disk"); } } MediaCacheEntry::Loaded(data) } - Err(e) => error_to_media_cache_entry(e, &request) + Err(e) => error_to_media_cache_entry(e, &request), }; *value_ref.lock().unwrap() = new_value; diff --git a/src/persistence/app_state.rs b/src/persistence/app_state.rs index 6bc88714f..811ad9895 100644 --- a/src/persistence/app_state.rs +++ b/src/persistence/app_state.rs @@ -5,12 +5,10 @@ use serde::{self, Deserialize, Serialize}; use matrix_sdk::ruma::{OwnedUserId, UserId}; use crate::{app::AppState, app_data_dir, persistence::persistent_state_dir}; - const LATEST_APP_STATE_FILE_NAME: &str = "latest_app_state.json"; const WINDOW_GEOM_STATE_FILE_NAME: &str = "window_geom_state.json"; - /// Persistable state of the window's size, position, and fullscreen status. #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct WindowGeomState { @@ -22,15 +20,10 @@ pub struct WindowGeomState { pub is_fullscreen: bool, } - /// Save the current app state to persistent storage. -pub fn save_app_state( - app_state: AppState, - user_id: OwnedUserId, -) -> anyhow::Result<()> { - let file = std::fs::File::create( - persistent_state_dir(&user_id).join(LATEST_APP_STATE_FILE_NAME) - )?; +pub fn save_app_state(app_state: AppState, user_id: OwnedUserId) -> anyhow::Result<()> { + let file = + std::fs::File::create(persistent_state_dir(&user_id).join(LATEST_APP_STATE_FILE_NAME))?; let mut writer = std::io::BufWriter::new(file); serde_json::to_writer(&mut writer, &app_state)?; writer.flush()?; @@ -67,7 +60,7 @@ pub async fn load_app_state(user_id: &UserId) -> anyhow::Result { log!("No saved app state found, using default."); return Ok(AppState::default()); } - Err(e) => return Err(e.into()) + Err(e) => return Err(e.into()), }; match serde_json::from_slice(&file_bytes) { Ok(app_state) => { @@ -75,7 +68,9 @@ pub async fn load_app_state(user_id: &UserId) -> anyhow::Result { Ok(app_state) } Err(e) => { - error!("Failed to deserialize app state: {e}. This may be due to an incompatible format from a previous version."); + error!( + "Failed to deserialize app state: {e}. This may be due to an incompatible format from a previous version." + ); // Backup the old file to preserve user's data let backup_path = state_path.with_extension("json.bak"); diff --git a/src/persistence/tsp_state.rs b/src/persistence/tsp_state.rs index 8f50d8e5a..59ec864d4 100644 --- a/src/persistence/tsp_state.rs +++ b/src/persistence/tsp_state.rs @@ -17,7 +17,6 @@ pub fn tsp_wallets_dir() -> std::path::PathBuf { app_data_dir().join(WALLETS_DIR_NAME) } - /// The TSP state that is saved to persistent storage. /// /// It contains metadata about all wallets that have been created or imported. @@ -39,29 +38,22 @@ pub struct SavedTspState { impl SavedTspState { /// Returns true if this TSP state has any content. pub fn has_content(&self) -> bool { - !self.wallets.is_empty() - || self.default_wallet.is_some() - || self.default_vid.is_some() + !self.wallets.is_empty() || self.default_wallet.is_some() || self.default_vid.is_some() } pub fn num_wallets(&self) -> usize { - self.default_wallet.is_some() as usize - + self.wallets.len() + self.default_wallet.is_some() as usize + self.wallets.len() } } - /// Loads the TSP state from persistent storage. pub async fn load_tsp_state() -> anyhow::Result { - let content = match tokio::fs::read_to_string( - app_data_dir().join(TSP_STATE_FILE_NAME) - ).await { + let content = match tokio::fs::read_to_string(app_data_dir().join(TSP_STATE_FILE_NAME)).await { Ok(file) => file, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(SavedTspState::default()), - Err(e) => return Err(e.into()) + Err(e) => return Err(e.into()), }; - serde_json::from_str(&content) - .map_err(anyhow::Error::msg) + serde_json::from_str(&content).map_err(anyhow::Error::msg) } /// Asynchronously save the current TSP state to persistent storage. diff --git a/src/profile/user_profile.rs b/src/profile/user_profile.rs index cedbbeba3..4bdeca0d2 100644 --- a/src/profile/user_profile.rs +++ b/src/profile/user_profile.rs @@ -1,14 +1,25 @@ //! Widgets and types related to displaying info about a user profile. -use std::{borrow::Cow, ops::{Deref, DerefMut}}; +use std::{ + borrow::Cow, + ops::{Deref, DerefMut}, +}; use makepad_widgets::*; -use matrix_sdk::{room::{RoomMember, RoomMemberRole}, ruma::{events::room::member::MembershipState, OwnedRoomId, OwnedUserId}}; +use matrix_sdk::{ + room::{RoomMember, RoomMemberRole}, + ruma::{events::room::member::MembershipState, OwnedRoomId, OwnedUserId}, +}; use crate::{ - avatar_cache, shared::{avatar::{AvatarState, AvatarWidgetExt}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{MatrixRequest, current_user_id, is_user_ignored, submit_async_request}, utils + avatar_cache, + shared::{ + avatar::{AvatarState, AvatarWidgetExt}, + popup_list::{PopupKind, enqueue_popup_notification}, + }, + sliding_sync::{MatrixRequest, current_user_id, is_user_ignored, submit_async_request}, + utils, }; use super::user_profile_cache; - /// Information retrieved about a user: their displayable name, ID, and known avatar state. #[derive(Clone, Debug)] pub struct UserProfile { @@ -34,14 +45,14 @@ impl UserProfile { /// skipping any leading "@" characters. #[allow(unused)] pub fn first_letter(&self) -> &str { - self.username.as_deref() + self.username + .as_deref() .and_then(|un| utils::user_name_first_letter(un)) .or_else(|| utils::user_name_first_letter(self.user_id.as_str())) .unwrap_or_default() } } - /// Basic info needed to populate the contents of an avatar widget. #[derive(Clone, Debug)] pub struct UserProfileAndRoomId { @@ -121,7 +132,7 @@ script_mod! { } LineH { padding: 15 } - + membership := View { width: Fill, height: Fit, @@ -285,7 +296,6 @@ script_mod! { } } - #[derive(Clone, Default, Debug)] pub enum ShowUserProfileAction { ShowUserProfile(UserProfileAndRoomId), @@ -321,48 +331,56 @@ impl UserProfilePaneInfo { } fn membership_status(&self) -> &str { - self.room_member.as_ref().map_or( - "Not a Member", - |member| match member.membership() { + self.room_member + .as_ref() + .map_or("Not a Member", |member| match member.membership() { MembershipState::Join => "Status: Joined", MembershipState::Leave => "Status: Left", MembershipState::Ban => "Status: Banned", MembershipState::Invite => "Status: Invited", MembershipState::Knock => "Status: Knocking", _ => "Status: Unknown", - } - ) + }) } fn role_in_room(&self) -> Cow<'_, str> { - self.room_member.as_ref().map_or( - "Role: Unknown".into(), - |member| match member.suggested_role_for_power_level() { - RoomMemberRole::Creator => "Role: Creator".into(), - RoomMemberRole::Administrator => "Role: Admin".into(), - RoomMemberRole::Moderator => "Role: Moderator".into(), - RoomMemberRole::User => "Role: Standard User".into(), - } - ) + self.room_member + .as_ref() + .map_or("Role: Unknown".into(), |member| { + match member.suggested_role_for_power_level() { + RoomMemberRole::Creator => "Role: Creator".into(), + RoomMemberRole::Administrator => "Role: Admin".into(), + RoomMemberRole::Moderator => "Role: Moderator".into(), + RoomMemberRole::User => "Role: Standard User".into(), + } + }) } } #[derive(Script, ScriptHook, Widget, Animator)] pub struct UserProfileSlidingPane { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, - #[live] slide: f32, - - #[rust] info: Option, - #[rust] is_animating_out: bool, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, + #[live] + slide: f32, + + #[rust] + info: Option, + #[rust] + is_animating_out: bool, } impl Widget for UserProfileSlidingPane { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); - if !self.visible { return; } + if !self.visible { + return; + } let animator_action = self.animator_handle_event(cx, event); if animator_action.must_redraw() { @@ -393,20 +411,23 @@ impl Widget for UserProfileSlidingPane { matches!( event, Event::Actions(actions) if self.button(cx, ids!(close_button)).clicked(actions) - ) - || event.back_pressed() - || match event.hits_with_capture_overload(cx, area, true) { - Hit::KeyUp(key) => key.key_code == KeyCode::Escape, - Hit::FingerDown(_fde) => { - cx.set_key_focus(area); - false - } - Hit::FingerUp(fue) if fue.is_over => { - fue.mouse_button().is_some_and(|b| b.is_back()) - || !self.view(cx, ids!(main_content)).area().rect(cx).contains(fue.abs) + ) || event.back_pressed() + || match event.hits_with_capture_overload(cx, area, true) { + Hit::KeyUp(key) => key.key_code == KeyCode::Escape, + Hit::FingerDown(_fde) => { + cx.set_key_focus(area); + false + } + Hit::FingerUp(fue) if fue.is_over => { + fue.mouse_button().is_some_and(|b| b.is_back()) + || !self + .view(cx, ids!(main_content)) + .area() + .rect(cx) + .contains(fue.abs) + } + _ => false, } - _ => false, - } }; if close_pane { self.is_animating_out = true; @@ -428,14 +449,16 @@ impl Widget for UserProfileSlidingPane { our_info.user_id.clone(), Some(&our_info.room_id), false, - |profile, rooms| (profile.clone(), rooms.get(&our_info.room_id).cloned()) + |profile, rooms| (profile.clone(), rooms.get(&our_info.room_id).cloned()), ) { let prev_avatar_state = our_info.avatar_state.clone(); our_info.user_profile = new_profile; our_info.room_member = room_member; // Use the avatar URI from the `room_member`, as it will be the most up-to-date // and specific to the room that this user profile sliding pane is currently being shown for. - if let Some(avatar_uri) = our_info.room_member.as_ref() + if let Some(avatar_uri) = our_info + .room_member + .as_ref() .and_then(|rm| rm.avatar_url().map(|u| u.to_owned())) { our_info.avatar_state = AvatarState::Known(Some(avatar_uri)); @@ -446,11 +469,11 @@ impl Widget for UserProfileSlidingPane { // If the new avatar state is fully `Loaded`, keep it as is. // If the new avatar state is *not* fully `Loaded`, but the previous one was, keep the previous one. match (prev_avatar_state, &mut our_info.avatar_state) { - (_, AvatarState::Loaded(_)) => { } - (prev @ AvatarState::Loaded(_), existing_avatar_state ) => { + (_, AvatarState::Loaded(_)) => {} + (prev @ AvatarState::Loaded(_), existing_avatar_state) => { *existing_avatar_state = prev; } - _ => { } + _ => {} } redraw_this_pane = true; } @@ -460,10 +483,15 @@ impl Widget for UserProfileSlidingPane { } } - let Some(info) = self.info.as_ref() else { return }; + let Some(info) = self.info.as_ref() else { + return; + }; if let Event::Actions(actions) = event { - if self.button(cx, ids!(direct_message_button)).clicked(actions) { + if self + .button(cx, ids!(direct_message_button)) + .clicked(actions) + { submit_async_request(MatrixRequest::OpenOrCreateDirectMessage { user_profile: info.user_profile.clone(), // Don't just create a new DM room; we want to first get confirmation from the user. @@ -471,7 +499,10 @@ impl Widget for UserProfileSlidingPane { }); } - if self.button(cx, ids!(copy_link_to_user_button)).clicked(actions) { + if self + .button(cx, ids!(copy_link_to_user_button)) + .clicked(actions) + { let matrix_to_uri = info.user_id.matrix_to_uri().to_string(); cx.copy_to_clipboard(&matrix_to_uri); enqueue_popup_notification( @@ -493,7 +524,8 @@ impl Widget for UserProfileSlidingPane { room_id: info.room_id.clone(), room_member: room_member.clone(), }); - log!("Submitting request to {}ignore user {}.", + log!( + "Submitting request to {}ignore user {}.", if room_member.is_ignored() { "un" } else { "" }, info.user_id, ); @@ -502,7 +534,6 @@ impl Widget for UserProfileSlidingPane { } } - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { let Some(info) = self.info.as_ref() else { self.visible = false; @@ -528,20 +559,29 @@ impl Widget for UserProfileSlidingPane { }); // Set the user name, using the user ID as a fallback. - self.label(cx, ids!(user_name)).set_text(cx, info.displayable_name()); - self.label(cx, ids!(user_id)).set_text(cx, info.user_id.as_str()); + self.label(cx, ids!(user_name)) + .set_text(cx, info.displayable_name()); + self.label(cx, ids!(user_id)) + .set_text(cx, info.user_id.as_str()); // Set the avatar image, using the user name as a fallback. let avatar_ref = self.avatar(cx, ids!(avatar)); info.avatar_state .data() - .and_then(|data| avatar_ref.show_image(cx, None, |cx, img| utils::load_png_or_jpg(&img, cx, data)).ok()) + .and_then(|data| { + avatar_ref + .show_image(cx, None, |cx, img| utils::load_png_or_jpg(&img, cx, data)) + .ok() + }) .unwrap_or_else(|| avatar_ref.show_text(cx, None, None, info.displayable_name())); // Set the membership status and role in the room. - self.label(cx, ids!(membership_title_label)).set_text(cx, &info.membership_title()); - self.label(cx, ids!(membership_status_label)).set_text(cx, info.membership_status()); - self.label(cx, ids!(role_info_label)).set_text(cx, info.role_in_room().as_ref()); + self.label(cx, ids!(membership_title_label)) + .set_text(cx, &info.membership_title()); + self.label(cx, ids!(membership_status_label)) + .set_text(cx, info.membership_status()); + self.label(cx, ids!(role_info_label)) + .set_text(cx, info.role_in_room().as_ref()); // Draw and show/hide the buttons according to user and room membership info: // * `direct_message_button` is hidden if the user is the same as the account user, @@ -551,28 +591,39 @@ impl Widget for UserProfileSlidingPane { // * `ignore_user_button` is hidden if the user is not a member of the room, // or if the user is the same as the account user, since you cannot ignore yourself. // * The button text changes to "Unignore" if the user is already ignored. - let is_pane_showing_current_account = info.room_member.as_ref() + let is_pane_showing_current_account = info + .room_member + .as_ref() .map(|rm| rm.is_account_user()) .unwrap_or_else(|| current_user_id().is_some_and(|uid| uid == info.user_id)); - self.button(cx, ids!(direct_message_button)).set_visible(cx, !is_pane_showing_current_account); + self.button(cx, ids!(direct_message_button)) + .set_visible(cx, !is_pane_showing_current_account); let ignore_user_button = self.button(cx, ids!(ignore_user_button)); - ignore_user_button.set_visible(cx, !is_pane_showing_current_account && info.room_member.is_some()); + ignore_user_button.set_visible( + cx, + !is_pane_showing_current_account && info.room_member.is_some(), + ); // Unfortunately the Matrix SDK's RoomMember type does not properly track // the `ignored` state of a user, so we have to maintain it separately. - let is_ignored = info.room_member.as_ref() + let is_ignored = info + .room_member + .as_ref() .is_some_and(|rm| is_user_ignored(rm.user_id())); ignore_user_button.set_text( cx, - if is_ignored { "Unignore (Unblock) User" } else { "Ignore (Block) User" } + if is_ignored { + "Unignore (Unblock) User" + } else { + "Ignore (Block) User" + }, ); self.view.draw_walk(cx, scope, walk) } } - impl UserProfileSlidingPane { /// Returns `true` if this pane is currently being shown. pub fn is_currently_shown(&self, _cx: &mut Cx) -> bool { @@ -592,14 +643,13 @@ impl UserProfileSlidingPane { info.user_id.clone(), Some(&info.room_id), true, - |profile, rooms| (profile.clone(), rooms.get(&info.room_id).cloned()) + |profile, rooms| (profile.clone(), rooms.get(&info.room_id).cloned()), ) { log!("Found user {} room member info in cache", info.user_id); // Update avatar state, preferring that of the room member info. if let Some(uri) = room_member.avatar_url() { info.avatar_state = AvatarState::Known(Some(uri.to_owned())); - } - else { + } else { match new_profile.avatar_state { s @ AvatarState::Known(Some(_)) | s @ AvatarState::Loaded(_) => { info.avatar_state = s.clone(); @@ -609,7 +659,8 @@ impl UserProfileSlidingPane { } // Update displayable username. if info.username.is_none() { - info.username = room_member.display_name() + info.username = room_member + .display_name() .map(|dn| dn.to_owned()) .or_else(|| new_profile.username.clone()); } @@ -619,9 +670,11 @@ impl UserProfileSlidingPane { info.avatar_state.update_from_cache(cx); // If TSP is enabled, populate the TSP verification info for this user. - #[cfg(feature = "tsp")] { + #[cfg(feature = "tsp")] + { use crate::tsp::verify_user::TspVerifyUserWidgetExt; - self.view.tsp_verify_user(cx, ids!(tsp_verify_user)) + self.view + .tsp_verify_user(cx, ids!(tsp_verify_user)) .show(cx, info.user_id.clone()); } @@ -636,10 +689,18 @@ impl UserProfileSlidingPane { self.view(cx, ids!(bg_view)).set_visible(cx, true); self.view.button(cx, ids!(close_button)).reset_hover(cx); - self.view.button(cx, ids!(direct_message_button)).reset_hover(cx); - self.view.button(cx, ids!(copy_link_to_user_button)).reset_hover(cx); - self.view.button(cx, ids!(jump_to_read_receipt_button)).reset_hover(cx); - self.view.button(cx, ids!(ignore_user_button)).reset_hover(cx); + self.view + .button(cx, ids!(direct_message_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(copy_link_to_user_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(jump_to_read_receipt_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(ignore_user_button)) + .reset_hover(cx); self.redraw(cx); } } @@ -647,19 +708,25 @@ impl UserProfileSlidingPane { impl UserProfileSlidingPaneRef { /// See [`UserProfileSlidingPane::is_currently_shown()`] pub fn is_currently_shown(&self, cx: &mut Cx) -> bool { - let Some(inner) = self.borrow() else { return false }; + let Some(inner) = self.borrow() else { + return false; + }; inner.is_currently_shown(cx) } /// See [`UserProfileSlidingPane::set_info()`] pub fn set_info(&self, cx: &mut Cx, info: UserProfilePaneInfo) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_info(cx, info); } /// See [`UserProfileSlidingPane::show()`] pub fn show(&self, cx: &mut Cx) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show(cx); } } diff --git a/src/profile/user_profile_cache.rs b/src/profile/user_profile_cache.rs index a669929e8..c57871276 100644 --- a/src/profile/user_profile_cache.rs +++ b/src/profile/user_profile_cache.rs @@ -4,10 +4,19 @@ use crossbeam_queue::SegQueue; use makepad_widgets::{warning, Cx, SignalToUI}; -use matrix_sdk::{room::RoomMember, ruma::{OwnedRoomId, OwnedUserId, UserId}}; -use std::{cell::RefCell, collections::{btree_map::Entry, BTreeMap}}; +use matrix_sdk::{ + room::RoomMember, + ruma::{OwnedRoomId, OwnedUserId, UserId}, +}; +use std::{ + cell::RefCell, + collections::{btree_map::Entry, BTreeMap}, +}; -use crate::{shared::avatar::AvatarState, sliding_sync::{submit_async_request, MatrixRequest}}; +use crate::{ + shared::avatar::AvatarState, + sliding_sync::{submit_async_request, MatrixRequest}, +}; use super::user_profile::UserProfile; @@ -67,47 +76,60 @@ impl UserProfileUpdate { /// Applies this update to the given user profile info cache. fn apply_to_cache(self, cache: &mut BTreeMap) { match self { - UserProfileUpdate::Full { new_profile, room_id, room_member } => { - match cache.entry(new_profile.user_id.clone()) { - Entry::Occupied(mut entry) => match entry.get_mut() { - e @ UserProfileCacheEntry::Requested => { - *e = UserProfileCacheEntry::Loaded { - user_profile: new_profile, - rooms: { - let mut room_members_map = BTreeMap::new(); - room_members_map.insert(room_id, room_member); - room_members_map - }, - }; - } - UserProfileCacheEntry::Loaded { user_profile, rooms } => { - *user_profile = new_profile; - rooms.insert(room_id, room_member); - } - } - Entry::Vacant(entry) => { - entry.insert(UserProfileCacheEntry::Loaded { + UserProfileUpdate::Full { + new_profile, + room_id, + room_member, + } => match cache.entry(new_profile.user_id.clone()) { + Entry::Occupied(mut entry) => match entry.get_mut() { + e @ UserProfileCacheEntry::Requested => { + *e = UserProfileCacheEntry::Loaded { user_profile: new_profile, rooms: { let mut room_members_map = BTreeMap::new(); room_members_map.insert(room_id, room_member); room_members_map }, - }); + }; + } + UserProfileCacheEntry::Loaded { + user_profile, + rooms, + } => { + *user_profile = new_profile; + rooms.insert(room_id, room_member); } + }, + Entry::Vacant(entry) => { + entry.insert(UserProfileCacheEntry::Loaded { + user_profile: new_profile, + rooms: { + let mut room_members_map = BTreeMap::new(); + room_members_map.insert(room_id, room_member); + room_members_map + }, + }); } - } - UserProfileUpdate::RoomMemberOnly { room_id, room_member } => { + }, + UserProfileUpdate::RoomMemberOnly { + room_id, + room_member, + } => { match cache.entry(room_member.user_id().to_owned()) { Entry::Occupied(mut entry) => match entry.get_mut() { e @ UserProfileCacheEntry::Requested => { // This shouldn't happen, but we can still technically handle it correctly. - warning!("BUG: User profile cache entry was `Requested` for user {} when handling RoomMemberOnly update", room_member.user_id()); + warning!( + "BUG: User profile cache entry was `Requested` for user {} when handling RoomMemberOnly update", + room_member.user_id() + ); *e = UserProfileCacheEntry::Loaded { user_profile: UserProfile { user_id: room_member.user_id().to_owned(), username: None, - avatar_state: AvatarState::Known(room_member.avatar_url().map(|url| url.to_owned())), + avatar_state: AvatarState::Known( + room_member.avatar_url().map(|url| url.to_owned()), + ), }, rooms: { let mut room_members_map = BTreeMap::new(); @@ -119,15 +141,20 @@ impl UserProfileUpdate { UserProfileCacheEntry::Loaded { rooms, .. } => { rooms.insert(room_id, room_member); } - } + }, Entry::Vacant(entry) => { // This shouldn't happen, but we can still technically handle it correctly. - warning!("BUG: User profile cache entry not found for user {} when handling RoomMemberOnly update", room_member.user_id()); + warning!( + "BUG: User profile cache entry not found for user {} when handling RoomMemberOnly update", + room_member.user_id() + ); entry.insert(UserProfileCacheEntry::Loaded { user_profile: UserProfile { user_id: room_member.user_id().to_owned(), username: None, - avatar_state: AvatarState::Known(room_member.avatar_url().map(|url| url.to_owned())), + avatar_state: AvatarState::Known( + room_member.avatar_url().map(|url| url.to_owned()), + ), }, rooms: { let mut room_members_map = BTreeMap::new(); @@ -150,7 +177,7 @@ impl UserProfileUpdate { UserProfileCacheEntry::Loaded { user_profile, .. } => { *user_profile = new_profile; } - } + }, Entry::Vacant(entry) => { entry.insert(UserProfileCacheEntry::Loaded { user_profile: new_profile, @@ -193,42 +220,42 @@ pub fn with_user_profile( where F: FnOnce(&UserProfile, &BTreeMap) -> R, { - USER_PROFILE_CACHE.with_borrow_mut(|cache| - match cache.entry(user_id) { - Entry::Occupied(entry) => match entry.get() { - UserProfileCacheEntry::Loaded { user_profile, rooms } => { - if room_id.is_some_and(|id| !rooms.contains_key(id)) { - submit_async_request(MatrixRequest::GetUserProfile { - user_id: entry.key().clone(), - room_id: room_id.cloned(), - local_only: false, - }); - } - Some(f(user_profile, rooms)) - } - UserProfileCacheEntry::Requested => { - // log!("User {} profile request is already in flight....", entry.key()); - None - } - } - Entry::Vacant(entry) => { - if fetch_if_missing { - // log!("Did not find User {} in cache, fetching from server.", entry.key()); - // TODO: use the extra `via` parameters from `matrix_to_uri.via()`. + USER_PROFILE_CACHE.with_borrow_mut(|cache| match cache.entry(user_id) { + Entry::Occupied(entry) => match entry.get() { + UserProfileCacheEntry::Loaded { + user_profile, + rooms, + } => { + if room_id.is_some_and(|id| !rooms.contains_key(id)) { submit_async_request(MatrixRequest::GetUserProfile { user_id: entry.key().clone(), room_id: room_id.cloned(), local_only: false, }); - entry.insert(UserProfileCacheEntry::Requested); } + Some(f(user_profile, rooms)) + } + UserProfileCacheEntry::Requested => { + // log!("User {} profile request is already in flight....", entry.key()); None } + }, + Entry::Vacant(entry) => { + if fetch_if_missing { + // log!("Did not find User {} in cache, fetching from server.", entry.key()); + // TODO: use the extra `via` parameters from `matrix_to_uri.via()`. + submit_async_request(MatrixRequest::GetUserProfile { + user_id: entry.key().clone(), + room_id: room_id.cloned(), + local_only: false, + }); + entry.insert(UserProfileCacheEntry::Requested); + } + None } - ) + }) } - /// Returns the given user's displayable name (optionally in the given room), /// using the user's account-wide displayable name as a fallback. /// @@ -276,8 +303,7 @@ impl CachedName { pub fn as_deref(&self) -> Option<&str> { match self { - CachedName::FoundInRoom(name) - | CachedName::FoundInProfile(name) => name.as_deref(), + CachedName::FoundInRoom(name) | CachedName::FoundInProfile(name) => name.as_deref(), CachedName::NotFound => None, } } @@ -294,7 +320,7 @@ impl From for Option { /// Clears cached user profile. /// This function requires passing in a reference to `Cx`, -/// which acts as a guarantee that these thread-local caches are cleared on the main UI thread, +/// which acts as a guarantee that these thread-local caches are cleared on the main UI thread, pub fn clear_user_profile_cache(_cx: &mut Cx) { // Clear user profile cache USER_PROFILE_CACHE.with_borrow_mut(|cache| { diff --git a/src/room/mod.rs b/src/room/mod.rs index 68b20bae9..e09e9407c 100644 --- a/src/room/mod.rs +++ b/src/room/mod.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use makepad_widgets::ScriptVm; use matrix_sdk::{RoomDisplayName, RoomHero, RoomState, SuccessorRoom, room_preview::RoomPreview}; -use ruma::{OwnedRoomAliasId, OwnedRoomId, room::{JoinRuleSummary, RoomType}}; +use ruma::{ + OwnedRoomAliasId, OwnedRoomId, + room::{JoinRuleSummary, RoomType}, +}; use crate::utils::RoomNameId; @@ -50,7 +53,7 @@ impl From<&SuccessorRoom> for BasicRoomDetails { } impl From for BasicRoomDetails { fn from(frp: FetchedRoomPreview) -> Self { - BasicRoomDetails::FetchedRoomPreview(frp) + BasicRoomDetails::FetchedRoomPreview(frp) } } impl BasicRoomDetails { @@ -58,7 +61,7 @@ impl BasicRoomDetails { match self { Self::RoomId(room_name_id) | Self::Name(room_name_id) - | Self::NameAndAvatar { room_name_id, ..} => room_name_id.room_id(), + | Self::NameAndAvatar { room_name_id, .. } => room_name_id.room_id(), Self::FetchedRoomPreview(frp) => frp.room_name_id.room_id(), } } @@ -80,15 +83,13 @@ impl BasicRoomDetails { /// If this is the `RoomId` or `Name` variants, the avatar will be empty. pub fn room_avatar(&self) -> &FetchedRoomAvatar { match self { - Self::RoomId(_) - | Self::Name(_) => &EMPTY_AVATAR, - Self::NameAndAvatar { room_avatar, ..} => room_avatar, + Self::RoomId(_) | Self::Name(_) => &EMPTY_AVATAR, + Self::NameAndAvatar { room_avatar, .. } => room_avatar, Self::FetchedRoomPreview(frp) => &frp.room_avatar, } } } - /// Actions related to room previews being fetched. #[derive(Debug)] pub enum RoomPreviewAction { @@ -104,7 +105,6 @@ pub struct FetchedRoomPreview { pub room_avatar: FetchedRoomAvatar, // Below: copied from the `RoomPreview` struct. - /// The canonical alias for the room. pub canonical_alias: Option, /// The room's topic, if set. @@ -131,10 +131,9 @@ pub struct FetchedRoomPreview { } impl FetchedRoomPreview { pub fn from(room_preview: RoomPreview, room_avatar: FetchedRoomAvatar) -> Self { - let display_name = room_preview.name.map_or( - RoomDisplayName::Empty, - RoomDisplayName::Named, - ); + let display_name = room_preview + .name + .map_or(RoomDisplayName::Empty, RoomDisplayName::Named); Self { room_name_id: RoomNameId::new(display_name, room_preview.room_id), room_avatar, @@ -152,7 +151,6 @@ impl FetchedRoomPreview { } } - static EMPTY_AVATAR: FetchedRoomAvatar = FetchedRoomAvatar::Text(String::new()); /// A fully-fetched room avatar ready to be displayed. diff --git a/src/room/room_display_filter.rs b/src/room/room_display_filter.rs index acbc17edb..5c38e6d49 100644 --- a/src/room/room_display_filter.rs +++ b/src/room/room_display_filter.rs @@ -1,12 +1,22 @@ use std::{ - borrow::Cow, cmp::Ordering, collections::{BTreeMap, HashSet}, ops::Deref + borrow::Cow, + cmp::Ordering, + collections::{BTreeMap, HashSet}, + ops::Deref, }; use bitflags::bitflags; -use matrix_sdk::{RoomDisplayName, ruma::{ - OwnedRoomAliasId, RoomAliasId, RoomId, events::tag::{TagName, Tags} -}}; +use matrix_sdk::{ + RoomDisplayName, + ruma::{ + OwnedRoomAliasId, RoomAliasId, RoomId, + events::tag::{TagName, Tags}, + }, +}; -use crate::{home::rooms_list::{InvitedRoomInfo, JoinedRoomInfo}, home::spaces_bar::JoinedSpaceInfo}; +use crate::{ + home::rooms_list::{InvitedRoomInfo, JoinedRoomInfo}, + home::spaces_bar::JoinedSpaceInfo, +}; static EMPTY_TAGS: Tags = BTreeMap::new(); @@ -142,7 +152,6 @@ impl FilterableRoom for JoinedSpaceInfo { } } - pub type RoomFilterFn = dyn Fn(&dyn FilterableRoom) -> bool; pub type SortFn = dyn Fn(&dyn FilterableRoom, &dyn FilterableRoom) -> Ordering; @@ -245,18 +254,16 @@ impl RoomDisplayFilterBuilder { } fn matches_room_name(room: &dyn FilterableRoom, keywords: &str) -> bool { - room.room_name() - .to_lowercase() - .contains(keywords) + room.room_name().to_lowercase().contains(keywords) } fn matches_room_alias(room: &dyn FilterableRoom, keywords: &str) -> bool { room.canonical_alias() .is_some_and(|alias| alias.as_str().eq_ignore_ascii_case(keywords)) - || - room.alt_aliases() - .iter() - .any(|alias| alias.as_str().eq_ignore_ascii_case(keywords)) + || room + .alt_aliases() + .iter() + .any(|alias| alias.as_str().eq_ignore_ascii_case(keywords)) } fn matches_room_tags(room: &dyn FilterableRoom, search_tags: &HashSet) -> bool { @@ -267,10 +274,13 @@ impl RoomDisplayFilterBuilder { ["low_priority", "low-priority", "lowpriority", "lowPriority"] .contains(&search_tag) } - TagName::ServerNotice => { - ["server_notice", "server-notice", "servernotice", "serverNotice"] - .contains(&search_tag) - } + TagName::ServerNotice => [ + "server_notice", + "server-notice", + "servernotice", + "serverNotice", + ] + .contains(&search_tag), TagName::User(user_tag) => user_tag.as_ref().eq_ignore_ascii_case(search_tag), _ => false, } @@ -316,10 +326,14 @@ impl RoomDisplayFilterBuilder { RoomFilterCriteria::RoomId if criteria.contains(RoomFilterCriteria::RoomId) => { Self::matches_room_id(room, &keywords) } - RoomFilterCriteria::RoomAlias if criteria.contains(RoomFilterCriteria::RoomAlias) => { + RoomFilterCriteria::RoomAlias + if criteria.contains(RoomFilterCriteria::RoomAlias) => + { Self::matches_room_alias(room, &keywords) } - RoomFilterCriteria::RoomTags if criteria.contains(RoomFilterCriteria::RoomTags) => { + RoomFilterCriteria::RoomTags + if criteria.contains(RoomFilterCriteria::RoomTags) => + { Self::matches_room_tags(room, &search_tags) } _ => false, diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index 93b8d4a9d..614017021 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -15,12 +15,33 @@ //! * A "cannot-send-message" notice, which is shown if the user cannot send messages to the room. //! - use makepad_widgets::*; use matrix_sdk::room::reply::{EnforceThread, Reply}; use matrix_sdk_ui::timeline::{EmbeddedEvent, EventTimelineItem, TimelineEventItemId}; -use ruma::{events::room::message::{LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent}, OwnedRoomId}; -use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}}, location::init_location_subscriber, shared::{avatar::AvatarWidgetRefExt, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; +use ruma::{ + events::room::message::{ + LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent, + }, + OwnedRoomId, +}; +use crate::{ + home::{ + editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, + location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, + room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, + tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}, + }, + location::init_location_subscriber, + shared::{ + avatar::AvatarWidgetRefExt, + html_or_plaintext::HtmlOrPlaintextWidgetRefExt, + mentionable_text_input::MentionableTextInputWidgetExt, + popup_list::{PopupKind, enqueue_popup_notification}, + styles::*, + }, + sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, + utils, +}; script_mod! { use mod.prelude.widgets.* @@ -161,14 +182,18 @@ script_mod! { /// Main component for message input with @mention support #[derive(Script, ScriptHook, Widget)] pub struct RoomInputBar { - #[source] source: ScriptObjectRef, - #[deref] view: View, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, /// Whether the `ReplyingPreview` was visible when the `EditingPane` was shown. /// If true, when the `EditingPane` gets hidden, we need to re-show the `ReplyingPreview`. - #[rust] was_replying_preview_visible: bool, + #[rust] + was_replying_preview_visible: bool, /// Info about the message event that the user is currently replying to, if any. - #[rust] replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, + #[rust] + replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, } impl Widget for RoomInputBar { @@ -178,14 +203,21 @@ impl Widget for RoomInputBar { .get::() .expect("BUG: RoomScreenProps should be available in Scope::props for RoomInputBar"); - match event.hits(cx, self.view.view(cx, ids!(replying_preview.reply_preview_content)).area()) { + match event.hits( + cx, + self.view + .view(cx, ids!(replying_preview.reply_preview_content)) + .area(), + ) { // If the hit occurred on the replying message preview, jump to it. Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { - if let Some(event_id) = self.replying_to.as_ref() + if let Some(event_id) = self + .replying_to + .as_ref() .and_then(|(event_tl_item, _)| event_tl_item.event_id().map(ToOwned::to_owned)) { cx.widget_action( - room_screen_props.room_screen_widget_uid, + room_screen_props.room_screen_widget_uid, MessageAction::JumpToEvent(event_id), ); } else { @@ -241,40 +273,56 @@ impl RoomInputBar { None, ); } - self.view.location_preview(cx, ids!(location_preview)).show(); + self.view + .location_preview(cx, ids!(location_preview)) + .show(); self.redraw(cx); } // Handle the send location button being clicked. - if self.button(cx, ids!(location_preview.send_location_button)).clicked(actions) { + if self + .button(cx, ids!(location_preview.send_location_button)) + .clicked(actions) + { let location_preview = self.location_preview(cx, ids!(location_preview)); if let Some((coords, _system_time_opt)) = location_preview.get_current_data() { - let geo_uri = format!("{}{},{}", utils::GEO_URI_SCHEME, coords.latitude, coords.longitude); - let message = RoomMessageEventContent::new( - MessageType::Location( - LocationMessageEventContent::new(geo_uri.clone(), geo_uri) - ) + let geo_uri = format!( + "{}{},{}", + utils::GEO_URI_SCHEME, + coords.latitude, + coords.longitude ); - let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| - event_tl_item.event_id().map(|event_id| { - let enforce_thread = if room_screen_props.timeline_kind.thread_root_event_id().is_some() { - EnforceThread::Threaded(ReplyWithinThread::Yes) - } else { - EnforceThread::MaybeThreaded - }; - Reply { - event_id: event_id.to_owned(), - enforce_thread, - } + let message = RoomMessageEventContent::new(MessageType::Location( + LocationMessageEventContent::new(geo_uri.clone(), geo_uri), + )); + let replied_to = self + .replying_to + .take() + .and_then(|(event_tl_item, _emb)| { + event_tl_item.event_id().map(|event_id| { + let enforce_thread = if room_screen_props + .timeline_kind + .thread_root_event_id() + .is_some() + { + EnforceThread::Threaded(ReplyWithinThread::Yes) + } else { + EnforceThread::MaybeThreaded + }; + Reply { + event_id: event_id.to_owned(), + enforce_thread, + } + }) }) - ).or_else(|| - room_screen_props.timeline_kind.thread_root_event_id().map(|thread_root_event_id| - Reply { - event_id: thread_root_event_id.clone(), - enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), - } - ) - ); + .or_else(|| { + room_screen_props.timeline_kind.thread_root_event_id().map( + |thread_root_event_id| Reply { + event_id: thread_root_event_id.clone(), + enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), + }, + ) + }); submit_async_request(MatrixRequest::SendMessage { timeline_kind: room_screen_props.timeline_kind.clone(), message, @@ -291,31 +339,41 @@ impl RoomInputBar { // Handle the send message button being clicked or Cmd/Ctrl + Return being pressed. if self.button(cx, ids!(send_message_button)).clicked(actions) - || text_input.returned(actions).is_some_and(|(_, m)| m.is_primary()) + || text_input + .returned(actions) + .is_some_and(|(_, m)| m.is_primary()) { let entered_text = mentionable_text_input.text().trim().to_string(); if !entered_text.is_empty() { let message = mentionable_text_input.create_message_with_mentions(&entered_text); - let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| - event_tl_item.event_id().map(|event_id| { - let enforce_thread = if room_screen_props.timeline_kind.thread_root_event_id().is_some() { - EnforceThread::Threaded(ReplyWithinThread::Yes) - } else { - EnforceThread::MaybeThreaded - }; - Reply { - event_id: event_id.to_owned(), - enforce_thread, - } + let replied_to = self + .replying_to + .take() + .and_then(|(event_tl_item, _emb)| { + event_tl_item.event_id().map(|event_id| { + let enforce_thread = if room_screen_props + .timeline_kind + .thread_root_event_id() + .is_some() + { + EnforceThread::Threaded(ReplyWithinThread::Yes) + } else { + EnforceThread::MaybeThreaded + }; + Reply { + event_id: event_id.to_owned(), + enforce_thread, + } + }) }) - ).or_else(|| - room_screen_props.timeline_kind.thread_root_event_id().map(|thread_root_event_id| - Reply { - event_id: thread_root_event_id.clone(), - enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), - } - ) - ); + .or_else(|| { + room_screen_props.timeline_kind.thread_root_event_id().map( + |thread_root_event_id| Reply { + event_id: thread_root_event_id.clone(), + enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), + }, + ) + }); submit_async_request(MatrixRequest::SendMessage { timeline_kind: room_screen_props.timeline_kind.clone(), message, @@ -349,18 +407,29 @@ impl RoomInputBar { if is_text_input_empty { if let Some(KeyEvent { key_code: KeyCode::ArrowUp, - modifiers: KeyModifiers { shift: false, control: false, alt: false, logo: false }, + modifiers: + KeyModifiers { + shift: false, + control: false, + alt: false, + logo: false, + }, .. - }) = text_input.key_down_unhandled(actions) { + }) = text_input.key_down_unhandled(actions) + { cx.widget_action( - room_screen_props.room_screen_widget_uid, + room_screen_props.room_screen_widget_uid, MessageAction::EditLatest, ); } } // If the EditingPane has been hidden, handle that. - if self.view.editing_pane(cx, ids!(editing_pane)).was_hidden(actions) { + if self + .view + .editing_pane(cx, ids!(editing_pane)) + .was_hidden(actions) + { self.on_editing_pane_hidden(cx); } } @@ -408,13 +477,15 @@ impl RoomInputBar { // 2. Hide other views that are irrelevant to a reply, e.g., // the `EditingPane` would improperly cover up the ReplyPreview. - self.editing_pane(cx, ids!(editing_pane)).force_reset_hide(cx); + self.editing_pane(cx, ids!(editing_pane)) + .force_reset_hide(cx); self.on_editing_pane_hidden(cx); // 3. Automatically focus the keyboard on the message input box // so that the user can immediately start typing their reply // without having to manually click on the message input box. if grab_key_focus { - self.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)).set_key_focus(cx); + self.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)) + .set_key_focus(cx); } self.button(cx, ids!(cancel_reply_button)).reset_hover(cx); self.redraw(cx); @@ -444,7 +515,9 @@ impl RoomInputBar { let replying_preview = self.view.view(cx, ids!(replying_preview)); self.was_replying_preview_visible = replying_preview.visible(); replying_preview.set_visible(cx, false); - self.view.location_preview(cx, ids!(location_preview)).clear(); + self.view + .location_preview(cx, ids!(location_preview)) + .clear(); let editing_pane = self.view.editing_pane(cx, ids!(editing_pane)); match behavior { @@ -466,12 +539,14 @@ impl RoomInputBar { // Same goes for the replying_preview, if it was previously shown. self.view.view(cx, ids!(input_bar)).set_visible(cx, true); if self.was_replying_preview_visible && self.replying_to.is_some() { - self.view.view(cx, ids!(replying_preview)).set_visible(cx, true); + self.view + .view(cx, ids!(replying_preview)) + .set_visible(cx, true); } self.redraw(cx); // We don't need to do anything with the editing pane itself here, // because it has already been hidden by the time this function gets called. - } + } /// Updates (populates and shows or hides) this room's tombstone footer /// based on the given successor room details. @@ -489,7 +564,10 @@ impl RoomInputBar { input_bar.set_visible(cx, false); } else { tombstone_footer.hide(cx); - if !self.editing_pane(cx, ids!(editing_pane)).is_currently_shown(cx) { + if !self + .editing_pane(cx, ids!(editing_pane)) + .is_currently_shown(cx) + { input_bar.set_visible(cx, true); } } @@ -515,14 +593,14 @@ impl RoomInputBar { /// Updates the visibility of select views based on the user's new power levels. /// /// This will show/hide the `input_bar` and the `can_not_send_message_notice` views. - fn update_user_power_levels( - &mut self, - cx: &mut Cx, - user_power_levels: UserPowerLevels, - ) { + fn update_user_power_levels(&mut self, cx: &mut Cx, user_power_levels: UserPowerLevels) { let can_send = user_power_levels.can_send_message(); - self.view.view(cx, ids!(input_bar)).set_visible(cx, can_send); - self.view.view(cx, ids!(can_not_send_message_notice)).set_visible(cx, !can_send); + self.view + .view(cx, ids!(input_bar)) + .set_visible(cx, can_send); + self.view + .view(cx, ids!(can_not_send_message_notice)) + .set_visible(cx, !can_send); } /// Returns true if the TSP signing checkbox is checked, false otherwise. @@ -543,7 +621,9 @@ impl RoomInputBarRef { replying_to: (EventTimelineItem, EmbeddedEvent), timeline_kind: &TimelineKind, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show_replying_to(cx, replying_to, timeline_kind, true); } @@ -554,7 +634,9 @@ impl RoomInputBarRef { event_tl_item: EventTimelineItem, timeline_kind: TimelineKind, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show_editing_pane( cx, ShowEditingPaneBehavior::ShowNew { event_tl_item }, @@ -565,12 +647,10 @@ impl RoomInputBarRef { /// Updates the visibility of select views based on the user's new power levels. /// /// This will show/hide the `input_bar` and the `can_not_send_message_notice` views. - pub fn update_user_power_levels( - &self, - cx: &mut Cx, - user_power_levels: UserPowerLevels, - ) { - let Some(mut inner) = self.borrow_mut() else { return }; + pub fn update_user_power_levels(&self, cx: &mut Cx, user_power_levels: UserPowerLevels) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.update_user_power_levels(cx, user_power_levels); } @@ -581,7 +661,9 @@ impl RoomInputBarRef { tombstoned_room_id: &OwnedRoomId, successor_room_details: Option<&SuccessorRoomDetails>, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.update_tombstone_footer(cx, tombstoned_room_id, successor_room_details); } @@ -593,22 +675,36 @@ impl RoomInputBarRef { timeline_event_item_id: TimelineEventItemId, edit_result: Result<(), matrix_sdk_ui::timeline::Error>, ) { - let Some(inner) = self.borrow_mut() else { return }; - inner.editing_pane(cx, ids!(editing_pane)) + let Some(inner) = self.borrow_mut() else { + return; + }; + inner + .editing_pane(cx, ids!(editing_pane)) .handle_edit_result(cx, timeline_event_item_id, edit_result); } /// Save a snapshot of the UI state of this `RoomInputBar`. pub fn save_state(&self) -> RoomInputBarState { - let Some(inner) = self.borrow() else { return Default::default() }; + let Some(inner) = self.borrow() else { + return Default::default(); + }; // Clear the location preview. We don't save this state because the // current location might change by the next time the user opens this same room. - inner.child_by_path(ids!(location_preview)).as_location_preview().clear(); + inner + .child_by_path(ids!(location_preview)) + .as_location_preview() + .clear(); RoomInputBarState { was_replying_preview_visible: inner.was_replying_preview_visible, replying_to: inner.replying_to.clone(), - editing_pane_state: inner.child_by_path(ids!(editing_pane)).as_editing_pane().save_state(), - text_input_state: inner.child_by_path(ids!(input_bar.mentionable_text_input.text_input)).as_text_input().save_state(), + editing_pane_state: inner + .child_by_path(ids!(editing_pane)) + .as_editing_pane() + .save_state(), + text_input_state: inner + .child_by_path(ids!(input_bar.mentionable_text_input.text_input)) + .as_text_input() + .save_state(), } } @@ -621,7 +717,9 @@ impl RoomInputBarRef { user_power_levels: UserPowerLevels, tombstone_info: Option<&SuccessorRoomDetails>, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; let RoomInputBarState { was_replying_preview_visible, text_input_state, @@ -637,7 +735,8 @@ impl RoomInputBarRef { inner.update_user_power_levels(cx, user_power_levels); // 1. Restore the state of the TextInput within the MentionableTextInput. - inner.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)) + inner + .text_input(cx, ids!(input_bar.mentionable_text_input.text_input)) .restore_state(cx, text_input_state); // 2. Restore the state of the replying-to preview. @@ -656,7 +755,9 @@ impl RoomInputBarRef { timeline_kind.clone(), ); } else { - inner.editing_pane(cx, ids!(editing_pane)).force_reset_hide(cx); + inner + .editing_pane(cx, ids!(editing_pane)) + .force_reset_hide(cx); inner.on_editing_pane_hidden(cx); } @@ -682,9 +783,7 @@ pub struct RoomInputBarState { /// Defines what to do when showing the `EditingPane` from the `RoomInputBar`. enum ShowEditingPaneBehavior { /// Show a new edit session, e.g., when first clicking "edit" on a message. - ShowNew { - event_tl_item: EventTimelineItem, - }, + ShowNew { event_tl_item: EventTimelineItem }, /// Restore the state of an `EditingPane` that already existed, e.g., when /// reopening a room that had an `EditingPane` open when it was closed. RestoreExisting { diff --git a/src/room/typing_notice.rs b/src/room/typing_notice.rs index 55fad31bd..437a25b70 100644 --- a/src/room/typing_notice.rs +++ b/src/room/typing_notice.rs @@ -62,9 +62,12 @@ script_mod! { /// A notice that slides into view when someone is typing. #[derive(Script, ScriptHook, Widget, Animator)] pub struct TypingNotice { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, } impl Widget for TypingNotice { @@ -87,7 +90,9 @@ impl TypingNotice { [] => { // Animate out the typing notice view (sliding it out towards the bottom). self.animator_play(cx, ids!(typing_notice_animator.hide)); - self.view.bouncing_dots(cx, ids!(bouncing_dots)).stop_animation(cx); + self.view + .bouncing_dots(cx, ids!(bouncing_dots)) + .stop_animation(cx); return; } [user] => format!("{user} is typing "), @@ -96,20 +101,21 @@ impl TypingNotice { if others.len() > 1 { format!("{user1}, {user2}, and {} are typing ", &others[0]) } else { - format!( - "{user1}, {user2}, and {} others are typing ", - others.len() - ) + format!("{user1}, {user2}, and {} others are typing ", others.len()) } } }; // Set the typing notice text and make its view visible. - self.view.label(cx, ids!(typing_label)).set_text(cx, &typing_notice_text); + self.view + .label(cx, ids!(typing_label)) + .set_text(cx, &typing_notice_text); self.view.set_visible(cx, true); // Animate in the typing notice view (sliding it up from the bottom). self.animator_play(cx, ids!(typing_notice_animator.show)); // Start the typing notice text animation of bouncing dots. - self.view.bouncing_dots(cx, ids!(bouncing_dots)).start_animation(cx); + self.view + .bouncing_dots(cx, ids!(bouncing_dots)) + .start_animation(cx); } } diff --git a/src/settings/account_settings.rs b/src/settings/account_settings.rs index 877f66bfc..b0d82cd9b 100644 --- a/src/settings/account_settings.rs +++ b/src/settings/account_settings.rs @@ -2,7 +2,20 @@ use std::cell::RefCell; use makepad_widgets::{text::selection::Cursor, *}; -use crate::{app::ConfirmDeleteAction, avatar_cache::{self}, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction}, profile::user_profile::UserProfile, shared::{avatar::{AvatarState, AvatarWidgetExt}, confirmation_modal::ConfirmationModalContent, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{AccountDataAction, MatrixRequest, submit_async_request}, utils}; +use crate::{ + app::ConfirmDeleteAction, + avatar_cache::{self}, + logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction}, + profile::user_profile::UserProfile, + shared::{ + avatar::{AvatarState, AvatarWidgetExt}, + confirmation_modal::ConfirmationModalContent, + popup_list::{PopupKind, enqueue_popup_notification}, + styles::*, + }, + sliding_sync::{AccountDataAction, MatrixRequest, submit_async_request}, + utils, +}; script_mod! { use mod.prelude.widgets.* @@ -207,9 +220,11 @@ script_mod! { /// The view containing all user account-related settings. #[derive(Script, ScriptHook, Widget)] pub struct AccountSettings { - #[deref] view: View, + #[deref] + view: View, - #[rust] own_profile: Option, + #[rust] + own_profile: Option, } impl Widget for AccountSettings { @@ -221,7 +236,7 @@ impl Widget for AccountSettings { match event.hits(cx, copy_user_id_button_area) { Hit::FingerHoverIn(_) | Hit::FingerLongPress(_) => { cx.widget_action( - copy_user_id_button.widget_uid(), + copy_user_id_button.widget_uid(), TooltipAction::HoverIn { text: "Copy User ID".to_string(), widget_rect: copy_user_id_button_area.rect(cx), @@ -233,10 +248,7 @@ impl Widget for AccountSettings { ); } Hit::FingerHoverOut(_) => { - cx.widget_action( - copy_user_id_button.widget_uid(), - TooltipAction::HoverOut, - ); + cx.widget_action(copy_user_id_button.widget_uid(), TooltipAction::HoverOut); } _ => {} } @@ -272,7 +284,14 @@ impl MatchEvent for AccountSettings { // Handle LogoutAction::InProgress to update button state if let Some(LogoutAction::InProgress(is_in_progress)) = action.downcast_ref() { let logout_button = self.view.button(cx, ids!(logout_button)); - logout_button.set_text(cx, if *is_in_progress { "Logging out..." } else { "Log out" }); + logout_button.set_text( + cx, + if *is_in_progress { + "Logging out..." + } else { + "Log out" + }, + ); logout_button.set_enabled(cx, !*is_in_progress); logout_button.reset_hover(cx); continue; @@ -283,15 +302,26 @@ impl MatchEvent for AccountSettings { // so here, we only need to update this widget's local profile info. match action.downcast_ref() { Some(AccountDataAction::AvatarChanged(new_avatar_url)) => { - self.view.widget(cx, ids!(upload_avatar_spinner)).set_visible(cx, false); - self.view.widget(cx, ids!(delete_avatar_spinner)).set_visible(cx, false); + self.view + .widget(cx, ids!(upload_avatar_spinner)) + .set_visible(cx, false); + self.view + .widget(cx, ids!(delete_avatar_spinner)) + .set_visible(cx, false); // Update our cached profile with the new avatar URL if let Some(profile) = self.own_profile.as_mut() { profile.avatar_state = AvatarState::Known(new_avatar_url.clone()); profile.avatar_state.update_from_cache(cx); self.populate_avatar_views(cx); enqueue_popup_notification( - format!("Successfully {} avatar.", if new_avatar_url.is_some() { "updated" } else { "deleted" }), + format!( + "Successfully {} avatar.", + if new_avatar_url.is_some() { + "updated" + } else { + "deleted" + } + ), PopupKind::Success, Some(4.0), ); @@ -299,53 +329,82 @@ impl MatchEvent for AccountSettings { continue; } Some(AccountDataAction::AvatarChangeFailed(err_msg)) => { - self.view.widget(cx, ids!(upload_avatar_spinner)).set_visible(cx, false); - self.view.widget(cx, ids!(delete_avatar_spinner)).set_visible(cx, false); + self.view + .widget(cx, ids!(upload_avatar_spinner)) + .set_visible(cx, false); + self.view + .widget(cx, ids!(delete_avatar_spinner)) + .set_visible(cx, false); // Re-enable the avatar buttons so user can try again Self::enable_upload_avatar_button(cx, true, &upload_avatar_button); Self::enable_delete_avatar_button( cx, - self.own_profile.as_ref().is_some_and(|p| p.avatar_state.has_avatar()), - &delete_avatar_button - ); - enqueue_popup_notification( - err_msg.clone(), - PopupKind::Error, - Some(4.0), + self.own_profile + .as_ref() + .is_some_and(|p| p.avatar_state.has_avatar()), + &delete_avatar_button, ); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, Some(4.0)); continue; } Some(AccountDataAction::DisplayNameChanged(new_name)) => { - self.view.widget(cx, ids!(save_name_spinner)).set_visible(cx, false); + self.view + .widget(cx, ids!(save_name_spinner)) + .set_visible(cx, false); // Update our cached profile with the new display name if let Some(profile) = self.own_profile.as_mut() { profile.username = new_name.clone(); } // Update the display name text input and disable buttons - let (text, len) = new_name.as_deref().map(|s| (s, s.len())).unwrap_or_default(); + let (text, len) = new_name + .as_deref() + .map(|s| (s, s.len())) + .unwrap_or_default(); display_name_input.set_text(cx, text); - display_name_input.set_cursor(cx, Cursor { index: len, prefer_next_row: false }, false); + display_name_input.set_cursor( + cx, + Cursor { + index: len, + prefer_next_row: false, + }, + false, + ); display_name_input.set_is_read_only(cx, false); display_name_input.set_disabled(cx, false); - Self::enable_display_name_buttons(cx, false, &accept_display_name_button, &cancel_display_name_button); + Self::enable_display_name_buttons( + cx, + false, + &accept_display_name_button, + &cancel_display_name_button, + ); enqueue_popup_notification( - format!("Successfully {} display name.", if new_name.is_some() { "updated" } else { "removed" }), + format!( + "Successfully {} display name.", + if new_name.is_some() { + "updated" + } else { + "removed" + } + ), PopupKind::Success, Some(4.0), ); continue; } Some(AccountDataAction::DisplayNameChangeFailed(err_msg)) => { - self.view.widget(cx, ids!(save_name_spinner)).set_visible(cx, false); + self.view + .widget(cx, ids!(save_name_spinner)) + .set_visible(cx, false); // Re-enable the buttons and text input so that the user can try again display_name_input.set_is_read_only(cx, false); display_name_input.set_disabled(cx, false); - Self::enable_display_name_buttons(cx, true, &accept_display_name_button, &cancel_display_name_button); - enqueue_popup_notification( - err_msg.clone(), - PopupKind::Error, - Some(4.0), + Self::enable_display_name_buttons( + cx, + true, + &accept_display_name_button, + &cancel_display_name_button, ); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, Some(4.0)); continue; } _ => {} @@ -353,13 +412,17 @@ impl MatchEvent for AccountSettings { match action.downcast_ref() { Some(AccountSettingsAction::AvatarDeleteStarted) => { - self.view.widget(cx, ids!(delete_avatar_spinner)).set_visible(cx, true); + self.view + .widget(cx, ids!(delete_avatar_spinner)) + .set_visible(cx, true); Self::enable_upload_avatar_button(cx, false, &upload_avatar_button); Self::enable_delete_avatar_button(cx, false, &delete_avatar_button); continue; } Some(AccountSettingsAction::AvatarUploadStarted) => { - self.view.widget(cx, ids!(upload_avatar_spinner)).set_visible(cx, true); + self.view + .widget(cx, ids!(upload_avatar_spinner)) + .set_visible(cx, true); Self::enable_upload_avatar_button(cx, false, &upload_avatar_button); Self::enable_delete_avatar_button(cx, false, &delete_avatar_button); continue; @@ -368,7 +431,9 @@ impl MatchEvent for AccountSettings { } } - let Some(own_profile) = &self.own_profile else { return }; + let Some(own_profile) = &self.own_profile else { + return; + }; if upload_avatar_button.clicked(actions) { // TODO: uncomment the below once avatar uploading is implemented @@ -408,15 +473,32 @@ impl MatchEvent for AccountSettings { let trimmed = new_name.trim(); let current_name = own_profile.username.as_deref().unwrap_or(""); let enable = trimmed != current_name; - Self::enable_display_name_buttons(cx, enable, &accept_display_name_button, &cancel_display_name_button); + Self::enable_display_name_buttons( + cx, + enable, + &accept_display_name_button, + &cancel_display_name_button, + ); } if cancel_display_name_button.clicked(actions) { // Reset the display name input and disable the name change buttons. let new_text = own_profile.username.as_deref().unwrap_or(""); display_name_input.set_text(cx, new_text); - display_name_input.set_cursor(cx, Cursor { index: new_text.len(), prefer_next_row: false }, false); - Self::enable_display_name_buttons(cx, false, &accept_display_name_button, &cancel_display_name_button); + display_name_input.set_cursor( + cx, + Cursor { + index: new_text.len(), + prefer_next_row: false, + }, + false, + ); + Self::enable_display_name_buttons( + cx, + false, + &accept_display_name_button, + &cancel_display_name_button, + ); } if accept_display_name_button.clicked(actions) { @@ -426,18 +508,25 @@ impl MatchEvent for AccountSettings { }; // While the request is in flight, show the loading spinner and disable the buttons & text input submit_async_request(MatrixRequest::SetDisplayName { new_display_name }); - self.view.widget(cx, ids!(save_name_spinner)).set_visible(cx, true); + self.view + .widget(cx, ids!(save_name_spinner)) + .set_visible(cx, true); display_name_input.set_disabled(cx, true); display_name_input.set_is_read_only(cx, true); - Self::enable_display_name_buttons(cx, false, &accept_display_name_button, &cancel_display_name_button); - enqueue_popup_notification( - "Uploading new display name...", - PopupKind::Info, - Some(5.0), + Self::enable_display_name_buttons( + cx, + false, + &accept_display_name_button, + &cancel_display_name_button, ); + enqueue_popup_notification("Uploading new display name...", PopupKind::Info, Some(5.0)); } - if self.view.button(cx, ids!(copy_user_id_button)).clicked(actions) { + if self + .view + .button(cx, ids!(copy_user_id_button)) + .clicked(actions) + { cx.copy_to_clipboard(own_profile.user_id.as_str()); enqueue_popup_notification( "Copied your User ID to the clipboard.", @@ -446,7 +535,11 @@ impl MatchEvent for AccountSettings { ); } - if self.view.button(cx, ids!(manage_account_button)).clicked(actions) { + if self + .view + .button(cx, ids!(manage_account_button)) + .clicked(actions) + { // TODO: support opening the user's account management page in a browser, // or perhaps in an in-app pane if that's what is needed for regular UN+PW login. enqueue_popup_notification( @@ -475,11 +568,13 @@ impl AccountSettings { let our_own_avatar = self.view.avatar(cx, ids!(our_own_avatar)); let mut drew_avatar = false; if let Some(avatar_img_data) = own_profile.avatar_state.data() { - drew_avatar = our_own_avatar.show_image( - cx, - None, // don't make this avatar clickable; we handle clicks on this ProfileIcon widget directly. - |cx, img| utils::load_png_or_jpg(&img, cx, avatar_img_data), - ).is_ok(); + drew_avatar = our_own_avatar + .show_image( + cx, + None, // don't make this avatar clickable; we handle clicks on this ProfileIcon widget directly. + |cx, img| utils::load_png_or_jpg(&img, cx, avatar_img_data), + ) + .is_ok(); } if !drew_avatar { our_own_avatar.show_text( @@ -493,20 +588,22 @@ impl AccountSettings { Self::enable_upload_avatar_button( cx, true, - &self.view.button(cx, ids!(upload_avatar_button)) + &self.view.button(cx, ids!(upload_avatar_button)), ); Self::enable_delete_avatar_button( cx, own_profile.avatar_state.has_avatar(), - &self.view.button(cx, ids!(delete_avatar_button)) + &self.view.button(cx, ids!(delete_avatar_button)), ); } /// Show and initializes the account settings within the SettingsScreen. pub fn populate(&mut self, cx: &mut Cx, own_profile: UserProfile) { - self.view.label(cx, ids!(user_id)) + self.view + .label(cx, ids!(user_id)) .set_text(cx, own_profile.user_id.as_str()); - self.view.text_input(cx, ids!(display_name_input)) + self.view + .text_input(cx, ids!(display_name_input)) .set_text(cx, own_profile.username.as_deref().unwrap_or_default()); Self::enable_display_name_buttons( cx, @@ -518,22 +615,30 @@ impl AccountSettings { self.own_profile = Some(own_profile); self.populate_avatar_views(cx); - self.view.button(cx, ids!(upload_avatar_button)).reset_hover(cx); - self.view.button(cx, ids!(delete_avatar_button)).reset_hover(cx); - self.view.button(cx, ids!(accept_display_name_button)).reset_hover(cx); - self.view.button(cx, ids!(cancel_display_name_button)).reset_hover(cx); - self.view.button(cx, ids!(copy_user_id_button)).reset_hover(cx); - self.view.button(cx, ids!(manage_account_button)).reset_hover(cx); + self.view + .button(cx, ids!(upload_avatar_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(delete_avatar_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(accept_display_name_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(cancel_display_name_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(copy_user_id_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(manage_account_button)) + .reset_hover(cx); self.view.button(cx, ids!(logout_button)).reset_hover(cx); self.view.redraw(cx); } /// Enable or disable the delete avatar button. - fn enable_delete_avatar_button( - cx: &mut Cx, - enable: bool, - delete_avatar_button: &ButtonRef, - ) { + fn enable_delete_avatar_button(cx: &mut Cx, enable: bool, delete_avatar_button: &ButtonRef) { let (delete_button_fg_color, delete_button_bg_color) = if enable { (COLOR_FG_DANGER_RED, COLOR_BG_DANGER_RED) } else { @@ -556,11 +661,7 @@ impl AccountSettings { } /// Enable or disable the upload avatar button. - fn enable_upload_avatar_button( - cx: &mut Cx, - enable: bool, - upload_avatar_button: &ButtonRef, - ) { + fn enable_upload_avatar_button(cx: &mut Cx, enable: bool, upload_avatar_button: &ButtonRef) { let (upload_button_fg_color, upload_button_bg_color) = if enable { (COLOR_PRIMARY, COLOR_ACTIVE_PRIMARY) } else { @@ -634,7 +735,9 @@ impl AccountSettings { impl AccountSettingsRef { /// See [`AccountSettings::show()`]. pub fn populate(&self, cx: &mut Cx, own_profile: UserProfile) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.populate(cx, own_profile); } } diff --git a/src/settings/settings_screen.rs b/src/settings/settings_screen.rs index 24baf849d..201ae14cc 100644 --- a/src/settings/settings_screen.rs +++ b/src/settings/settings_screen.rs @@ -1,7 +1,10 @@ - use makepad_widgets::*; -use crate::{home::navigation_tab_bar::{NavigationBarAction, get_own_profile}, profile::user_profile::UserProfile, settings::account_settings::AccountSettingsWidgetExt}; +use crate::{ + home::navigation_tab_bar::{NavigationBarAction, get_own_profile}, + profile::user_profile::UserProfile, + settings::account_settings::AccountSettingsWidgetExt, +}; script_mod! { use mod.prelude.widgets.* @@ -84,11 +87,11 @@ script_mod! { } } - /// The top-level widget showing all app and user settings/preferences. #[derive(Script, ScriptHook, Widget)] pub struct SettingsScreen { - #[deref] view: View, + #[deref] + view: View, } impl Widget for SettingsScreen { @@ -105,16 +108,15 @@ impl Widget for SettingsScreen { matches!( event, Event::Actions(actions) if self.button(cx, ids!(close_button)).clicked(actions) - ) - || event.back_pressed() - || match event.hits(cx, area) { - Hit::KeyUp(key) => key.key_code == KeyCode::Escape, - Hit::FingerDown(_fde) => { - cx.set_key_focus(area); - false + ) || event.back_pressed() + || match event.hits(cx, area) { + Hit::KeyUp(key) => key.key_code == KeyCode::Escape, + Hit::FingerDown(_fde) => { + cx.set_key_focus(area); + false + } + _ => false, } - _ => false, - } }; if close_pane { cx.action(NavigationBarAction::CloseSettings); @@ -132,26 +134,30 @@ impl Widget for SettingsScreen { match action.downcast_ref() { Some(CreateWalletModalAction::Open) => { use crate::tsp::create_wallet_modal::CreateWalletModalWidgetExt; - self.view.create_wallet_modal(cx, ids!(create_wallet_modal_inner)).show(cx); + self.view + .create_wallet_modal(cx, ids!(create_wallet_modal_inner)) + .show(cx); self.view.modal(cx, ids!(create_wallet_modal)).open(cx); } Some(CreateWalletModalAction::Close) => { self.view.modal(cx, ids!(create_wallet_modal)).close(cx); } - None => { } + None => {} } // Handle the create DID modal being opened or closed. match action.downcast_ref() { Some(CreateDidModalAction::Open) => { use crate::tsp::create_did_modal::CreateDidModalWidgetExt; - self.view.create_did_modal(cx, ids!(create_did_modal_inner)).show(cx); + self.view + .create_did_modal(cx, ids!(create_did_modal_inner)) + .show(cx); self.view.modal(cx, ids!(create_did_modal)).open(cx); } Some(CreateDidModalAction::Close) => { self.view.modal(cx, ids!(create_did_modal)).close(cx); } - None => { } + None => {} } } } @@ -169,7 +175,9 @@ impl SettingsScreen { error!("Failed to get own profile for settings screen."); return; }; - self.view.account_settings(cx, ids!(account_settings)).populate(cx, profile); + self.view + .account_settings(cx, ids!(account_settings)) + .populate(cx, profile); self.view.button(cx, ids!(close_button)).reset_hover(cx); cx.set_key_focus(self.view.area()); self.redraw(cx); @@ -179,7 +187,9 @@ impl SettingsScreen { impl SettingsScreenRef { /// See [`SettingsScreen::populate()`]. pub fn populate(&self, cx: &mut Cx, own_profile: Option) { - let Some(mut inner) = self.borrow_mut() else { return; }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.populate(cx, own_profile); } } diff --git a/src/shared/avatar.rs b/src/shared/avatar.rs index 3e9a73842..370dfa6df 100644 --- a/src/shared/avatar.rs +++ b/src/shared/avatar.rs @@ -9,13 +9,18 @@ use std::sync::Arc; use makepad_widgets::*; -use matrix_sdk::{ruma::{EventId, OwnedRoomId, OwnedUserId, UserId}}; +use matrix_sdk::{ + ruma::{EventId, OwnedRoomId, OwnedUserId, UserId}, +}; use matrix_sdk_ui::timeline::{Profile, TimelineDetails}; use ruma::OwnedMxcUri; use crate::{ avatar_cache::{self, AvatarCacheEntry}, - profile::{user_profile::{ShowUserProfileAction, UserProfile, UserProfileAndRoomId}, user_profile_cache}, + profile::{ + user_profile::{ShowUserProfileAction, UserProfile, UserProfileAndRoomId}, + user_profile_cache, + }, sliding_sync::{submit_async_request, MatrixRequest, TimelineKind}, utils, }; @@ -81,35 +86,38 @@ script_mod! { } } - #[derive(ScriptHook, Script, Widget)] pub struct Avatar { - #[source] source: ScriptObjectRef, - #[deref] view: View, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, /// Information about the user profile being shown in this Avatar. /// If `Some`, this Avatar will respond to clicks/taps. - #[rust] info: Option, + #[rust] + info: Option, } impl Widget for Avatar { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); - let Some(info) = self.info.clone() else { return }; + let Some(info) = self.info.clone() else { + return; + }; let area = self.view.area(); let widget_uid = self.widget_uid(); match event.hits(cx, area) { Hit::FingerDown(_fde) => { cx.set_key_focus(area); } - Hit::FingerUp(fue) => if fue.is_over && fue.is_primary_hit() && fue.was_tap() { - cx.widget_action( - widget_uid, - ShowUserProfileAction::ShowUserProfile(info), - ); + Hit::FingerUp(fue) => { + if fue.is_over && fue.is_primary_hit() && fue.was_tap() { + cx.widget_action(widget_uid, ShowUserProfileAction::ShowUserProfile(info)); + } } - _ =>() + _ => (), } } @@ -119,7 +127,8 @@ impl Widget for Avatar { fn set_text(&mut self, cx: &mut Cx, v: &str) { let f = utils::user_name_first_letter(v) - .unwrap_or("?").to_uppercase(); + .unwrap_or("?") + .to_uppercase(); self.label(cx, ids!(text_view.text)).set_text(cx, &f); self.view(cx, ids!(img_view)).set_visible(cx, false); self.view(cx, ids!(text_view)).set_visible(cx, true); @@ -144,7 +153,12 @@ impl Avatar { info: Option, username: T, ) { - if let Some(AvatarTextInfo { user_id, username, room_id }) = info { + if let Some(AvatarTextInfo { + user_id, + username, + room_id, + }) = info + { self.info = Some(UserProfileAndRoomId { user_profile: UserProfile { user_id, @@ -187,7 +201,8 @@ impl Avatar { info: Option, image_set_function: F, ) -> Result<(), E> - where F: FnOnce(&mut Cx, ImageRef) -> Result<(), E> + where + F: FnOnce(&mut Cx, ImageRef) -> Result<(), E>, { let img_ref = self.image(cx, ids!(img_view.img)); let res = image_set_function(cx, img_ref); @@ -195,7 +210,13 @@ impl Avatar { self.view(cx, ids!(img_view)).set_visible(cx, true); self.view(cx, ids!(text_view)).set_visible(cx, false); - if let Some(AvatarImageInfo { user_id, username, room_id, img_data }) = info { + if let Some(AvatarImageInfo { + user_id, + username, + room_id, + img_data, + }) = info + { self.info = Some(UserProfileAndRoomId { user_profile: UserProfile { user_id, @@ -268,14 +289,16 @@ impl Avatar { Some(timeline_kind.room_id()), true, |profile, rooms| { - rooms.get(timeline_kind.room_id()).map(|rm| { - ( - rm.display_name().map(|n| n.to_owned()), - AvatarState::Known(rm.avatar_url().map(|u| u.to_owned())), - ) - }) - .unwrap_or_else(|| (profile.username.clone(), profile.avatar_state.clone())) - } + rooms + .get(timeline_kind.room_id()) + .map(|rm| { + ( + rm.display_name().map(|n| n.to_owned()), + AvatarState::Known(rm.avatar_url().map(|u| u.to_owned())), + ) + }) + .unwrap_or_else(|| (profile.username.clone(), profile.avatar_state.clone())) + }, ) }; @@ -322,12 +345,14 @@ impl Avatar { .and_then(|data| { self.show_image( cx, - is_clickable.then(|| AvatarImageInfo::from(( - avatar_user_id.to_owned(), - username_opt.clone(), - timeline_kind.room_id().to_owned(), - data.clone() - ))), + is_clickable.then(|| { + AvatarImageInfo::from(( + avatar_user_id.to_owned(), + username_opt.clone(), + timeline_kind.room_id().to_owned(), + data.clone(), + )) + }), |cx, img| utils::load_png_or_jpg(&img, cx, &data), ) .ok() @@ -336,11 +361,13 @@ impl Avatar { self.show_text( cx, None, - is_clickable.then(|| AvatarTextInfo::from(( - avatar_user_id.to_owned(), - username_opt, - timeline_kind.room_id().to_owned(), - ))), + is_clickable.then(|| { + AvatarTextInfo::from(( + avatar_user_id.to_owned(), + username_opt, + timeline_kind.room_id().to_owned(), + )) + }), &username, ) }); @@ -369,7 +396,8 @@ impl AvatarRef { info: Option, image_set_function: F, ) -> Result<(), E> - where F: FnOnce(&mut Cx, ImageRef) -> Result<(), E> + where + F: FnOnce(&mut Cx, ImageRef) -> Result<(), E>, { if let Some(mut inner) = self.borrow_mut() { inner.show_image(cx, info, image_set_function) @@ -428,7 +456,11 @@ pub struct AvatarTextInfo { } impl From<(OwnedUserId, Option, OwnedRoomId)> for AvatarTextInfo { fn from((user_id, username, room_id): (OwnedUserId, Option, OwnedRoomId)) -> Self { - Self { user_id, username, room_id } + Self { + user_id, + username, + room_id, + } } } @@ -440,17 +472,29 @@ pub struct AvatarImageInfo { pub img_data: Arc<[u8]>, } impl From<(OwnedUserId, Option, OwnedRoomId, Arc<[u8]>)> for AvatarImageInfo { - fn from((user_id, username, room_id, img_data): (OwnedUserId, Option, OwnedRoomId, Arc<[u8]>)) -> Self { - Self { user_id, username, room_id, img_data } + fn from( + (user_id, username, room_id, img_data): ( + OwnedUserId, + Option, + OwnedRoomId, + Arc<[u8]>, + ), + ) -> Self { + Self { + user_id, + username, + room_id, + img_data, + } } } - /// The currently-known state of an avatar for a user, room, or space. #[derive(Clone, Default)] pub enum AvatarState { /// It isn't yet known if this user/room/space has an avatar. - #[default] Unknown, + #[default] + Unknown, /// It is known that this user/room/space does or does not have an avatar. Known(Option), /// The avatar is known to exist and has been fetched successfully. @@ -461,11 +505,11 @@ pub enum AvatarState { impl std::fmt::Debug for AvatarState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - AvatarState::Unknown => write!(f, "Unknown"), + AvatarState::Unknown => write!(f, "Unknown"), AvatarState::Known(Some(_)) => write!(f, "Known(Some)"), - AvatarState::Known(None) => write!(f, "Known(None)"), - AvatarState::Loaded(data) => write!(f, "Loaded({} bytes)", data.len()), - AvatarState::Failed => write!(f, "Failed"), + AvatarState::Known(None) => write!(f, "Known(None)"), + AvatarState::Loaded(data) => write!(f, "Loaded({} bytes)", data.len()), + AvatarState::Failed => write!(f, "Failed"), } } } diff --git a/src/shared/bouncing_dots.rs b/src/shared/bouncing_dots.rs index 5b8e79024..b99fc2fe1 100644 --- a/src/shared/bouncing_dots.rs +++ b/src/shared/bouncing_dots.rs @@ -22,20 +22,20 @@ script_mod! { let center_y = self.rect_size.y * 0.5; // Create three circle SDFs sdf.circle( - self.rect_size.x * 0.25, - amplitude * sin(self.anim_time * 2.0 * PI * self.freq) + center_y, + self.rect_size.x * 0.25, + amplitude * sin(self.anim_time * 2.0 * PI * self.freq) + center_y, self.dot_radius ); sdf.fill(self.color); sdf.circle( - self.rect_size.x * 0.5, - amplitude * sin(self.anim_time * 2.0 * PI * self.freq + self.phase_offset) + center_y, + self.rect_size.x * 0.5, + amplitude * sin(self.anim_time * 2.0 * PI * self.freq + self.phase_offset) + center_y, self.dot_radius ); sdf.fill(self.color); sdf.circle( - self.rect_size.x * 0.75, - amplitude * sin(self.anim_time * 2.0 * PI * self.freq + self.phase_offset * 2) + center_y, + self.rect_size.x * 0.75, + amplitude * sin(self.anim_time * 2.0 * PI * self.freq + self.phase_offset * 2) + center_y, self.dot_radius ); sdf.fill(self.color); @@ -62,15 +62,18 @@ script_mod! { } } } - + } } #[derive(Script, ScriptHook, Widget, Animator)] pub struct BouncingDots { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, } impl Widget for BouncingDots { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { @@ -85,7 +88,6 @@ impl Widget for BouncingDots { } } - impl BouncingDotsRef { /// Starts animation of the bouncing dots. pub fn start_animation(&self, cx: &mut Cx) { diff --git a/src/shared/collapsible_header.rs b/src/shared/collapsible_header.rs index e9ad7e337..e412b770a 100644 --- a/src/shared/collapsible_header.rs +++ b/src/shared/collapsible_header.rs @@ -98,19 +98,21 @@ impl HeaderCategory { #[derive(Clone, Debug, Default)] pub enum CollapsibleHeaderAction { /// The header was clicked to toggled its expanded/collapsed state. - Toggled { - category: HeaderCategory, - }, + Toggled { category: HeaderCategory }, #[default] None, } #[derive(Script, ScriptHook, Widget)] pub struct CollapsibleHeader { - #[deref] view: View, - #[rust(true)] is_expanded: bool, - #[rust] category: HeaderCategory, - #[rust] num_unread_mentions: u64, + #[deref] + view: View, + #[rust(true)] + is_expanded: bool, + #[rust] + category: HeaderCategory, + #[rust] + num_unread_mentions: u64, } impl Widget for CollapsibleHeader { @@ -122,22 +124,33 @@ impl Widget for CollapsibleHeader { cx.set_key_focus(self.view.area()); } Hit::FingerUp(fe) => { - if !rooms_list_props.was_scrolling && fe.is_over && fe.is_primary_hit() && fe.was_tap() { + if !rooms_list_props.was_scrolling + && fe.is_over + && fe.is_primary_hit() + && fe.was_tap() + { self.toggle_collapse(cx, scope); } } - _ => { } + _ => {} } self.view.handle_event(cx, event, scope); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // Set arrow and label state during draw to ensure child widgets are available. - if let Some(mut arrow) = self.view.child_by_path(ids!(collapse_icon)).borrow_mut::() { + if let Some(mut arrow) = self + .view + .child_by_path(ids!(collapse_icon)) + .borrow_mut::() + { arrow.set_is_open_no_animate(self.is_expanded); } - self.view.child_by_path(ids!(label)).set_text(cx, self.category.as_str()); - self.view.child_by_path(ids!(unread_badge)) + self.view + .child_by_path(ids!(label)) + .set_text(cx, self.category.as_str()); + self.view + .child_by_path(ids!(unread_badge)) .as_unread_badge() .update_counts(false, self.num_unread_mentions, 0); self.view.draw_walk(cx, scope, walk) @@ -147,12 +160,16 @@ impl Widget for CollapsibleHeader { impl CollapsibleHeader { fn toggle_collapse(&mut self, cx: &mut Cx, _scope: &mut Scope) { self.is_expanded = !self.is_expanded; - if let Some(mut arrow) = self.view.child_by_path(ids!(collapse_icon)).borrow_mut::() { + if let Some(mut arrow) = self + .view + .child_by_path(ids!(collapse_icon)) + .borrow_mut::() + { arrow.set_is_open(cx, self.is_expanded, Animate::Yes); } self.redraw(cx); cx.widget_action( - self.widget_uid(), + self.widget_uid(), CollapsibleHeaderAction::Toggled { category: self.category, }, diff --git a/src/shared/command_text_input.rs b/src/shared/command_text_input.rs index bf8a4d091..33adbc2a7 100644 --- a/src/shared/command_text_input.rs +++ b/src/shared/command_text_input.rs @@ -609,8 +609,12 @@ impl CommandTextInput { if let (Some(t_idx), Some(h_idx)) = (trigger_grapheme_idx, head_grapheme_idx) { // Additional range check to prevent index errors if t_idx >= text_graphemes.len() || h_idx > text_graphemes.len() { - log!("Error: Grapheme indices out of range: t_idx={}, h_idx={}, graphemes_len={}", - t_idx, h_idx, text_graphemes.len()); + log!( + "Error: Grapheme indices out of range: t_idx={}, h_idx={}, graphemes_len={}", + t_idx, + h_idx, + text_graphemes.len() + ); return String::new(); } @@ -641,14 +645,26 @@ impl CommandTextInput { return String::new(); } else { // Abnormal case: trigger character is after the cursor - log!("Warning: Trigger character is after cursor: trigger_idx={}, head_idx={}, trigger_pos={}, head={}", - t_idx, h_idx, trigger_pos, head); + log!( + "Warning: Trigger character is after cursor: trigger_idx={}, head_idx={}, trigger_pos={}, head={}", + t_idx, + h_idx, + trigger_pos, + head + ); return String::new(); } } else { // Comprehensive diagnostic information - log!("Warning: Unable to find valid grapheme indices: trigger_idx={:?}, head_idx={:?}, trigger_pos={}, head={}, text_len={}, graphemes_len={}", - trigger_grapheme_idx, head_grapheme_idx, trigger_pos, head, text.len(), text_graphemes.len()); + log!( + "Warning: Unable to find valid grapheme indices: trigger_idx={:?}, head_idx={:?}, trigger_pos={}, head={}, text_len={}, graphemes_len={}", + trigger_grapheme_idx, + head_grapheme_idx, + trigger_pos, + head, + text.len(), + text_graphemes.len() + ); return String::new(); } } diff --git a/src/shared/confirmation_modal.rs b/src/shared/confirmation_modal.rs index 998b76eb4..83b9b6dc6 100644 --- a/src/shared/confirmation_modal.rs +++ b/src/shared/confirmation_modal.rs @@ -4,7 +4,6 @@ use std::borrow::Cow; use makepad_widgets::*; - script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -135,7 +134,7 @@ pub enum ConfirmationModalAction { /// accept button (true) or cancel button (false). Close(bool), #[default] - None + None, } impl ActionDefaultRef for ConfirmationModalAction { @@ -187,11 +186,12 @@ impl std::fmt::Debug for ConfirmationModalContent { } } - #[derive(Script, ScriptHook, Widget)] pub struct ConfirmationModal { - #[deref] view: View, - #[rust] content: ConfirmationModalContent, + #[deref] + view: View, + #[rust] + content: ConfirmationModalContent, } impl Widget for ConfirmationModal { @@ -212,17 +212,16 @@ impl WidgetMatchEvent for ConfirmationModal { // Handle canceling/closing the modal. let cancel_clicked = cancel_button.clicked(actions); - if cancel_clicked || - actions.iter().any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) + if cancel_clicked + || actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) { // If the modal was dismissed by clicking outside of it, we MUST NOT emit // a `ConfirmationModalAction::Close` action, as that would cause // an infinite action feedback loop. if cancel_clicked { - cx.widget_action( - self.widget_uid(), - ConfirmationModalAction::Close(false), - ); + cx.widget_action(self.widget_uid(), ConfirmationModalAction::Close(false)); } if let Some(on_cancel_clicked) = self.content.on_cancel_clicked.take() { on_cancel_clicked(cx); @@ -235,10 +234,7 @@ impl WidgetMatchEvent for ConfirmationModal { if let Some(on_accept_clicked) = self.content.on_accept_clicked.take() { on_accept_clicked(cx); } - cx.widget_action( - self.widget_uid(), - ConfirmationModalAction::Close(true), - ); + cx.widget_action(self.widget_uid(), ConfirmationModalAction::Close(true)); } } } @@ -250,21 +246,35 @@ impl ConfirmationModal { } fn apply_content(&mut self, cx: &mut Cx) { - self.view.label(cx, ids!(title)).set_text(cx, &self.content.title_text); - self.view.label(cx, ids!(body)).set_text(cx, &self.content.body_text); + self.view + .label(cx, ids!(title)) + .set_text(cx, &self.content.title_text); + self.view + .label(cx, ids!(body)) + .set_text(cx, &self.content.body_text); self.view.button(cx, ids!(accept_button)).set_text( cx, - self.content.accept_button_text.as_deref().unwrap_or("Confirm"), + self.content + .accept_button_text + .as_deref() + .unwrap_or("Confirm"), ); self.view.button(cx, ids!(cancel_button)).set_text( cx, - self.content.cancel_button_text.as_deref().unwrap_or("Cancel"), + self.content + .cancel_button_text + .as_deref() + .unwrap_or("Cancel"), ); self.view.button(cx, ids!(cancel_button)).reset_hover(cx); self.view.button(cx, ids!(accept_button)).reset_hover(cx); - self.view.button(cx, ids!(accept_button)).set_enabled(cx, true); - self.view.button(cx, ids!(cancel_button)).set_enabled(cx, true); + self.view + .button(cx, ids!(accept_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(cancel_button)) + .set_enabled(cx, true); self.view.redraw(cx); } } @@ -272,7 +282,9 @@ impl ConfirmationModal { impl ConfirmationModalRef { /// Shows the confirmation modal with the given content. pub fn show(&self, cx: &mut Cx, content: ConfirmationModalContent) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show(cx, content); } @@ -281,7 +293,9 @@ impl ConfirmationModalRef { /// If `true`, the user clicked the accept button; if `false`, the user clicked the cancel button. /// See [`ConfirmationModalAction::Close`] for more. pub fn closed(&self, actions: &Actions) -> Option { - if let ConfirmationModalAction::Close(accepted) = actions.find_widget_action(self.widget_uid()).cast_ref() { + if let ConfirmationModalAction::Close(accepted) = + actions.find_widget_action(self.widget_uid()).cast_ref() + { Some(*accepted) } else { None diff --git a/src/shared/expand_arrow.rs b/src/shared/expand_arrow.rs index 4528568d7..125b9282b 100644 --- a/src/shared/expand_arrow.rs +++ b/src/shared/expand_arrow.rs @@ -60,21 +60,34 @@ script_mod! { /// Animated expand/collapse triangle arrow. #[derive(Script, ScriptHook, Widget, Animator)] pub struct ExpandArrow { - #[uid] uid: WidgetUid, - #[source] source: ScriptObjectRef, - #[apply_default] animator: Animator, - #[redraw] #[live] draw_bg: DrawQuad, - #[walk] walk: Walk, + #[uid] + uid: WidgetUid, + #[source] + source: ScriptObjectRef, + #[apply_default] + animator: Animator, + #[redraw] + #[live] + draw_bg: DrawQuad, + #[walk] + walk: Walk, /// Tracks the desired opened state set from outside. /// Applied to draw_bg.opened during draw_walk. - #[rust] opened_value: f32, + #[rust] + opened_value: f32, } impl ExpandArrow { /// Animate open/close (use in event handlers only, not during draw). pub fn set_is_open(&mut self, cx: &mut Cx, is_open: bool, animate: Animate) { self.opened_value = if is_open { 1.0 } else { 0.0 }; - self.animator_toggle(cx, is_open, animate, ids!(expand.expanded), ids!(expand.collapsed)) + self.animator_toggle( + cx, + is_open, + animate, + ids!(expand.expanded), + ids!(expand.collapsed), + ) } /// Set open/close state without animation (safe to call anytime). @@ -92,7 +105,8 @@ impl Widget for ExpandArrow { fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { if !self.animator.is_track_animating(id!(expand)) { - self.draw_bg.set_dyn_instance(cx, id!(opened), &[self.opened_value]); + self.draw_bg + .set_dyn_instance(cx, id!(opened), &[self.opened_value]); } self.draw_bg.draw_walk(cx, walk); DrawStep::done() diff --git a/src/shared/html_or_plaintext.rs b/src/shared/html_or_plaintext.rs index c86eac05d..a83976374 100644 --- a/src/shared/html_or_plaintext.rs +++ b/src/shared/html_or_plaintext.rs @@ -1,9 +1,17 @@ //! A `HtmlOrPlaintext` view can display either plaintext or rich HTML content. use makepad_widgets::*; -use matrix_sdk::{ruma::{matrix_uri::MatrixId, OwnedMxcUri}, OwnedServerName}; - -use crate::{avatar_cache::{self, AvatarCacheEntry}, profile::user_profile_cache, sliding_sync::{current_user_id, submit_async_request, MatrixRequest}, utils}; +use matrix_sdk::{ + ruma::{matrix_uri::MatrixId, OwnedMxcUri}, + OwnedServerName, +}; + +use crate::{ + avatar_cache::{self, AvatarCacheEntry}, + profile::user_profile_cache, + sliding_sync::{current_user_id, submit_async_request, MatrixRequest}, + utils, +}; use super::avatar::AvatarWidgetExt; @@ -190,7 +198,7 @@ script_mod! { } #[derive(Debug, Clone, Default)] -pub enum RobrixHtmlLinkAction{ +pub enum RobrixHtmlLinkAction { ClickedMatrixLink { /// The URL of the link, which is only temporarily needed here /// because we don't fully handle MatrixId links directly in-app yet. @@ -208,15 +216,18 @@ pub enum RobrixHtmlLinkAction{ /// Matrix links are displayed using the [`MatrixLinkPill`] widget. #[derive(Script, Widget)] struct RobrixHtmlLink { - #[deref] view: View, + #[deref] + view: View, /// The displayable text of the link. /// This should be set automatically by the Html widget /// when it parses and draws an Html `` tag. - #[live] pub text: ArcStringMut, + #[live] + pub text: ArcStringMut, /// The URL of the link. /// This is set by the `on_after_new_scoped()` hook below. - #[live] pub url: String, + #[live] + pub url: String, } impl ScriptHook for RobrixHtmlLink { @@ -229,7 +240,7 @@ impl ScriptHook for RobrixHtmlLink { self.url = attr.into(); break; } - _ => { } + _ => {} } } } @@ -305,19 +316,26 @@ pub enum MatrixLinkPillState { /// This can be a link to a user, a room, or a message in a room. #[derive(Script, ScriptHook, Widget)] struct MatrixLinkPill { - #[deref] view: View, - - #[rust] matrix_id: Option, - #[rust] via: Vec, - #[rust] state: MatrixLinkPillState, - #[rust] url: String, + #[deref] + view: View, + + #[rust] + matrix_id: Option, + #[rust] + via: Vec, + #[rust] + state: MatrixLinkPillState, + #[rust] + url: String, } impl Widget for MatrixLinkPill { fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) { if let Event::Actions(actions) = event { for action in actions { - if let Some(loaded @ MatrixLinkPillState::Loaded { matrix_id, .. }) = action.downcast_ref() { + if let Some(loaded @ MatrixLinkPillState::Loaded { matrix_id, .. }) = + action.downcast_ref() + { if self.matrix_id.as_ref() == Some(matrix_id) { self.state = loaded.clone(); self.redraw(cx); @@ -335,13 +353,13 @@ impl Widget for MatrixLinkPill { if fe.is_over && fe.is_primary_hit() && fe.was_tap() { if let Some(matrix_id) = self.matrix_id.clone() { cx.widget_action( - self.widget_uid(), + self.widget_uid(), RobrixHtmlLinkAction::ClickedMatrixLink { matrix_id, via: self.via.clone(), key_modifiers: fe.modifiers, url: self.url.clone(), - } + }, ); } } @@ -366,7 +384,13 @@ impl Widget for MatrixLinkPill { impl MatrixLinkPill { /// Populates this pill's info based on the given Matrix ID and via servers. - fn populate_pill(&mut self, cx: &mut Cx, url: String, matrix_id: &MatrixId, via: &[OwnedServerName]) { + fn populate_pill( + &mut self, + cx: &mut Cx, + url: String, + matrix_id: &MatrixId, + via: &[OwnedServerName], + ) { self.url = url; self.matrix_id = Some(matrix_id.clone()); self.via = via.to_vec(); @@ -385,7 +409,12 @@ impl MatrixLinkPill { user_id.clone(), None, true, - |profile, _| { (profile.displayable_name().to_owned(), profile.avatar_state.clone()) } + |profile, _| { + ( + profile.displayable_name().to_owned(), + profile.avatar_state.clone(), + ) + }, ) { Some((name, avatar)) => { self.set_text(cx, &name); @@ -401,7 +430,9 @@ impl MatrixLinkPill { // Handle room ID or alias match &self.state { - MatrixLinkPillState::Loaded { name, avatar_url, .. } => { + MatrixLinkPillState::Loaded { + name, avatar_url, .. + } => { self.label(cx, ids!(title)).set_text(cx, name); self.populate_avatar(cx, avatar_url.as_ref()); return; @@ -413,14 +444,16 @@ impl MatrixLinkPill { }); self.state = MatrixLinkPillState::Requested; } - MatrixLinkPillState::Requested => { } + MatrixLinkPillState::Requested => {} } // While waiting for the async request to complete, show the matrix room ID/alias. match matrix_id { MatrixId::Room(room_id) => self.set_text(cx, room_id.as_str()), MatrixId::RoomAlias(alias) => self.set_text(cx, alias.as_str()), - MatrixId::Event(room_or_alias, _) => self.set_text(cx, &format!("Message in {}", room_or_alias.as_str())), - _ => { } + MatrixId::Event(room_or_alias, _) => { + self.set_text(cx, &format!("Message in {}", room_or_alias.as_str())) + } + _ => {} } self.populate_avatar(cx, None); } @@ -428,7 +461,9 @@ impl MatrixLinkPill { fn populate_avatar(&self, cx: &mut Cx, avatar_url: Option<&OwnedMxcUri>) { let avatar_ref = self.avatar(cx, ids!(avatar)); if let Some(avatar_url) = avatar_url { - if let AvatarCacheEntry::Loaded(data) = avatar_cache::get_or_fetch_avatar(cx, avatar_url) { + if let AvatarCacheEntry::Loaded(data) = + avatar_cache::get_or_fetch_avatar(cx, avatar_url) + { let res = avatar_ref.show_image( cx, None, // Don't make this avatar clickable @@ -442,7 +477,6 @@ impl MatrixLinkPill { // Show a text avatar if we couldn't load an image into the avatar. avatar_ref.show_text(cx, None, None, self.text()); } - } impl MatrixLinkPillRef { @@ -451,35 +485,48 @@ impl MatrixLinkPillRef { } pub fn get_via(&self) -> Vec { - self.borrow().map(|inner| inner.via.clone()).unwrap_or_default() + self.borrow() + .map(|inner| inner.via.clone()) + .unwrap_or_default() } } /// A widget used to display a single HTML `` tag or a `` tag. #[derive(Script, Widget)] struct MatrixHtmlSpan { - #[uid] uid: WidgetUid, + #[uid] + uid: WidgetUid, // TODO: this is unused; just here to invalidly satisfy the area provider. // I'm not sure how to implement `fn area()` given that it has multiple area rects. - #[redraw] #[area] area: Area, + #[redraw] + #[area] + area: Area, // TODO: remove these if they're unneeded - #[walk] walk: Walk, - #[layout] layout: Layout, + #[walk] + walk: Walk, + #[layout] + layout: Layout, - #[rust] drawn_areas: SmallVec<[Area; 2]>, + #[rust] + drawn_areas: SmallVec<[Area; 2]>, /// Whether to grab key focus when pressed. - #[live(true)] grab_key_focus: bool, + #[live(true)] + grab_key_focus: bool, /// The text content within the `` tag. - #[live] text: ArcStringMut, + #[live] + text: ArcStringMut, /// The current display state of the spoiler. - #[rust] spoiler: SpoilerDisplay, + #[rust] + spoiler: SpoilerDisplay, /// Foreground (text) color: the `data-mx-color` or `color` attributes. - #[rust] fg_color: Option, + #[rust] + fg_color: Option, /// Background color: the `data-mx-bg-color` attribute. - #[rust] bg_color: Option, + #[rust] + bg_color: Option, } impl ScriptHook for MatrixHtmlSpan { @@ -494,20 +541,22 @@ impl ScriptHook for MatrixHtmlSpan { while let Some((lc, attr)) = walker.while_attr_lc() { let attr = attr.trim_matches(['"', '\'']); match lc { - id!(color) - | id!(data-mx-color) => self.fg_color = utils::vec4_from_hex_str(attr), - id!(data-mx-bg-color) => self.bg_color = utils::vec4_from_hex_str(attr), - id!(data-mx-spoiler) => self.spoiler = SpoilerDisplay::Hidden { reason: attr.into() }, - _ => () + id!(color) | id!(data - mx - color) => { + self.fg_color = utils::vec4_from_hex_str(attr) + } + id!(data - mx - bg - color) => self.bg_color = utils::vec4_from_hex_str(attr), + id!(data - mx - spoiler) => { + self.spoiler = SpoilerDisplay::Hidden { + reason: attr.into(), + } + } + _ => (), } } } } } - - - /// The possible states that a spoiler can be in: hidden or revealed. /// /// The enclosed `reason` string is an optional reason given for why @@ -534,7 +583,7 @@ impl SpoilerDisplay { let s = std::mem::take(reason); *self = SpoilerDisplay::Hidden { reason: s }; } - SpoilerDisplay::None => { } + SpoilerDisplay::None => {} } } @@ -595,8 +644,7 @@ impl Widget for MatrixHtmlSpan { } match &self.spoiler { - SpoilerDisplay::Hidden { reason } - | SpoilerDisplay::Revealed { reason } => { + SpoilerDisplay::Hidden { reason } | SpoilerDisplay::Revealed { reason } => { // Draw the spoiler reason text in an italic gray font. tf.font_colors.push(COLOR_SPOILER_REASON); tf.italic.push(); @@ -611,11 +659,12 @@ impl Widget for MatrixHtmlSpan { tf.font_colors.pop(); // Now, draw the spoiler context text itself, either hidden or revealed. - if matches!(self.spoiler, SpoilerDisplay::Hidden {..}) { + if matches!(self.spoiler, SpoilerDisplay::Hidden { .. }) { // Use a background color that is the same as the foreground color, // which is a hacky way to make the spoiled text non-readable. // In the future, we should use a proper blur effect. - let spoiler_bg_color = self.fg_color + let spoiler_bg_color = self + .fg_color .or_else(|| tf.font_colors.last().copied()) .unwrap_or(tf.font_color); @@ -627,7 +676,6 @@ impl Widget for MatrixHtmlSpan { tf.draw_block.code_color = old_bg_color; tf.inline_code.pop(); - } else { tf.draw_text(cx, self.text.as_ref()); } @@ -648,9 +696,7 @@ impl Widget for MatrixHtmlSpan { } let (start, end) = tf.areas_tracker.pop_tracker(); - self.drawn_areas = SmallVec::from( - &tf.areas_tracker.areas[start..end] - ); + self.drawn_areas = SmallVec::from(&tf.areas_tracker.areas[start..end]); DrawStep::done() } @@ -665,11 +711,12 @@ impl Widget for MatrixHtmlSpan { } } - #[derive(ScriptHook, Script, Widget)] pub struct HtmlOrPlaintext { - #[source] source: ScriptObjectRef, - #[deref] view: View, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, } impl Widget for HtmlOrPlaintext { @@ -687,12 +734,14 @@ impl HtmlOrPlaintext { pub fn show_plaintext>(&mut self, cx: &mut Cx, text: T) { self.view(cx, ids!(html_view)).set_visible(cx, false); self.view(cx, ids!(plaintext_view)).set_visible(cx, true); - self.label(cx, ids!(plaintext_view.pt_label)).set_text(cx, text.as_ref()); + self.label(cx, ids!(plaintext_view.pt_label)) + .set_text(cx, text.as_ref()); } /// Sets the HTML content, making the HTML visible and the plaintext invisible. pub fn show_html>(&mut self, cx: &mut Cx, html_body: T) { - self.html(cx, ids!(html_view.html)).set_text(cx, html_body.as_ref()); + self.html(cx, ids!(html_view.html)) + .set_text(cx, html_body.as_ref()); self.view(cx, ids!(html_view)).set_visible(cx, true); self.view(cx, ids!(plaintext_view)).set_visible(cx, false); } @@ -730,13 +779,17 @@ impl HtmlOrPlaintext { /// See [`HtmlOrPlaintextRef::set_link_color()`]. pub fn set_link_color(&mut self, cx: &mut Cx, color: Option) { let html_ref = self.html(cx, ids!(html_view.html)); - let Some(mut html) = html_ref.borrow_mut() else { return }; + let Some(mut html) = html_ref.borrow_mut() else { + return; + }; // Iterate over cached TextFlow items (auto-generated IDs start at 1) // until we hit a non-existent item. let mut i = 1u64; loop { let item = html.existing_item(LiveId(i)); - if item.is_empty() { break; } + if item.is_empty() { + break; + } // Check if this item is a RobrixHtmlLink and modify its inner HtmlLink. if let Some(link) = item.borrow_mut::() { let mut html_link = link.html_link(cx, ids!(html_link)); diff --git a/src/shared/image_viewer.rs b/src/shared/image_viewer.rs index 93eaeec82..7e2487f72 100644 --- a/src/shared/image_viewer.rs +++ b/src/shared/image_viewer.rs @@ -25,18 +25,10 @@ const SHOW_UI_DURATION: f64 = 3.0; /// Returns an error if either load fails or if the image format is unknown. pub fn get_png_or_jpg_image_buffer(data: Vec) -> Result { match imghdr::from_bytes(&data) { - Some(imghdr::Type::Png) => { - ImageBuffer::from_png(&data) - }, - Some(imghdr::Type::Jpeg) => { - ImageBuffer::from_jpg(&data) - }, - Some(_unsupported) => { - Err(ImageError::UnsupportedFormat) - } - None => { - Err(ImageError::UnsupportedFormat) - } + Some(imghdr::Type::Png) => ImageBuffer::from_png(&data), + Some(imghdr::Type::Jpeg) => ImageBuffer::from_jpg(&data), + Some(_unsupported) => Err(ImageError::UnsupportedFormat), + None => Err(ImageError::UnsupportedFormat), } } @@ -217,7 +209,7 @@ script_mod! { flow: Right, spacing: 13, align: Align{ y: 0.5 } - + avatar := Avatar { width: 45, height: 45, text_view +: { @@ -445,40 +437,58 @@ pub enum ImageViewerAction { #[derive(Script, ScriptHook, Widget, Animator)] struct ImageViewer { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[rust] drag_state: DragState, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[rust] + drag_state: DragState, /// The current rotation angle of the image. Max of 4, each step represents 90 degrees - #[rust] rotation_step: i8, + #[rust] + rotation_step: i8, /// A lock to prevent multiple rotation animations from running at the same time - #[rust] is_animating_rotation: bool, - #[apply_default] animator: Animator, + #[rust] + is_animating_rotation: bool, + #[apply_default] + animator: Animator, /// Zoom constraints for the image viewer - #[rust] config: ImageViewerZoomConfig, + #[rust] + config: ImageViewerZoomConfig, /// Indicates if the mouse cursor is currently hovering over the image. /// If true, allows wheel scroll to zoom the image. - #[rust] mouse_cursor_hover_over_image: bool, + #[rust] + mouse_cursor_hover_over_image: bool, /// Distance between two touch points for pinch-to-zoom functionality - #[rust] previous_pinch_distance: Option, + #[rust] + previous_pinch_distance: Option, /// The ID of the background task that is currently running - #[rust] background_task_id: u32, + #[rust] + background_task_id: u32, /// The mpsc::Receiver used to receive the result of the background task - #[rust] receiver: Option<(u32, Receiver>)>, + #[rust] + receiver: Option<(u32, Receiver>)>, /// Whether the full image file has been loaded - #[rust] is_loaded: bool, + #[rust] + is_loaded: bool, /// The size of the image container. /// /// Used to compute the necessary width and height for the full screen image. - #[rust] image_container_size: DVec2, + #[rust] + image_container_size: DVec2, /// The texture containing the loaded image - #[rust] texture: Option, + #[rust] + texture: Option, /// The event to trigger displaying with the loaded image after peek_walk_turtle of the widget. - #[rust] next_frame: NextFrame, + #[rust] + next_frame: NextFrame, /// Whether to display the UI overlay, including buttons and metadata. - #[rust] ui_visible_toggle: bool, + #[rust] + ui_visible_toggle: bool, /// Timer used to animate-out (hide) the UI view after the latest user input. - #[rust] hide_ui_timer: Timer, - #[rust] capped_dimension: DVec2, + #[rust] + hide_ui_timer: Timer, + #[rust] + capped_dimension: DVec2, } impl Widget for ImageViewer { @@ -608,9 +618,7 @@ impl Widget for ImageViewer { cx.set_cursor(MouseCursor::Default); } Hit::FingerHoverOver(_) => { - if !self.ui_visible_toggle - && !self.animator.in_state(cx, ids!(ui_animator.show)) - { + if !self.ui_visible_toggle && !self.animator.in_state(cx, ids!(ui_animator.show)) { self.animator_cut(cx, ids!(ui_animator.hide)); self.animator_play(cx, ids!(ui_animator.show)); cx.stop_timer(self.hide_ui_timer); @@ -651,7 +659,8 @@ impl Widget for ImageViewer { self.handle_pinch_to_zoom(cx, touch_event); } - if let (Event::Signal, Some((_background_task_id, receiver))) = (event, &mut self.receiver) { + if let (Event::Signal, Some((_background_task_id, receiver))) = (event, &mut self.receiver) + { let mut remove_receiver = false; match receiver.try_recv() { Ok(Ok(image_buffer)) => { @@ -685,8 +694,7 @@ impl Widget for ImageViewer { let animator_action = self.animator_handle_event(cx, event); if self.next_frame.is_event(event).is_some() { self.display_using_texture(cx); - } - else if let Event::NextFrame(_) = event { + } else if let Event::NextFrame(_) = event { let animation_id = match self.rotation_step { 0 => ids!(mode.upright), // 0° 1 => ids!(mode.degree_90), // 90° @@ -695,12 +703,19 @@ impl Widget for ImageViewer { _ => ids!(mode.upright), }; if self.animator.in_state(cx, animation_id) { - self.is_animating_rotation = matches!(animator_action, AnimatorAction::Animating { .. }); + self.is_animating_rotation = + matches!(animator_action, AnimatorAction::Animating { .. }); } } if event.back_pressed() - || matches!(event, Event::KeyDown(KeyEvent { key_code: KeyCode::Escape, .. })) + || matches!( + event, + Event::KeyDown(KeyEvent { + key_code: KeyCode::Escape, + .. + }) + ) { self.reset(cx); cx.action(ImageViewerAction::Hide); @@ -730,19 +745,11 @@ impl MatchEvent for ImageViewer { if self.view.button(cx, ids!(reset_button)).clicked(actions) { self.reset(cx); } - if self - .view - .button(cx, ids!(zoom_out_button)) - .clicked(actions) - { + if self.view.button(cx, ids!(zoom_out_button)).clicked(actions) { self.adjust_zoom(cx, 1.0 / self.config.zoom_scale_factor); } - if self - .view - .button(cx, ids!(zoom_in_button)) - .clicked(actions) - { + if self.view.button(cx, ids!(zoom_in_button)).clicked(actions) { self.adjust_zoom(cx, self.config.zoom_scale_factor); } @@ -794,7 +801,7 @@ impl MatchEvent for ImageViewer { LoadState::FinishedBackgroundDecoding => { self.is_loaded = true; self.hide_footer(cx); - }, + } LoadState::Error(error) => { self.show_error(cx, error); } @@ -892,7 +899,7 @@ impl ImageViewer { } /// Displays an image in the image viewer widget using the provided texture. - /// + /// /// `Texture` is an optional `Texture` that can be set to display an image. If `None`, the image is cleared. pub fn display_using_texture(&mut self, cx: &mut Cx) { if self.image_container_size.length() == 0.0 { @@ -904,21 +911,21 @@ impl ImageViewer { .as_ref() .and_then(|texture| texture.get_format(cx).vec_width_height()) .unwrap_or_default(); - + // Calculate scaling factors for both dimensions let scale_x = self.image_container_size.x / texture_width as f64; let scale_y = self.image_container_size.y / texture_height as f64; - + // Use the smaller scale factor to ensure image fits within container let scale = scale_x.min(scale_y); - + let capped_width = (texture_width as f64 * scale).floor(); let capped_height = (texture_height as f64 * scale).floor(); - self.capped_dimension = DVec2{ + self.capped_dimension = DVec2 { x: capped_width, - y: capped_height + y: capped_height, }; - + rotated_image.set_texture(cx, texture); script_apply_eval!(cx, rotated_image, { width: #(capped_width), @@ -933,7 +940,10 @@ impl ImageViewer { let capped_dimension = self.capped_dimension; let target_zoom = self.drag_state.zoom_level * zoom_factor; let (width, height) = if target_zoom < self.config.min_zoom { - (capped_dimension.x * self.config.min_zoom, capped_dimension.y * self.config.min_zoom) + ( + capped_dimension.x * self.config.min_zoom, + capped_dimension.y * self.config.min_zoom, + ) } else { let actual_zoom_factor = target_zoom / self.drag_state.zoom_level; self.drag_state.zoom_level = target_zoom; @@ -986,11 +996,14 @@ impl ImageViewer { /// status label is set to "Loading...". pub fn show_loading(&mut self, cx: &mut Cx) { let footer = self.view.view(cx, ids!(image_layer.footer)); - footer.view(cx, ids!(image_viewer_loading_spinner_view)) + footer + .view(cx, ids!(image_viewer_loading_spinner_view)) .set_visible(cx, true); - footer.label(cx, ids!(image_viewer_status_label)) + footer + .label(cx, ids!(image_viewer_status_label)) .set_text(cx, "Loading..."); - footer.view(cx, ids!(image_viewer_forbidden_view)) + footer + .view(cx, ids!(image_viewer_forbidden_view)) .set_visible(cx, false); footer.set_visible(cx, true); self.ui_visible_toggle = true; @@ -1007,11 +1020,14 @@ impl ImageViewer { return; } let footer = self.view.view(cx, ids!(image_layer.footer)); - footer.view(cx, ids!(image_viewer_loading_spinner_view)) + footer + .view(cx, ids!(image_viewer_loading_spinner_view)) .set_visible(cx, false); - footer.view(cx, ids!(image_viewer_forbidden_view)) + footer + .view(cx, ids!(image_viewer_forbidden_view)) .set_visible(cx, true); - footer.label(cx, ids!(image_viewer_status_label)) + footer + .label(cx, ids!(image_viewer_status_label)) .set_text(cx, &error.to_string()); footer.set_visible(cx, true); } @@ -1046,14 +1062,17 @@ impl ImageViewer { } if let Some((timeline_kind, event_timeline_item)) = &metadata.avatar_parameter { - let (sender, _) = self.view.avatar(cx, ids!(user_profile_view.avatar)).set_avatar_and_get_username( - cx, - timeline_kind, - event_timeline_item.sender(), - Some(event_timeline_item.sender_profile()), - event_timeline_item.event_id(), - false, - ); + let (sender, _) = self + .view + .avatar(cx, ids!(user_profile_view.avatar)) + .set_avatar_and_get_username( + cx, + timeline_kind, + event_timeline_item.sender(), + Some(event_timeline_item.sender_profile()), + event_timeline_item.event_id(), + false, + ); if sender.len() > MAX_USERNAME_LENGTH { meta_view .label(cx, ids!(user_profile_view.content.username)) @@ -1070,13 +1089,17 @@ impl ImageViewer { impl ImageViewerRef { /// Configure zoom and pan settings for the image viewer pub fn configure_zoom(&mut self, config: ImageViewerZoomConfig) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.config = config; } /// See [`ImageViewer::show_loaded()`]. pub fn show_loaded(&mut self, cx: &mut Cx, image_bytes: &[u8]) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show_loaded(cx, image_bytes) } @@ -1087,7 +1110,9 @@ impl ImageViewerRef { texture: Option, metadata: &Option, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.texture = texture.clone(); inner.next_frame = cx.new_next_frame(); if let Some(metadata) = metadata { @@ -1098,19 +1123,25 @@ impl ImageViewerRef { /// See [`ImageViewer::show_error()`]. pub fn show_error(&mut self, cx: &mut Cx, error: &ImageViewerError) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show_error(cx, error); } /// See [`ImageViewer::hide_footer()`]. pub fn hide_footer(&mut self, cx: &mut Cx) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.hide_footer(cx); } /// See [`ImageViewer::reset()`]. pub fn reset(&mut self, cx: &mut Cx) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.reset(cx); } } diff --git a/src/shared/jump_to_bottom_button.rs b/src/shared/jump_to_bottom_button.rs index 9fb9a840f..d15c9b3c6 100644 --- a/src/shared/jump_to_bottom_button.rs +++ b/src/shared/jump_to_bottom_button.rs @@ -68,7 +68,7 @@ script_mod! { draw_bg +: { color: instance(COLOR_UNREAD_BADGE_MESSAGES) border_radius: uniform(4.0) - // Adjust this border_size to larger value to make oval smaller + // Adjust this border_size to larger value to make oval smaller border_size: uniform(2.0) pixel: fn() { @@ -98,15 +98,16 @@ script_mod! { } } } - + } } - #[derive(ScriptHook, Script, Widget)] pub struct JumpToBottomButton { - #[source] source: ScriptObjectRef, - #[deref] view: View, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, } impl Widget for JumpToBottomButton { @@ -115,7 +116,7 @@ impl Widget for JumpToBottomButton { match event.hits(cx, button_area) { Hit::FingerHoverIn(_) | Hit::FingerLongPress(_) => { cx.widget_action( - self.widget_uid(), + self.widget_uid(), TooltipAction::HoverIn { text: "Jump to bottom".to_string(), widget_rect: button_area.rect(cx), @@ -127,10 +128,7 @@ impl Widget for JumpToBottomButton { ); } Hit::FingerHoverOut(_) => { - cx.widget_action( - self.widget_uid(), - TooltipAction::HoverOut, - ); + cx.widget_action(self.widget_uid(), TooltipAction::HoverOut); } _ => {} } @@ -155,7 +153,8 @@ impl JumpToBottomButton { pub fn update_visibility(&mut self, cx: &mut Cx, is_at_bottom: bool) { if is_at_bottom { self.visible = false; - self.view(cx, ids!(unread_message_badge)).set_visible(cx, false); + self.view(cx, ids!(unread_message_badge)) + .set_visible(cx, false); } else { self.visible = true; } @@ -169,17 +168,20 @@ impl JumpToBottomButton { match count { UnreadMessageCount::Unknown => { self.visible = true; - self.view(cx, ids!(unread_message_badge)).set_visible(cx, true); + self.view(cx, ids!(unread_message_badge)) + .set_visible(cx, true); self.label(cx, ids!(unread_messages_count)).set_text(cx, ""); } UnreadMessageCount::Known(0) => { self.visible = false; - self.view(cx, ids!(unread_message_badge)).set_visible(cx, false); + self.view(cx, ids!(unread_message_badge)) + .set_visible(cx, false); self.label(cx, ids!(unread_messages_count)).set_text(cx, ""); } UnreadMessageCount::Known(unread_message_count) => { self.visible = true; - self.view(cx, ids!(unread_message_badge)).set_visible(cx, true); + self.view(cx, ids!(unread_message_badge)) + .set_visible(cx, true); let (border_size, plus_sign) = if unread_message_count > 99 { (0.0, "+") } else if unread_message_count > 9 { @@ -189,7 +191,7 @@ impl JumpToBottomButton { }; self.label(cx, ids!(unread_messages_count)).set_text( cx, - &format!("{}{plus_sign}", std::cmp::min(unread_message_count, 99)) + &format!("{}{plus_sign}", std::cmp::min(unread_message_count, 99)), ); let mut badge_view = self.view(cx, ids!(unread_message_badge.green_rounded_label)); script_apply_eval!(cx, badge_view, { @@ -218,11 +220,7 @@ impl JumpToBottomButton { // query the portallist's `at_end` state and set the visibility accordingly. if self.button(cx, ids!(inner_button)).clicked(actions) { - portal_list.smooth_scroll_to_end( - cx, - SCROLL_TO_BOTTOM_SPEED, - None, - ); + portal_list.smooth_scroll_to_end(cx, SCROLL_TO_BOTTOM_SPEED, None); self.update_visibility(cx, false); } else { self.update_visibility(cx, portal_list.is_at_end()); @@ -232,7 +230,6 @@ impl JumpToBottomButton { self.redraw(cx); } } - } impl JumpToBottomButtonRef { @@ -251,12 +248,7 @@ impl JumpToBottomButtonRef { } /// See [`JumpToBottomButton::update_from_actions()`]. - pub fn update_from_actions( - &self, - cx: &mut Cx, - portal_list: &PortalListRef, - actions: &Actions, - ) { + pub fn update_from_actions(&self, cx: &mut Cx, portal_list: &PortalListRef, actions: &Actions) { if let Some(mut inner) = self.borrow_mut() { inner.update_from_actions(cx, portal_list, actions); } @@ -269,5 +261,5 @@ pub enum UnreadMessageCount { /// There are unread messages, but we do not know how many. Unknown, /// There are unread messages, and we know exactly how many. - Known(u64) + Known(u64), } diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 31c422935..b074e0337 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -5,10 +5,7 @@ //! can be slotted back in later without changing the code that depends on it. use makepad_widgets::*; -use matrix_sdk::ruma::{ - events::room::message::RoomMessageEventContent, - OwnedRoomId, -}; +use matrix_sdk::ruma::{events::room::message::RoomMessageEventContent, OwnedRoomId}; script_mod! { use mod.prelude.widgets.* @@ -43,18 +40,21 @@ pub enum MentionableTextInputAction { PowerLevelsUpdated { room_id: OwnedRoomId, can_notify_room: bool, - } + }, } /// Temporary mock widget that wraps a simple TextInput (RobrixTextInput) /// while preserving the same external API as the real MentionableTextInput. #[derive(Script, ScriptHook, Widget)] pub struct MentionableTextInput { - #[source] source: ScriptObjectRef, - #[deref] view: View, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, /// Whether the current user can notify everyone in the room (@room mention). /// Stored but not used in this mock; kept for API compatibility. - #[rust] can_notify_room: bool, + #[rust] + can_notify_room: bool, } impl Widget for MentionableTextInput { @@ -65,7 +65,8 @@ impl Widget for MentionableTextInput { if let Event::Actions(actions) = event { for action in actions { if let Some(MentionableTextInputAction::PowerLevelsUpdated { - can_notify_room, .. + can_notify_room, + .. }) = action.downcast_ref() { self.can_notify_room = *can_notify_room; @@ -83,17 +84,18 @@ impl Widget for MentionableTextInput { } fn set_text(&mut self, cx: &mut Cx, text: &str) { - self.text_input(cx, ids!(persistent.center.text_input)).set_text(cx, text); + self.text_input(cx, ids!(persistent.center.text_input)) + .set_text(cx, text); self.redraw(cx); } fn set_key_focus(&self, cx: &mut Cx) { - self.text_input(cx, ids!(persistent.center.text_input)).set_key_focus(cx); + self.text_input(cx, ids!(persistent.center.text_input)) + .set_key_focus(cx); } } impl MentionableTextInput { - /// Sets whether the current user can notify the entire room (@room mention). pub fn set_can_notify_room(&mut self, can_notify: bool) { self.can_notify_room = can_notify; @@ -108,7 +110,8 @@ impl MentionableTextInput { impl MentionableTextInputRef { /// Returns a reference to the inner `TextInput` widget. pub fn text_input_ref(&self) -> TextInputRef { - self.child_by_path(ids!(persistent.center.text_input)).as_text_input() + self.child_by_path(ids!(persistent.center.text_input)) + .as_text_input() } /// Sets whether the current user can notify the entire room (@room mention). diff --git a/src/shared/mod.rs b/src/shared/mod.rs index a92a81fd9..7c5de0224 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -21,7 +21,6 @@ pub mod verification_badge; pub mod restore_status_view; pub mod image_viewer; - pub fn script_mod(vm: &mut ScriptVm) { // Order matters here, as some widget definitions depend on others. styles::script_mod(vm); diff --git a/src/shared/popup_list.rs b/src/shared/popup_list.rs index e0838aaf0..195644272 100644 --- a/src/shared/popup_list.rs +++ b/src/shared/popup_list.rs @@ -271,7 +271,7 @@ script_mod! { main_content := mod.widgets.MainContent {} } progress_bar := mod.widgets.ProgressBar {} - // Add a small gap between the progress bar and the end of the popup + // Add a small gap between the progress bar and the end of the popup // to ensure the progress bar is within the popup. View { height: 0.2 @@ -355,16 +355,25 @@ struct PopupEntry { /// A widget that displays a vertical list of popups. #[derive(Script, Widget)] pub struct RobrixPopupNotification { - #[uid] uid: WidgetUid, - #[source] source: ScriptObjectRef, - #[live] pub content: Option, - - #[rust] draw_list: Option, - #[redraw] #[live] draw_bg: DrawQuad, - #[layout] layout: Layout, - #[walk] walk: Walk, + #[uid] + uid: WidgetUid, + #[source] + source: ScriptObjectRef, + #[live] + pub content: Option, + + #[rust] + draw_list: Option, + #[redraw] + #[live] + draw_bg: DrawQuad, + #[layout] + layout: Layout, + #[walk] + walk: Walk, // A list of tuples containing individual widgets, its content and the close timer in the order they were added. - #[rust] popups: Vec, + #[rust] + popups: Vec, } impl ScriptHook for RobrixPopupNotification { @@ -566,10 +575,7 @@ impl RobrixPopupNotification { progress_bar.animator_cut(cx, ids!(progress.off)); Timer::empty() }; - self.popups.push(PopupEntry { - view, - close_timer, - }); + self.popups.push(PopupEntry { view, close_timer }); self.redraw_overlay(cx); } @@ -616,10 +622,7 @@ impl RobrixPopupNotification { popup_item.auto_dismissal_duration = popup_item .auto_dismissal_duration .map(|duration| duration.min(3. * 60.)); - self.popups.push(PopupEntry { - view, - close_timer, - }); + self.popups.push(PopupEntry { view, close_timer }); } /// Returns a clone of the template for each popup in the list. diff --git a/src/shared/restore_status_view.rs b/src/shared/restore_status_view.rs index 5e1e89b29..e0beee6a0 100644 --- a/src/shared/restore_status_view.rs +++ b/src/shared/restore_status_view.rs @@ -45,8 +45,10 @@ script_mod! { /// A view that displays a spinner and a label to indicate that a restore operation is in progress for a room. #[derive(Script, ScriptHook, Widget)] pub struct RestoreStatusView { - #[deref] view: View, - #[live(true)] visible: bool, + #[deref] + view: View, + #[live(true)] + visible: bool, } impl Widget for RestoreStatusView { @@ -55,7 +57,7 @@ impl Widget for RestoreStatusView { self.view.handle_event(cx, event, scope); } } - + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { if self.visible { self.view.draw_walk(cx, scope, walk) @@ -74,8 +76,7 @@ impl RestoreStatusViewRef { if let Some(mut inner) = self.borrow_mut() { inner.visible = visible; if !visible { - inner.label(cx, ids!(restore_status_label)) - .set_text(cx, ""); + inner.label(cx, ids!(restore_status_label)).set_text(cx, ""); } } } @@ -91,12 +92,7 @@ impl RestoreStatusViewRef { /// /// The `room_name` parameter is used to fill in the room name in the error message. /// Its `Display` implementation automatically handles Empty names by falling back to the room ID. - pub fn set_content( - &self, - cx: &mut Cx, - all_rooms_loaded: bool, - room_name: &RoomNameId, - ) { + pub fn set_content(&self, cx: &mut Cx, all_rooms_loaded: bool, room_name: &RoomNameId) { let Some(inner) = self.borrow() else { return }; let restore_status_spinner = inner.view.view(cx, ids!(restore_status_spinner)); let restore_status_label = inner.view.label(cx, ids!(restore_status_label)); @@ -111,10 +107,8 @@ impl RestoreStatusViewRef { ); } else { restore_status_spinner.set_visible(cx, true); - restore_status_label.set_text( - cx, - "Waiting for this room to be loaded from the homeserver", - ); + restore_status_label + .set_text(cx, "Waiting for this room to be loaded from the homeserver"); } } } diff --git a/src/shared/room_filter_input_bar.rs b/src/shared/room_filter_input_bar.rs index ccbee0601..63e87e3c4 100644 --- a/src/shared/room_filter_input_bar.rs +++ b/src/shared/room_filter_input_bar.rs @@ -43,9 +43,9 @@ script_mod! { height: Fit, flow: Right, // do not wrap padding: 5 - + empty_text: "Filter rooms & spaces..." - + draw_bg.border_size: 0.0 draw_text +: { text_style: theme.font_regular { font_size: 10 }, @@ -68,7 +68,8 @@ script_mod! { /// See the module-level docs for more detail. #[derive(Script, ScriptHook, Widget)] pub struct RoomFilterInputBar { - #[deref] view: View, + #[deref] + view: View, } /// Actions emitted by the `RoomFilterInputBar` based on user interaction with it. @@ -114,20 +115,14 @@ impl WidgetMatchEvent for RoomFilterInputBar { }; clear_button.set_visible(cx, !keywords.is_empty()); clear_button.reset_hover(cx); - cx.widget_action( - self.widget_uid(), - RoomFilterAction::Changed(keywords) - ); + cx.widget_action(self.widget_uid(), RoomFilterAction::Changed(keywords)); } if clear_button.clicked(actions) { input.set_text(cx, ""); clear_button.set_visible(cx, false); input.set_key_focus(cx); - cx.widget_action( - self.widget_uid(), - RoomFilterAction::Changed(String::new()) - ); + cx.widget_action(self.widget_uid(), RoomFilterAction::Changed(String::new())); } } } diff --git a/src/shared/styles.rs b/src/shared/styles.rs index a80fa55e5..8e5026260 100644 --- a/src/shared/styles.rs +++ b/src/shared/styles.rs @@ -246,45 +246,44 @@ script_mod! { } } - /// #FFFFFF -pub const COLOR_PRIMARY: Vec4 = vec4(1.0, 1.0, 1.0, 1.0); +pub const COLOR_PRIMARY: Vec4 = vec4(1.0, 1.0, 1.0, 1.0); /// #0F88FE -pub const COLOR_ACTIVE_PRIMARY: Vec4 = vec4(0.059, 0.533, 0.996, 1.0); +pub const COLOR_ACTIVE_PRIMARY: Vec4 = vec4(0.059, 0.533, 0.996, 1.0); /// #106FCC pub const COLOR_ACTIVE_PRIMARY_DARKER: Vec4 = vec4(0.063, 0.435, 0.682, 1.0); /// #138808 -pub const COLOR_FG_ACCEPT_GREEN: Vec4 = vec4(0.074, 0.533, 0.031, 1.0); +pub const COLOR_FG_ACCEPT_GREEN: Vec4 = vec4(0.074, 0.533, 0.031, 1.0); /// #F0FFF0 -pub const COLOR_BG_ACCEPT_GREEN: Vec4 = vec4(0.941, 1.0, 0.941, 1.0); +pub const COLOR_BG_ACCEPT_GREEN: Vec4 = vec4(0.941, 1.0, 0.941, 1.0); /// #B3B3B3 -pub const COLOR_FG_DISABLED: Vec4 = vec4(0.7, 0.7, 0.7, 1.0); +pub const COLOR_FG_DISABLED: Vec4 = vec4(0.7, 0.7, 0.7, 1.0); /// #E0E0E0 -pub const COLOR_BG_DISABLED: Vec4 = vec4(0.878, 0.878, 0.878, 1.0); +pub const COLOR_BG_DISABLED: Vec4 = vec4(0.878, 0.878, 0.878, 1.0); /// #DC0005 -pub const COLOR_FG_DANGER_RED: Vec4 = vec4(0.863, 0.0, 0.02, 1.0); +pub const COLOR_FG_DANGER_RED: Vec4 = vec4(0.863, 0.0, 0.02, 1.0); /// #FFF0F0 -pub const COLOR_BG_DANGER_RED: Vec4 = vec4(1.0, 0.941, 0.941, 1.0); +pub const COLOR_BG_DANGER_RED: Vec4 = vec4(1.0, 0.941, 0.941, 1.0); /// #572DCC -pub const COLOR_ROBRIX_PURPLE: Vec4 = vec4(0.341, 0.176, 0.8, 1.0); +pub const COLOR_ROBRIX_PURPLE: Vec4 = vec4(0.341, 0.176, 0.8, 1.0); /// #05CDC7 -pub const COLOR_ROBRIX_CYAN: Vec4 = vec4(0.031, 0.804, 0.78, 1.0); +pub const COLOR_ROBRIX_CYAN: Vec4 = vec4(0.031, 0.804, 0.78, 1.0); /// #FF0000 pub const COLOR_UNREAD_BADGE_MENTIONS: Vec4 = vec4(1.0, 0.0, 0.0, 1.0); /// #572DCC -pub const COLOR_UNREAD_BADGE_MARKED: Vec4 = COLOR_ROBRIX_CYAN; +pub const COLOR_UNREAD_BADGE_MARKED: Vec4 = COLOR_ROBRIX_CYAN; /// #AAAAAA pub const COLOR_UNREAD_BADGE_MESSAGES: Vec4 = vec4(0.667, 0.667, 0.667, 1.0); /// #FF6e00 -pub const COLOR_UNKNOWN_ROOM_AVATAR: Vec4 = vec4(1.0, 0.431, 0.0, 1.0); +pub const COLOR_UNKNOWN_ROOM_AVATAR: Vec4 = vec4(1.0, 0.431, 0.0, 1.0); /// #888888 -pub const COLOR_MESSAGE_NOTICE_TEXT: Vec4 = vec4(0.5, 0.5, 0.5, 1.0); +pub const COLOR_MESSAGE_NOTICE_TEXT: Vec4 = vec4(0.5, 0.5, 0.5, 1.0); /// #953800 pub const COLOR_TEXT_WARNING_NOT_FOUND: Vec4 = vec4(0.584, 0.219, 0.0, 1.0); /// #F0F5FF -pub const COLOR_BG_PREVIEW: Vec4 = vec4(0.941, 0.961, 1.0, 1.0); +pub const COLOR_BG_PREVIEW: Vec4 = vec4(0.941, 0.961, 1.0, 1.0); /// #CDEDDF -pub const COLOR_BG_PREVIEW_HOVER: Vec4 = vec4(0.804, 0.929, 0.875, 1.0); +pub const COLOR_BG_PREVIEW_HOVER: Vec4 = vec4(0.804, 0.929, 0.875, 1.0); /// Applies positive (green) button styling to the given button. pub fn apply_positive_button_style(cx: &mut Cx, button: &mut ButtonRef) { diff --git a/src/shared/text_or_image.rs b/src/shared/text_or_image.rs index a535661ff..4f3f4c8d1 100644 --- a/src/shared/text_or_image.rs +++ b/src/shared/text_or_image.rs @@ -54,7 +54,6 @@ script_mod! { } } - /// A view that holds an image or text content, and can switch between the two. /// /// This is useful for displaying alternate text when an image is not (yet) available @@ -62,10 +61,13 @@ script_mod! { /// is being fetched. #[derive(Script, Widget, ScriptHook)] pub struct TextOrImage { - #[deref] view: View, - #[rust] status: TextOrImageStatus, + #[deref] + view: View, + #[rust] + status: TextOrImageStatus, // #[rust(TextOrImageStatus::Text)] status: TextOrImageStatus, - #[rust] size_in_pixels: (usize, usize), + #[rust] + size_in_pixels: (usize, usize), } impl Widget for TextOrImage { @@ -79,7 +81,7 @@ impl Widget for TextOrImage { } Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { cx.widget_action( - self.widget_uid(), + self.widget_uid(), TextOrImageAction::Clicked(mxc_uri.clone()), ); cx.set_cursor(MouseCursor::Default); @@ -108,9 +110,12 @@ impl TextOrImage { /// a message like "Loading..." or an error message. pub fn show_text>(&mut self, cx: &mut Cx, text: T) { self.view(cx, ids!(image_view)).set_visible(cx, false); - self.view(cx, ids!(default_image_view)).set_visible(cx, false); + self.view(cx, ids!(default_image_view)) + .set_visible(cx, false); self.view(cx, ids!(text_view)).set_visible(cx, true); - self.view.label(cx, ids!(text_view.label)).set_text(cx, text.as_ref()); + self.view + .label(cx, ids!(text_view.label)) + .set_text(cx, text.as_ref()); self.status = TextOrImageStatus::Text; } @@ -123,8 +128,14 @@ impl TextOrImage { /// * If successful, the `image_set_function` should return the size of the image /// in pixels as a tuple, `(width, height)`. /// * If `image_set_function` returns an error, no change is made to this `TextOrImage`. - pub fn show_image(&mut self, cx: &mut Cx, source_url: Option, image_set_function: F) -> Result<(), E> - where F: FnOnce(&mut Cx, ImageRef) -> Result<(usize, usize), E> + pub fn show_image( + &mut self, + cx: &mut Cx, + source_url: Option, + image_set_function: F, + ) -> Result<(), E> + where + F: FnOnce(&mut Cx, ImageRef) -> Result<(usize, usize), E>, { let image_ref = self.view.image(cx, ids!(image_view.image)); match image_set_function(cx, image_ref) { @@ -133,7 +144,8 @@ impl TextOrImage { self.size_in_pixels = size_in_pixels; self.view(cx, ids!(image_view)).set_visible(cx, true); self.view(cx, ids!(text_view)).set_visible(cx, false); - self.view(cx, ids!(default_image_view)).set_visible(cx, false); + self.view(cx, ids!(default_image_view)) + .set_visible(cx, false); Ok(()) } Err(e) => { @@ -150,7 +162,8 @@ impl TextOrImage { /// Displays the default image that is used when no image is available. pub fn show_default_image(&self, cx: &mut Cx) { - self.view(cx, ids!(default_image_view)).set_visible(cx, true); + self.view(cx, ids!(default_image_view)) + .set_visible(cx, true); self.view(cx, ids!(text_view)).set_visible(cx, false); self.view(cx, ids!(image_view)).set_visible(cx, false); } @@ -165,8 +178,14 @@ impl TextOrImageRef { } /// See [TextOrImage::show_image()]. - pub fn show_image(&self, cx: &mut Cx, source_url: Option, image_set_function: F) -> Result<(), E> - where F: FnOnce(&mut Cx, ImageRef) -> Result<(usize, usize), E> + pub fn show_image( + &self, + cx: &mut Cx, + source_url: Option, + image_set_function: F, + ) -> Result<(), E> + where + F: FnOnce(&mut Cx, ImageRef) -> Result<(usize, usize), E>, { if let Some(mut inner) = self.borrow_mut() { inner.show_image(cx, source_url, image_set_function) @@ -212,7 +231,7 @@ impl TextOrImageRef { pub enum TextOrImageStatus { #[default] Text, - /// Image source URL stored in this variant to be used + /// Image source URL stored in this variant to be used Image(Option), } @@ -222,5 +241,5 @@ pub enum TextOrImageAction { /// The user has clicked the `TextOrImage`, with source URL stored in this variant. Clicked(Option), #[default] - None + None, } diff --git a/src/shared/timestamp.rs b/src/shared/timestamp.rs index c84935fd3..42585a34d 100644 --- a/src/shared/timestamp.rs +++ b/src/shared/timestamp.rs @@ -4,7 +4,6 @@ use chrono::{DateTime, Local}; use makepad_widgets::*; - script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -33,9 +32,11 @@ script_mod! { /// See the module-level docs for more detail. #[derive(Script, ScriptHook, Widget)] pub struct Timestamp { - #[deref] view: View, + #[deref] + view: View, - #[rust] dt: DateTime, + #[rust] + dt: DateTime, } impl Widget for Timestamp { @@ -44,20 +45,19 @@ impl Widget for Timestamp { let area = self.view.area(); let should_hover_in = match event.hits(cx, area) { - Hit::FingerLongPress(_) - | Hit::FingerHoverIn(..) => true, + Hit::FingerLongPress(_) | Hit::FingerHoverIn(..) => true, Hit::FingerUp(fue) if fue.is_over && fue.is_primary_hit() => true, Hit::FingerHoverOut(_) => { - cx.widget_action(self.widget_uid(), TooltipAction::HoverOut); + cx.widget_action(self.widget_uid(), TooltipAction::HoverOut); false } _ => false, }; if should_hover_in { // TODO: use pure_rust_locales crate to format the time based on the chosen Locale. - let locale_extended_fmt_en_us= "%a %b %-d, %Y, %r"; + let locale_extended_fmt_en_us = "%a %b %-d, %Y, %r"; cx.widget_action( - self.widget_uid(), + self.widget_uid(), TooltipAction::HoverIn { text: self.dt.format(locale_extended_fmt_en_us).to_string(), widget_rect: area.rect(cx), @@ -79,10 +79,8 @@ impl Timestamp { pub fn set_date_time(&mut self, cx: &mut Cx, dt: DateTime) { // TODO: use pure_rust_locales crate to format the time based on the chosen Locale. let locale_fmt_en_us = "%-I:%M %P"; - self.label(cx, ids!(ts_label)).set_text( - cx, - &dt.format(locale_fmt_en_us).to_string() - ); + self.label(cx, ids!(ts_label)) + .set_text(cx, &dt.format(locale_fmt_en_us).to_string()); self.dt = dt; } } diff --git a/src/shared/unread_badge.rs b/src/shared/unread_badge.rs index ab184fa57..1f04894c7 100644 --- a/src/shared/unread_badge.rs +++ b/src/shared/unread_badge.rs @@ -3,7 +3,6 @@ use makepad_widgets::*; - script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -21,7 +20,7 @@ script_mod! { draw_bg +: { badge_color: instance((COLOR_UNREAD_BADGE_MESSAGES)), border_radius: instance(4.0) - // Set this border_size to a larger value to make the oval smaller + // Set this border_size to a larger value to make the oval smaller border_size: instance(2.0) pixel: fn() { @@ -53,14 +52,18 @@ script_mod! { } } - #[derive(Script, ScriptHook, Widget)] pub struct UnreadBadge { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[live] is_marked_unread: bool, - #[live] unread_mentions: u64, - #[live] unread_messages: u64, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[live] + is_marked_unread: bool, + #[live] + unread_mentions: u64, + #[live] + unread_messages: u64, } impl Widget for UnreadBadge { @@ -69,11 +72,10 @@ impl Widget for UnreadBadge { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - /// Helper function to format the badge's rounded rectangle. /// /// The rounded rectangle needs to be wider for longer text. - /// It also adds a plus sign at the end if the unread count is greater than 99. + /// It also adds a plus sign at the end if the unread count is greater than 99. fn format_border_and_truncation(count: u64) -> (f64, &'static str) { let (border_size, plus_sign) = if count > 99 { (0.0, "+") @@ -88,8 +90,10 @@ impl Widget for UnreadBadge { // If there are unread mentions, show red badge and the number of unread mentions if self.unread_mentions > 0 { let (border_size, plus_sign) = format_border_and_truncation(self.unread_mentions); - self.label(cx, ids!(label_count)) - .set_text(cx, &format!("{}{plus_sign}", std::cmp::min(self.unread_mentions, 99))); + self.label(cx, ids!(label_count)).set_text( + cx, + &format!("{}{plus_sign}", std::cmp::min(self.unread_mentions, 99)), + ); let mut rounded_view = self.view(cx, ids!(rounded_view)); script_apply_eval!(cx, rounded_view, { draw_bg +: { @@ -114,8 +118,10 @@ impl Widget for UnreadBadge { // If there are no unread mentions but there are unread messages, show gray badge and the number of unread messages else if self.unread_messages > 0 { let (border_size, plus_sign) = format_border_and_truncation(self.unread_messages); - self.label(cx, ids!(label_count)) - .set_text(cx, &format!("{}{plus_sign}", std::cmp::min(self.unread_messages, 99))); + self.label(cx, ids!(label_count)).set_text( + cx, + &format!("{}{plus_sign}", std::cmp::min(self.unread_messages, 99)), + ); let mut rounded_view = self.view(cx, ids!(rounded_view)); script_apply_eval!(cx, rounded_view, { draw_bg +: { @@ -124,8 +130,7 @@ impl Widget for UnreadBadge { } }); self.visible = true; - } - else { + } else { // If there are no unreads of any kind, hide the badge self.visible = false; } @@ -136,7 +141,12 @@ impl Widget for UnreadBadge { impl UnreadBadgeRef { /// Sets the unread mentions and messages counts without explicitly redrawing the badge. - pub fn update_counts(&self, is_marked_unread: bool, num_unread_mentions: u64, num_unread_messages: u64) { + pub fn update_counts( + &self, + is_marked_unread: bool, + num_unread_mentions: u64, + num_unread_messages: u64, + ) { if let Some(mut inner) = self.borrow_mut() { inner.is_marked_unread = is_marked_unread; inner.unread_mentions = num_unread_mentions; diff --git a/src/shared/verification_badge.rs b/src/shared/verification_badge.rs index 2a0ef3588..e2a4b5b86 100644 --- a/src/shared/verification_badge.rs +++ b/src/shared/verification_badge.rs @@ -7,7 +7,6 @@ use crate::{ verification::VerificationStateAction, }; - // First, define the verification icons component layout script_mod! { use mod.prelude.widgets.* @@ -159,10 +158,7 @@ impl VerificationBadgeRef { please verify Robrix from another client.", Some(COLOR_FG_DANGER_RED), ), - _ => ( - "Verification state is unknown.", - None, - ), + _ => ("Verification state is unknown.", None), } } } diff --git a/src/space_service_sync.rs b/src/space_service_sync.rs index c02bbc8a1..a2c450a28 100644 --- a/src/space_service_sync.rs +++ b/src/space_service_sync.rs @@ -1,16 +1,33 @@ //! Background tasks that subscribe to the Matrix SpaceService in order to //! track changes to the user's joined spaces and send updates the UI. -use std::{collections::{HashMap, HashSet, hash_map::Entry}, iter::Peekable, sync::Arc}; +use std::{ + collections::{HashMap, HashSet, hash_map::Entry}, + iter::Peekable, + sync::Arc, +}; use eyeball_im::VectorDiff; use futures_util::StreamExt; use imbl::Vector; use makepad_widgets::*; use matrix_sdk::{Client, RoomState, media::MediaRequestParameters}; -use matrix_sdk_ui::spaces::{SpaceRoom, SpaceRoomList, SpaceService, room_list::SpaceRoomListPaginationState}; +use matrix_sdk_ui::spaces::{ + SpaceRoom, SpaceRoomList, SpaceService, room_list::SpaceRoomListPaginationState, +}; use ruma::{OwnedMxcUri, OwnedRoomId, events::room::MediaSource, room::RoomType}; -use tokio::{runtime::Handle, sync::mpsc::{UnboundedReceiver, UnboundedSender}, task::JoinHandle}; -use crate::{home::{rooms_list::{RoomsListUpdate, enqueue_rooms_list_update}, spaces_bar::{JoinedSpaceInfo, SpacesListUpdate, enqueue_spaces_list_update}}, room::FetchedRoomAvatar, utils::{self, RoomNameId}}; +use tokio::{ + runtime::Handle, + sync::mpsc::{UnboundedReceiver, UnboundedSender}, + task::JoinHandle, +}; +use crate::{ + home::{ + rooms_list::{RoomsListUpdate, enqueue_rooms_list_update}, + spaces_bar::{JoinedSpaceInfo, SpacesListUpdate, enqueue_spaces_list_update}, + }, + room::FetchedRoomAvatar, + utils::{self, RoomNameId}, +}; /// Whether to enable verbose logging of all spaces service diff updates. const LOG_SPACE_SERVICE_DIFFS: bool = cfg!(feature = "log_space_service_diffs"); @@ -21,7 +38,6 @@ const LOG_SPACE_SERVICE_DIFFS: bool = cfg!(feature = "log_space_service_diffs"); /// while the last element is the direct parent. pub type ParentChain = SmallVec<[OwnedRoomId; 2]>; - /// Requests related to obtaining info about Spaces, via the background space service. pub enum SpaceRequest { /// Start obtaining the list of rooms in the given space from the homeserver, @@ -34,15 +50,11 @@ pub enum SpaceRequest { /// /// Note: the Matrix SDK offers no way to unsubscribe from a space room list, /// so this just stops the async background task that runs the subscriber loop. - UnsubscribeFromSpaceRoomList { - space_id: OwnedRoomId, - }, + UnsubscribeFromSpaceRoomList { space_id: OwnedRoomId }, /// Leave the given space and all joined rooms within it. /// /// Will emit a [`SpaceRoomListAction::LeaveSpaceResult`] action. - LeaveSpace { - space_name_id: RoomNameId, - }, + LeaveSpace { space_name_id: RoomNameId }, /// Paginate the given space's room list, i.e., fetch the next batch of rooms in the list. /// /// This will result in a [`SpaceRoomListAction::PaginationState`] action being emitted, @@ -70,9 +82,7 @@ pub enum SpaceRequest { /// Get full details about a top-level space. /// /// This will result in a [`SpaceRoomListAction::TopLevelSpaceDetails`] action being emitted. - GetTopLevelSpaceDetails { - space_id: OwnedRoomId, - }, + GetTopLevelSpaceDetails { space_id: OwnedRoomId }, } /// Internal requests sent from the [`space_service_loop`] to a specific space's [`space_room_list_loop`]. @@ -88,13 +98,15 @@ enum SpaceRoomListRequest { Shutdown, } - /// The main async loop task that listens for changes to all top-level joined spaces. pub async fn space_service_loop(client: Client) -> anyhow::Result<()> { // Create a channel for sending space-related requests to this background worker. - let (space_request_sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::(); + let (space_request_sender, mut receiver) = + tokio::sync::mpsc::unbounded_channel::(); // Give the request sender channel endpoint to the RoomsList widget. - enqueue_rooms_list_update(RoomsListUpdate::SpaceRequestSender(space_request_sender.clone())); + enqueue_rooms_list_update(RoomsListUpdate::SpaceRequestSender( + space_request_sender.clone(), + )); // Create the actual space service. let space_service = SpaceService::new(client.clone()).await; @@ -103,247 +115,256 @@ pub async fn space_service_loop(client: Client) -> anyhow::Result<()> { // along with a sender to send `SpaceRoomListRequest`s to those tasks. let mut space_room_list_tasks = HashMap::new(); // A closure to make it easier to use/spawn a `space_room_list_loop` task. - let get_or_spawn_space_room_list = async | - space_room_list_tasks: &mut HashMap, JoinHandle<()>)>, - space_id: &OwnedRoomId, - parent_chain: &ParentChain, - | -> UnboundedSender { + let get_or_spawn_space_room_list = async |space_room_list_tasks: &mut HashMap< + OwnedRoomId, + (UnboundedSender, JoinHandle<()>), + >, + space_id: &OwnedRoomId, + parent_chain: &ParentChain| + -> UnboundedSender { match space_room_list_tasks.entry(space_id.clone()) { Entry::Occupied(occ) => occ.get().0.clone(), Entry::Vacant(vac) => { - let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); + let (sender, receiver) = + tokio::sync::mpsc::unbounded_channel::(); let space_room_list = space_service.space_room_list(space_id.clone()).await; - let join_handle = Handle::current().spawn( - space_room_list_loop( - space_id.clone(), - parent_chain.clone(), - receiver, - space_room_list, - space_request_sender.clone(), - ) - ); - vac.insert((sender, join_handle)) - .0.clone() + let join_handle = Handle::current().spawn(space_room_list_loop( + space_id.clone(), + parent_chain.clone(), + receiver, + space_room_list, + space_request_sender.clone(), + )); + vac.insert((sender, join_handle)).0.clone() } } }; // Get the set of top-level (root) spaces that the user has joined. - let (initial_spaces, mut spaces_diff_stream) = space_service.subscribe_to_top_level_joined_spaces().await; + let (initial_spaces, mut spaces_diff_stream) = + space_service.subscribe_to_top_level_joined_spaces().await; for space in &initial_spaces { add_new_space(space, &client).await; } let mut all_joined_spaces: Vector = initial_spaces; - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: initial set: {all_joined_spaces:?}"); } - - - loop { tokio::select! { - // Handle new space requests. - request_opt = receiver.recv() => { - let Some(request) = request_opt else { break }; - match request { - SpaceRequest::GetChildren { space_id, parent_chain } => { - let sender = get_or_spawn_space_room_list(&mut space_room_list_tasks, &space_id, &parent_chain).await; - if sender.send(SpaceRoomListRequest::GetChildren).is_err() { - error!("BUG: failed to send GetRooms request to space room list loop for space {space_id}"); + if LOG_SPACE_SERVICE_DIFFS { + log!("space_service: initial set: {all_joined_spaces:?}"); + } + + loop { + tokio::select! { + // Handle new space requests. + request_opt = receiver.recv() => { + let Some(request) = request_opt else { break }; + match request { + SpaceRequest::GetChildren { space_id, parent_chain } => { + let sender = get_or_spawn_space_room_list(&mut space_room_list_tasks, &space_id, &parent_chain).await; + if sender.send(SpaceRoomListRequest::GetChildren).is_err() { + error!("BUG: failed to send GetRooms request to space room list loop for space {space_id}"); + } } - } - SpaceRequest::SubscribeToSpaceRoomList { space_id, parent_chain } => { - let _sender = get_or_spawn_space_room_list(&mut space_room_list_tasks, &space_id, &parent_chain).await; - } - SpaceRequest::PaginateSpaceRoomList { space_id, parent_chain } => { - let sender = get_or_spawn_space_room_list(&mut space_room_list_tasks, &space_id, &parent_chain).await; - if sender.send(SpaceRoomListRequest::Paginate).is_err() { - error!("BUG: failed to send paginate request to space room list loop for space {space_id}"); + SpaceRequest::SubscribeToSpaceRoomList { space_id, parent_chain } => { + let _sender = get_or_spawn_space_room_list(&mut space_room_list_tasks, &space_id, &parent_chain).await; } - } - SpaceRequest::UnsubscribeFromSpaceRoomList { space_id } => { - if let Some((sender, join_handle)) = space_room_list_tasks.remove(&space_id) { - let _ = sender.send(SpaceRoomListRequest::Shutdown); - join_handle.abort(); + SpaceRequest::PaginateSpaceRoomList { space_id, parent_chain } => { + let sender = get_or_spawn_space_room_list(&mut space_room_list_tasks, &space_id, &parent_chain).await; + if sender.send(SpaceRoomListRequest::Paginate).is_err() { + error!("BUG: failed to send paginate request to space room list loop for space {space_id}"); + } } - } - SpaceRequest::LeaveSpace { space_name_id } => { - match space_service.leave_space(space_name_id.room_id()).await { - Ok(leave_handle) => { - match leave_handle.leave(|_| true).await { - Ok(()) => { - if let Some((sender, join_handle)) = space_room_list_tasks.remove(space_name_id.room_id()) { - match sender.send(SpaceRoomListRequest::Shutdown) { - // If we successfully sent shutdown message, just let the space room list loop task - // end gracefully on its own in the background. - Ok(_) => { } - // If we failed to send the shutdown message, just abort the space room list loop task. - Err(_) => join_handle.abort(), + SpaceRequest::UnsubscribeFromSpaceRoomList { space_id } => { + if let Some((sender, join_handle)) = space_room_list_tasks.remove(&space_id) { + let _ = sender.send(SpaceRoomListRequest::Shutdown); + join_handle.abort(); + } + } + SpaceRequest::LeaveSpace { space_name_id } => { + match space_service.leave_space(space_name_id.room_id()).await { + Ok(leave_handle) => { + match leave_handle.leave(|_| true).await { + Ok(()) => { + if let Some((sender, join_handle)) = space_room_list_tasks.remove(space_name_id.room_id()) { + match sender.send(SpaceRoomListRequest::Shutdown) { + // If we successfully sent shutdown message, just let the space room list loop task + // end gracefully on its own in the background. + Ok(_) => { } + // If we failed to send the shutdown message, just abort the space room list loop task. + Err(_) => join_handle.abort(), + } } + Cx::post_action(SpaceRoomListAction::LeaveSpaceResult { + space_name_id, + result: Ok(()), + }); + } + Err(error) => { + error!("LeaveSpace: failed to leave all rooms in space {space_name_id}: {error:?}"); + Cx::post_action(SpaceRoomListAction::LeaveSpaceResult { + space_name_id, + result: Err(error), + }); } - Cx::post_action(SpaceRoomListAction::LeaveSpaceResult { - space_name_id, - result: Ok(()), - }); - } - Err(error) => { - error!("LeaveSpace: failed to leave all rooms in space {space_name_id}: {error:?}"); - Cx::post_action(SpaceRoomListAction::LeaveSpaceResult { - space_name_id, - result: Err(error), - }); } } - } - Err(error) => { - error!("Failed to leave space {space_name_id}: {error:?}"); - Cx::post_action(SpaceRoomListAction::LeaveSpaceResult { - space_name_id, - result: Err(error), - }); + Err(error) => { + error!("Failed to leave space {space_name_id}: {error:?}"); + Cx::post_action(SpaceRoomListAction::LeaveSpaceResult { + space_name_id, + result: Err(error), + }); + } } } - } - SpaceRequest::GetDetailedChildren { space_id, parent_chain } => { - let sender = get_or_spawn_space_room_list(&mut space_room_list_tasks, &space_id, &parent_chain).await; - if sender.send(SpaceRoomListRequest::GetDetailedChildren).is_err() { - error!("BUG: failed to send GetDetailedChildren request to space room list loop for space {space_id}"); + SpaceRequest::GetDetailedChildren { space_id, parent_chain } => { + let sender = get_or_spawn_space_room_list(&mut space_room_list_tasks, &space_id, &parent_chain).await; + if sender.send(SpaceRoomListRequest::GetDetailedChildren).is_err() { + error!("BUG: failed to send GetDetailedChildren request to space room list loop for space {space_id}"); + } } - } - SpaceRequest::GetTopLevelSpaceDetails { space_id } => { - if let Some(space) = all_joined_spaces.iter().find(|s| s.room_id == space_id) { - Cx::post_action(SpaceRoomListAction::TopLevelSpaceDetails(space.clone())); - } else { - error!("GetSpaceDetails: space {space_id} not found in all_joined_spaces"); + SpaceRequest::GetTopLevelSpaceDetails { space_id } => { + if let Some(space) = all_joined_spaces.iter().find(|s| s.room_id == space_id) { + Cx::post_action(SpaceRoomListAction::TopLevelSpaceDetails(space.clone())); + } else { + error!("GetSpaceDetails: space {space_id} not found in all_joined_spaces"); + } } } } - } - // Handle updates to the list of spaces. - batch_opt = spaces_diff_stream.next() => { - let Some(batch) = batch_opt else { break }; - let mut peekable_diffs = batch.into_iter().peekable(); - while let Some(diff) = peekable_diffs.next() { - match diff { - VectorDiff::Append { values: new_spaces } => { - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Append {}", new_spaces.len()); } - for new_space in new_spaces { + // Handle updates to the list of spaces. + batch_opt = spaces_diff_stream.next() => { + let Some(batch) = batch_opt else { break }; + let mut peekable_diffs = batch.into_iter().peekable(); + while let Some(diff) = peekable_diffs.next() { + match diff { + VectorDiff::Append { values: new_spaces } => { + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Append {}", new_spaces.len()); } + for new_space in new_spaces { + add_new_space(&new_space, &client).await; + all_joined_spaces.push_back(new_space); + } + } + VectorDiff::Clear => { + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Clear"); } + all_joined_spaces.clear(); + enqueue_spaces_list_update(SpacesListUpdate::ClearSpaces); + } + VectorDiff::PushFront { value: new_space } => { + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff PushFront"); } + add_new_space(&new_space, &client).await; + all_joined_spaces.push_front(new_space); + } + VectorDiff::PushBack { value: new_space } => { + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff PushBack"); } add_new_space(&new_space, &client).await; all_joined_spaces.push_back(new_space); } - } - VectorDiff::Clear => { - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Clear"); } - all_joined_spaces.clear(); - enqueue_spaces_list_update(SpacesListUpdate::ClearSpaces); - } - VectorDiff::PushFront { value: new_space } => { - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff PushFront"); } - add_new_space(&new_space, &client).await; - all_joined_spaces.push_front(new_space); - } - VectorDiff::PushBack { value: new_space } => { - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff PushBack"); } - add_new_space(&new_space, &client).await; - all_joined_spaces.push_back(new_space); - } - remove_diff @ VectorDiff::PopFront => { - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff PopFront"); } - if let Some(space) = all_joined_spaces.pop_front() { - optimize_remove_then_add_into_update( - remove_diff, - space, - &mut peekable_diffs, - &mut all_joined_spaces, - &client, - ).await; + remove_diff @ VectorDiff::PopFront => { + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff PopFront"); } + if let Some(space) = all_joined_spaces.pop_front() { + optimize_remove_then_add_into_update( + remove_diff, + space, + &mut peekable_diffs, + &mut all_joined_spaces, + &client, + ).await; + } } - } - remove_diff @ VectorDiff::PopBack => { - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff PopBack"); } - if let Some(space) = all_joined_spaces.pop_back() { - optimize_remove_then_add_into_update( - remove_diff, - space, - &mut peekable_diffs, - &mut all_joined_spaces, - &client, - ).await; + remove_diff @ VectorDiff::PopBack => { + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff PopBack"); } + if let Some(space) = all_joined_spaces.pop_back() { + optimize_remove_then_add_into_update( + remove_diff, + space, + &mut peekable_diffs, + &mut all_joined_spaces, + &client, + ).await; + } } - } - VectorDiff::Insert { index, value: new_space } => { - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Insert at {index}"); } - add_new_space(&new_space, &client).await; - all_joined_spaces.insert(index, new_space); - } - VectorDiff::Set { index, value: changed_space } => { - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Set at {index}"); } - if let Some(old_space) = all_joined_spaces.get(index) { - update_space(old_space, &changed_space, &client).await; - } else { - error!("BUG: space_service diff: Set index {index} was out of bounds."); + VectorDiff::Insert { index, value: new_space } => { + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Insert at {index}"); } + add_new_space(&new_space, &client).await; + all_joined_spaces.insert(index, new_space); } - all_joined_spaces.set(index, changed_space); - } - remove_diff @ VectorDiff::Remove { index: remove_index } => { - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Remove at {remove_index}"); } - if remove_index < all_joined_spaces.len() { - let space = all_joined_spaces.remove(remove_index); - optimize_remove_then_add_into_update( - remove_diff, - space, - &mut peekable_diffs, - &mut all_joined_spaces, - &client, - ).await; - } else { - error!("BUG: space_service: diff Remove index {remove_index} out of bounds, len {}", all_joined_spaces.len()); + VectorDiff::Set { index, value: changed_space } => { + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Set at {index}"); } + if let Some(old_space) = all_joined_spaces.get(index) { + update_space(old_space, &changed_space, &client).await; + } else { + error!("BUG: space_service diff: Set index {index} was out of bounds."); + } + all_joined_spaces.set(index, changed_space); } - } - VectorDiff::Truncate { length } => { - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Truncate to {length}"); } - // Iterate manually so we can know which spaces are being removed. - while all_joined_spaces.len() > length { - if let Some(space) = all_joined_spaces.pop_back() { - remove_space(&space); + remove_diff @ VectorDiff::Remove { index: remove_index } => { + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Remove at {remove_index}"); } + if remove_index < all_joined_spaces.len() { + let space = all_joined_spaces.remove(remove_index); + optimize_remove_then_add_into_update( + remove_diff, + space, + &mut peekable_diffs, + &mut all_joined_spaces, + &client, + ).await; + } else { + error!("BUG: space_service: diff Remove index {remove_index} out of bounds, len {}", all_joined_spaces.len()); } } - all_joined_spaces.truncate(length); // sanity check - } - VectorDiff::Reset { values: new_spaces } => { - // We implement this by clearing all spaces and then adding back the new values. - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Reset, old length {}, new length {}", all_joined_spaces.len(), new_spaces.len()); } - // Iterate manually so we can know which spaces are being removed. - while let Some(space) = all_joined_spaces.pop_back() { - remove_space(&space); + VectorDiff::Truncate { length } => { + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Truncate to {length}"); } + // Iterate manually so we can know which spaces are being removed. + while all_joined_spaces.len() > length { + if let Some(space) = all_joined_spaces.pop_back() { + remove_space(&space); + } + } + all_joined_spaces.truncate(length); // sanity check } - enqueue_spaces_list_update(SpacesListUpdate::ClearSpaces); - for new_space in &new_spaces { - add_new_space(new_space, &client).await; + VectorDiff::Reset { values: new_spaces } => { + // We implement this by clearing all spaces and then adding back the new values. + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: diff Reset, old length {}, new length {}", all_joined_spaces.len(), new_spaces.len()); } + // Iterate manually so we can know which spaces are being removed. + while let Some(space) = all_joined_spaces.pop_back() { + remove_space(&space); + } + enqueue_spaces_list_update(SpacesListUpdate::ClearSpaces); + for new_space in &new_spaces { + add_new_space(new_space, &client).await; + } + all_joined_spaces = new_spaces; } - all_joined_spaces = new_spaces; } } + if LOG_SPACE_SERVICE_DIFFS { log!("space_service: after batch diff: {all_joined_spaces:?}"); } } - if LOG_SPACE_SERVICE_DIFFS { log!("space_service: after batch diff: {all_joined_spaces:?}"); } - } - else => { - break; + else => { + break; + } } - } } + } anyhow::bail!("Space service sync loop ended unexpectedly") } - async fn add_new_space(space: &SpaceRoom, client: &Client) { let space_avatar_opt = if let Some(url) = &space.avatar_url { fetch_space_avatar(url.clone(), client) .await - .inspect_err(|e| error!("Failed to fetch avatar for new space {:?} ({}): {e}", space.display_name, space.room_id)) + .inspect_err(|e| { + error!( + "Failed to fetch avatar for new space {:?} ({}): {e}", + space.display_name, space.room_id + ) + }) .ok() - } else { None }; - let space_avatar = space_avatar_opt.unwrap_or_else( - || utils::avatar_from_room_name(Some(&space.display_name)) - ); + } else { + None + }; + let space_avatar = + space_avatar_opt.unwrap_or_else(|| utils::avatar_from_room_name(Some(&space.display_name))); let jsi = JoinedSpaceInfo { space_name_id: RoomNameId::new( @@ -362,7 +383,6 @@ async fn add_new_space(space: &SpaceRoom, client: &Client) { enqueue_spaces_list_update(SpacesListUpdate::AddJoinedSpace(jsi)); } - /// Attempts to optimize a common SpaceService operation of remove + add. /// /// If a `Remove` diff (or `PopBack` or `PopFront`) is immediately followed by @@ -381,31 +401,37 @@ async fn optimize_remove_then_add_into_update( ) { let next_diff_was_handled: bool; match peekable_diffs.peek() { - Some(VectorDiff::Insert { index: insert_index, value: new_space }) - if space.room_id == new_space.room_id => - { + Some(VectorDiff::Insert { + index: insert_index, + value: new_space, + }) if space.room_id == new_space.room_id => { if LOG_SPACE_SERVICE_DIFFS { - log!("Optimizing {remove_diff:?} + Insert({insert_index}) into Update for space {}", space.room_id); + log!( + "Optimizing {remove_diff:?} + Insert({insert_index}) into Update for space {}", + space.room_id + ); } update_space(&space, new_space, client).await; all_joined_spaces.insert(*insert_index, new_space.clone()); next_diff_was_handled = true; } - Some(VectorDiff::PushFront { value: new_space }) - if space.room_id == new_space.room_id => - { + Some(VectorDiff::PushFront { value: new_space }) if space.room_id == new_space.room_id => { if LOG_SPACE_SERVICE_DIFFS { - log!("Optimizing {remove_diff:?} + PushFront into Update for space {}", space.room_id); + log!( + "Optimizing {remove_diff:?} + PushFront into Update for space {}", + space.room_id + ); } update_space(&space, new_space, client).await; all_joined_spaces.push_front(new_space.clone()); next_diff_was_handled = true; } - Some(VectorDiff::PushBack { value: new_space }) - if space.room_id == new_space.room_id => - { + Some(VectorDiff::PushBack { value: new_space }) if space.room_id == new_space.room_id => { if LOG_SPACE_SERVICE_DIFFS { - log!("Optimizing {remove_diff:?} + PushBack into Update for space {}", space.room_id); + log!( + "Optimizing {remove_diff:?} + PushBack into Update for space {}", + space.room_id + ); } update_space(&space, new_space, client).await; all_joined_spaces.push_back(new_space.clone()); @@ -420,29 +446,35 @@ async fn optimize_remove_then_add_into_update( } } - /// Invoked when the space service has received an update that changes an existing space. -async fn update_space( - old_space: &SpaceRoom, - new_space: &SpaceRoom, - client: &Client, -) { +async fn update_space(old_space: &SpaceRoom, new_space: &SpaceRoom, client: &Client) { let new_space_id = new_space.room_id.clone(); if old_space.room_id == new_space_id { // Handle state transitions for a space. if LOG_SPACE_SERVICE_DIFFS { - log!("Space {:?} ({new_space_id}) state went from {:?} --> {:?}", new_space.display_name, old_space.state, new_space.state); + log!( + "Space {:?} ({new_space_id}) state went from {:?} --> {:?}", + new_space.display_name, + old_space.state, + new_space.state + ); } if old_space.state != new_space.state { match new_space.state { Some(RoomState::Banned) => { // TODO: handle spaces that this user has been banned from. - log!("Removing Banned space: {:?} ({new_space_id})", new_space.display_name); + log!( + "Removing Banned space: {:?} ({new_space_id})", + new_space.display_name + ); remove_space(new_space); return; } Some(RoomState::Left) => { - log!("Removing Left space: {:?} ({new_space_id})", new_space.display_name); + log!( + "Removing Left space: {:?} ({new_space_id})", + new_space.display_name + ); // TODO: instead of removing this, we could optionally add it to // a separate list of left space, which would be collapsed by default. // Upon clicking a left space, we could show a splash page @@ -452,12 +484,18 @@ async fn update_space( return; } Some(RoomState::Joined) => { - log!("update_space(): adding new Joined space: {:?} ({new_space_id})", new_space.display_name); + log!( + "update_space(): adding new Joined space: {:?} ({new_space_id})", + new_space.display_name + ); add_new_space(new_space, client).await; return; } Some(RoomState::Invited) => { - log!("update_space(): adding new Invited space: {:?} ({new_space_id})", new_space.display_name); + log!( + "update_space(): adding new Invited space: {:?} ({new_space_id})", + new_space.display_name + ); add_new_space(new_space, client).await; return; } @@ -466,13 +504,21 @@ async fn update_space( return; } None => { - error!("WARNING: UNTESTED: new space {} ({}) RoomState is None", new_space.display_name, new_space.room_id); + error!( + "WARNING: UNTESTED: new space {} ({}) RoomState is None", + new_space.display_name, new_space.room_id + ); } } } if old_space.canonical_alias != new_space.canonical_alias { - log!("Updating space {} alias: {:?} --> {:?}", new_space_id, old_space.canonical_alias, new_space.canonical_alias); + log!( + "Updating space {} alias: {:?} --> {:?}", + new_space_id, + old_space.canonical_alias, + new_space.canonical_alias + ); enqueue_spaces_list_update(SpacesListUpdate::UpdateCanonicalAlias { space_id: new_space_id.clone(), new_canonical_alias: new_space.canonical_alias.clone(), @@ -480,7 +526,12 @@ async fn update_space( } if old_space.display_name != new_space.display_name { - log!("Updating space {} name: {:?} --> {:?}", new_space_id, old_space.display_name, new_space.display_name); + log!( + "Updating space {} name: {:?} --> {:?}", + new_space_id, + old_space.display_name, + new_space.display_name + ); enqueue_spaces_list_update(SpacesListUpdate::UpdateSpaceName { space_id: new_space_id.clone(), new_space_name: new_space.display_name.clone(), @@ -488,7 +539,12 @@ async fn update_space( } if old_space.topic != new_space.topic { - log!("Updating space {} topic:\n {:?}\n -->\n {:?}", new_space_id, old_space.topic, new_space.topic); + log!( + "Updating space {} topic:\n {:?}\n -->\n {:?}", + new_space_id, + old_space.topic, + new_space.topic + ); enqueue_spaces_list_update(SpacesListUpdate::UpdateSpaceTopic { space_id: new_space_id.clone(), topic: new_space.topic.clone(), @@ -507,18 +563,32 @@ async fn update_space( let space_avatar_opt = if let Some(url) = url_opt { fetch_space_avatar(url, &client2) .await - .inspect_err(|e| error!("Failed to fetch avatar for space {:?} ({}): {e}", space_display_name, space_id)) + .inspect_err(|e| { + error!( + "Failed to fetch avatar for space {:?} ({}): {e}", + space_display_name, space_id + ) + }) .ok() - } else { None }; - let avatar = space_avatar_opt.unwrap_or_else( - || utils::avatar_from_room_name(Some(&space_display_name)) - ); - enqueue_spaces_list_update(SpacesListUpdate::UpdateSpaceAvatar { space_id, avatar }); + } else { + None + }; + let avatar = space_avatar_opt + .unwrap_or_else(|| utils::avatar_from_room_name(Some(&space_display_name))); + enqueue_spaces_list_update(SpacesListUpdate::UpdateSpaceAvatar { + space_id, + avatar, + }); }); } if old_space.num_joined_members != new_space.num_joined_members { - log!("Updating space {} joined members: {} --> {}", new_space_id, old_space.num_joined_members, new_space.num_joined_members); + log!( + "Updating space {} joined members: {} --> {}", + new_space_id, + old_space.num_joined_members, + new_space.num_joined_members + ); enqueue_spaces_list_update(SpacesListUpdate::UpdateNumJoinedMembers { space_id: new_space_id.clone(), num_joined_members: new_space.num_joined_members, @@ -526,7 +596,12 @@ async fn update_space( } if old_space.join_rule != new_space.join_rule { - log!("Updating space {} join rule: {:?} --> {:?}", new_space_id, old_space.join_rule, new_space.join_rule); + log!( + "Updating space {} join rule: {:?} --> {:?}", + new_space_id, + old_space.join_rule, + new_space.join_rule + ); enqueue_spaces_list_update(SpacesListUpdate::UpdateJoinRule { space_id: new_space_id.clone(), join_rule: new_space.join_rule.clone(), @@ -534,7 +609,12 @@ async fn update_space( } if old_space.world_readable != new_space.world_readable { - log!("Updating space {} world readable: {:?} --> {:?}", new_space_id, old_space.world_readable, new_space.world_readable); + log!( + "Updating space {} world readable: {:?} --> {:?}", + new_space_id, + old_space.world_readable, + new_space.world_readable + ); enqueue_spaces_list_update(SpacesListUpdate::UpdateWorldReadable { space_id: new_space_id.clone(), world_readable: new_space.world_readable, @@ -542,7 +622,12 @@ async fn update_space( } if old_space.guest_can_join != new_space.guest_can_join { - log!("Updating space {} guest can join: {:?} --> {:?}", new_space_id, old_space.guest_can_join, new_space.guest_can_join); + log!( + "Updating space {} guest can join: {:?} --> {:?}", + new_space_id, + old_space.guest_can_join, + new_space.guest_can_join + ); enqueue_spaces_list_update(SpacesListUpdate::UpdateGuestCanJoin { space_id: new_space_id.clone(), guest_can_join: new_space.guest_can_join, @@ -550,23 +635,28 @@ async fn update_space( } if old_space.children_count != new_space.children_count { - log!("Updating space {} children count: {:?} --> {:?}", new_space_id, old_space.children_count, new_space.children_count); + log!( + "Updating space {} children count: {:?} --> {:?}", + new_space_id, + old_space.children_count, + new_space.children_count + ); enqueue_spaces_list_update(SpacesListUpdate::UpdateChildrenCount { space_id: new_space_id.clone(), children_count: new_space.children_count, }); } - } - else { - warning!("UNTESTED SCENARIO: update_space(): removing old room {}, replacing with new room {}", - old_space.room_id, new_space_id, + } else { + warning!( + "UNTESTED SCENARIO: update_space(): removing old room {}, replacing with new room {}", + old_space.room_id, + new_space_id, ); remove_space(old_space); add_new_space(new_space, client).await; } } - /// Invoked when the space service has received an update to remove an existing space. fn remove_space(space: &SpaceRoom) { enqueue_spaces_list_update(SpacesListUpdate::RemoveSpace { @@ -575,23 +665,24 @@ fn remove_space(space: &SpaceRoom) { }); } - /// Fetches the avatar for the space at the given URL. /// /// Returns `Some` if the avatar image was successfully fetched. -async fn fetch_space_avatar(url: OwnedMxcUri, client: &Client) -> matrix_sdk::Result { +async fn fetch_space_avatar( + url: OwnedMxcUri, + client: &Client, +) -> matrix_sdk::Result { let request = MediaRequestParameters { source: MediaSource::Plain(url), format: utils::AVATAR_THUMBNAIL_FORMAT.into(), }; - client.media() + client + .media() .get_media_content(&request, true) .await .map(|img_data| FetchedRoomAvatar::Image(img_data.into())) } - - /// Extension trait for `SpaceRoom` to provide utility methods. pub trait SpaceRoomExt { /// Returns true if this `SpaceRoom` is a space itself; @@ -605,8 +696,6 @@ impl SpaceRoomExt for SpaceRoom { } } - - /// A loop that listens for changes to the set of rooms in a given space. async fn space_room_list_loop( space_id: OwnedRoomId, @@ -628,87 +717,96 @@ async fn space_room_list_loop( }), }; - // First, we paginate the space once to get at least *some* child rooms. + // First, we paginate the space once to get at least *some* child rooms. paginate_once().await; // The set of subspaces within this `space_id` that are already known to us. let mut known_subspaces = HashSet::new(); - let (mut all_rooms_in_space, mut space_room_stream) = space_room_list.subscribe_to_room_updates(); - handle_subspaces(&space_id, &parent_chain, &mut known_subspaces, all_rooms_in_space.iter(), &request_sender); + let (mut all_rooms_in_space, mut space_room_stream) = + space_room_list.subscribe_to_room_updates(); + handle_subspaces( + &space_id, + &parent_chain, + &mut known_subspaces, + all_rooms_in_space.iter(), + &request_sender, + ); // A tuple of: the latest `(direct child rooms, and direct subspaces)` within this space. // This makes it very cheap & fast to repeatedly handle `GetChildren` requests. let mut cached_hash_sets = space_children_to_hash_sets(&all_rooms_in_space); - loop { tokio::select! { - // Handle new requests. - request_opt = receiver.recv() => { - let Some(request) = request_opt else { break }; - match request { - SpaceRoomListRequest::GetChildren => { - Cx::post_action(SpaceRoomListAction::UpdatedChildren { - space_id: space_id.clone(), - parent_chain: parent_chain.clone(), - direct_child_rooms: Arc::clone(&cached_hash_sets.0), - direct_subspaces: Arc::clone(&cached_hash_sets.1), - }); - } - SpaceRoomListRequest::GetDetailedChildren => { - Cx::post_action(SpaceRoomListAction::DetailedChildren { - space_id: space_id.clone(), - parent_chain: parent_chain.clone(), - // The `imbl::Vector` type is very cheap to clone here - // because we're not modifying it, so we just send that value directly. - children: all_rooms_in_space.clone(), - }); - } - SpaceRoomListRequest::Paginate => { - paginate_once().await; + loop { + tokio::select! { + // Handle new requests. + request_opt = receiver.recv() => { + let Some(request) = request_opt else { break }; + match request { + SpaceRoomListRequest::GetChildren => { + Cx::post_action(SpaceRoomListAction::UpdatedChildren { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + direct_child_rooms: Arc::clone(&cached_hash_sets.0), + direct_subspaces: Arc::clone(&cached_hash_sets.1), + }); + } + SpaceRoomListRequest::GetDetailedChildren => { + Cx::post_action(SpaceRoomListAction::DetailedChildren { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + // The `imbl::Vector` type is very cheap to clone here + // because we're not modifying it, so we just send that value directly. + children: all_rooms_in_space.clone(), + }); + } + SpaceRoomListRequest::Paginate => { + paginate_once().await; + } + SpaceRoomListRequest::Shutdown => return, } - SpaceRoomListRequest::Shutdown => return, } - } - // Handle updates to the list of rooms and subspaces in this space. - batch_opt = space_room_stream.next() => { - let Some(batch) = batch_opt else { break }; - for diff in batch { - // Manually inspect any diff that could result in new space room(s), - // such that we can check to see if any of them are nested subspaces. - match &diff { - VectorDiff::Append { values } - | VectorDiff::Reset { values } => handle_subspaces( - &space_id, - &parent_chain, - &mut known_subspaces, - values.iter(), - &request_sender, - ), - VectorDiff::PushFront { value } - | VectorDiff::PushBack { value } - | VectorDiff::Insert { value, .. } - | VectorDiff::Set { value, .. } => handle_subspaces( - &space_id, - &parent_chain, - &mut known_subspaces, - std::iter::once(value), - &request_sender, - ), - _ => { } - }; - diff.apply(&mut all_rooms_in_space); + // Handle updates to the list of rooms and subspaces in this space. + batch_opt = space_room_stream.next() => { + let Some(batch) = batch_opt else { break }; + for diff in batch { + // Manually inspect any diff that could result in new space room(s), + // such that we can check to see if any of them are nested subspaces. + match &diff { + VectorDiff::Append { values } + | VectorDiff::Reset { values } => handle_subspaces( + &space_id, + &parent_chain, + &mut known_subspaces, + values.iter(), + &request_sender, + ), + VectorDiff::PushFront { value } + | VectorDiff::PushBack { value } + | VectorDiff::Insert { value, .. } + | VectorDiff::Set { value, .. } => handle_subspaces( + &space_id, + &parent_chain, + &mut known_subspaces, + std::iter::once(value), + &request_sender, + ), + _ => { } + }; + diff.apply(&mut all_rooms_in_space); + } + // Here: children have changed, so we re-calculate the sets of child rooms and subspaces. + cached_hash_sets = space_children_to_hash_sets(&all_rooms_in_space); + Cx::post_action(SpaceRoomListAction::UpdatedChildren { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + direct_child_rooms: Arc::clone(&cached_hash_sets.0), + direct_subspaces: Arc::clone(&cached_hash_sets.1), + }); } - // Here: children have changed, so we re-calculate the sets of child rooms and subspaces. - cached_hash_sets = space_children_to_hash_sets(&all_rooms_in_space); - Cx::post_action(SpaceRoomListAction::UpdatedChildren { - space_id: space_id.clone(), - parent_chain: parent_chain.clone(), - direct_child_rooms: Arc::clone(&cached_hash_sets.0), - direct_subspaces: Arc::clone(&cached_hash_sets.1), - }); } - } } + } } /// Finds nested/subspaces within a list of space rooms and submits a request @@ -720,7 +818,7 @@ fn handle_subspaces<'a>( changed_space_rooms: impl Iterator, request_sender: &UnboundedSender, ) { - for sr in changed_space_rooms.filter(|&sr| sr.is_space()) { + for sr in changed_space_rooms.filter(|&sr| sr.is_space()) { if known_subspaces.contains(&sr.room_id) { continue; } @@ -732,11 +830,17 @@ fn handle_subspaces<'a>( npc.push(parent_space_id.clone()); npc }; - if request_sender.send(SpaceRequest::SubscribeToSpaceRoomList { - space_id: sr.room_id.clone(), - parent_chain: new_parent_chain, - }).is_err() { - error!("BUG: failed to send subscribe request to nested/subspace {}.", sr.room_id); + if request_sender + .send(SpaceRequest::SubscribeToSpaceRoomList { + space_id: sr.room_id.clone(), + parent_chain: new_parent_chain, + }) + .is_err() + { + error!( + "BUG: failed to send subscribe request to nested/subspace {}.", + sr.room_id + ); } } } @@ -745,7 +849,7 @@ fn handle_subspaces<'a>( /// 1. the set of child rooms directly within this space. /// 2. the set of subspaces directly within this space. fn space_children_to_hash_sets( - all_rooms_in_space: &Vector + all_rooms_in_space: &Vector, ) -> (Arc>, Arc>) { let mut direct_child_rooms = HashSet::new(); let mut direct_subspaces = HashSet::new(); @@ -807,45 +911,55 @@ pub enum SpaceRoomListAction { impl std::fmt::Debug for SpaceRoomListAction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - SpaceRoomListAction::UpdatedChildren { space_id, parent_chain, direct_child_rooms, direct_subspaces } => { - f.debug_struct("SpaceRoomListAction::UpdatedChildren") - .field("space_id", space_id) - .field("parent_chain", &parent_chain) - .field("num_direct_child_rooms", &direct_child_rooms.len()) - .field("num_direct_subspaces", &direct_subspaces.len()) - .finish() - } - SpaceRoomListAction::PaginationState { space_id, parent_chain, state } => { - f.debug_struct("SpaceRoomListAction::PaginationState") - .field("space_id", space_id) - .field("parent_chain", &parent_chain) - .field("state", state) - .finish() - } - SpaceRoomListAction::PaginationError { space_id, error } => { - f.debug_struct("SpaceRoomListAction::PaginationError") - .field("space_id", space_id) - .field("error", error) - .finish() - } - SpaceRoomListAction::DetailedChildren { space_id, parent_chain, children } => { - f.debug_struct("SpaceRoomListAction::DetailedChildren") - .field("space_id", space_id) - .field("parent_chain", &parent_chain) - .field("num_children", &children.len()) - .finish() - } - SpaceRoomListAction::TopLevelSpaceDetails(space) => { - f.debug_tuple("SpaceRoomListAction::TopLevelSpaceDetails") - .field(space) - .finish() - } - SpaceRoomListAction::LeaveSpaceResult { space_name_id, result } => { - f.debug_struct("SpaceRoomListAction::LeaveSpaceResult") - .field("space_name_id", space_name_id) - .field("result", result) - .finish() - } + SpaceRoomListAction::UpdatedChildren { + space_id, + parent_chain, + direct_child_rooms, + direct_subspaces, + } => f + .debug_struct("SpaceRoomListAction::UpdatedChildren") + .field("space_id", space_id) + .field("parent_chain", &parent_chain) + .field("num_direct_child_rooms", &direct_child_rooms.len()) + .field("num_direct_subspaces", &direct_subspaces.len()) + .finish(), + SpaceRoomListAction::PaginationState { + space_id, + parent_chain, + state, + } => f + .debug_struct("SpaceRoomListAction::PaginationState") + .field("space_id", space_id) + .field("parent_chain", &parent_chain) + .field("state", state) + .finish(), + SpaceRoomListAction::PaginationError { space_id, error } => f + .debug_struct("SpaceRoomListAction::PaginationError") + .field("space_id", space_id) + .field("error", error) + .finish(), + SpaceRoomListAction::DetailedChildren { + space_id, + parent_chain, + children, + } => f + .debug_struct("SpaceRoomListAction::DetailedChildren") + .field("space_id", space_id) + .field("parent_chain", &parent_chain) + .field("num_children", &children.len()) + .finish(), + SpaceRoomListAction::TopLevelSpaceDetails(space) => f + .debug_tuple("SpaceRoomListAction::TopLevelSpaceDetails") + .field(space) + .finish(), + SpaceRoomListAction::LeaveSpaceResult { + space_name_id, + result, + } => f + .debug_struct("SpaceRoomListAction::LeaveSpaceResult") + .field("space_name_id", space_name_id) + .field("result", result) + .finish(), } } } diff --git a/src/temp_storage.rs b/src/temp_storage.rs index 9142020c6..37c232c07 100644 --- a/src/temp_storage.rs +++ b/src/temp_storage.rs @@ -1,6 +1,5 @@ use std::{sync::OnceLock, path::PathBuf}; - /// Creates and returns the path to a temp directory for storage. /// /// This is very efficient to call multiple times because the result is cached @@ -16,4 +15,3 @@ pub fn get_temp_dir_path() -> &'static PathBuf { path }) } - diff --git a/src/tsp/create_did_modal.rs b/src/tsp/create_did_modal.rs index f51e8bccb..abb722faf 100644 --- a/src/tsp/create_did_modal.rs +++ b/src/tsp/create_did_modal.rs @@ -4,7 +4,6 @@ use makepad_widgets::*; use crate::tsp; - script_mod! { link tsp_enabled @@ -249,12 +248,14 @@ enum CreateDidModalState { IdentityCreationError, } - #[derive(Script, ScriptHook, Widget)] pub struct CreateDidModal { - #[deref] view: View, - #[rust] state: CreateDidModalState, - #[rust] is_showing_error: bool, + #[deref] + view: View, + #[rust] + state: CreateDidModalState, + #[rust] + is_showing_error: bool, } impl Widget for CreateDidModal { @@ -275,8 +276,10 @@ impl WidgetMatchEvent for CreateDidModal { // Handle canceling/closing the modal. let cancel_clicked = cancel_button.clicked(actions); - if cancel_clicked || - actions.iter().any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) + if cancel_clicked + || actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) { // If the modal was dismissed by clicking outside of it, we MUST NOT emit // a `CreateDidModalAction::Close` action, as that would cause @@ -338,7 +341,7 @@ impl WidgetMatchEvent for CreateDidModal { username: username.to_string(), alias, server, - did_server + did_server, }); self.state = CreateDidModalState::WaitingForIdentityCreation; @@ -360,11 +363,10 @@ impl WidgetMatchEvent for CreateDidModal { needs_redraw = true; } - _ => { } + _ => {} } } - // If the user changes any of the input fields, clear the error message // and reset the accept button to its default state. if self.is_showing_error { @@ -389,7 +391,7 @@ impl WidgetMatchEvent for CreateDidModal { for action in actions { match action.downcast_ref() { - Some(tsp::TspIdentityAction::DidCreationResult(Ok(did)))=> { + Some(tsp::TspIdentityAction::DidCreationResult(Ok(did))) => { self.state = CreateDidModalState::IdentityCreated; self.is_showing_error = false; let message = format!("Successfully created and published DID: \"{}\"", did); @@ -418,7 +420,7 @@ impl WidgetMatchEvent for CreateDidModal { // Upon an error, update the status label and disable the accept button. // Re-enable the input fields so the user can change the input values to try again. - Some(tsp::TspIdentityAction::DidCreationResult(Err(e)))=> { + Some(tsp::TspIdentityAction::DidCreationResult(Err(e))) => { self.state = CreateDidModalState::IdentityCreationError; self.is_showing_error = true; let message = format!("Failed to create DID: {e}"); @@ -437,10 +439,10 @@ impl WidgetMatchEvent for CreateDidModal { needs_redraw = true; } - _ => { } + _ => {} } } - + if needs_redraw { self.view.redraw(cx); } @@ -461,19 +463,29 @@ impl CreateDidModal { accept_button.set_visible(cx, true); cancel_button.set_visible(cx, true); // TODO: return buttons to their default state/appearance - self.view.text_input(cx, ids!(username_input)).set_is_read_only(cx, false); - self.view.text_input(cx, ids!(alias_input)).set_is_read_only(cx, false); - self.view.text_input(cx, ids!(server_input)).set_is_read_only(cx, false); - self.view.text_input(cx, ids!(did_server_input)).set_is_read_only(cx, false); + self.view + .text_input(cx, ids!(username_input)) + .set_is_read_only(cx, false); + self.view + .text_input(cx, ids!(alias_input)) + .set_is_read_only(cx, false); + self.view + .text_input(cx, ids!(server_input)) + .set_is_read_only(cx, false); + self.view + .text_input(cx, ids!(did_server_input)) + .set_is_read_only(cx, false); self.view.label(cx, ids!(status_label)).set_text(cx, ""); self.is_showing_error = false; - self.view.redraw(cx); + self.view.redraw(cx); } } impl CreateDidModalRef { pub fn show(&self, cx: &mut Cx) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show(cx); } } diff --git a/src/tsp/create_wallet_modal.rs b/src/tsp/create_wallet_modal.rs index 79c477597..a64e71288 100644 --- a/src/tsp/create_wallet_modal.rs +++ b/src/tsp/create_wallet_modal.rs @@ -4,7 +4,6 @@ use makepad_widgets::*; use crate::tsp::{self, TspWalletMetadata}; - script_mod! { link tsp_enabled @@ -201,12 +200,14 @@ enum CreateWalletModalState { WalletCreationError, } - #[derive(Script, ScriptHook, Widget)] pub struct CreateWalletModal { - #[deref] view: View, - #[rust] state: CreateWalletModalState, - #[rust] is_showing_error: bool, + #[deref] + view: View, + #[rust] + state: CreateWalletModalState, + #[rust] + is_showing_error: bool, } impl Widget for CreateWalletModal { @@ -227,8 +228,10 @@ impl WidgetMatchEvent for CreateWalletModal { // Handle canceling/closing the modal. let cancel_clicked = cancel_button.clicked(actions); - if cancel_clicked || - actions.iter().any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) + if cancel_clicked + || actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) { // If the modal was dismissed by clicking outside of it, we MUST NOT emit // a `CreateWalletModalAction::Close` action, as that would cause @@ -294,7 +297,7 @@ impl WidgetMatchEvent for CreateWalletModal { empty if empty.is_empty() => wallet_file_name_input.empty_text(), non_empty => tsp::sanitize_wallet_name(&non_empty), } - .as_str() + .as_str(), ); let metadata = TspWalletMetadata { wallet_name, @@ -322,11 +325,10 @@ impl WidgetMatchEvent for CreateWalletModal { needs_redraw = true; } - _ => { } + _ => {} } } - // Clear the error message if the user changes any of the input fields. if self.is_showing_error { if wallet_name_input.changed(actions).is_some() @@ -357,11 +359,17 @@ impl WidgetMatchEvent for CreateWalletModal { for action in actions { match action.downcast_ref() { // Handle the wallet creation success action. - Some(tsp::TspWalletAction::CreateWalletSuccess { metadata, is_default }) => { + Some(tsp::TspWalletAction::CreateWalletSuccess { + metadata, + is_default, + }) => { self.state = CreateWalletModalState::WalletCreated; self.is_showing_error = false; let message = if *is_default { - format!("Wallet \"{}\" created successfully and set as the default.", metadata.wallet_name) + format!( + "Wallet \"{}\" created successfully and set as the default.", + metadata.wallet_name + ) } else { format!("Wallet \"{}\" created successfully.", metadata.wallet_name) }; @@ -406,10 +414,10 @@ impl WidgetMatchEvent for CreateWalletModal { confirm_password_input.set_is_read_only(cx, false); } - _ => { } + _ => {} } } - + if needs_redraw { self.view.redraw(cx); } @@ -430,19 +438,29 @@ impl CreateWalletModal { accept_button.set_visible(cx, true); cancel_button.set_visible(cx, true); // TODO: return buttons to their default state/appearance - self.view.text_input(cx, ids!(wallet_name_input)).set_is_read_only(cx, false); - self.view.text_input(cx, ids!(wallet_file_name_input)).set_is_read_only(cx, false); - self.view.text_input(cx, ids!(password_input)).set_is_read_only(cx, false); - self.view.text_input(cx, ids!(confirm_password_input)).set_is_read_only(cx, false); + self.view + .text_input(cx, ids!(wallet_name_input)) + .set_is_read_only(cx, false); + self.view + .text_input(cx, ids!(wallet_file_name_input)) + .set_is_read_only(cx, false); + self.view + .text_input(cx, ids!(password_input)) + .set_is_read_only(cx, false); + self.view + .text_input(cx, ids!(confirm_password_input)) + .set_is_read_only(cx, false); self.view.label(cx, ids!(status_label)).set_text(cx, ""); self.is_showing_error = false; - self.view.redraw(cx); + self.view.redraw(cx); } } impl CreateWalletModalRef { pub fn show(&self, cx: &mut Cx) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show(cx); } } diff --git a/src/tsp/mod.rs b/src/tsp/mod.rs index 17335d889..54c07f418 100644 --- a/src/tsp/mod.rs +++ b/src/tsp/mod.rs @@ -1,4 +1,10 @@ -use std::{borrow::Cow, collections::BTreeMap, ops::Deref, path::Path, sync::{Arc, Mutex, OnceLock}}; +use std::{ + borrow::Cow, + collections::BTreeMap, + ops::Deref, + path::Path, + sync::{Arc, Mutex, OnceLock}, +}; use anyhow::anyhow; use futures_util::StreamExt; @@ -6,12 +12,28 @@ use makepad_widgets::*; use matrix_sdk::ruma::{OwnedUserId, UserId}; use quinn::rustls::crypto::{CryptoProvider, aws_lc_rs}; use serde::{Deserialize, Serialize}; -use tokio::{task::JoinHandle, runtime::Handle, sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}}; -use tsp_sdk::{definitions::{PublicKeyData, PublicVerificationKeyData, VidEncryptionKeyType, VidSignatureKeyType}, vid::{verify_vid, VidError}, AskarSecureStorage, AsyncSecureStore, OwnedVid, ReceivedTspMessage, SecureStorage, VerifiedVid, Vid}; +use tokio::{ + task::JoinHandle, + runtime::Handle, + sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, +}; +use tsp_sdk::{ + definitions::{ + PublicKeyData, PublicVerificationKeyData, VidEncryptionKeyType, VidSignatureKeyType, + }, + vid::{verify_vid, VidError}, + AskarSecureStorage, AsyncSecureStore, OwnedVid, ReceivedTspMessage, SecureStorage, VerifiedVid, + Vid, +}; use url::Url; -use crate::{persistence::{self, tsp_wallets_dir, SavedTspState}, shared::popup_list::{enqueue_popup_notification, PopupKind}, sliding_sync::current_user_id, tsp::tsp_verification_modal::TspVerificationModalAction, utils::DebugWrapper}; - +use crate::{ + persistence::{self, tsp_wallets_dir, SavedTspState}, + shared::popup_list::{enqueue_popup_notification, PopupKind}, + sliding_sync::current_user_id, + tsp::tsp_verification_modal::TspVerificationModalAction, + utils::DebugWrapper, +}; pub mod create_did_modal; pub mod create_wallet_modal; @@ -68,7 +90,6 @@ struct ReceiveLoopTask { sender: UnboundedSender, } - /// The global singleton TSP state, storing all known TSP wallets. static TSP_STATE: OnceLock> = OnceLock::new(); pub fn tsp_state_ref() -> &'static Mutex { @@ -143,21 +164,29 @@ impl TspState { log!("Restored current local VID {saved_local_vid} from in default wallet."); current_local_vid = Some(saved_local_vid); } else { - warning!("Previously-saved local VID {saved_local_vid} was not found in default wallet."); + warning!( + "Previously-saved local VID {saved_local_vid} was not found in default wallet." + ); enqueue_popup_notification( - format!("Previously-saved local VID \"{saved_local_vid}\" \ + format!( + "Previously-saved local VID \"{saved_local_vid}\" \ was not found in default wallet.\n\n\ - Please select a default wallet and then a new default VID."), - PopupKind::Warning, - None, + Please select a default wallet and then a new default VID." + ), + PopupKind::Warning, + None, ); } } else { - warning!("Found a previously-saved local VID {saved_local_vid}, but not the default wallet that contained it."); + warning!( + "Found a previously-saved local VID {saved_local_vid}, but not the default wallet that contained it." + ); enqueue_popup_notification( - format!("Found a previously-saved local VID \"{saved_local_vid}\", \ + format!( + "Found a previously-saved local VID \"{saved_local_vid}\", \ but not the default wallet that contained it.\n\n\ - Please select or create a default wallet and a new default VID."), + Please select or create a default wallet and a new default VID." + ), PopupKind::Warning, None, ); @@ -185,7 +214,7 @@ impl TspState { pub async fn close_and_serialize(self) -> Result { let mut default_wallet = None; let mut wallets = Vec::::with_capacity( - self.current_wallet.is_some() as usize + self.other_wallets.len() + self.current_wallet.is_some() as usize + self.other_wallets.len(), ); if let Some(current_wallet) = self.current_wallet { @@ -216,12 +245,10 @@ impl TspState { /// Returns the verified VID for a given Matrix user ID, if the association exists /// and the user's associated DID is in the current default wallet. - pub fn get_verified_vid_for( - &self, - user_id: &UserId, - ) -> Option> { + pub fn get_verified_vid_for(&self, user_id: &UserId) -> Option> { let did = self.get_associated_did(user_id)?; - self.current_wallet.as_ref()? + self.current_wallet + .as_ref()? .db .as_store() .get_verified_vid(did) @@ -242,12 +269,17 @@ impl TspState { } let (sender, receiver) = unbounded_channel::(); - let join_handle = rt_handle.spawn( - receive_messages_for_vid(wallet_db.clone(), vid.to_string(), receiver) - ); + let join_handle = rt_handle.spawn(receive_messages_for_vid( + wallet_db.clone(), + vid.to_string(), + receiver, + )); let old = self.receive_loop_tasks.insert( vid.to_string(), - ReceiveLoopTask { join_handle, sender: sender.clone() } + ReceiveLoopTask { + join_handle, + sender: sender.clone(), + }, ); if let Some(old) = old { warning!("BUG: aborting previous receive loop for VID \"{}\".", vid); @@ -257,7 +289,6 @@ impl TspState { } } - /// A TSP wallet entry known to Robrix. Derefs to `TspWalletMetadata`. #[derive(Debug)] pub enum TspWalletEntry { @@ -284,7 +315,6 @@ impl TspWalletEntry { } } - /// A TSP wallet that exists and is currently opened / ready to use. pub struct OpenedTspWallet { pub vault: AskarSecureStorage, @@ -378,7 +408,9 @@ pub fn tsp_init(rt_handle: tokio::runtime::Handle) -> anyhow::Result<()> { // Create a channel to be used between UI thread(s) and the TSP async worker thread. // We do this early on in order to allow TSP init routines to submit requests. let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); - TSP_REQUEST_SENDER.set(sender).expect("BUG: TSP_REQUEST_SENDER already set!"); + TSP_REQUEST_SENDER + .set(sender) + .expect("BUG: TSP_REQUEST_SENDER already set!"); // Start a high-level async task that will start and monitor all other tasks. let _monitor = rt_handle.spawn(async move { @@ -445,7 +477,6 @@ pub fn tsp_init(rt_handle: tokio::runtime::Handle) -> anyhow::Result<()> { Ok(()) } - async fn inner_tsp_init() -> anyhow::Result<()> { // Load the TSP state from persistent storage. let saved_tsp_state = persistence::load_tsp_state().await?; @@ -460,21 +491,17 @@ async fn inner_tsp_init() -> anyhow::Result<()> { } // If there is a private VID and a current wallet, spawn a receive loop // to listen for incoming messages for that private VID. - if let (Some(private_vid), Some(cw)) = - (new_tsp_state.current_local_vid.clone(), new_tsp_state.current_wallet.as_ref()) - { + if let (Some(private_vid), Some(cw)) = ( + new_tsp_state.current_local_vid.clone(), + new_tsp_state.current_wallet.as_ref(), + ) { log!("Starting receive loop for private VID \"{}\".", private_vid); - new_tsp_state.get_or_spawn_receive_loop( - Handle::current(), - &cw.db.clone(), - &private_vid, - ); + new_tsp_state.get_or_spawn_receive_loop(Handle::current(), &cw.db.clone(), &private_vid); } *tsp_state_ref().lock().unwrap() = new_tsp_state; Ok(()) } - /// Actions related to TSP wallets. #[derive(Debug)] pub enum TspWalletAction { @@ -514,10 +541,7 @@ pub enum TspIdentityAction { /// with their Matrix user ID. /// /// This does *NOT* mean that the response has been received yet. - SentDidAssociationRequest { - did: String, - user_id: OwnedUserId, - }, + SentDidAssociationRequest { did: String, user_id: OwnedUserId }, /// An error occurred while sending the request to associate another /// user's DID with their Matrix user ID. ErrorSendingDidAssociationRequest { @@ -548,21 +572,16 @@ pub enum TspIdentityAction { }, } - /// Requests that can be sent to the TSP async worker thread. pub enum TspRequest { /// Request to create a new TSP wallet. - CreateWallet { - metadata: TspWalletMetadata, - }, + CreateWallet { metadata: TspWalletMetadata }, /// Request to open an existing TSP wallet. /// /// This does not modify the current active/default wallet. /// If the wallet exists in the list of other wallets, it will be opened in-place, /// otherwise it will be opened and added to the end of the other wallets list. - OpenWallet { - metadata: TspWalletMetadata, - }, + OpenWallet { metadata: TspWalletMetadata }, /// Request to set an existing open wallet as the default. SetDefaultWallet(TspWalletMetadata), /// Request to remove a TSP wallet from the list without deleting it. @@ -580,18 +599,13 @@ pub enum TspRequest { /// Request to re-publish/re-upload our own DID back up to the DID server. /// /// The given `did` must already exist in the current default wallet. - RepublishDid { - did: String, - }, + RepublishDid { did: String }, /// Request to associate another user's identity (DID) with their Matrix User ID. /// /// This will verify the DID and store it in the current default wallet /// (using their Matrix User ID as the alias for that new verified ID), /// and then send a verification/relationship request to that new verified ID. - AssociateDidWithUserId { - did: String, - user_id: OwnedUserId, - }, + AssociateDidWithUserId { did: String, user_id: OwnedUserId }, /// Request to respond to a previously-received `DidAssociationRequest`. RespondToDidAssociationRequest { details: TspVerificationDetails, @@ -603,14 +617,12 @@ pub enum TspRequest { // CancelAssociateDidRequest(TspVerificationDetails), } - fn create_reqwest_client() -> reqwest::Result { reqwest::ClientBuilder::new() .user_agent(format!("Robrix v{}", env!("CARGO_PKG_VERSION"))) .build() } - /// The entry point for an async worker thread that processes TSP-related async tasks. /// /// All this task does is wait for [`TspRequests`] from other threads @@ -623,218 +635,266 @@ async fn async_tsp_worker( // Allow lazy initialization of the reqwest client. let mut __reqwest_client = None; let mut get_reqwest_client = || { - __reqwest_client.get_or_insert_with(|| create_reqwest_client().unwrap()).clone() + __reqwest_client + .get_or_insert_with(|| create_reqwest_client().unwrap()) + .clone() }; - while let Some(req) = request_receiver.recv().await { match req { - TspRequest::CreateWallet { metadata } => { - log!("Received TspRequest::CreateWallet({metadata:?})"); - Handle::current().spawn(async move { - if let Some(sqlite_path) = metadata.url.get_path() { - if let Ok(true) = tokio::fs::try_exists(sqlite_path).await { - error!("Wallet already exists at path: {}", sqlite_path.display()); - Cx::post_action(TspWalletAction::CreateWalletError { - metadata: metadata.clone(), - error: anyhow!("Wallet already exists at path: {}", sqlite_path.display()), - }); - return; - } - if let Some(parent_dir) = sqlite_path.parent() { - log!("Ensuring that new wallet's parent dir exists: {}", parent_dir.display()); - if let Err(e) = tokio::fs::create_dir_all(parent_dir).await { - error!("Failed to create directory to hold new wallet: {e:?}"); + while let Some(req) = request_receiver.recv().await { + match req { + TspRequest::CreateWallet { metadata } => { + log!("Received TspRequest::CreateWallet({metadata:?})"); + Handle::current().spawn(async move { + if let Some(sqlite_path) = metadata.url.get_path() { + if let Ok(true) = tokio::fs::try_exists(sqlite_path).await { + error!("Wallet already exists at path: {}", sqlite_path.display()); Cx::post_action(TspWalletAction::CreateWalletError { metadata: metadata.clone(), - error: anyhow!("Failed to create directory for new wallet: {}, error: {}", parent_dir.display(), e), + error: anyhow!( + "Wallet already exists at path: {}", + sqlite_path.display() + ), }); return; } - } - } - let encoded_url = metadata.url.to_url_encoded(); - log!("Attempting to create new wallet at:\n Reg: {}\n Enc: {}", metadata.url, encoded_url); - match AskarSecureStorage::new(&encoded_url, metadata.password.as_bytes()).await { - Ok(vault) => { - log!("Successfully created new wallet: {metadata:?}"); - let db = AsyncSecureStore::new(); - let mut tsp_state = tsp_state_ref().lock().unwrap(); - let opened_wallet = OpenedTspWallet { - vault, - db, - metadata: metadata.clone(), - }; - let is_default: bool; - if tsp_state.current_wallet.is_none() { - tsp_state.current_wallet = Some(opened_wallet); - is_default = true; - } else { - tsp_state.other_wallets.push(TspWalletEntry::Opened(opened_wallet)); - is_default = false; + if let Some(parent_dir) = sqlite_path.parent() { + log!( + "Ensuring that new wallet's parent dir exists: {}", + parent_dir.display() + ); + if let Err(e) = tokio::fs::create_dir_all(parent_dir).await { + error!("Failed to create directory to hold new wallet: {e:?}"); + Cx::post_action(TspWalletAction::CreateWalletError { + metadata: metadata.clone(), + error: anyhow!( + "Failed to create directory for new wallet: {}, error: {}", + parent_dir.display(), + e + ), + }); + return; + } } - Cx::post_action( - TspWalletAction::CreateWalletSuccess { + } + let encoded_url = metadata.url.to_url_encoded(); + log!( + "Attempting to create new wallet at:\n Reg: {}\n Enc: {}", + metadata.url, + encoded_url + ); + match AskarSecureStorage::new(&encoded_url, metadata.password.as_bytes()).await + { + Ok(vault) => { + log!("Successfully created new wallet: {metadata:?}"); + let db = AsyncSecureStore::new(); + let mut tsp_state = tsp_state_ref().lock().unwrap(); + let opened_wallet = OpenedTspWallet { + vault, + db, + metadata: metadata.clone(), + }; + let is_default: bool; + if tsp_state.current_wallet.is_none() { + tsp_state.current_wallet = Some(opened_wallet); + is_default = true; + } else { + tsp_state + .other_wallets + .push(TspWalletEntry::Opened(opened_wallet)); + is_default = false; + } + Cx::post_action(TspWalletAction::CreateWalletSuccess { metadata, is_default, - } - ); - } - Err(error) => { - error!("Failed to create new wallet: {error:?}"); - Cx::post_action( - TspWalletAction::CreateWalletError { + }); + } + Err(error) => { + error!("Failed to create new wallet: {error:?}"); + Cx::post_action(TspWalletAction::CreateWalletError { metadata: metadata.clone(), error: error.into(), - } - ); + }); + } } - } - }); - } - - TspRequest::SetDefaultWallet(metadata) => { - log!("Received TspRequest::SetDefaultWallet({metadata:?})"); - match tsp_state_ref().lock().unwrap().current_wallet.as_ref() { - Some(cw) if cw.metadata == metadata => { - log!("Wallet was already set as default: {metadata:?}"); - continue; - } - _ => {} + }); } - // If the new default wallet exists and is already opened, set it as default. - Handle::current().spawn(async move { - let mut result = Err(()); - let mut tsp_state = tsp_state_ref().lock().unwrap(); - if let Some(TspWalletEntry::Opened(opened)) = tsp_state.other_wallets.iter() - .position(|w| match w { - TspWalletEntry::Opened(opened) => opened.metadata == metadata, - _ => false, - }) - .map(|idx| tsp_state.other_wallets.remove(idx)) - { - let prev_opt = tsp_state.current_wallet.replace(opened); - if let Some(previous_active) = prev_opt { - tsp_state.other_wallets.insert(0, TspWalletEntry::Opened(previous_active)); + TspRequest::SetDefaultWallet(metadata) => { + log!("Received TspRequest::SetDefaultWallet({metadata:?})"); + match tsp_state_ref().lock().unwrap().current_wallet.as_ref() { + Some(cw) if cw.metadata == metadata => { + log!("Wallet was already set as default: {metadata:?}"); + continue; } - result = Ok(metadata); + _ => {} } - Cx::post_action(TspWalletAction::DefaultWalletChanged(result)); - }); - } - TspRequest::OpenWallet { metadata } => { - log!("Received TspRequest::OpenWallet({metadata:?})"); - Handle::current().spawn(async move { - let result = match metadata.open_wallet().await { - Ok(opened_wallet) => { - log!("Successfully opened wallet: {metadata:?}"); - let mut tsp_state = tsp_state_ref().lock().unwrap(); - // If the newly-opened wallet exists in the other wallets list, - // convert it into an opened wallet in-place. - // Otherwise, add it to the end of the other wallet list - if let Some(w) = tsp_state.other_wallets.iter_mut().find(|w| w.metadata() == &metadata) { - *w = TspWalletEntry::Opened(opened_wallet); - } else { - tsp_state.other_wallets.push(TspWalletEntry::Opened(opened_wallet)); + // If the new default wallet exists and is already opened, set it as default. + Handle::current().spawn(async move { + let mut result = Err(()); + let mut tsp_state = tsp_state_ref().lock().unwrap(); + if let Some(TspWalletEntry::Opened(opened)) = tsp_state + .other_wallets + .iter() + .position(|w| match w { + TspWalletEntry::Opened(opened) => opened.metadata == metadata, + _ => false, + }) + .map(|idx| tsp_state.other_wallets.remove(idx)) + { + let prev_opt = tsp_state.current_wallet.replace(opened); + if let Some(previous_active) = prev_opt { + tsp_state + .other_wallets + .insert(0, TspWalletEntry::Opened(previous_active)); } - Ok(metadata) - } - Err(error) => { - error!("Error opening wallet {metadata:?}: {error:?}"); - Err(error) + result = Ok(metadata); } - }; - Cx::post_action(TspWalletAction::WalletOpened(result)); - }); - } + Cx::post_action(TspWalletAction::DefaultWalletChanged(result)); + }); + } - TspRequest::RemoveWallet(metadata) => { - log!("Received TspRequest::RemoveWallet({metadata:?})"); - Handle::current().spawn(async move { - let mut tsp_state = tsp_state_ref().lock().unwrap(); - let was_default = if tsp_state.current_wallet.as_ref().is_some_and(|cw| cw.metadata == metadata) { - tsp_state.current_wallet = None; - true - } - else if let Some(i) = tsp_state.other_wallets.iter().position(|w| w.metadata() == &metadata) { - tsp_state.other_wallets.remove(i); - false - } else { - error!("BUG: failed to remove wallet not found in TSP state: {metadata:?}"); - return; - }; - Cx::post_action(TspWalletAction::WalletRemoved { metadata, was_default }); - }); - } + TspRequest::OpenWallet { metadata } => { + log!("Received TspRequest::OpenWallet({metadata:?})"); + Handle::current().spawn(async move { + let result = match metadata.open_wallet().await { + Ok(opened_wallet) => { + log!("Successfully opened wallet: {metadata:?}"); + let mut tsp_state = tsp_state_ref().lock().unwrap(); + // If the newly-opened wallet exists in the other wallets list, + // convert it into an opened wallet in-place. + // Otherwise, add it to the end of the other wallet list + if let Some(w) = tsp_state + .other_wallets + .iter_mut() + .find(|w| w.metadata() == &metadata) + { + *w = TspWalletEntry::Opened(opened_wallet); + } else { + tsp_state + .other_wallets + .push(TspWalletEntry::Opened(opened_wallet)); + } + Ok(metadata) + } + Err(error) => { + error!("Error opening wallet {metadata:?}: {error:?}"); + Err(error) + } + }; + Cx::post_action(TspWalletAction::WalletOpened(result)); + }); + } - TspRequest::DeleteWallet(metadata) => { - log!("Received TspRequest::DeleteWallet({metadata:?})"); - todo!("handle deleting a wallet"); - } + TspRequest::RemoveWallet(metadata) => { + log!("Received TspRequest::RemoveWallet({metadata:?})"); + Handle::current().spawn(async move { + let mut tsp_state = tsp_state_ref().lock().unwrap(); + let was_default = if tsp_state + .current_wallet + .as_ref() + .is_some_and(|cw| cw.metadata == metadata) + { + tsp_state.current_wallet = None; + true + } else if let Some(i) = tsp_state + .other_wallets + .iter() + .position(|w| w.metadata() == &metadata) + { + tsp_state.other_wallets.remove(i); + false + } else { + error!("BUG: failed to remove wallet not found in TSP state: {metadata:?}"); + return; + }; + Cx::post_action(TspWalletAction::WalletRemoved { + metadata, + was_default, + }); + }); + } - TspRequest::CreateDid { username, alias, server, did_server } => { - log!("Received TspRequest::CreateDid(username: {username}, alias: {alias:?}, server: {server}, did_server: {did_server})"); - let client = get_reqwest_client(); - - Handle::current().spawn(async move { - let result = create_did_and_add_to_wallet( - &client, - username, - alias, - server, - did_server, - ).await; - Cx::post_action(TspIdentityAction::DidCreationResult(result)); - }); - } + TspRequest::DeleteWallet(metadata) => { + log!("Received TspRequest::DeleteWallet({metadata:?})"); + todo!("handle deleting a wallet"); + } - TspRequest::RepublishDid { did } => { - log!("Received TspRequest::RepublishDid(did: {did})"); - let client = get_reqwest_client(); + TspRequest::CreateDid { + username, + alias, + server, + did_server, + } => { + log!( + "Received TspRequest::CreateDid(username: {username}, alias: {alias:?}, server: {server}, did_server: {did_server})" + ); + let client = get_reqwest_client(); - Handle::current().spawn(async move { - let result = republish_did(&did, &client).await - .map(|_| did); - Cx::post_action(TspIdentityAction::DidRepublishResult(result)); - }); - } + Handle::current().spawn(async move { + let result = + create_did_and_add_to_wallet(&client, username, alias, server, did_server) + .await; + Cx::post_action(TspIdentityAction::DidCreationResult(result)); + }); + } - TspRequest::AssociateDidWithUserId { did, user_id } => { - log!("Received TspRequest::AssociateDidWithUserId(did: {did}, user_id: {user_id})"); - Handle::current().spawn(async move { - let action = match associate_did_with_user_id(&did, &user_id).await { - Ok(_) => TspIdentityAction::SentDidAssociationRequest { did, user_id }, - Err(error) => TspIdentityAction::ErrorSendingDidAssociationRequest { did, user_id, error }, - }; - Cx::post_action(action); - }); - } + TspRequest::RepublishDid { did } => { + log!("Received TspRequest::RepublishDid(did: {did})"); + let client = get_reqwest_client(); - TspRequest::RespondToDidAssociationRequest { details, wallet_db, accepted } => { - log!("Received TspRequest::RespondToDidAssociationRequest(details: {details:?}, accepted: {accepted})"); - Handle::current().spawn(async move { - let result = respond_to_did_association_request(&details, &wallet_db, accepted).await; - // If all was successful, add this new association to the TSP state. - if result.is_ok() { - tsp_state_ref().lock().unwrap().associations.insert( - details.initiating_user_id.clone(), - details.initiating_vid.clone(), - ); - } - Cx::post_action(TspVerificationModalAction::SentDidAssociationResponse { - details, - result, + Handle::current().spawn(async move { + let result = republish_did(&did, &client).await.map(|_| did); + Cx::post_action(TspIdentityAction::DidRepublishResult(result)); + }); + } + + TspRequest::AssociateDidWithUserId { did, user_id } => { + log!("Received TspRequest::AssociateDidWithUserId(did: {did}, user_id: {user_id})"); + Handle::current().spawn(async move { + let action = match associate_did_with_user_id(&did, &user_id).await { + Ok(_) => TspIdentityAction::SentDidAssociationRequest { did, user_id }, + Err(error) => TspIdentityAction::ErrorSendingDidAssociationRequest { + did, + user_id, + error, + }, + }; + Cx::post_action(action); + }); + } + + TspRequest::RespondToDidAssociationRequest { + details, + wallet_db, + accepted, + } => { + log!( + "Received TspRequest::RespondToDidAssociationRequest(details: {details:?}, accepted: {accepted})" + ); + Handle::current().spawn(async move { + let result = + respond_to_did_association_request(&details, &wallet_db, accepted).await; + // If all was successful, add this new association to the TSP state. + if result.is_ok() { + tsp_state_ref().lock().unwrap().associations.insert( + details.initiating_user_id.clone(), + details.initiating_vid.clone(), + ); + } + Cx::post_action(TspVerificationModalAction::SentDidAssociationResponse { + details, + result, + }); }); - }); + } } } -} error!("async_tsp_worker task ended unexpectedly"); anyhow::bail!("async_tsp_worker task ended unexpectedly") } - /// Creates & publishes a new DID, adds it to the default wallet, /// and sets the new private VID to be default if none exists. /// @@ -846,13 +906,18 @@ async fn create_did_and_add_to_wallet( server: String, did_server: String, ) -> Result { - let cw_db = tsp_state_ref().lock().unwrap() - .current_wallet.as_ref() + let cw_db = tsp_state_ref() + .lock() + .unwrap() + .current_wallet + .as_ref() .map(|w| w.db.clone()) .ok_or_else(|| anyhow!("Please choose a default TSP wallet to hold the DID."))?; - let (did, private_vid, metadata) = create_did_web(&did_server, &server, &username, client).await?; + let (did, private_vid, metadata) = + create_did_web(&did_server, &server, &username, client).await?; let new_vid = private_vid.identifier().to_string(); - log!("Successfully created & published new DID: {did}.\n\ + log!( + "Successfully created & published new DID: {did}.\n\ Adding private VID {new_vid} to current wallet...", ); let did = store_did_in_wallet(&cw_db, private_vid, metadata, alias, did)?; @@ -863,13 +928,13 @@ async fn create_did_and_add_to_wallet( // and start a receive loop to listen for incoming requests for it. let mut tsp_state = tsp_state_ref().lock().unwrap(); if tsp_state.current_local_vid.is_none() { - log!("Setting new VID \"{}\" (from DID \"{}\") as current local VID and starting receive loop...", new_vid, did); - tsp_state.current_local_vid = Some(new_vid.clone()); - tsp_state.get_or_spawn_receive_loop( - Handle::current(), - &cw_db, - &new_vid, + log!( + "Setting new VID \"{}\" (from DID \"{}\") as current local VID and starting receive loop...", + new_vid, + did ); + tsp_state.current_local_vid = Some(new_vid.clone()); + tsp_state.get_or_spawn_receive_loop(Handle::current(), &cw_db, &new_vid); if let Some(user_id) = current_user_id() { tsp_state.associations .entry(user_id.clone()) @@ -902,12 +967,12 @@ async fn create_did_web( username, ); - let transport = Url::parse( - &format!("https://{}/endpoint/{}", - server, - &did.replace("%", "%25") - ) - ).map_err(|e| anyhow!("Invalid transport URL: {e}"))?; + let transport = Url::parse(&format!( + "https://{}/endpoint/{}", + server, + &did.replace("%", "%25") + )) + .map_err(|e| anyhow!("Invalid transport URL: {e}"))?; let private_vid = OwnedVid::bind(&did, transport); log!("created identity {}", private_vid.identifier()); @@ -921,12 +986,15 @@ async fn create_did_web( .map_err(|e| anyhow!("Could not publish VID. The DID server responded with error: {e}"))?; let vid_result: Result = match response.status() { - r if r.is_success() => { - response.json().await - .map_err(|e| anyhow!("Could not decode response from DID server as a valid VID: {e}")) - } + r if r.is_success() => response + .json() + .await + .map_err(|e| anyhow!("Could not decode response from DID server as a valid VID: {e}")), r => { - let text = response.text().await.unwrap_or_else(|_| "[Unknown]".to_string()); + let text = response + .text() + .await + .unwrap_or_else(|_| "[Unknown]".to_string()); if r.as_u16() == 500 { return Err(anyhow!( "The DID server returned error code 500. The DID username may already exist, \ @@ -943,7 +1011,8 @@ async fn create_did_web( let _vid = vid_result?; - log!("published DID document at {}", + log!( + "published DID document at {}", tsp_sdk::vid::did::get_resolve_url(&did)?.to_string() ); @@ -954,7 +1023,6 @@ async fn create_did_web( Ok((did, private_vid, metadata)) } - /// Stores the given private VID in the current default TSP wallet, /// and optionally establishes an alias for the given `did`. /// @@ -974,13 +1042,8 @@ fn store_did_in_wallet( Ok(did) } - /// Re-publishes/re-uploads our own DID to the DID server it was originally created on. -async fn republish_did( - did: &str, - client: &reqwest::Client, -) -> Result<(), anyhow::Error> { - +async fn republish_did(did: &str, client: &reqwest::Client) -> Result<(), anyhow::Error> { /// A copy of the Vid struct that we can actually instantiate /// from an existing VID in a local wallet. /// @@ -999,15 +1062,20 @@ async fn republish_did( public_enckey: PublicKeyData, } - let our_vid = { let tsp_state = tsp_state_ref().lock().unwrap(); - tsp_state.current_wallet.as_ref() + tsp_state + .current_wallet + .as_ref() .ok_or_else(no_default_wallet_error)? .db .as_store() .get_verified_vid(did) - .map_err(|_e| anyhow!("The DID to republish \"{did}\" was not found in the current default wallet."))? + .map_err(|_e| { + anyhow!( + "The DID to republish \"{did}\" was not found in the current default wallet." + ) + })? }; let vid_dup = VidDuplicate { @@ -1022,11 +1090,16 @@ async fn republish_did( let did_transport_url = tsp_sdk::vid::did::get_resolve_url(did)?; let response = client - .post(format!("{}/add-vid", did_transport_url.origin().ascii_serialization())) + .post(format!( + "{}/add-vid", + did_transport_url.origin().ascii_serialization() + )) .json(&vid_dup) .send() .await - .map_err(|e| anyhow!("Could not republish VID. The DID server responded with error: {e}"))?; + .map_err(|e| { + anyhow!("Could not republish VID. The DID server responded with error: {e}") + })?; match response.status() { r if r.is_success() => { @@ -1034,7 +1107,10 @@ async fn republish_did( Ok(()) } r => { - let text = response.text().await.unwrap_or_else(|_| "[Unknown]".to_string()); + let text = response + .text() + .await + .unwrap_or_else(|_| "[Unknown]".to_string()); if r.as_u16() == 500 { Err(anyhow!( "The DID server returned error code 500. The DID username may already exist, \ @@ -1050,14 +1126,16 @@ async fn republish_did( } } - async fn receive_messages_for_vid( wallet_db: AsyncSecureStore, private_vid_to_receive_on: String, mut request_rx: UnboundedReceiver, ) -> Result<(), anyhow::Error> { // Ensure that our receiving VID is currently published to the DID server. - if republish_did(&private_vid_to_receive_on, &create_reqwest_client()?).await.is_ok() { + if republish_did(&private_vid_to_receive_on, &create_reqwest_client()?) + .await + .is_ok() + { log!("Auto-republished DID \"{private_vid_to_receive_on}\" to its DID server."); } @@ -1135,32 +1213,36 @@ async fn receive_messages_for_vid( Ok(()) } - fn no_default_wallet_error() -> anyhow::Error { anyhow!("Please choose a default TSP wallet.") } fn no_default_vid_error() -> anyhow::Error { - anyhow!("Please choose a default VID from your default \ - TSP wallet to represent your own Matrix account.") + anyhow!( + "Please choose a default VID from your default \ + TSP wallet to represent your own Matrix account." + ) } - /// Associates the given DID with a Matrix User ID. /// /// This function only performs the local verification of the given DID into /// the local default wallet, and then sends a verification request to the user. /// It does not wait to receive a verification response. -async fn associate_did_with_user_id( - did: &str, - user_id: &OwnedUserId, -) -> Result<(), anyhow::Error> { - let our_user_id = crate::sliding_sync::current_user_id() - .ok_or_else(|| anyhow!("Must be logged into Matrix in order to associate a DID with a Matrix User ID."))?; +async fn associate_did_with_user_id(did: &str, user_id: &OwnedUserId) -> Result<(), anyhow::Error> { + let our_user_id = crate::sliding_sync::current_user_id().ok_or_else(|| { + anyhow!("Must be logged into Matrix in order to associate a DID with a Matrix User ID.") + })?; let (wallet_db, our_vid) = { let tsp_state = tsp_state_ref().lock().unwrap(); - let wallet = tsp_state.current_wallet.as_ref().ok_or_else(no_default_wallet_error)?; - let our_vid = tsp_state.current_local_vid.clone().ok_or_else(no_default_vid_error)?; + let wallet = tsp_state + .current_wallet + .as_ref() + .ok_or_else(no_default_wallet_error)?; + let our_vid = tsp_state + .current_local_vid + .clone() + .ok_or_else(no_default_vid_error)?; (wallet.db.clone(), our_vid) }; if !wallet_db.has_verified_vid(did)? { @@ -1182,15 +1264,21 @@ async fn associate_did_with_user_id( .collect() }, }; - tsp_state_ref().lock().unwrap().pending_verification_requests.push(verification_details.clone()); + tsp_state_ref() + .lock() + .unwrap() + .pending_verification_requests + .push(verification_details.clone()); let request_msg = TspMessage::VerificationRequest(verification_details); - wallet_db.send( - &our_vid, - did, - // This is just for debugging and should be removed before production. - Some(format!("Verification from {our_user_id} to {user_id}").as_bytes()), - serde_json::to_string(&request_msg)?.as_bytes(), - ).await?; + wallet_db + .send( + &our_vid, + did, + // This is just for debugging and should be removed before production. + Some(format!("Verification from {our_user_id} to {user_id}").as_bytes()), + serde_json::to_string(&request_msg)?.as_bytes(), + ) + .await?; // Note: the receive loop will wait to receive the verification response, // upon which the verification procedure will be completed @@ -1198,27 +1286,42 @@ async fn associate_did_with_user_id( Ok(()) } - /// Sends a positive/negative response to a previous incoming DID association request. async fn respond_to_did_association_request( details: &TspVerificationDetails, wallet_db: &AsyncSecureStore, accepted: bool, ) -> Result<(), anyhow::Error> { - wallet_db.verify_vid(&details.initiating_vid, Some(details.initiating_user_id.to_string())).await?; - log!("Verification requester's initiating DID {} was verified and added to your wallet.", details.initiating_vid); + wallet_db + .verify_vid( + &details.initiating_vid, + Some(details.initiating_user_id.to_string()), + ) + .await?; + log!( + "Verification requester's initiating DID {} was verified and added to your wallet.", + details.initiating_vid + ); let response_msg = TspMessage::VerificationResponse { details: details.clone(), accepted, }; - wallet_db.send( - &details.responding_vid, - &details.initiating_vid, - // This is just for debugging and should be removed before production. - Some(format!("Verification Response ({accepted}) from {} to {}", details.responding_user_id, details.initiating_user_id).as_bytes()), - serde_json::to_string(&response_msg)?.as_bytes(), - ).await?; + wallet_db + .send( + &details.responding_vid, + &details.initiating_vid, + // This is just for debugging and should be removed before production. + Some( + format!( + "Verification Response ({accepted}) from {} to {}", + details.responding_user_id, details.initiating_user_id + ) + .as_bytes(), + ), + serde_json::to_string(&response_msg)?.as_bytes(), + ) + .await?; Ok(()) } @@ -1228,15 +1331,22 @@ pub fn sign_anycast_with_default_vid(message: &[u8]) -> Result, anyhow:: let (wallet_db, signing_vid) = { let tsp_state = tsp_state_ref().lock().unwrap(); ( - tsp_state.current_wallet.as_ref().ok_or_else(no_default_wallet_error)?.db.clone(), - tsp_state.current_local_vid.clone().ok_or_else(no_default_vid_error)?, + tsp_state + .current_wallet + .as_ref() + .ok_or_else(no_default_wallet_error)? + .db + .clone(), + tsp_state + .current_local_vid + .clone() + .ok_or_else(no_default_vid_error)?, ) }; let signed = wallet_db.as_store().sign_anycast(&signing_vid, message)?; Ok(signed) } - /// The types/schema of messages that we send over the TSP protocol. #[derive(Debug, Serialize, Deserialize)] enum TspMessage { @@ -1269,11 +1379,9 @@ pub struct TspVerificationDetails { /// Sanitizes a wallet name to ensure it is safe to use in file paths. pub fn sanitize_wallet_name(name: &str) -> String { - sanitize_filename::sanitize(name) - .replace(char::is_whitespace, "_") + sanitize_filename::sanitize(name).replace(char::is_whitespace, "_") } - /// Represents a SQLite URL for a TSP wallet, which is *NOT* percent-encoded yet. /// /// Currently the scheme is always "sqlite://" (or "sqlite:///" for absolute paths), @@ -1308,14 +1416,15 @@ impl TspWalletSqliteUrl { pub fn get_path(&self) -> Option<&Path> { let url = &self.0; // Handle URLs with a scheme for absolute paths, e.g., "sqlite:///" - if let Some(p) = url.find(":///").and_then(|pos| url.get(pos + 4 ..)) { + if let Some(p) = url.find(":///").and_then(|pos| url.get(pos + 4..)) { Some(Path::new(p)) } // Handle URLs with a scheme for relative paths, e.g., "sqlite://" - else if let Some(p) = url.find("://").and_then(|pos| url.get(pos + 3 ..)) { + else if let Some(p) = url.find("://").and_then(|pos| url.get(pos + 3..)) { Some(Path::new(p)) + } else { + None } - else { None } } /// Returns the URL as a string that is not percent-encoded. @@ -1327,7 +1436,6 @@ impl TspWalletSqliteUrl { &self.0 } - /// Converts this wallet URL to a percent-encoded URL. /// /// ## Usage notes @@ -1338,7 +1446,7 @@ impl TspWalletSqliteUrl { /// We cannot use the `Path`/`PathBuf` type because the sqlite backend /// always expects URLs with filename paths encoded using Unix-style `/` path separators, /// even on Windows. Therefore, we manually percent-encode each part of the path - /// and push them in between manually-added `/` separators, instead of using + /// and push them in between manually-added `/` separators, instead of using /// the Rust `std::path` functions like `Path::join()` or `PathBuf::push()`. pub fn to_url_encoded(&self) -> Cow<'_, str> { const DELIMITER_ABS: &str = ":///"; @@ -1351,14 +1459,19 @@ impl TspWalletSqliteUrl { let try_encode = |delim: &str| -> Option { if let Some(idx) = self.0.find(delim) { - let before = self.0.get(.. (idx + delim.len())).unwrap_or(""); - let after = self.0.get((idx + delim.len()) ..).unwrap_or(""); + let before = self.0.get(..(idx + delim.len())).unwrap_or(""); + let after = self.0.get((idx + delim.len())..).unwrap_or(""); let mut after_encoded = String::new(); for component in Path::new(after).components() { match component { std::path::Component::Prefix(prefix) => { // Windows drive prefixes must not be percent-encoded. - after_encoded = format!("{}{}{}", after_encoded, SEPARATOR, prefix.as_os_str().to_string_lossy()); + after_encoded = format!( + "{}{}{}", + after_encoded, + SEPARATOR, + prefix.as_os_str().to_string_lossy() + ); } std::path::Component::RootDir => { // ignore, since we already manually add '/' between components. @@ -1366,12 +1479,18 @@ impl TspWalletSqliteUrl { std::path::Component::Normal(p) => { let percent_encoded = percent_encoding::percent_encode( p.as_encoded_bytes(), - percent_encoding::NON_ALPHANUMERIC + percent_encoding::NON_ALPHANUMERIC, ); - after_encoded = format!("{}{}{}", after_encoded, SEPARATOR_PE, percent_encoded); + after_encoded = + format!("{}{}{}", after_encoded, SEPARATOR_PE, percent_encoded); } other => { - after_encoded = format!("{}{}{}", after_encoded, SEPARATOR_PE, other.as_os_str().to_string_lossy()); + after_encoded = format!( + "{}{}{}", + after_encoded, + SEPARATOR_PE, + other.as_os_str().to_string_lossy() + ); } } } @@ -1385,10 +1504,8 @@ impl TspWalletSqliteUrl { .or_else(|| try_encode(DELIMITER_REG)) .map(Cow::from) .unwrap_or_else(|| { - percent_encoding::utf8_percent_encode( - &self.0, - percent_encoding::NON_ALPHANUMERIC, - ).into() + percent_encoding::utf8_percent_encode(&self.0, percent_encoding::NON_ALPHANUMERIC) + .into() }) } } diff --git a/src/tsp/tsp_settings_screen.rs b/src/tsp/tsp_settings_screen.rs index 83d0e6f87..822ebd6ea 100644 --- a/src/tsp/tsp_settings_screen.rs +++ b/src/tsp/tsp_settings_screen.rs @@ -1,7 +1,16 @@ - use makepad_widgets::*; -use crate::{shared::{popup_list::{enqueue_popup_notification, PopupKind}, styles::*}, tsp::{create_did_modal::CreateDidModalAction, create_wallet_modal::CreateWalletModalAction, submit_tsp_request, tsp_state_ref, TspIdentityAction, TspRequest, TspWalletAction, TspWalletEntry, TspWalletMetadata}}; +use crate::{ + shared::{ + popup_list::{enqueue_popup_notification, PopupKind}, + styles::*, + }, + tsp::{ + create_did_modal::CreateDidModalAction, create_wallet_modal::CreateWalletModalAction, + submit_tsp_request, tsp_state_ref, TspIdentityAction, TspRequest, TspWalletAction, + TspWalletEntry, TspWalletMetadata, + }, +}; const REPUBLISH_IDENTITY_BUTTON_TEXT: &str = "Republish Current Identity to DID Server"; @@ -164,16 +173,19 @@ impl WalletState { fn get(&self, index: usize) -> Option<(&TspWalletMetadata, WalletStatusAndDefault)> { if let Some(active) = self.active_wallet.as_ref() { if index == 0 { - Some((active, WalletStatusAndDefault::new(WalletStatus::Opened, true))) + Some(( + active, + WalletStatusAndDefault::new(WalletStatus::Opened, true), + )) } else { - self.other_wallets.get(index - 1).map(|(m, s)| - (m, WalletStatusAndDefault::new(*s, false)) - ) + self.other_wallets + .get(index - 1) + .map(|(m, s)| (m, WalletStatusAndDefault::new(*s, false))) } } else { - self.other_wallets.get(index).map(|(m, s)| - (m, WalletStatusAndDefault::new(*s, false)) - ) + self.other_wallets + .get(index) + .map(|(m, s)| (m, WalletStatusAndDefault::new(*s, false))) } } } @@ -198,7 +210,8 @@ impl WalletStatusAndDefault { /// The view containing all TSP-related settings. #[derive(Script, ScriptHook, Widget)] pub struct TspSettingsScreen { - #[deref] view: View, + #[deref] + view: View, /// The list of wallets that are known by this widget. /// @@ -210,7 +223,8 @@ pub struct TspSettingsScreen { /// This is sort of a "cache" of the wallets that have been drawn /// to avoid having to re-fetch them from the shared TSP state every time, /// as that requires locking the mutex and can be expensive. - #[rust] wallets: Option, + #[rust] + wallets: Option, } impl Widget for TspSettingsScreen { @@ -227,36 +241,49 @@ impl Widget for TspSettingsScreen { } // Draw the current identity label and republish button based on the active identity. - let (current_did_text, current_did_text_color, show_republish_button) = match - self.wallets.as_ref().and_then(|ws| ws.active_identity.as_deref()) + let (current_did_text, current_did_text_color, show_republish_button) = match self + .wallets + .as_ref() + .and_then(|ws| ws.active_identity.as_deref()) { Some(current_did) => (current_did.to_string(), COLOR_FG_ACCEPT_GREEN, true), - None => ("No default identity has been set.".to_string(), COLOR_TEXT_WARNING_NOT_FOUND, false), + None => ( + "No default identity has been set.".to_string(), + COLOR_TEXT_WARNING_NOT_FOUND, + false, + ), }; let mut current_identity_label = self.view.label(cx, ids!(current_identity_label)); script_apply_eval!(cx, current_identity_label, { text: #(current_did_text), draw_text +: { color: #(current_did_text_color) }, }); - self.view.button(cx, ids!(republish_identity_button)).set_visible(cx, show_republish_button); - + self.view + .button(cx, ids!(republish_identity_button)) + .set_visible(cx, show_republish_button); // If we don't have any wallets, show the "no wallets" label. let is_wallets_empty = self.wallets.as_ref().is_none_or(|w| w.is_empty()); - self.view.view(cx, ids!(no_wallets_label)).set_visible(cx, is_wallets_empty); + self.view + .view(cx, ids!(no_wallets_label)) + .set_visible(cx, is_wallets_empty); while let Some(subview) = self.view.draw_walk(cx, scope, walk).step() { // Here, we only need to handle drawing the wallet list. let flat_list_ref = subview.as_flat_list(); let Some(mut list) = flat_list_ref.borrow_mut() else { - error!("!!! TspSettingsScreen::draw_walk(): BUG: expected a FlatList widget, but got something else"); + error!( + "!!! TspSettingsScreen::draw_walk(): BUG: expected a FlatList widget, but got something else" + ); continue; }; let Some(wallets) = self.wallets.as_ref() else { return DrawStep::done(); }; - for (metadata, mut status_and_default) in (0..wallets.len()).filter_map(|i| wallets.get(i)) { + for (metadata, mut status_and_default) in + (0..wallets.len()).filter_map(|i| wallets.get(i)) + { let item_live_id = LiveId::from_str(metadata.url.as_url_unencoded()); let item = list.item(cx, item_live_id, id!(wallet_entry)).unwrap(); // Pass the wallet metadata in through Scope via props, @@ -276,27 +303,39 @@ impl MatchEvent for TspSettingsScreen { for action in actions { match action.downcast_ref() { // Add the new wallet to the list of drawn wallets. - Some(TspWalletAction::CreateWalletSuccess { metadata, is_default }) => { + Some(TspWalletAction::CreateWalletSuccess { + metadata, + is_default, + }) => { let wallets = self.wallets.get_or_insert_default(); if *is_default { wallets.active_wallet = Some(metadata.clone()); } else { - wallets.other_wallets.push((metadata.clone(), WalletStatus::Opened)); + wallets + .other_wallets + .push((metadata.clone(), WalletStatus::Opened)); } self.view.redraw(cx); continue; } // Remove the wallet from the list of drawn wallets. - Some(TspWalletAction::WalletRemoved { metadata, was_default }) => { - let Some(wallets) = &mut self.wallets.as_mut() else { continue }; + Some(TspWalletAction::WalletRemoved { + metadata, + was_default, + }) => { + let Some(wallets) = &mut self.wallets.as_mut() else { + continue; + }; if *was_default { wallets.active_wallet = None; - } - else if let Some(pos) = wallets.other_wallets.iter().position(|(w, _)| w == metadata) { + } else if let Some(pos) = wallets + .other_wallets + .iter() + .position(|(w, _)| w == metadata) + { wallets.other_wallets.remove(pos); - } - else { + } else { continue; } enqueue_popup_notification( @@ -324,11 +363,17 @@ impl MatchEvent for TspSettingsScreen { let previous_active = wallets.active_wallet.replace(metadata.clone()); // If the newly-default wallet was in the other wallets list, remove it // and then add the previous active wallet back to that other wallets list. - if let Some(idx_to_remove) = wallets.other_wallets.iter().position(|(w, _)| w == metadata) { + if let Some(idx_to_remove) = wallets + .other_wallets + .iter() + .position(|(w, _)| w == metadata) + { wallets.other_wallets.remove(idx_to_remove); } if let Some(previous_active) = previous_active { - wallets.other_wallets.insert(0, (previous_active, WalletStatus::Opened)); + wallets + .other_wallets + .insert(0, (previous_active, WalletStatus::Opened)); } self.view.redraw(cx); continue; @@ -345,10 +390,16 @@ impl MatchEvent for TspSettingsScreen { // Handle a newly-opened wallet. Some(TspWalletAction::WalletOpened(Ok(metadata))) => { let wallets = self.wallets.get_or_insert_default(); - if let Some((_m, status)) = wallets.other_wallets.iter_mut().find(|(w, _)| w == metadata) { + if let Some((_m, status)) = wallets + .other_wallets + .iter_mut() + .find(|(w, _)| w == metadata) + { *status = WalletStatus::Opened; } else { - wallets.other_wallets.push((metadata.clone(), WalletStatus::Opened)); + wallets + .other_wallets + .push((metadata.clone(), WalletStatus::Opened)); } self.view.redraw(cx); continue; @@ -363,8 +414,10 @@ impl MatchEvent for TspSettingsScreen { } // This is handled in the CreateWalletModal - Some(TspWalletAction::CreateWalletError { .. }) => { continue; } - None => { } + Some(TspWalletAction::CreateWalletError { .. }) => { + continue; + } + None => {} } match action.downcast_ref() { @@ -386,7 +439,10 @@ impl MatchEvent for TspSettingsScreen { match result { Ok(did) => { enqueue_popup_notification( - format!("Successfully republished identity \"{}\" to the DID server.", did), + format!( + "Successfully republished identity \"{}\" to the DID server.", + did + ), PopupKind::Success, Some(5.0), ); @@ -401,18 +457,35 @@ impl MatchEvent for TspSettingsScreen { } continue; } - Some(TspIdentityAction::SentDidAssociationRequest { .. }) => { continue; } // handled in the TspVerifyUser widget - Some(TspIdentityAction::ErrorSendingDidAssociationRequest { .. }) => { continue; } // handled in the TspVerifyUser widget - Some(TspIdentityAction::ReceivedDidAssociationResponse { .. }) => { continue; } // handled in the TspVerifyUser widget - Some(TspIdentityAction::ReceivedDidAssociationRequest { .. }) => { continue; } // handled in the TspVerificationModal widget - Some(TspIdentityAction::ReceiveLoopError { .. }) => { continue; } // handled in the top-level app - None => { } + Some(TspIdentityAction::SentDidAssociationRequest { .. }) => { + continue; + } // handled in the TspVerifyUser widget + Some(TspIdentityAction::ErrorSendingDidAssociationRequest { .. }) => { + continue; + } // handled in the TspVerifyUser widget + Some(TspIdentityAction::ReceivedDidAssociationResponse { .. }) => { + continue; + } // handled in the TspVerifyUser widget + Some(TspIdentityAction::ReceivedDidAssociationRequest { .. }) => { + continue; + } // handled in the TspVerificationModal widget + Some(TspIdentityAction::ReceiveLoopError { .. }) => { + continue; + } // handled in the top-level app + None => {} } } - - if self.view.button(cx, ids!(copy_identity_button)).clicked(actions) { - if let Some(did) = self.wallets.as_ref().and_then(|ws| ws.active_identity.as_deref()) { + if self + .view + .button(cx, ids!(copy_identity_button)) + .clicked(actions) + { + if let Some(did) = self + .wallets + .as_ref() + .and_then(|ws| ws.active_identity.as_deref()) + { cx.copy_to_clipboard(did); enqueue_popup_notification( "Copied your default TSP identity to the clipboard.", @@ -431,15 +504,25 @@ impl MatchEvent for TspSettingsScreen { // Allow the user to republish their identity to the DID server. // This is primarily needed because some DID servers (e.g., the test servers) // frequently wipe their identity storage after a certain period of time. - if self.view.button(cx, ids!(republish_identity_button)).clicked(actions) { + if self + .view + .button(cx, ids!(republish_identity_button)) + .clicked(actions) + { if self.has_default_wallet() { - if let Some(our_did) = self.wallets.as_ref().and_then(|ws| ws.active_identity.as_deref()) { + if let Some(our_did) = self + .wallets + .as_ref() + .and_then(|ws| ws.active_identity.as_deref()) + { script_apply_eval!(cx, republish_identity_button, { enabled: false, text: "Republishing DID now...", }); - submit_tsp_request(TspRequest::RepublishDid { did: our_did.to_string() }); + submit_tsp_request(TspRequest::RepublishDid { + did: our_did.to_string(), + }); } else { enqueue_popup_notification( "You must set a default TSP identity to be republished.", @@ -450,17 +533,29 @@ impl MatchEvent for TspSettingsScreen { } } - if self.view.button(cx, ids!(create_wallet_button)).clicked(actions) { + if self + .view + .button(cx, ids!(create_wallet_button)) + .clicked(actions) + { cx.action(CreateWalletModalAction::Open); } - if self.view.button(cx, ids!(create_did_button)).clicked(actions) { + if self + .view + .button(cx, ids!(create_did_button)) + .clicked(actions) + { if self.has_default_wallet() { cx.action(CreateDidModalAction::Open); } } - if self.view.button(cx, ids!(import_wallet_button)).clicked(actions) { + if self + .view + .button(cx, ids!(import_wallet_button)) + .clicked(actions) + { // TODO: support importing an existing wallet. enqueue_popup_notification( "Importing an existing wallet is not yet implemented.", @@ -475,8 +570,12 @@ impl TspSettingsScreen { /// Re-fetches the TSP state and populates this widget's list of wallets. fn refresh_wallets(&mut self) { let tsp_state = tsp_state_ref().lock().unwrap(); - let current_wallet = tsp_state.current_wallet.as_ref().map(|w| w.metadata.clone()); - let other_wallets = tsp_state.other_wallets + let current_wallet = tsp_state + .current_wallet + .as_ref() + .map(|w| w.metadata.clone()); + let other_wallets = tsp_state + .other_wallets .iter() .map(|entry| match entry { TspWalletEntry::Opened(opened) => (opened.metadata.clone(), WalletStatus::Opened), diff --git a/src/tsp/tsp_sign_indicator.rs b/src/tsp/tsp_sign_indicator.rs index 2c95bd168..3375b9775 100644 --- a/src/tsp/tsp_sign_indicator.rs +++ b/src/tsp/tsp_sign_indicator.rs @@ -47,7 +47,6 @@ pub enum TspSignState { WrongSignature, } - /// An indicator that is shown nearby a message that has a TSP signature. /// /// This widget is basically just a clickable icon group that shows @@ -61,8 +60,10 @@ pub enum TspSignState { /// #[derive(Script, ScriptHook, Widget)] pub struct TspSignIndicator { - #[deref] view: View, - #[rust] state: TspSignState, + #[deref] + view: View, + #[rust] + state: TspSignState, } impl Widget for TspSignIndicator { @@ -71,15 +72,14 @@ impl Widget for TspSignIndicator { let area = self.view.area(); let should_hover_in = match event.hits(cx, area) { - Hit::FingerLongPress(_) - | Hit::FingerHoverIn(..) => true, + Hit::FingerLongPress(_) | Hit::FingerHoverIn(..) => true, // TODO: show user profile and TSP info on click // Hit::FingerUp(fue) if fue.is_over && fue.is_primary_hit() => { // log!("todo: show user profile and TSP info."); // false // }, Hit::FingerHoverOut(_) => { - cx.widget_action(self.widget_uid(), TooltipAction::HoverOut); + cx.widget_action(self.widget_uid(), TooltipAction::HoverOut); false } _ => false, @@ -92,7 +92,7 @@ impl Widget for TspSignIndicator { ), TspSignState::Verified => ( "This message was signed with the user's verified TSP identity.", - COLOR_FG_ACCEPT_GREEN, + COLOR_FG_ACCEPT_GREEN, ), TspSignState::WrongSignature => ( "Warning: this message's TSP signature does NOT match the expected sender signature.", @@ -100,7 +100,7 @@ impl Widget for TspSignIndicator { ), }; cx.widget_action( - self.widget_uid(), + self.widget_uid(), TooltipAction::HoverIn { text: text.to_string(), widget_rect: area.rect(cx), @@ -124,15 +124,9 @@ impl TspSignIndicator { let tsp_html_ref = self.view.html(cx, ids!(tsp_html)); if let Some(mut tsp_html) = tsp_html_ref.borrow_mut() { let (text, font_color) = match state { - TspSignState::Unknown => { - ("TSP ❔", COLOR_MESSAGE_NOTICE_TEXT) - } - TspSignState::Verified => { - ("TSP ✅", COLOR_FG_ACCEPT_GREEN) - } - TspSignState::WrongSignature => { - ("❗TSP❗", COLOR_FG_DANGER_RED) - } + TspSignState::Unknown => ("TSP ❔", COLOR_MESSAGE_NOTICE_TEXT), + TspSignState::Verified => ("TSP ✅", COLOR_FG_ACCEPT_GREEN), + TspSignState::WrongSignature => ("❗TSP❗", COLOR_FG_DANGER_RED), }; tsp_html.set_text(cx, text); tsp_html.font_color = font_color; @@ -152,7 +146,6 @@ impl TspSignIndicatorRef { } } - /// Actions emitted by an `TspSignIndicator` widget. #[derive(Clone, Debug, Default)] pub enum TspSignIndicatorAction { diff --git a/src/tsp/tsp_verification_modal.rs b/src/tsp/tsp_verification_modal.rs index 95a97089d..2e6aec302 100644 --- a/src/tsp/tsp_verification_modal.rs +++ b/src/tsp/tsp_verification_modal.rs @@ -1,8 +1,10 @@ - use makepad_widgets::*; use tsp_sdk::AsyncSecureStore; -use crate::{sliding_sync::current_user_id, tsp::{submit_tsp_request, TspRequest, TspVerificationDetails}}; +use crate::{ + sliding_sync::current_user_id, + tsp::{submit_tsp_request, TspRequest, TspVerificationDetails}, +}; script_mod! { link tsp_enabled @@ -90,8 +92,10 @@ script_mod! { #[derive(Script, ScriptHook, Widget)] pub struct TspVerificationModal { - #[deref] view: View, - #[rust] state: TspVerificationModalState, + #[deref] + view: View, + #[rust] + state: TspVerificationModalState, } #[derive(Default)] @@ -185,7 +189,10 @@ impl WidgetMatchEvent for TspVerificationModal { // the wallet. If not, we need to show an error instructing the user // to add that VID to their wallet first and then retry the verification process. // Then, we need to send a negative response to the initiator of the request. - let error_text = if !wallet_db.has_private_vid(&details.responding_vid).is_ok_and(|v| v) { + let error_text = if !wallet_db + .has_private_vid(&details.responding_vid) + .is_ok_and(|v| v) + { Some(format!( "Error: the VID \"{}\" was not found in your current wallet.\n\n\ Either the requestor has the wrong VID for you, or you have not yet added that VID to your wallet.\n\n\ @@ -224,16 +231,17 @@ impl WidgetMatchEvent for TspVerificationModal { }, }); new_state = TspVerificationModalState::RequestDeclined; - } - else { - let prompt = format!("You have accepted the TSP verification request.\n\n\ + } else { + let prompt = format!( + "You have accepted the TSP verification request.\n\n\ Please confirm that the following code matches for both users:\n\n\ Code: \"{}\"\n", details.random_str, ); prompt_label.set_text(cx, &prompt); accept_button.set_text(cx, "Yes, they match!"); - new_state = TspVerificationModalState::RequestAccepted { details, wallet_db }; + new_state = + TspVerificationModalState::RequestAccepted { details, wallet_db }; } } @@ -246,7 +254,7 @@ impl WidgetMatchEvent for TspVerificationModal { let prompt_text = "You have confirmed the TSP verification request.\n\nSending a response now..."; prompt_label.set_text(cx, prompt_text); accept_button.set_enabled(cx, false); - // stay in this same state until we get an acknowledgment back + // stay in this same state until we get an acknowledgment back // that we sent the response (the `SentDidAssociationResponse` action). new_state = TspVerificationModalState::RequestAccepted { details, wallet_db }; } @@ -263,16 +271,22 @@ impl WidgetMatchEvent for TspVerificationModal { for action in actions { match action.downcast_ref() { - Some(TspVerificationModalAction::SentDidAssociationResponse { details, result }) - if self.state.details().is_some_and(|d| d == details) => - { + Some(TspVerificationModalAction::SentDidAssociationResponse { + details, + result, + }) if self.state.details().is_some_and(|d| d == details) => { match result { Ok(()) => { self.label(cx, ids!(prompt)).set_text(cx, "The TSP verification process has completed successfully.\n\nYou may now close this."); self.state = TspVerificationModalState::RequestVerified; } Err(e) => { - self.label(cx, ids!(prompt)).set_text(cx, &format!("Error: failed to complete the TSP verification process:\n\n{e}")); + self.label(cx, ids!(prompt)).set_text( + cx, + &format!( + "Error: failed to complete the TSP verification process:\n\n{e}" + ), + ); self.state = TspVerificationModalState::RequestDeclined; } } @@ -306,7 +320,8 @@ impl TspVerificationModal { wallet_db: AsyncSecureStore, ) { log!("Initializing TSP verification modal with: {:?}", details); - let prompt_text = format!("Matrix User \"{}\" is requesting to verify your identity via TSP.\n\ + let prompt_text = format!( + "Matrix User \"{}\" is requesting to verify your identity via TSP.\n\ Their TSP identity is: \"{}\".\n\n\ They want to verify your TSP identity \"{}\" associated with Matrix User ID \"{}\".\n\n\ If you recognize these details, would you like to accept this request?", @@ -328,10 +343,7 @@ impl TspVerificationModal { cancel_button.set_visible(cx, true); cancel_button.reset_hover(cx); - self.state = TspVerificationModalState::ReceivedRequest { - details, - wallet_db, - }; + self.state = TspVerificationModalState::ReceivedRequest { details, wallet_db }; } } @@ -339,7 +351,7 @@ impl TspVerificationModalRef { /// Initialize this modal with the details of a TSP verification request. pub fn initialize_with_details( &self, - cx: &mut Cx, + cx: &mut Cx, details: TspVerificationDetails, wallet_db: AsyncSecureStore, ) { diff --git a/src/tsp/verify_user.rs b/src/tsp/verify_user.rs index e41593f43..a28091292 100644 --- a/src/tsp/verify_user.rs +++ b/src/tsp/verify_user.rs @@ -1,8 +1,10 @@ - use makepad_widgets::*; use matrix_sdk::ruma::OwnedUserId; -use crate::{shared::popup_list::{enqueue_popup_notification, PopupKind}, tsp::{submit_tsp_request, tsp_state_ref, TspIdentityAction, TspRequest}}; +use crate::{ + shared::popup_list::{enqueue_popup_notification, PopupKind}, + tsp::{submit_tsp_request, tsp_state_ref, TspIdentityAction, TspRequest}, +}; script_mod! { link tsp_enabled @@ -113,11 +115,14 @@ pub enum TspVerifiedInfo { #[derive(Script, ScriptHook, Widget)] pub struct TspVerifyUser { - #[deref] view: View, + #[deref] + view: View, /// The Matrix User ID of the other user that we want to verify. - #[rust] user_id: Option, + #[rust] + user_id: Option, /// Info about whether the other user has or has not been verified via TSP. - #[rust] verified_info: TspVerifiedInfo, + #[rust] + verified_info: TspVerifiedInfo, } impl Widget for TspVerifyUser { @@ -132,7 +137,11 @@ impl Widget for TspVerifyUser { } impl MatchEvent for TspVerifyUser { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { - if self.view.button(cx, ids!(remove_tsp_association_button)).clicked(actions) { + if self + .view + .button(cx, ids!(remove_tsp_association_button)) + .clicked(actions) + { enqueue_popup_notification( "Removing a TSP association is not yet implemented", PopupKind::Warning, @@ -165,14 +174,18 @@ impl MatchEvent for TspVerifyUser { { verify_user_button.set_text(cx, "Sent request!"); enqueue_popup_notification( - format!("Sent TSP verification request.\n\nWaiting for \"{user_id}\" to respond..."), + format!( + "Sent TSP verification request.\n\nWaiting for \"{user_id}\" to respond..." + ), PopupKind::Info, Some(5.0), ); } - Some(TspIdentityAction::ErrorSendingDidAssociationRequest { user_id, error, .. }) - if Some(user_id) == self.user_id.as_ref() => - { + Some(TspIdentityAction::ErrorSendingDidAssociationRequest { + user_id, + error, + .. + }) if Some(user_id) == self.user_id.as_ref() => { verify_user_button.set_enabled(cx, true); verify_user_button.set_text(cx, "Verify this user via TSP"); enqueue_popup_notification( @@ -181,9 +194,11 @@ impl MatchEvent for TspVerifyUser { None, ); } - Some(TspIdentityAction::ReceivedDidAssociationResponse { did, user_id, accepted }) - if Some(user_id) == self.user_id.as_ref() => - { + Some(TspIdentityAction::ReceivedDidAssociationResponse { + did, + user_id, + accepted, + }) if Some(user_id) == self.user_id.as_ref() => { if *accepted { enqueue_popup_notification( format!("User \"{user_id}\" accepted your TSP verification request."), @@ -217,12 +232,16 @@ impl TspVerifyUser { TspVerifiedInfo::Verified { did } => { verified_tsp_view.set_visible(cx, true); unverified_tsp_view.set_visible(cx, false); - verified_tsp_view.text_input(cx, ids!(tsp_did_read_only_input)).set_text(cx, did); + verified_tsp_view + .text_input(cx, ids!(tsp_did_read_only_input)) + .set_text(cx, did); } TspVerifiedInfo::Unverified => { verified_tsp_view.set_visible(cx, false); unverified_tsp_view.set_visible(cx, true); - unverified_tsp_view.text_input(cx, ids!(tsp_did_input)).set_text(cx, ""); + unverified_tsp_view + .text_input(cx, ids!(tsp_did_input)) + .set_text(cx, ""); let verify_user_button = unverified_tsp_view.button(cx, ids!(verify_user_button)); verify_user_button.set_enabled(cx, true); verify_user_button.set_text(cx, "Verify this user via TSP"); @@ -231,12 +250,15 @@ impl TspVerifyUser { } fn show(&mut self, cx: &mut Cx, user_id: OwnedUserId) { - let verified_info = tsp_state_ref().lock().unwrap() + let verified_info = tsp_state_ref() + .lock() + .unwrap() .get_associated_did(&user_id) - .map_or( - TspVerifiedInfo::Unverified, - |did| TspVerifiedInfo::Verified { did: did.to_string() }, - ); + .map_or(TspVerifiedInfo::Unverified, |did| { + TspVerifiedInfo::Verified { + did: did.to_string(), + } + }); self.verified_info = verified_info; self.user_id = Some(user_id); @@ -246,7 +268,9 @@ impl TspVerifyUser { impl TspVerifyUserRef { pub fn show(&self, cx: &mut Cx, user_id: OwnedUserId) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show(cx, user_id); } } diff --git a/src/tsp/wallet_entry/mod.rs b/src/tsp/wallet_entry/mod.rs index 2c2de8ab4..991bd0d5b 100644 --- a/src/tsp/wallet_entry/mod.rs +++ b/src/tsp/wallet_entry/mod.rs @@ -1,12 +1,18 @@ - use std::cell::RefCell; use makepad_widgets::*; use crate::{ app::ConfirmDeleteAction, - shared::{confirmation_modal::ConfirmationModalContent, popup_list::{enqueue_popup_notification, PopupKind}}, - tsp::{submit_tsp_request, tsp_settings_screen::{WalletStatus, WalletStatusAndDefault}, TspRequest, TspWalletMetadata} + shared::{ + confirmation_modal::ConfirmationModalContent, + popup_list::{enqueue_popup_notification, PopupKind}, + }, + tsp::{ + submit_tsp_request, + tsp_settings_screen::{WalletStatus, WalletStatusAndDefault}, + TspRequest, TspWalletMetadata, + }, }; script_mod! { @@ -108,26 +114,37 @@ script_mod! { } - /// A view showing the details of a single TSP wallet (one entry in the wallets list). #[derive(Script, ScriptHook, Widget)] pub struct WalletEntry { - #[deref] view: View, + #[deref] + view: View, - #[rust] metadata: Option, + #[rust] + metadata: Option, } impl Widget for WalletEntry { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); - let Some(metadata) = self.metadata.as_ref() else { return }; + let Some(metadata) = self.metadata.as_ref() else { + return; + }; if let Event::Actions(actions) = event { - if self.view.button(cx, ids!(set_default_wallet_button)).clicked(actions) { + if self + .view + .button(cx, ids!(set_default_wallet_button)) + .clicked(actions) + { submit_tsp_request(TspRequest::SetDefaultWallet(metadata.clone())); } - if self.view.button(cx, ids!(remove_wallet_button)).clicked(actions) { + if self + .view + .button(cx, ids!(remove_wallet_button)) + .clicked(actions) + { let metadata_clone = metadata.clone(); let content = ConfirmationModalContent { title_text: "Remove Wallet".into(), @@ -135,7 +152,8 @@ impl Widget for WalletEntry { "Are you sure you want to remove the wallet \"{}\" \ from the list?\n\nThis won't delete the actual wallet file.", metadata.wallet_name - ).into(), + ) + .into(), accept_button_text: Some("Remove".into()), on_accept_clicked: Some(Box::new(move |_cx| { submit_tsp_request(TspRequest::RemoveWallet(metadata_clone)); @@ -145,7 +163,11 @@ impl Widget for WalletEntry { cx.action(ConfirmDeleteAction::Show(RefCell::new(Some(content)))); } - if self.view.button(cx, ids!(delete_wallet_button)).clicked(actions) { + if self + .view + .button(cx, ids!(delete_wallet_button)) + .clicked(actions) + { // TODO: Implement the delete wallet feature. enqueue_popup_notification( "Delete wallet feature is not yet implemented.", @@ -165,35 +187,23 @@ impl Widget for WalletEntry { self.metadata = Some(metadata.clone()); } - self.label(cx, ids!(wallet_name)).set_text( - cx, - &metadata.wallet_name, - ); - self.label(cx, ids!(wallet_path)).set_text( - cx, - metadata.url.as_url_unencoded() - ); + self.label(cx, ids!(wallet_name)) + .set_text(cx, &metadata.wallet_name); + self.label(cx, ids!(wallet_path)) + .set_text(cx, metadata.url.as_url_unencoded()); // There is a weird makepad bug where if we re-style one instance of the // `set_default_wallet_button` in one WalletEntry, all other instances of that button // get their styling messed up in weird ways. // So, as a workaround, we just hide the button entirely and show a `is_default_label_view` instead. - self.view(cx, ids!(is_default_label_view)).set_visible( - cx, - sd.is_default - ); - self.view(cx, ids!(not_found_label_view)).set_visible( - cx, - sd.status == WalletStatus::NotFound, - ); - self.button(cx, ids!(set_default_wallet_button)).set_visible( - cx, - !sd.is_default && sd.status != WalletStatus::NotFound, - ); - self.button(cx, ids!(delete_wallet_button)).set_visible( - cx, - sd.status != WalletStatus::NotFound, - ); + self.view(cx, ids!(is_default_label_view)) + .set_visible(cx, sd.is_default); + self.view(cx, ids!(not_found_label_view)) + .set_visible(cx, sd.status == WalletStatus::NotFound); + self.button(cx, ids!(set_default_wallet_button)) + .set_visible(cx, !sd.is_default && sd.status != WalletStatus::NotFound); + self.button(cx, ids!(delete_wallet_button)) + .set_visible(cx, sd.status != WalletStatus::NotFound); self.view.draw_walk(cx, scope, walk) } diff --git a/src/utils.rs b/src/utils.rs index aa3ac8143..25d68db19 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,11 +1,22 @@ -use std::{borrow::Cow, ops::{Deref, DerefMut}, time::SystemTime}; +use std::{ + borrow::Cow, + ops::{Deref, DerefMut}, + time::SystemTime, +}; use serde::{Deserialize, Serialize}; use url::Url; use unicode_segmentation::UnicodeSegmentation; use chrono::{DateTime, Duration, Local, TimeZone}; use makepad_widgets::{Cx, Event, ImageRef, error, image_cache::ImageError}; -use matrix_sdk::{media::{MediaFormat, MediaThumbnailSettings}, ruma::{api::client::media::get_content_thumbnail::v3::Method, MilliSecondsSinceUnixEpoch, OwnedRoomId, RoomId}, RoomDisplayName}; +use matrix_sdk::{ + media::{MediaFormat, MediaThumbnailSettings}, + ruma::{ + api::client::media::get_content_thumbnail::v3::Method, MilliSecondsSinceUnixEpoch, + OwnedRoomId, RoomId, + }, + RoomDisplayName, +}; use matrix_sdk_ui::timeline::{EventTimelineItem, PaginationError, TimelineDetails}; use crate::{ @@ -16,7 +27,6 @@ use crate::{ /// The scheme for GEO links, used for location messages in Matrix. pub const GEO_URI_SCHEME: &str = "geo:"; - /// A wrapper type that implements the `Debug` trait for non-`Debug` types. pub struct DebugWrapper(T); impl std::fmt::Debug for DebugWrapper { @@ -58,16 +68,16 @@ pub fn is_interactive_hit_event(event: &Event) -> bool { matches!( event, Event::MouseDown(..) - | Event::MouseUp(..) - | Event::MouseMove(..) - | Event::MouseLeave(..) - | Event::TouchUpdate(..) - | Event::Scroll(..) - | Event::KeyDown(..) - | Event::KeyUp(..) - | Event::TextInput(..) - | Event::TextCopy(..) - | Event::TextCut(..) + | Event::MouseUp(..) + | Event::MouseMove(..) + | Event::MouseLeave(..) + | Event::TouchUpdate(..) + | Event::Scroll(..) + | Event::KeyDown(..) + | Event::KeyUp(..) + | Event::TextInput(..) + | Event::TextCopy(..) + | Event::TextCut(..) ) } @@ -94,7 +104,6 @@ impl ImageFormat { /// /// Returns an error if either load fails or if the image format is unknown. pub fn load_png_or_jpg(img: &ImageRef, cx: &mut Cx, data: &[u8]) -> Result<(), ImageError> { - fn attempt_both(img: &ImageRef, cx: &mut Cx, data: &[u8]) -> Result<(), ImageError> { img.load_png_from_data(cx, data) .or_else(|_| img.load_jpg_from_data(cx, data)) @@ -130,14 +139,16 @@ pub fn load_png_or_jpg(img: &ImageRef, cx: &mut Cx, data: &[u8]) -> Result<(), I ); path.push(filename); path.set_extension("unknown"); - error!("Failed to load PNG/JPG: {err}. Dumping bad image: {:?}", path); + error!( + "Failed to load PNG/JPG: {err}. Dumping bad image: {:?}", + path + ); let _ = std::fs::write(path, data) .inspect_err(|e| error!("Failed to write bad image to disk: {e}")); } res } - /// Parses a CSS-style hex color string into a `Vec4` with RGBA components in `[0.0, 1.0]`. /// /// Supports the following formats (with or without a leading `#`): @@ -211,7 +222,6 @@ pub enum VecDiff { Truncate { length: usize }, } - pub fn unix_time_millis_to_datetime(millis: MilliSecondsSinceUnixEpoch) -> Option> { let millis: i64 = millis.get().into(); Local.timestamp_millis_opt(millis).single() @@ -338,10 +348,10 @@ pub fn replace_linebreaks_separators<'a>(s: &'a str, is_html: bool) -> Cow<'a, s /// pub fn remove_mx_reply(html_message_body: &str) -> &str { const MX_REPLY_START: &str = ""; - const MX_REPLY_END: &str = ""; + const MX_REPLY_END: &str = ""; if html_message_body.trim().starts_with(MX_REPLY_START) { if let Some(end) = html_message_body.find(MX_REPLY_END) { - if let Some(after) = html_message_body.get(end + MX_REPLY_END.len() ..) { + if let Some(after) = html_message_body.get(end + MX_REPLY_END.len()..) { return after; } } @@ -361,9 +371,13 @@ pub fn stringify_join_leave_error( // We get the string representation of the error and then search for the "got" state. matrix_sdk::Error::WrongRoomState(wrs) => { if was_join && wrs.to_string().contains(", got: Joined") { - Some(format!("Failed to join {room_name_id}: it has already been joined.")) + Some(format!( + "Failed to join {room_name_id}: it has already been joined." + )) } else if !was_join && wrs.to_string().contains(", got: Left") { - Some(format!("Failed to leave {room_name_id}: it has already been left.")) + Some(format!( + "Failed to leave {room_name_id}: it has already been left." + )) } else { None } @@ -372,27 +386,35 @@ pub fn stringify_join_leave_error( // This avoids the weird "no known servers" error, which is misleading and incorrect. // See: . matrix_sdk::Error::Http(error) - if error.as_client_api_error().is_some_and(|e| e.status_code.as_u16() == 404) => + if error + .as_client_api_error() + .is_some_and(|e| e.status_code.as_u16() == 404) => { Some(format!( "Failed to {} {room_name_id}: the room no longer exists on the server.{}", if was_join { "join" } else { "leave" }, - if was_join && was_invite { "\n\nYou may safely reject this invite." } else { "" }, + if was_join && was_invite { + "\n\nYou may safely reject this invite." + } else { + "" + }, )) } _ => None, }; - msg_opt.unwrap_or_else(|| format!( - "Failed to {} {}: {}", - match (was_join, was_invite) { - (true, true) => "accept invite to", - (true, false) => "join", - (false, true) => "reject invite to", - (false, false) => "leave", - }, - room_name_id, - error - )) + msg_opt.unwrap_or_else(|| { + format!( + "Failed to {} {}: {}", + match (was_join, was_invite) { + (true, true) => "accept invite to", + (true, false) => "join", + (false, true) => "reject invite to", + (false, false) => "leave", + }, + room_name_id, + error + ) + }) } /// Returns a string error message for pagination errors, @@ -409,10 +431,12 @@ pub fn stringify_pagination_error( match sdk_error { matrix_sdk::Error::Http(http_error) => match http_error.deref() { matrix_sdk::HttpError::Reqwest(reqwest_error) if reqwest_error.is_timeout() => { - return Some(format!("Failed to load earlier messages in \"{room_name}\": request timed out.")); + return Some(format!( + "Failed to load earlier messages in \"{room_name}\": request timed out." + )); } _ => {} - } + }, _ => {} } None @@ -420,14 +444,17 @@ pub fn stringify_pagination_error( match error { TimelineError::PaginationError(PaginationError::NotSupported) => { - return format!("Failed to load earlier messages in \"{room_name}\": \ - pagination is not supported in this timeline focus mode."); + return format!( + "Failed to load earlier messages in \"{room_name}\": \ + pagination is not supported in this timeline focus mode." + ); } - TimelineError::PaginationError(PaginationError::Paginator(PaginatorError::SdkError(sdk_error))) - | TimelineError::EventCacheError(EventCacheError::BackpaginationError(sdk_error)) => - { + TimelineError::PaginationError(PaginationError::Paginator(PaginatorError::SdkError( + sdk_error, + ))) + | TimelineError::EventCacheError(EventCacheError::BackpaginationError(sdk_error)) => { if let Some(message) = match_sdk_error(sdk_error) { - return message; + return message; } } _ => {} @@ -435,8 +462,6 @@ pub fn stringify_pagination_error( format!("Failed to load earlier messages in \"{room_name}\": {error}") } - - /// Formats a given Unix timestamp in milliseconds into a relative human-readable date. /// /// # Cases: @@ -463,7 +488,11 @@ pub fn relative_format(millis: MilliSecondsSinceUnixEpoch) -> Option { if duration < Duration::seconds(60) { Some("Now".to_string()) } else if duration < Duration::minutes(60) { - let minutes_text = if duration.num_minutes() == 1 { "min" } else { "mins" }; + let minutes_text = if duration.num_minutes() == 1 { + "min" + } else { + "mins" + }; Some(format!("{} {} ago", duration.num_minutes(), minutes_text)) } else if duration < Duration::hours(24) && now.date_naive() == datetime.date_naive() { Some(format!("{}", datetime.format("%H:%M"))) // "HH:MM" format for today @@ -485,12 +514,9 @@ pub fn relative_format(millis: MilliSecondsSinceUnixEpoch) -> Option { /// skipping any leading "@" characters. pub fn user_name_first_letter(user_name: &str) -> Option<&str> { use unicode_segmentation::UnicodeSegmentation; - user_name - .graphemes(true) - .find(|&g| g != "@") + user_name.graphemes(true).find(|&g| g != "@") } - /// A const-compatible version of [`MediaFormat`]. #[derive(Clone, Debug)] pub enum MediaFormatConst { @@ -538,26 +564,23 @@ impl From for MediaThumbnailSettings { } } - /// The thumbnail format to use for user and room avatars. -pub const AVATAR_THUMBNAIL_FORMAT: MediaFormatConst = MediaFormatConst::Thumbnail( - MediaThumbnailSettingsConst { +pub const AVATAR_THUMBNAIL_FORMAT: MediaFormatConst = + MediaFormatConst::Thumbnail(MediaThumbnailSettingsConst { method: Method::Scale, width: 40, height: 40, animated: false, - } -); + }); /// The thumbnail format to use for regular media images. -pub const MEDIA_THUMBNAIL_FORMAT: MediaFormatConst = MediaFormatConst::Thumbnail( - MediaThumbnailSettingsConst { +pub const MEDIA_THUMBNAIL_FORMAT: MediaFormatConst = + MediaFormatConst::Thumbnail(MediaThumbnailSettingsConst { method: Method::Scale, width: 400, height: 400, animated: false, - } -); + }); /// Removes leading whitespace and HTML whitespace tags (`

You can enter a room/space address using either:

  • An alias, starting with #, like #robrix:matrix.org.
  • An ID, starting with !, like !moVNEIUPxJZpxRHDUv:matrix.org.
  • A Matrix link, like https:matrix.to/... or matrix:....
", + "add_room.popup.cannot_add_self": "You cannot add yourself as a friend.", + "add_room.popup.invalid_user_id": "Invalid Matrix user ID.\n\nError: {error}", + "add_room.popup.parse_error": "Could not parse the text as a valid room address.\nError: {error}.", + "add_room.popup.fetch_error": "Failed to fetch room info.\n\nError: {error}.", + "add_room.popup.knock_success": "Successfully knocked on {room_type} {room_name}.", + "add_room.popup.knock_failed": "Failed to knock on room.\n\nError: {error}.", + "add_room.popup.join_success": "Successfully joined {room_type} {room_name}.", + "add_room.popup.join_failed": "Failed to join room.\n\nError: {error}.", + "add_room.popup.created_room_success": "Successfully created room \"{room_name}\".", + "add_room.popup.created_room_space_link_suffix": "\n\nThe room was created, but it could not be linked into the selected space.\nError: {error}", + "add_room.popup.create_room_failed": "Failed to create room \"{room_name}\".\n\nError: {error}", + "add_room.feedback.create_room_failed": "Failed to create room: {error}", + "add_room.feedback.creating_room": "Creating room...", + "add_room.feedback.room_created_syncing": "Room created. Syncing it into the space...", + "add_room.feedback.room_created_link_failed_opening": "Room created, but linking it into the space failed. Opening the room...", + "add_room.feedback.room_created_opening": "Room created. Opening the room...", + "add_room.loading.fetching": "Fetching {target}...", + "add_room.fetched.room_name.unnamed": "Unnamed {room_or_space_uc}, ID: {room_id}", + "add_room.fetched.main_alias_and_id": "Main {room_or_space_uc} Alias and ID", + "add_room.fetched.alias.not_set": "not set", + "add_room.fetched.alias": "Alias: {alias}", + "add_room.fetched.id": "ID: {room_id}", + "add_room.fetched.topic_title": "{room_or_space_uc} Topic", + "add_room.fetched.topic.not_set_html": "No topic set", + "add_room.summary.already_joined": "You have already joined this {room_or_space_lc}.", + "add_room.summary.banned": "You have been banned from this {room_or_space_lc}.", + "add_room.summary.already_invited": "You have already been invited to this {room_or_space_lc}.", + "add_room.summary.already_knocked": "You have already knocked on this {room_or_space_lc}.", + "add_room.summary.previously_left": "You previously left this {room_or_space_lc}.", + "add_room.summary.member_count": "This is a {directness} {room_or_space_lc} with {num_members} {member_word}.", + "add_room.summary.knocked_waiting": "You have knocked on this {room_or_space_lc} and must now wait for someone to invite you in.", + "add_room.summary.joined_loading": "You have joined this {room_or_space_lc}. It is now being loaded from the homeserver; please wait...", + "add_room.summary.loaded": "You have {verb} this {room_or_space_lc}.", + "add_room.button.go_to": "Go to {room_or_space_lc}", + "add_room.button.cannot_join_until_unbanned": "Cannot join until un-banned", + "add_room.button.go_to_invitation": "Go to invitation", + "add_room.button.knock_again": "Knock again (be nice!)", + "add_room.button.rejoin": "Re-join this {room_or_space_lc}", + "add_room.button.rejoin_requires_invite": "Re-joining {room_or_space_lc} requires an invite", + "add_room.button.knock_to_rejoin": "Knock to re-join {room_or_space_lc}", + "add_room.button.rejoin_requires_other_membership": "Re-joining {room_or_space_lc} requires an invite or other room membership", + "add_room.button.not_allowed_to_rejoin": "Not allowed to re-join this {room_or_space_lc}", + "add_room.button.join": "Join this {room_or_space_lc}", + "add_room.button.join_requires_invite": "Joining {room_or_space_lc} requires an invite", + "add_room.button.knock_to_join": "Knock to join {room_or_space_lc}", + "add_room.button.join_requires_other_membership": "Joining {room_or_space_lc} requires an invite or other room membership", + "add_room.button.not_allowed_to_join": "Not allowed to join this {room_or_space_lc}", + "add_room.button.successfully_knocked": "Successfully knocked!", + "add_room.button.successfully_joined": "Successfully joined!", + "add_room.button.go_to_loaded": "Go to {adj} {room_or_space_lc}", + "add_room.word.direct": "direct", + "add_room.word.regular": "regular", + "add_room.word.member": "member", + "add_room.word.members": "members", + "add_room.word.room_lc": "room", + "add_room.word.space_lc": "space", + "add_room.word.room_uc": "Room", + "add_room.word.space_uc": "Space", + "add_room.word.verb.invited": "been invited to", + "add_room.word.verb.joined": "fully joined", + "add_room.word.adj.invited": "invited", + "add_room.word.adj.joined": "joined", + + "settings.account.title": "Account Settings", + "settings.account.section.your_avatar": "Your Avatar:", + "settings.account.section.your_display_name": "Your Display Name:", + "settings.account.section.your_user_id": "Your User ID:", + "settings.account.section.other_actions": "Other actions:", + "settings.account.display_name.placeholder": "Add a display name...", + "settings.account.user_id.not_logged_in": "You are not logged in.", + "settings.account.button.upload_avatar": "Upload Avatar", + "settings.account.button.delete_avatar": "Delete Avatar", + "settings.account.button.cancel": "Cancel", + "settings.account.button.save_name": "Save Name", + "settings.account.button.manage_account": "Manage Account", + "settings.account.button.log_out": "Log out", + "settings.account.button.logging_out": "Logging out...", + "settings.account.tooltip.copy_user_id": "Copy User ID", + "settings.account.popup.avatar_updated": "Successfully updated avatar.", + "settings.account.popup.avatar_deleted": "Successfully deleted avatar.", + "settings.account.popup.avatar_upload_not_implemented": "Avatar uploading is not yet implemented.", + "settings.account.popup.deleting_avatar": "Deleting your avatar...", + "settings.account.popup.display_name_updated": "Successfully updated display name.", + "settings.account.popup.display_name_removed": "Successfully removed display name.", + "settings.account.popup.uploading_display_name": "Uploading new display name...", + "settings.account.popup.copied_user_id": "Copied your User ID to the clipboard.", + "settings.account.popup.account_management_not_implemented": "Account management is not yet implemented.", + "settings.account.modal.delete_avatar.title": "Delete Avatar", + "settings.account.modal.delete_avatar.body": "Are you sure you want to delete your avatar?", + "settings.account.modal.delete_avatar.accept": "Delete", + + "settings.labs.app_service.title": "App Service", + "settings.labs.app_service.description": "Enable Matrix app service support here. Robrix stays a normal Matrix client: it binds BotFather to a room and sends the matching slash commands.", + "settings.labs.app_service.enable_label": "Enable App Service", + "settings.labs.app_service.botfather_user_id": "BotFather User ID:", + "settings.labs.app_service.botfather_placeholder": "bot or @bot:server", + "settings.labs.app_service.button.enable": "Enable App Service", + "settings.labs.app_service.button.disable": "Disable App Service", + "settings.labs.app_service.button.save": "Save", + "settings.labs.app_service.popup.saved": "Saved Matrix app service settings.", + + "invite_screen.message.invited_by": "has invited you to join:", + "invite_screen.message.invited_generic": "You have been invited to join:", + "invite_screen.button.reject": "Reject Invite", + "invite_screen.button.join": "Join Room", + "invite_screen.button.joining": "Joining...", + "invite_screen.button.rejecting": "Rejecting...", + "invite_screen.button.joined": "Joined!", + "invite_screen.popup.joined_success": "Successfully joined room.", + "invite_screen.popup.rejected_success": "Successfully rejected invite.", + "invite_screen.popup.reject_failed": "Failed to reject invite: {error}", + "invite_screen.completion.rejected": "Invite successfully rejected. You may close this invite.", + "invite_modal.title.invite_to_room_name": "Invite to {room_name}", + "invite_modal.input.placeholder": "@user:example.org", + "invite_modal.button.cancel": "Cancel", + "invite_modal.button.invite": "Invite", + "invite_modal.button.okay": "Okay", + "invite_modal.status.enter_user_id": "Please enter a user ID.", + "invite_modal.status.sending": "Sending invite...", + "invite_modal.status.invalid_user_id": "Invalid User ID. Expected format: @user:server.xyz", + "invite_modal.status.success_invited": "Successfully invited {user_id}!", + "invite_modal.status.send_failed": "Failed to send invite: {error}", + "rooms_list_entry.invited.by_name_and_user": "Invited by {display_name} ({user_id})", + "rooms_list_entry.invited.by_user": "Invited by {user_id}", + "rooms_list_entry.invited.generic": "You were invited", + + "loading_pane.title.default": "Loading content...", + "loading_pane.title.searching_older": "Searching older messages...", + "loading_pane.status.searching_event": "Looking for event {target_event_id}\n\nFetched {events_paginated} messages so far...", + "loading_pane.title.error": "Error loading content", + "loading_pane.button.cancel": "Cancel", + "loading_pane.button.okay": "Okay", + + "rooms_list_header.title.all_rooms": "All Rooms", + "rooms_list_header.popup.offline": "Cannot reach the Matrix homeserver. Please check your connection.", + "rooms_list_header.tooltip.syncing": "Syncing...", + "rooms_list_header.tooltip.offline": "Offline", + "rooms_list_header.tooltip.synced": "Fully synced", + + "room_filter_input.placeholder": "Filter rooms & spaces...", + "search_messages.button.todo": "Search (TODO)", + + "welcome_screen.title": "Welcome to Robrix!", + "welcome_screen.body_html": "

Our Matrix client is under heavy development. Currently, you can access the rooms and spaces that you've joined in other clients.


But don't worry, we're constantly expanding the featureset of Robrix!


Look for the latest announcements in our Matrix channel:

#robrix:matrix.org

", + + "room_screen.bot.delete.error.empty_user_id": "Please enter the bot Matrix user ID to delete.", + "room_screen.bot.delete.error.invalid_user_id": "Invalid Matrix user ID: {full_user_id}", + "room_screen.bot.delete.error.current_user_unavailable": "Current user ID is unavailable, so the bot homeserver cannot be resolved.", + "room_screen.tooltip.reacted_with_suffix": " reacted with: {reaction}", + "room_screen.modal.invite.title": "Send Invitation", + "room_screen.modal.invite.body": "Are you sure you want to invite {username} to this room?", + "room_screen.modal.invite.accept": "Invite", + "room_screen.popup.invite.sent_success": "Sent invite successfully.", + "room_screen.popup.invite.failed": "Failed to send invite.\n\nError: {error}", + "room_screen.popup.app_service.enable_before_create": "Enable App Service before creating bots in a room.", + "room_screen.popup.app_service.bind_before_create": "Bind BotFather to this room before creating a bot.", + "room_screen.popup.app_service.state_unavailable_create": "App state is unavailable, so bot creation is temporarily unavailable.", + "room_screen.popup.app_service.enable_before_delete": "Enable App Service before deleting bots in a room.", + "room_screen.popup.app_service.bind_before_delete": "Bind BotFather to this room before deleting a bot.", + "room_screen.popup.app_service.state_unavailable_delete": "App state is unavailable, so bot deletion is temporarily unavailable.", + "room_screen.popup.app_service.room_not_bound": "This room is not currently bound to BotFather.", + "room_screen.popup.app_service.removing_botfather": "Removing BotFather {bot_user_id} from this room...", + "room_screen.popup.app_service.state_unavailable_unbind": "App state is unavailable, so BotFather could not be removed from this room.", + "room_screen.popup.bot.main_timeline_only": "Bot commands are only supported in the main room timeline.", + "room_screen.popup.bot.enable_in_settings_before_bot": "Enable App Service in Settings before using /bot.", + "room_screen.popup.bot.bind_before_bot": "Bind BotFather to this room before using /bot.", + "room_screen.popup.bot.enable_before_commands": "Enable App Service before using BotFather commands in a room.", + "room_screen.popup.bot.bind_before_commands": "Bind BotFather to this room before using BotFather commands.", + "room_screen.popup.bot.creation_main_timeline_only": "Bot creation commands are only supported in the main room timeline.", + "room_screen.popup.bot.sent_listbots": "Sent `/listbots` to BotFather.", + "room_screen.popup.bot.sent_bothelp": "Sent `/bothelp` to BotFather.", + "room_screen.popup.bot.sent_createbot": "Sent `/createbot` for `{username}` to BotFather.", + "room_screen.popup.bot.sent_deletebot": "Sent `/deletebot` for {matrix_user_id} to BotFather.", + "room_screen.popup.bot.state_unavailable_create_command": "App state is unavailable, so the create-bot command was not sent.", + "room_screen.popup.bot.state_unavailable_delete_command": "App state is unavailable, so the delete-bot command was not sent.", + "room_screen.fallback.unnamed_room": "Unnamed Room", + "room_screen.unsupported.prefix": "[Unsupported]", + "room_screen.read_marker.new_messages": "New Messages", + "room_screen.top_space.loading_earlier": "Loading earlier messages...", + "room_screen.loading.found_related_message": "Successfully found replied-to message!", + "room_screen.loading.related_message_not_found": "Unable to find related message; it may have been deleted.", + "room_screen.popup.pin.pinned_success": "Successfully pinned event.", + "room_screen.popup.pin.unpinned_success": "Successfully unpinned event.", + "room_screen.popup.pin.already_pinned": "Message was already pinned.", + "room_screen.popup.pin.already_unpinned": "Message was already unpinned.", + "room_screen.popup.pin.pin_failed": "Failed to pin event. Error: {error}", + "room_screen.popup.pin.unpin_failed": "Failed to unpin event. Error: {error}", + "room_screen.popup.already_viewing_room": "You are already viewing that room.", + "room_screen.popup.open_url_failed": "Could not open URL: {url}", + "room_screen.popup.message.reply_not_found": "Could not find message in timeline to reply to. Please try again.", + "room_screen.popup.message.edit_not_found": "Could not find message in timeline to edit. Please try again.", + "room_screen.popup.message.no_recent_editable": "No recent message available to edit. Please manually select a message to edit.", + "room_screen.popup.message.cannot_pin": "This event cannot be pinned.", + "room_screen.popup.message.cannot_unpin": "This event cannot be unpinned.", + "room_screen.popup.message.copy_text_not_found": "Could not find message in timeline to copy text from. Please try again.", + "room_screen.popup.message.copy_html_not_found": "Could not find message in timeline to copy HTML from. Please try again.", + "room_screen.popup.message.copy_link_failed": "Couldn't create permalink to message. Please try again.", + "room_screen.popup.message.view_source_not_found": "Could not find message in timeline to view source.", + "room_screen.popup.message.related_not_found": "Could not find related message or event in timeline.", + "room_screen.modal.delete_message.title": "Delete Message", + "room_screen.modal.delete_message.body": "Are you sure you want to delete this message? This cannot be undone.", + "room_screen.modal.delete_message.accept": "Delete", + "room_screen.server_notice.title": "Server notice:", + "room_screen.server_notice.notice_type": "Notice type", + "room_screen.server_notice.limit_type": "Limit type", + "room_screen.server_notice.admin_contact": "Admin contact", + "room_screen.server_notice.username": "Server notice", + "room_screen.verification.sent_prefix": "Sent a ", + "room_screen.verification.request": "verification request", + "room_screen.verification.sent_to_suffix": " to {user_id}.", + "room_screen.verification.supported_methods": "Supported methods", + "room_screen.image.unsupported_type": "{body}\n\nUnsupported type {mime}", + "room_screen.image.failed_to_display": "{body}\n\nFailed to display image: {error}", + "room_screen.image.failed_to_fetch": "{body}\n\nFailed to fetch image from {mxc_uri}", + "room_screen.image.encrypted_todo": "{body}\n\n[TODO] fetch encrypted image at {url}", + "room_screen.image.no_source_url": "{body}\n\nImage message had no source URL.", + "room_screen.file.preview_html": "{filename}{size}{caption}
File download not yet supported.", + "room_screen.audio.preview_html": "Audio: {filename}{mime}{duration}{size}{caption}
Audio playback not yet supported.", + "room_screen.video.preview_html": "Video: {filename}{mime}{duration}{size}{dimensions}{caption}
Video playback not yet supported.", + "room_screen.location.label": "Location:", + "room_screen.location.open_osm": "Open in OpenStreetMap", + "room_screen.location.open_google_maps": "Open in Google Maps", + "room_screen.location.open_apple_maps": "Open in Apple Maps", + "room_screen.location.invalid_html": "[Location invalid] {body}", + "room_screen.redacted.self_with_reason": "⛔ Deleted their own message. Reason: \"{reason}\".", + "room_screen.redacted.self": "⛔ Deleted their own message.", + "room_screen.redacted.other_with_reason": "⛔ {redactor} deleted this message. Reason: \"{reason}\".", + "room_screen.redacted.other": "⛔ {redactor} deleted this message.", + "room_screen.redacted.generic": "⛔ Message deleted.", + "room_screen.reply_preview.error_username": "[Error fetching username]", + "room_screen.reply_preview.error_event": "[Error fetching replied-to event]", + "room_screen.reply_preview.loading_username": "[Loading username...]", + "room_screen.reply_preview.loading_event": "[Loading replied-to message...]", + "room_screen.thread_summary.loading_latest_reply": "Loading latest reply...", + "room_screen.thread_summary.error_latest_reply": "Unable to load latest reply", + "room_screen.thread_summary.one_reply": "1 reply", + "room_screen.thread_summary.n_replies": "{n} replies", + "room_screen.small_state.invite_to_room": "Invite to Room", + "room_screen.app_service.sender_name": "BotFather", + "room_screen.app_service.sender_tag": "bot", + "room_screen.app_service.title": "App Service Actions", + "room_screen.app_service.subtitle": "Create a bot through BotFather. Robrix only sends the matching slash command.", + "room_screen.app_service.timestamp_now": "now", + "room_screen.app_service.button.create_bot": "Create Bot", + "room_screen.app_service.button.list_bots": "List Bots", + "room_screen.app_service.button.delete_bot": "Delete Bot", + "room_screen.app_service.button.bot_help": "Bot Help", + "room_screen.app_service.button.unbind": "Unbind", + + "spaces_bar.tooltip.unknown_space_name": "Unknown Space Name", + "spaces_bar.status.none_matching": "Found no\nmatching spaces.", + "spaces_bar.status.none_joined": "Found no\njoined spaces.", + "spaces_bar.status.one_matching": "Found 1\nmatching space.", + "spaces_bar.status.one_joined": "Found 1\njoined space.", + "spaces_bar.status.n_matching": "Found {count}\nmatching spaces.", + "spaces_bar.status.n_joined": "Found {count}\njoined spaces.", + "spaces_bar.status.many_matching": "Found 99+\nmatching spaces.", + "spaces_bar.status.many_joined": "Found 99+\njoined spaces.", + + "tsp.settings.title": "TSP Wallet Settings", + "tsp.settings.section.active_identity": "Your active identity:", + "tsp.settings.section.wallets": "Your Wallets:", + "tsp.settings.wallet.none": "No wallets found. Create or import a wallet.", + "tsp.settings.identity.none_set": "No default identity has been set.", + "tsp.settings.button.republish_identity": "Republish Current Identity to DID Server", + "tsp.settings.button.republishing_now": "Republishing DID now...", + "tsp.settings.button.create_identity": "Create New Identity (DID)", + "tsp.settings.button.create_wallet": "Create New Wallet", + "tsp.settings.button.import_wallet": "Import Existing Wallet", + "tsp.settings.popup.wallet.removed": "Removed wallet \"{wallet_name}\".", + "tsp.settings.popup.wallet.default_removed_warning": "The default wallet was removed.\n\nTSP features will not work properly until you set a default wallet.", + "tsp.settings.popup.wallet.set_default_failed": "Failed to set default wallet, could not find or open selected wallet.", + "tsp.settings.popup.wallet.open_failed": "Failed to open wallet: {error}", + "tsp.settings.popup.wallet.import_not_implemented": "Importing an existing wallet is not yet implemented.", + "tsp.settings.popup.wallet.none_found": "No TSP wallets found.\n\nPlease create or import a wallet.", + "tsp.settings.popup.wallet.no_default": "No default TSP wallet is set.\n\nPlease select or create a default wallet.", + "tsp.settings.popup.identity.republish_success": "Successfully republished identity \"{did}\" to the DID server.", + "tsp.settings.popup.identity.republish_failed": "Failed to republish identity to the DID server: {error}", + "tsp.settings.popup.identity.copied": "Copied your default TSP identity to the clipboard.", + "tsp.settings.popup.identity.none_set": "No default TSP identity has been set.", + "tsp.settings.popup.identity.must_set_default": "You must set a default TSP identity to be republished.", + "tsp_dummy.message.disabled": "TSP features are not included in this build.\nTo use TSP, build Robrix with the 'tsp' feature enabled.", + "tsp.wallet_entry.default_label": "✅ Default", + "tsp.wallet_entry.not_found": "Wallet not found!", + "tsp.wallet_entry.button.set_default": "Set As Default", + "tsp.wallet_entry.button.remove": "Remove From List", + "tsp.wallet_entry.button.delete": "Delete Wallet", + "tsp.wallet_entry.modal.remove.title": "Remove Wallet", + "tsp.wallet_entry.modal.remove.body": "Are you sure you want to remove the wallet \"{wallet_name}\" from the list?\n\nThis won't delete the actual wallet file.", + "tsp.wallet_entry.modal.remove.accept": "Remove", + "tsp.wallet_entry.popup.delete_not_implemented": "Delete wallet feature is not yet implemented.", + + "app.room_filter.search_results_title": "Search Results", + "app.room_filter.empty_hint": "Type to search rooms and spaces...", + "app.room_filter.no_local_results": "No local results for \"{keywords}\". Choose a type below to search server.", + "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.kind.people": "people", + "app.room_filter.remote.kind.rooms": "rooms", + "app.room_filter.remote.kind.spaces": "spaces", + + "rooms_list.category.invites": "Invites", + "rooms_list.category.favorites": "Favorites", + "rooms_list.category.rooms": "Rooms", + "rooms_list.category.people": "People", + "rooms_list.category.low_priority": "Low Priority", + "rooms_list.category.left_rooms": "Left Rooms", + + "space_lobby.entry.explore_space": "Explore this Space", + "space_lobby.header.welcome": "Welcome to the space:", + "space_lobby.header.public_space": "🌐 Public space", + "space_lobby.header.private_space": "🔒 Private space", + "space_lobby.header.member_one": "1 member", + "space_lobby.header.member_n": "{count} members", + "space_lobby.header.button.new_room": "New Room", + "space_lobby.header.button.invite": "Invite", + "space_lobby.status.loading_rooms_spaces": "Loading rooms and spaces...", + "space_lobby.status.no_rooms_spaces": "No rooms or spaces found.", + "space_lobby.status.loading": "Loading...", + "space_lobby.item.button.join": "Join", + "space_lobby.item.button.view": "View", + "space_lobby.item.button.leave": "Leave", + "space_lobby.item.state.joined": "✅ Joined", + "space_lobby.item.state.left": "Left", + "space_lobby.item.state.invited": "Invited", + "space_lobby.item.state.knocked": "Knocked", + "space_lobby.item.state.banned": "Banned", + "space_lobby.item.member_one": "1 member", + "space_lobby.item.member_n": "{count} members", + "space_lobby.item.child_room_one": "~{count} room", + "space_lobby.item.child_room_n": "~{count} rooms" +} diff --git a/resources/i18n/zh-CN.json b/resources/i18n/zh-CN.json new file mode 100644 index 000000000..d170c0f38 --- /dev/null +++ b/resources/i18n/zh-CN.json @@ -0,0 +1,420 @@ +{ + "settings.all_settings_title": "全部设置", + "settings.category.account": "账号", + "settings.category.preferences": "偏好", + "settings.category.labs": "实验室", + "settings.preferences.language.title": "语言", + "settings.preferences.language.application_label": "应用语言", + "settings.preferences.language.reload_hint": "选择其他语言后,应用将重新加载", + "language.option.english": "English", + "language.option.chinese_simplified": "简体中文", + + "login.title.login_to_robrix": "登录 Robrix", + "login.title.create_account": "创建你的 Robrix 账号", + "login.input.user_id": "用户 ID", + "login.input.password": "密码", + "login.input.confirm_password": "确认密码", + "login.input.homeserver": "matrix.org", + "login.label.homeserver_optional": "Homeserver URL(可选)", + "login.button.login": "登录", + "login.button.create_account": "创建账号", + "login.sso.prompt": "或者,使用 SSO 提供商登录:", + "login.account_prompt.no_account": "还没有账号?", + "login.account_prompt.already_have": "已经有账号了?", + "login.mode_toggle.sign_up_here": "去注册", + "login.mode_toggle.back_to_login": "返回登录", + "login.status.missing_user_id.title": "缺少用户 ID", + "login.status.missing_user_id.body": "请输入有效的用户 ID。", + "login.status.missing_password.title": "缺少密码", + "login.status.missing_password.body": "请输入有效密码。", + "login.status.password_mismatch.title": "两次密码不一致", + "login.status.password_mismatch.body": "请在两个密码输入框中输入相同的密码。", + "login.status.creating_account.title": "正在创建账号...", + "login.status.creating_account.body": "正在等待服务器创建你的账号...", + "login.status.logging_in.title": "正在登录...", + "login.status.logging_in.body": "正在等待登录响应...", + "login.status.logging_in_cli.title": "正在通过命令行自动登录...", + "login.status.auto_logging_in_as_user": "正在以用户 {user_id} 自动登录...", + "login.status.account_creation_failed": "账号创建失败。", + "login.status.login_failed": "登录失败。", + "login.status.okay": "确定", + "login.status.cancel": "取消", + "login_status_modal.title": "登录状态", + "login_status_modal.button.cancel": "取消", + + "room_context_menu.button.mark_unread": "标记为未读", + "room_context_menu.button.mark_read": "标记为已读", + "room_context_menu.button.favorite": "收藏", + "room_context_menu.button.unfavorite": "取消收藏", + "room_context_menu.button.set_low_priority": "设为低优先级", + "room_context_menu.button.unset_low_priority": "取消低优先级", + "room_context_menu.button.copy_link_to_room": "复制房间链接", + "room_context_menu.button.settings": "设置", + "room_context_menu.button.notifications": "通知", + "room_context_menu.button.invite": "邀请", + "room_context_menu.button.bind_botfather": "绑定 BotFather", + "room_context_menu.button.unbind_botfather": "解绑 BotFather", + "room_context_menu.button.leave_room": "离开房间", + "room_context_menu.popup.settings_not_implemented": "房间设置页面暂未实现。", + "room_context_menu.popup.notifications_not_implemented": "房间通知页面暂未实现。", + "room_context_menu.popup.removing_botfather": "正在将 BotFather {bot_user_id} 从该房间移除...", + "room_context_menu.popup.inviting_botfather": "正在邀请 BotFather {bot_user_id} 加入该房间...", + "room_context_menu.popup.bot_settings_unavailable": "当前无法获取机器人设置。", + + "add_room.title": "添加/探索房间与空间", + "add_room.section.create_new_room": "创建新房间:", + "add_room.section.add_friend": "添加好友:", + "add_room.section.join_existing": "加入已有房间或空间:", + "add_room.create_room.help.default": "你可以创建独立房间,或将房间创建到你有权限的空间下。", + "add_room.create_room.help.fixed_parent": "输入房间名后,将直接在当前空间中创建该房间。", + "add_room.create_room.dropdown.no_space": "不放入任何空间", + "add_room.create_room.dropdown.hint.choose_space": "选择一个你有权限创建子房间的空间。", + "add_room.create_room.dropdown.hint.no_creatable_spaces": "当前没有你可创建子房间的已加入空间。", + "add_room.create_room.dropdown.hint.new_room_under": "新房间将创建在:{selected_name}", + "add_room.create_room.dropdown.hint.default": "可创建独立房间,或在下拉框中选择一个空间。", + "add_room.create_room.input.placeholder": "输入新房间名称...", + "add_room.create_room.button.create": "创建房间", + "add_room.create_room.button.syncing": "同步中...", + "add_room.create_room.modal.title": "创建新房间", + "add_room.create_room.modal.subtitle": "在当前选中的空间中直接创建一个新房间。", + "add_room.button.cancel": "取消", + "add_room.add_friend.help": "输入 Matrix 用户 ID 以打开或创建一个私聊房间。", + "add_room.add_friend.input.placeholder": "输入 Matrix 用户 ID,例如 @alice:matrix.org...", + "add_room.add_friend.button": "添加好友", + "add_room.join.input.placeholder": "输入别名、ID 或 Matrix 链接...", + "add_room.join.button.go": "前往", + "add_room.join.help_html": "

你可以使用以下任一种方式输入房间/空间地址:

  • # 开头的别名,例如 #robrix:matrix.org
  • ! 开头的ID,例如 !moVNEIUPxJZpxRHDUv:matrix.org
  • Matrix 链接,例如 https:matrix.to/...matrix:...
", + "add_room.popup.cannot_add_self": "你不能把自己添加为好友。", + "add_room.popup.invalid_user_id": "无效的 Matrix 用户 ID。\n\n错误:{error}", + "add_room.popup.parse_error": "无法将输入解析为有效的房间地址。\n错误:{error}。", + "add_room.popup.fetch_error": "获取房间信息失败。\n\n错误:{error}。", + "add_room.popup.knock_success": "已成功向{room_type} {room_name} 发起敲门请求。", + "add_room.popup.knock_failed": "敲门请求失败。\n\n错误:{error}。", + "add_room.popup.join_success": "已成功加入{room_type} {room_name}。", + "add_room.popup.join_failed": "加入房间失败。\n\n错误:{error}。", + "add_room.popup.created_room_success": "已成功创建房间“{room_name}”。", + "add_room.popup.created_room_space_link_suffix": "\n\n房间已创建,但无法关联到所选空间。\n错误:{error}", + "add_room.popup.create_room_failed": "创建房间“{room_name}”失败。\n\n错误:{error}", + "add_room.feedback.create_room_failed": "创建房间失败:{error}", + "add_room.feedback.creating_room": "正在创建房间...", + "add_room.feedback.room_created_syncing": "房间已创建,正在同步到该空间...", + "add_room.feedback.room_created_link_failed_opening": "房间已创建,但关联到空间失败,正在打开房间...", + "add_room.feedback.room_created_opening": "房间已创建,正在打开房间...", + "add_room.loading.fetching": "正在获取 {target}...", + "add_room.fetched.room_name.unnamed": "未命名{room_or_space_uc},ID:{room_id}", + "add_room.fetched.main_alias_and_id": "主要{room_or_space_uc}别名与 ID", + "add_room.fetched.alias.not_set": "未设置", + "add_room.fetched.alias": "别名:{alias}", + "add_room.fetched.id": "ID:{room_id}", + "add_room.fetched.topic_title": "{room_or_space_uc}主题", + "add_room.fetched.topic.not_set_html": "未设置主题", + "add_room.summary.already_joined": "你已经加入此{room_or_space_lc}。", + "add_room.summary.banned": "你已被此{room_or_space_lc}封禁。", + "add_room.summary.already_invited": "你已被邀请到此{room_or_space_lc}。", + "add_room.summary.already_knocked": "你已经向此{room_or_space_lc}敲门过。", + "add_room.summary.previously_left": "你之前离开了此{room_or_space_lc}。", + "add_room.summary.member_count": "这是一个{directness}{room_or_space_lc},共有 {num_members} 位{member_word}。", + "add_room.summary.knocked_waiting": "你已向此{room_or_space_lc}敲门,正在等待对方邀请你加入。", + "add_room.summary.joined_loading": "你已加入此{room_or_space_lc}。正在从服务器加载,请稍候...", + "add_room.summary.loaded": "你已{verb}此{room_or_space_lc}。", + "add_room.button.go_to": "前往{room_or_space_lc}", + "add_room.button.cannot_join_until_unbanned": "解除封禁后才能加入", + "add_room.button.go_to_invitation": "前往邀请", + "add_room.button.knock_again": "再次敲门(礼貌点)", + "add_room.button.rejoin": "重新加入此{room_or_space_lc}", + "add_room.button.rejoin_requires_invite": "重新加入{room_or_space_lc}需要邀请", + "add_room.button.knock_to_rejoin": "敲门以重新加入{room_or_space_lc}", + "add_room.button.rejoin_requires_other_membership": "重新加入{room_or_space_lc}需要邀请或其他房间成员身份", + "add_room.button.not_allowed_to_rejoin": "不允许重新加入此{room_or_space_lc}", + "add_room.button.join": "加入此{room_or_space_lc}", + "add_room.button.join_requires_invite": "加入{room_or_space_lc}需要邀请", + "add_room.button.knock_to_join": "敲门以加入{room_or_space_lc}", + "add_room.button.join_requires_other_membership": "加入{room_or_space_lc}需要邀请或其他房间成员身份", + "add_room.button.not_allowed_to_join": "不允许加入此{room_or_space_lc}", + "add_room.button.successfully_knocked": "已成功敲门!", + "add_room.button.successfully_joined": "已成功加入!", + "add_room.button.go_to_loaded": "前往{adj}{room_or_space_lc}", + "add_room.word.direct": "私聊", + "add_room.word.regular": "普通", + "add_room.word.member": "成员", + "add_room.word.members": "成员", + "add_room.word.room_lc": "房间", + "add_room.word.space_lc": "空间", + "add_room.word.room_uc": "房间", + "add_room.word.space_uc": "空间", + "add_room.word.verb.invited": "被邀请到", + "add_room.word.verb.joined": "完整加入", + "add_room.word.adj.invited": "受邀", + "add_room.word.adj.joined": "已加入", + + "settings.account.title": "账号设置", + "settings.account.section.your_avatar": "你的头像:", + "settings.account.section.your_display_name": "你的显示名称:", + "settings.account.section.your_user_id": "你的用户 ID:", + "settings.account.section.other_actions": "其他操作:", + "settings.account.display_name.placeholder": "添加显示名称...", + "settings.account.user_id.not_logged_in": "你尚未登录。", + "settings.account.button.upload_avatar": "上传头像", + "settings.account.button.delete_avatar": "删除头像", + "settings.account.button.cancel": "取消", + "settings.account.button.save_name": "保存名称", + "settings.account.button.manage_account": "管理账号", + "settings.account.button.log_out": "退出登录", + "settings.account.button.logging_out": "正在退出登录...", + "settings.account.tooltip.copy_user_id": "复制用户 ID", + "settings.account.popup.avatar_updated": "头像更新成功。", + "settings.account.popup.avatar_deleted": "头像删除成功。", + "settings.account.popup.avatar_upload_not_implemented": "头像上传功能暂未实现。", + "settings.account.popup.deleting_avatar": "正在删除你的头像...", + "settings.account.popup.display_name_updated": "显示名称更新成功。", + "settings.account.popup.display_name_removed": "显示名称已移除。", + "settings.account.popup.uploading_display_name": "正在上传新的显示名称...", + "settings.account.popup.copied_user_id": "已将你的用户 ID 复制到剪贴板。", + "settings.account.popup.account_management_not_implemented": "账号管理功能暂未实现。", + "settings.account.modal.delete_avatar.title": "删除头像", + "settings.account.modal.delete_avatar.body": "你确定要删除你的头像吗?", + "settings.account.modal.delete_avatar.accept": "删除", + + "settings.labs.app_service.title": "应用服务", + "settings.labs.app_service.description": "在这里启用 Matrix 应用服务支持。Robrix 仍然是普通 Matrix 客户端:它会把 BotFather 绑定到房间,并发送对应的斜杠命令。", + "settings.labs.app_service.enable_label": "启用应用服务", + "settings.labs.app_service.botfather_user_id": "BotFather 用户 ID:", + "settings.labs.app_service.botfather_placeholder": "bot 或 @bot:server", + "settings.labs.app_service.button.enable": "启用应用服务", + "settings.labs.app_service.button.disable": "禁用应用服务", + "settings.labs.app_service.button.save": "保存", + "settings.labs.app_service.popup.saved": "已保存 Matrix 应用服务设置。", + + "invite_screen.message.invited_by": "邀请你加入:", + "invite_screen.message.invited_generic": "你被邀请加入:", + "invite_screen.button.reject": "拒绝邀请", + "invite_screen.button.join": "加入房间", + "invite_screen.button.joining": "加入中...", + "invite_screen.button.rejecting": "拒绝中...", + "invite_screen.button.joined": "已加入!", + "invite_screen.popup.joined_success": "已成功加入房间。", + "invite_screen.popup.rejected_success": "已成功拒绝邀请。", + "invite_screen.popup.reject_failed": "拒绝邀请失败:{error}", + "invite_screen.completion.rejected": "已成功拒绝邀请。你现在可以关闭该邀请页面。", + "invite_modal.title.invite_to_room_name": "邀请加入 {room_name}", + "invite_modal.input.placeholder": "@user:example.org", + "invite_modal.button.cancel": "取消", + "invite_modal.button.invite": "邀请", + "invite_modal.button.okay": "确定", + "invite_modal.status.enter_user_id": "请输入用户 ID。", + "invite_modal.status.sending": "正在发送邀请...", + "invite_modal.status.invalid_user_id": "无效的用户 ID。应为格式:@user:server.xyz", + "invite_modal.status.success_invited": "已成功邀请 {user_id}!", + "invite_modal.status.send_failed": "发送邀请失败:{error}", + "rooms_list_entry.invited.by_name_and_user": "由 {display_name} ({user_id}) 邀请", + "rooms_list_entry.invited.by_user": "由 {user_id} 邀请", + "rooms_list_entry.invited.generic": "你收到了邀请", + + "loading_pane.title.default": "正在加载内容...", + "loading_pane.title.searching_older": "正在搜索更早的消息...", + "loading_pane.status.searching_event": "正在查找事件 {target_event_id}\n\n目前已拉取 {events_paginated} 条消息...", + "loading_pane.title.error": "内容加载失败", + "loading_pane.button.cancel": "取消", + "loading_pane.button.okay": "确定", + + "rooms_list_header.title.all_rooms": "全部房间", + "rooms_list_header.popup.offline": "无法连接 Matrix 服务器,请检查网络连接。", + "rooms_list_header.tooltip.syncing": "同步中...", + "rooms_list_header.tooltip.offline": "离线", + "rooms_list_header.tooltip.synced": "已完全同步", + + "room_filter_input.placeholder": "筛选房间与空间...", + "search_messages.button.todo": "搜索(待实现)", + + "welcome_screen.title": "欢迎来到 Robrix!", + "welcome_screen.body_html": "

我们的 Matrix 客户端仍在快速开发中。目前,你可以访问你在其他客户端中已加入的房间和空间。


不过别担心,我们正在持续扩展 Robrix 的功能!


欢迎在我们的 Matrix 频道查看最新公告:

#robrix:matrix.org

", + + "room_screen.bot.delete.error.empty_user_id": "请输入要删除的机器人 Matrix 用户 ID。", + "room_screen.bot.delete.error.invalid_user_id": "无效的 Matrix 用户 ID:{full_user_id}", + "room_screen.bot.delete.error.current_user_unavailable": "当前用户 ID 不可用,无法解析机器人的 homeserver。", + "room_screen.tooltip.reacted_with_suffix": " 反应:{reaction}", + "room_screen.modal.invite.title": "发送邀请", + "room_screen.modal.invite.body": "确认要邀请 {username} 加入这个房间吗?", + "room_screen.modal.invite.accept": "邀请", + "room_screen.popup.invite.sent_success": "邀请已发送。", + "room_screen.popup.invite.failed": "发送邀请失败。\n\n错误:{error}", + "room_screen.popup.app_service.enable_before_create": "请先启用 App Service,再在房间中创建机器人。", + "room_screen.popup.app_service.bind_before_create": "请先将 BotFather 绑定到此房间,再创建机器人。", + "room_screen.popup.app_service.state_unavailable_create": "应用状态当前不可用,暂时无法创建机器人。", + "room_screen.popup.app_service.enable_before_delete": "请先启用 App Service,再在房间中删除机器人。", + "room_screen.popup.app_service.bind_before_delete": "请先将 BotFather 绑定到此房间,再删除机器人。", + "room_screen.popup.app_service.state_unavailable_delete": "应用状态当前不可用,暂时无法删除机器人。", + "room_screen.popup.app_service.room_not_bound": "该房间当前未绑定 BotFather。", + "room_screen.popup.app_service.removing_botfather": "正在将 BotFather {bot_user_id} 从该房间移除...", + "room_screen.popup.app_service.state_unavailable_unbind": "应用状态当前不可用,无法从该房间移除 BotFather。", + "room_screen.popup.bot.main_timeline_only": "机器人命令仅支持在主房间时间线中使用。", + "room_screen.popup.bot.enable_in_settings_before_bot": "使用 /bot 前请先在设置中启用 App Service。", + "room_screen.popup.bot.bind_before_bot": "使用 /bot 前请先将 BotFather 绑定到此房间。", + "room_screen.popup.bot.enable_before_commands": "在房间中使用 BotFather 命令前请先启用 App Service。", + "room_screen.popup.bot.bind_before_commands": "使用 BotFather 命令前请先将 BotFather 绑定到此房间。", + "room_screen.popup.bot.creation_main_timeline_only": "创建机器人命令仅支持在主房间时间线中使用。", + "room_screen.popup.bot.sent_listbots": "已向 BotFather 发送 `/listbots`。", + "room_screen.popup.bot.sent_bothelp": "已向 BotFather 发送 `/bothelp`。", + "room_screen.popup.bot.sent_createbot": "已向 BotFather 发送 `/createbot`(`{username}`)。", + "room_screen.popup.bot.sent_deletebot": "已向 BotFather 发送 `/deletebot`({matrix_user_id})。", + "room_screen.popup.bot.state_unavailable_create_command": "应用状态当前不可用,未发送创建机器人命令。", + "room_screen.popup.bot.state_unavailable_delete_command": "应用状态当前不可用,未发送删除机器人命令。", + "room_screen.fallback.unnamed_room": "未命名房间", + "room_screen.unsupported.prefix": "[不支持]", + "room_screen.read_marker.new_messages": "新消息", + "room_screen.top_space.loading_earlier": "正在加载更早的消息...", + "room_screen.loading.found_related_message": "已成功找到被回复的消息!", + "room_screen.loading.related_message_not_found": "未找到关联消息,可能已被删除。", + "room_screen.popup.pin.pinned_success": "已成功置顶事件。", + "room_screen.popup.pin.unpinned_success": "已成功取消置顶事件。", + "room_screen.popup.pin.already_pinned": "该消息已置顶。", + "room_screen.popup.pin.already_unpinned": "该消息尚未置顶。", + "room_screen.popup.pin.pin_failed": "置顶事件失败。错误:{error}", + "room_screen.popup.pin.unpin_failed": "取消置顶事件失败。错误:{error}", + "room_screen.popup.already_viewing_room": "你已经在查看这个房间了。", + "room_screen.popup.open_url_failed": "无法打开 URL:{url}", + "room_screen.popup.message.reply_not_found": "在时间线中找不到要回复的消息,请重试。", + "room_screen.popup.message.edit_not_found": "在时间线中找不到要编辑的消息,请重试。", + "room_screen.popup.message.no_recent_editable": "没有可编辑的近期消息,请手动选择一条消息进行编辑。", + "room_screen.popup.message.cannot_pin": "该事件无法置顶。", + "room_screen.popup.message.cannot_unpin": "该事件无法取消置顶。", + "room_screen.popup.message.copy_text_not_found": "在时间线中找不到可复制文本的消息,请重试。", + "room_screen.popup.message.copy_html_not_found": "在时间线中找不到可复制 HTML 的消息,请重试。", + "room_screen.popup.message.copy_link_failed": "无法创建消息永久链接,请重试。", + "room_screen.popup.message.view_source_not_found": "在时间线中找不到要查看源码的消息。", + "room_screen.popup.message.related_not_found": "在时间线中找不到关联消息或事件。", + "room_screen.modal.delete_message.title": "删除消息", + "room_screen.modal.delete_message.body": "确认要删除这条消息吗?此操作无法撤销。", + "room_screen.modal.delete_message.accept": "删除", + "room_screen.server_notice.title": "服务器通知:", + "room_screen.server_notice.notice_type": "通知类型", + "room_screen.server_notice.limit_type": "限制类型", + "room_screen.server_notice.admin_contact": "管理员联系方式", + "room_screen.server_notice.username": "服务器通知", + "room_screen.verification.sent_prefix": "已发送", + "room_screen.verification.request": "验证请求", + "room_screen.verification.sent_to_suffix": " 给 {user_id}。", + "room_screen.verification.supported_methods": "支持的方法", + "room_screen.image.unsupported_type": "{body}\n\n不支持的类型:{mime}", + "room_screen.image.failed_to_display": "{body}\n\n显示图片失败:{error}", + "room_screen.image.failed_to_fetch": "{body}\n\n从 {mxc_uri} 获取图片失败", + "room_screen.image.encrypted_todo": "{body}\n\n[TODO] 获取加密图片:{url}", + "room_screen.image.no_source_url": "{body}\n\n图片消息缺少来源 URL。", + "room_screen.file.preview_html": "{filename}{size}{caption}
暂不支持文件下载。", + "room_screen.audio.preview_html": "音频:{filename}{mime}{duration}{size}{caption}
暂不支持音频播放。", + "room_screen.video.preview_html": "视频:{filename}{mime}{duration}{size}{dimensions}{caption}
暂不支持视频播放。", + "room_screen.location.label": "位置:", + "room_screen.location.open_osm": "在 OpenStreetMap 中打开", + "room_screen.location.open_google_maps": "在 Google 地图中打开", + "room_screen.location.open_apple_maps": "在 Apple 地图中打开", + "room_screen.location.invalid_html": "[位置无效] {body}", + "room_screen.redacted.self_with_reason": "⛔ 删除了自己的消息。原因:“{reason}”。", + "room_screen.redacted.self": "⛔ 删除了自己的消息。", + "room_screen.redacted.other_with_reason": "⛔ {redactor} 删除了这条消息。原因:“{reason}”。", + "room_screen.redacted.other": "⛔ {redactor} 删除了这条消息。", + "room_screen.redacted.generic": "⛔ 消息已删除。", + "room_screen.reply_preview.error_username": "[获取用户名失败]", + "room_screen.reply_preview.error_event": "[获取被回复事件失败]", + "room_screen.reply_preview.loading_username": "[正在加载用户名...]", + "room_screen.reply_preview.loading_event": "[正在加载被回复消息...]", + "room_screen.thread_summary.loading_latest_reply": "正在加载最新回复...", + "room_screen.thread_summary.error_latest_reply": "无法加载最新回复", + "room_screen.thread_summary.one_reply": "1 条回复", + "room_screen.thread_summary.n_replies": "{n} 条回复", + "room_screen.small_state.invite_to_room": "邀请加入房间", + "room_screen.app_service.sender_name": "BotFather", + "room_screen.app_service.sender_tag": "机器人", + "room_screen.app_service.title": "App Service 操作", + "room_screen.app_service.subtitle": "通过 BotFather 创建机器人。Robrix 只会发送对应的斜杠命令。", + "room_screen.app_service.timestamp_now": "刚刚", + "room_screen.app_service.button.create_bot": "创建机器人", + "room_screen.app_service.button.list_bots": "列出机器人", + "room_screen.app_service.button.delete_bot": "删除机器人", + "room_screen.app_service.button.bot_help": "Bot 帮助", + "room_screen.app_service.button.unbind": "解绑", + + "spaces_bar.tooltip.unknown_space_name": "未知空间名称", + "spaces_bar.status.none_matching": "未找到\n匹配的空间。", + "spaces_bar.status.none_joined": "未找到\n已加入的空间。", + "spaces_bar.status.one_matching": "找到 1 个\n匹配的空间。", + "spaces_bar.status.one_joined": "找到 1 个\n已加入的空间。", + "spaces_bar.status.n_matching": "找到 {count} 个\n匹配的空间。", + "spaces_bar.status.n_joined": "找到 {count} 个\n已加入的空间。", + "spaces_bar.status.many_matching": "找到 99+ 个\n匹配的空间。", + "spaces_bar.status.many_joined": "找到 99+ 个\n已加入的空间。", + + "tsp.settings.title": "TSP 钱包设置", + "tsp.settings.section.active_identity": "当前活跃身份:", + "tsp.settings.section.wallets": "你的钱包:", + "tsp.settings.wallet.none": "未找到钱包。请创建或导入钱包。", + "tsp.settings.identity.none_set": "尚未设置默认身份。", + "tsp.settings.button.republish_identity": "重新发布当前身份到 DID 服务器", + "tsp.settings.button.republishing_now": "正在重新发布 DID...", + "tsp.settings.button.create_identity": "创建新身份(DID)", + "tsp.settings.button.create_wallet": "创建新钱包", + "tsp.settings.button.import_wallet": "导入已有钱包", + "tsp.settings.popup.wallet.removed": "已移除钱包“{wallet_name}”。", + "tsp.settings.popup.wallet.default_removed_warning": "默认钱包已被移除。\n\n在重新设置默认钱包前,TSP 功能将无法正常工作。", + "tsp.settings.popup.wallet.set_default_failed": "设置默认钱包失败,找不到或无法打开所选钱包。", + "tsp.settings.popup.wallet.open_failed": "打开钱包失败:{error}", + "tsp.settings.popup.wallet.import_not_implemented": "导入已有钱包功能暂未实现。", + "tsp.settings.popup.wallet.none_found": "未找到 TSP 钱包。\n\n请创建或导入钱包。", + "tsp.settings.popup.wallet.no_default": "尚未设置默认 TSP 钱包。\n\n请选择或创建一个默认钱包。", + "tsp.settings.popup.identity.republish_success": "已成功将身份“{did}”重新发布到 DID 服务器。", + "tsp.settings.popup.identity.republish_failed": "重新发布身份到 DID 服务器失败:{error}", + "tsp.settings.popup.identity.copied": "已将默认 TSP 身份复制到剪贴板。", + "tsp.settings.popup.identity.none_set": "尚未设置默认 TSP 身份。", + "tsp.settings.popup.identity.must_set_default": "必须先设置默认 TSP 身份,才能重新发布。", + "tsp_dummy.message.disabled": "当前构建未包含 TSP 功能。\n如需使用 TSP,请使用启用 'tsp' feature 的方式构建 Robrix。", + "tsp.wallet_entry.default_label": "✅ 默认", + "tsp.wallet_entry.not_found": "未找到钱包!", + "tsp.wallet_entry.button.set_default": "设为默认", + "tsp.wallet_entry.button.remove": "从列表移除", + "tsp.wallet_entry.button.delete": "删除钱包", + "tsp.wallet_entry.modal.remove.title": "移除钱包", + "tsp.wallet_entry.modal.remove.body": "确认要将钱包“{wallet_name}”从列表中移除吗?\n\n这不会删除实际的钱包文件。", + "tsp.wallet_entry.modal.remove.accept": "移除", + "tsp.wallet_entry.popup.delete_not_implemented": "删除钱包功能暂未实现。", + + "app.room_filter.search_results_title": "搜索结果", + "app.room_filter.empty_hint": "输入关键词以搜索房间和空间...", + "app.room_filter.no_local_results": "本地未找到“{keywords}”相关结果。请选择下方类型以搜索服务器。", + "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.kind.people": "联系人", + "app.room_filter.remote.kind.rooms": "房间", + "app.room_filter.remote.kind.spaces": "空间", + + "rooms_list.category.invites": "邀请", + "rooms_list.category.favorites": "收藏", + "rooms_list.category.rooms": "房间", + "rooms_list.category.people": "联系人", + "rooms_list.category.low_priority": "低优先级", + "rooms_list.category.left_rooms": "已离开房间", + + "space_lobby.entry.explore_space": "探索此空间", + "space_lobby.header.welcome": "欢迎来到此空间:", + "space_lobby.header.public_space": "🌐 公开空间", + "space_lobby.header.private_space": "🔒 私有空间", + "space_lobby.header.member_one": "1 位成员", + "space_lobby.header.member_n": "{count} 位成员", + "space_lobby.header.button.new_room": "新建房间", + "space_lobby.header.button.invite": "邀请", + "space_lobby.status.loading_rooms_spaces": "正在加载房间和空间...", + "space_lobby.status.no_rooms_spaces": "未找到房间或空间。", + "space_lobby.status.loading": "加载中...", + "space_lobby.item.button.join": "加入", + "space_lobby.item.button.view": "查看", + "space_lobby.item.button.leave": "离开", + "space_lobby.item.state.joined": "✅ 已加入", + "space_lobby.item.state.left": "已离开", + "space_lobby.item.state.invited": "已邀请", + "space_lobby.item.state.knocked": "已敲门", + "space_lobby.item.state.banned": "已封禁", + "space_lobby.item.member_one": "1 位成员", + "space_lobby.item.member_n": "{count} 位成员", + "space_lobby.item.child_room_one": "~{count} 个房间", + "space_lobby.item.child_room_n": "~{count} 个房间" +} diff --git a/src/app.rs b/src/app.rs index e2e72e28c..5e0f17913 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,7 +12,7 @@ use crate::{ avatar_cache::{self, AvatarCacheEntry, clear_avatar_cache}, home::{ add_room::{CreateRoomModalAction, CreateRoomModalWidgetRefExt}, event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, invite_screen::InviteScreenWidgetRefExt, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, space_lobby::SpaceLobbyScreenWidgetRefExt, spaces_bar::SpacesBarRef - }, join_leave_room_modal::{ + }, i18n::{AppLanguage, tr_fmt, tr_key}, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, @@ -186,7 +186,7 @@ script_mod! { width: Fill, height: Fit, margin: Inset{left: 4, top: 2} - text: "Search Results" + text: "" draw_text +: { color: (COLOR_TEXT_INPUT_IDLE) text_style: REGULAR_TEXT {font_size: 10} @@ -208,7 +208,7 @@ script_mod! { width: Fill, height: Fit, flow: Flow.Right{wrap: true}, - text: "Type to search rooms and spaces..." + text: "" draw_text +: { color: (COLOR_TEXT) text_style: REGULAR_TEXT {font_size: 10} @@ -225,15 +225,15 @@ script_mod! { remote_search_people_button := RobrixNeutralIconButton { width: Fit, - text: "People" + text: "" } remote_search_rooms_button := RobrixNeutralIconButton { width: Fit, - text: "Rooms" + text: "" } remote_search_spaces_button := RobrixNeutralIconButton { width: Fit, - text: "Spaces" + text: "" } } @@ -626,6 +626,7 @@ impl MatchEvent for App { } self.update_login_visibility(cx); + self.sync_app_language(cx); log!("App::Startup: starting matrix sdk loop"); let _tokio_rt_handle = crate::sliding_sync::start_matrix_tokio().unwrap(); @@ -650,6 +651,8 @@ impl MatchEvent for App { } fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { + self.sync_app_language(cx); + let invite_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(invite_confirmation_modal_inner)); if let Some(_accepted) = invite_confirmation_modal_inner.closed(actions) { self.ui.modal(cx, ids!(invite_confirmation_modal)).close(cx); @@ -707,13 +710,14 @@ impl MatchEvent for App { let query = room_filter_input.text().trim().to_owned(); if !query.is_empty() { let kind_text = match &kind { - RemoteDirectorySearchKind::People => "people", - RemoteDirectorySearchKind::Rooms => "rooms", - RemoteDirectorySearchKind::Spaces => "spaces", + RemoteDirectorySearchKind::People => tr_key(self.app_state.app_language, "app.room_filter.remote.kind.people"), + RemoteDirectorySearchKind::Rooms => tr_key(self.app_state.app_language, "app.room_filter.remote.kind.rooms"), + RemoteDirectorySearchKind::Spaces => tr_key(self.app_state.app_language, "app.room_filter.remote.kind.spaces"), }; + let searching_text = tr_fmt(self.app_state.app_language, "app.room_filter.searching_remote", &[("kind", kind_text)]); self.set_room_filter_modal_empty_state( cx, - &format!("Searching {} on server...", kind_text), + &searching_text, false, ); submit_async_request(MatrixRequest::SearchDirectory { @@ -881,7 +885,7 @@ impl MatchEvent for App { if let RoomsListAction::OpenRoomContextMenu { details, pos } = action.as_widget_action().cast() { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); let room_context_menu = self.ui.room_context_menu(cx, ids!(room_context_menu)); - let expected_dimensions = room_context_menu.show(cx, details); + let expected_dimensions = room_context_menu.show(cx, details, self.app_state.app_language); // Ensure the context menu does not spill over the window's bounds. let rect = self.ui.window(cx, ids!(main_window)).area().rect(cx); let pos_x = min(pos.x, rect.size.x - expected_dimensions.x); @@ -1165,7 +1169,7 @@ impl MatchEvent for App { // Handle InviteModalAction to open/close the invite modal. match action.downcast_ref() { Some(InviteModalAction::Open(room_name_id)) => { - self.ui.invite_modal(cx, ids!(invite_modal_inner)).show(cx, room_name_id.clone()); + self.ui.invite_modal(cx, ids!(invite_modal_inner)).show(cx, room_name_id.clone(), self.app_state.app_language); self.ui.modal(cx, ids!(invite_modal)).open(cx); continue; } @@ -1383,6 +1387,20 @@ impl App { live_id!(result_item_6), live_id!(result_item_7), ]; + fn sync_app_language(&self, cx: &mut Cx) { + let app_language = self.app_state.app_language; + self.ui.label(cx, ids!(room_filter_modal_inner.search_results_title)) + .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.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)) + .set_text(cx, tr_key(app_language, "app.room_filter.remote.rooms")); + self.ui.button(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_options.remote_search_spaces_button)) + .set_text(cx, tr_key(app_language, "app.room_filter.remote.spaces")); + } + fn open_join_from_search_result( &mut self, cx: &mut Cx, @@ -1597,13 +1615,17 @@ impl App { if keywords.is_empty() { self.set_room_filter_modal_empty_state( cx, - "Type to search rooms and spaces...", + tr_key(self.app_state.app_language, "app.room_filter.empty_hint"), false, ); } else if self.room_filter_modal_results.is_empty() { self.set_room_filter_modal_empty_state( cx, - &format!("No local results for \"{}\". Choose a type below to search server.", keywords), + &tr_fmt( + self.app_state.app_language, + "app.room_filter.no_local_results", + &[("keywords", keywords)], + ), true, ); } else { @@ -1788,6 +1810,7 @@ impl App { /// App-wide state that is stored persistently across multiple app runs /// and shared/updated across various parts of the app. #[derive(Clone, Default, Debug, Serialize, Deserialize)] +#[serde(default)] pub struct AppState { /// The currently-selected room, which is highlighted (selected) in the RoomsList /// and considered "active" in the main rooms screen. @@ -1808,6 +1831,8 @@ pub struct AppState { pub saved_dock_state_per_space: HashMap, /// Whether a user is currently logged in to Robrix or not. pub logged_in: bool, + /// The preferred app language. + pub app_language: AppLanguage, /// Local configuration and UI state for bot-assisted room binding. pub bot_settings: BotSettingsState, } diff --git a/src/home/add_room.rs b/src/home/add_room.rs index a6a32edc7..bac89be74 100644 --- a/src/home/add_room.rs +++ b/src/home/add_room.rs @@ -6,8 +6,9 @@ use matrix_sdk::RoomState; use ruma::{IdParseError, MatrixToUri, MatrixUri, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, matrix_uri::MatrixId, room::{JoinRuleSummary, RoomType}}; use crate::{ - app::AppStateAction, + app::{AppState, AppStateAction}, home::{invite_screen::JoinRoomResultAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, rooms_list::RoomsListRef}, + i18n::{AppLanguage, tr_fmt, tr_key}, profile::user_profile::UserProfile, room::{BasicRoomDetails, FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ @@ -158,7 +159,7 @@ script_mod! { LineH { padding: 10, margin: Inset{top: 10, right: 2} } - SubsectionLabel { + create_new_room_label := SubsectionLabel { margin: Inset{top: 8} text: "Create a new room:" } @@ -167,7 +168,7 @@ script_mod! { LineH { padding: 10, margin: Inset{right: 2} } - SubsectionLabel { + add_friend_label := SubsectionLabel { margin: Inset{top: 4} text: "Add a friend:" } @@ -210,7 +211,7 @@ script_mod! { LineH { padding: 10, margin: Inset{right: 2} } - SubsectionLabel { + join_existing_label := SubsectionLabel { text: "Join an existing room or space:" } @@ -513,6 +514,7 @@ pub struct AddRoomScreen { /// The function to perform when the user clicks the `join_room_button`. #[rust(JoinButtonFunction::None)] join_function: JoinButtonFunction, #[rust(false)] adding_friend: bool, + #[rust] app_language: AppLanguage, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -615,10 +617,17 @@ pub struct CreateRoomForm { #[rust(Vec::new())] creatable_spaces: Vec, #[rust(None)] preferred_parent_space_id: Option, #[rust(None)] fixed_parent_space_id: Option, + #[rust] app_language: AppLanguage, } impl Widget for CreateRoomForm { 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.view.handle_event(cx, event, scope); self.widget_match_event(cx, event, scope); } @@ -641,6 +650,7 @@ impl Widget for CreateRoomForm { &create_room_space_hint, &self.creatable_spaces, selected_space_id.as_ref(), + self.app_language, ); self.sync_mode_views(cx); @@ -671,6 +681,7 @@ impl WidgetMatchEvent for CreateRoomForm { &create_room_space_hint, &self.creatable_spaces, self.preferred_parent_space_id.as_ref(), + self.app_language, ); self.view.redraw(cx); } @@ -695,11 +706,14 @@ impl WidgetMatchEvent for CreateRoomForm { refresh_space_children(cx, space_id); } - let mut popup_message = format!("Successfully created room \"{}\".", room_name_id); + let room_name_text = room_name_id.to_string(); + let mut popup_message = tr_fmt(self.app_language, "add_room.popup.created_room_success", &[ + ("room_name", room_name_text.as_str()), + ]); let popup_kind = if let Some(link_error) = space_link_error { - popup_message.push_str(&format!( - "\n\nThe room was created, but it could not be linked into the selected space.\nError: {link_error}" - )); + popup_message.push_str(&tr_fmt(self.app_language, "add_room.popup.created_room_space_link_suffix", &[ + ("error", link_error.as_str()), + ])); PopupKind::Warning } else { PopupKind::Success @@ -720,9 +734,9 @@ impl WidgetMatchEvent for CreateRoomForm { } else { self.pending_created_room = Some(room_name_id.clone()); let feedback_text = match (parent_space_id.as_ref(), space_link_error.as_ref()) { - (Some(_), None) => "Room created. Syncing it into the space...", - (Some(_), Some(_)) => "Room created, but linking it into the space failed. Opening the room...", - (None, _) => "Room created. Opening the room...", + (Some(_), None) => tr_key(self.app_language, "add_room.feedback.room_created_syncing"), + (Some(_), Some(_)) => tr_key(self.app_language, "add_room.feedback.room_created_link_failed_opening"), + (None, _) => tr_key(self.app_language, "add_room.feedback.room_created_opening"), }; self.set_feedback(cx, feedback_text, true, false); } @@ -736,12 +750,23 @@ impl WidgetMatchEvent for CreateRoomForm { create_room_button.set_enabled(cx, !create_room_name_input.text().trim().is_empty()); self.set_feedback( cx, - &format!("Failed to create room: {error}"), + &{ + let error_text = error.to_string(); + tr_fmt(self.app_language, "add_room.feedback.create_room_failed", &[ + ("error", error_text.as_str()), + ]) + }, false, true, ); enqueue_popup_notification( - format!("Failed to create room \"{room_name}\".\n\nError: {error}"), + { + let error_text = error.to_string(); + tr_fmt(self.app_language, "add_room.popup.create_room_failed", &[ + ("room_name", room_name.as_str()), + ("error", error_text.as_str()), + ]) + }, PopupKind::Error, None, ); @@ -759,6 +784,7 @@ impl WidgetMatchEvent for CreateRoomForm { &create_room_space_hint, &self.creatable_spaces, self.preferred_parent_space_id.as_ref(), + self.app_language, ); self.sync_mode_views(cx); self.view.redraw(cx); @@ -795,6 +821,15 @@ impl CreateRoomForm { self.creating_room || self.pending_created_room.is_some() } + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.view.text_input(cx, ids!(create_room_name_input)) + .set_empty_text(cx, tr_key(self.app_language, "add_room.create_room.input.placeholder").to_string()); + self.view.button(cx, ids!(create_room_button)) + .set_text(cx, tr_key(self.app_language, "add_room.create_room.button.create")); + self.sync_mode_views(cx); + } + fn set_feedback(&mut self, cx: &mut Cx, text: &str, show_spinner: bool, is_error: bool) { self.view.view(cx, ids!(create_room_feedback)).set_visible(cx, true); self.view.view(cx, ids!(create_room_feedback_spinner_wrap)) @@ -831,7 +866,7 @@ impl CreateRoomForm { ); self.creating_room = true; - self.set_feedback(cx, "Creating room...", true, false); + self.set_feedback(cx, tr_key(self.app_language, "add_room.feedback.creating_room"), true, false); submit_async_request(MatrixRequest::CreateRoom { room_name: room_name.to_owned(), parent_space_id, @@ -866,7 +901,7 @@ impl CreateRoomForm { } self.clear_feedback(cx); create_room_button.set_enabled(cx, !create_room_name_input.text().trim().is_empty()); - create_room_button.set_text(cx, "Create room"); + create_room_button.set_text(cx, tr_key(self.app_language, "add_room.create_room.button.create")); create_room_button.reset_hover(cx); sync_space_dropdown( @@ -875,6 +910,7 @@ impl CreateRoomForm { &create_room_space_hint, &self.creatable_spaces, self.preferred_parent_space_id.as_ref(), + self.app_language, ); self.sync_mode_views(cx); @@ -900,15 +936,20 @@ impl CreateRoomForm { self.view.view(cx, ids!(create_room_button_row)).set_visible(cx, !show_fixed_parent); let help_text = if show_fixed_parent { - "Enter a room name. It will be created directly in this space." + tr_key(self.app_language, "add_room.create_room.help.fixed_parent") } else { - "Create a standalone room, or attach it under a space where you can create child rooms." + tr_key(self.app_language, "add_room.create_room.help.default") }; self.view.label(cx, ids!(create_room_help)).set_text(cx, help_text); } } impl CreateRoomFormRef { + pub fn set_app_language(&self, cx: &mut Cx, app_language: AppLanguage) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.set_app_language(cx, app_language); + } + pub fn can_submit(&self, cx: &mut Cx) -> bool { self.borrow().is_some_and(|inner| inner.can_submit(cx)) } @@ -941,21 +982,38 @@ impl CreateRoomFormRef { #[derive(Script, ScriptHook, Widget)] pub struct CreateRoomModal { #[deref] view: View, + #[rust] app_language: AppLanguage, } impl Widget for CreateRoomModal { 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.view.handle_event(cx, event, scope); self.widget_match_event(cx, event, scope); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + 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); + } let create_room_form = self.view.create_room_form(cx, ids!(create_room_form)); let is_busy = create_room_form.is_busy(); let create_button = self.view.button(cx, ids!(create_button)); let can_submit = create_room_form.can_submit(cx); create_button.set_enabled(cx, can_submit); - create_button.set_text(cx, if is_busy { "Syncing..." } else { "Create room" }); + create_button.set_text(cx, if is_busy { + tr_key(self.app_language, "add_room.create_room.button.syncing") + } else { + tr_key(self.app_language, "add_room.create_room.button.create") + }); self.view.button(cx, ids!(cancel_button)).set_enabled(cx, !is_busy); self.view.draw_walk(cx, scope, walk) } @@ -981,14 +1039,32 @@ impl WidgetMatchEvent for CreateRoomModal { } impl CreateRoomModal { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.view.label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "add_room.create_room.modal.title")); + self.view.label(cx, ids!(subtitle)) + .set_text(cx, tr_key(self.app_language, "add_room.create_room.modal.subtitle")); + self.view.button(cx, ids!(create_button)) + .set_text(cx, tr_key(self.app_language, "add_room.create_room.button.create")); + self.view.button(cx, ids!(cancel_button)) + .set_text(cx, tr_key(self.app_language, "add_room.button.cancel")); + self.view.create_room_form(cx, ids!(create_room_form)) + .set_app_language(cx, app_language); + self.view.redraw(cx); + } + pub fn show(&mut self, cx: &mut Cx, preferred_parent_space_id: Option) { + self.view.create_room_form(cx, ids!(create_room_form)) + .set_app_language(cx, self.app_language); self.view.create_room_form(cx, ids!(create_room_form)).prepare( cx, preferred_parent_space_id, CreateRoomContext::SpaceLobbyModal, true, ); - self.view.button(cx, ids!(create_button)).set_text(cx, "Create room"); + self.view.button(cx, ids!(create_button)) + .set_text(cx, tr_key(self.app_language, "add_room.create_room.button.create")); self.view.button(cx, ids!(create_button)).reset_hover(cx); self.view.button(cx, ids!(cancel_button)).reset_hover(cx); self.view.redraw(cx); @@ -1002,8 +1078,45 @@ impl CreateRoomModalRef { } } +impl AddRoomScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.view.label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "add_room.title")); + self.view.label(cx, ids!(create_new_room_label)) + .set_text(cx, tr_key(self.app_language, "add_room.section.create_new_room")); + self.view.label(cx, ids!(add_friend_label)) + .set_text(cx, tr_key(self.app_language, "add_room.section.add_friend")); + self.view.label(cx, ids!(join_existing_label)) + .set_text(cx, tr_key(self.app_language, "add_room.section.join_existing")); + self.view.label(cx, ids!(add_friend_help)) + .set_text(cx, tr_key(self.app_language, "add_room.add_friend.help")); + self.view.html(cx, ids!(help_info)) + .set_text(cx, tr_key(self.app_language, "add_room.join.help_html")); + self.view.text_input(cx, ids!(friend_user_id_input)) + .set_empty_text(cx, tr_key(self.app_language, "add_room.add_friend.input.placeholder").to_string()); + self.view.text_input(cx, ids!(room_alias_id_input)) + .set_empty_text(cx, tr_key(self.app_language, "add_room.join.input.placeholder").to_string()); + self.view.button(cx, ids!(add_friend_button)) + .set_text(cx, tr_key(self.app_language, "add_room.add_friend.button")); + self.view.button(cx, ids!(search_for_room_button)) + .set_text(cx, tr_key(self.app_language, "add_room.join.button.go")); + self.view.button(cx, ids!(fetched_room_summary.buttons_view.cancel_button)) + .set_text(cx, tr_key(self.app_language, "add_room.button.cancel")); + self.view.create_room_form(cx, ids!(create_room_form)) + .set_app_language(cx, app_language); + self.view.redraw(cx); + } +} + impl Widget for AddRoomScreen { 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.view.handle_event(cx, event, scope); if let Event::Actions(actions) = event { @@ -1032,7 +1145,7 @@ impl Widget for AddRoomScreen { Ok(user_id) => { if current_user_id().as_ref().is_some_and(|current| current == &user_id) { enqueue_popup_notification( - "You cannot add yourself as a friend.".to_string(), + tr_key(self.app_language, "add_room.popup.cannot_add_self").to_string(), PopupKind::Warning, Some(4.0), ); @@ -1050,8 +1163,11 @@ impl Widget for AddRoomScreen { } } Err(e) => { + let error_text = e.to_string(); enqueue_popup_notification( - format!("Invalid Matrix user ID.\n\nError: {e}"), + tr_fmt(self.app_language, "add_room.popup.invalid_user_id", &[ + ("error", error_text.as_str()), + ]), PopupKind::Error, None, ); @@ -1112,7 +1228,10 @@ impl Widget for AddRoomScreen { submit_async_request(MatrixRequest::GetRoomPreview { room_or_alias_id, via }); } Err(e) => { - let err_str = format!("Could not parse the text as a valid room address.\nError: {e}."); + let error_text = e.to_string(); + let err_str = tr_fmt(self.app_language, "add_room.popup.parse_error", &[ + ("error", error_text.as_str()), + ]); enqueue_popup_notification( err_str.clone(), PopupKind::Error, @@ -1145,7 +1264,10 @@ impl Widget for AddRoomScreen { break; } Some(RoomPreviewAction::Fetched(Err(e))) => { - let err_str = format!("Failed to fetch room info.\n\nError: {e}."); + let error_text = e.to_string(); + let err_str = tr_fmt(self.app_language, "add_room.popup.fetch_error", &[ + ("error", error_text.as_str()), + ]); enqueue_popup_notification( err_str.clone(), PopupKind::Error, @@ -1170,11 +1292,15 @@ impl Widget for AddRoomScreen { match action.downcast_ref() { Some(KnockResultAction::Knocked { room, .. }) if room.room_id() == frp.room_name_id.room_id() => { let room_type = match room.room_type() { - Some(RoomType::Space) => "space", - _ => "room", + Some(RoomType::Space) => tr_key(self.app_language, "add_room.word.space_lc"), + _ => tr_key(self.app_language, "add_room.word.room_lc"), }; + let room_name_text = frp.room_name_id.to_string(); enqueue_popup_notification( - format!("Successfully knocked on {room_type} {}.", frp.room_name_id), + tr_fmt(self.app_language, "add_room.popup.knock_success", &[ + ("room_type", room_type), + ("room_name", room_name_text.as_str()), + ]), PopupKind::Success, Some(4.0), ); @@ -1182,8 +1308,11 @@ impl Widget for AddRoomScreen { break; } Some(KnockResultAction::Failed { error, room_or_alias_id: roai }) if room_or_alias_id == roai => { + let error_text = error.to_string(); enqueue_popup_notification( - format!("Failed to knock on room.\n\nError: {error}."), + tr_fmt(self.app_language, "add_room.popup.knock_failed", &[ + ("error", error_text.as_str()), + ]), PopupKind::Error, None, ); @@ -1195,11 +1324,15 @@ impl Widget for AddRoomScreen { match action.downcast_ref() { Some(JoinRoomResultAction::Joined { room_id }) if room_id == frp.room_name_id.room_id() => { let room_type = match &frp.room_type { - Some(RoomType::Space) => "space", - _ => "room", + Some(RoomType::Space) => tr_key(self.app_language, "add_room.word.space_lc"), + _ => tr_key(self.app_language, "add_room.word.room_lc"), }; + let room_name_text = frp.room_name_id.to_string(); enqueue_popup_notification( - format!("Successfully joined {room_type} {}.", frp.room_name_id), + tr_fmt(self.app_language, "add_room.popup.join_success", &[ + ("room_type", room_type), + ("room_name", room_name_text.as_str()), + ]), PopupKind::Success, Some(4.0), ); @@ -1207,8 +1340,11 @@ impl Widget for AddRoomScreen { break; } Some(JoinRoomResultAction::Failed { room_id, error }) if room_id == frp.room_name_id.room_id() => { + let error_text = error.to_string(); enqueue_popup_notification( - format!("Failed to join room.\n\nError: {error}."), + tr_fmt(self.app_language, "add_room.popup.join_failed", &[ + ("error", error_text.as_str()), + ]), PopupKind::Error, None, ); @@ -1261,6 +1397,13 @@ impl Widget for AddRoomScreen { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + 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); + } + let add_friend_text_is_empty = self.view .text_input(cx, ids!(friend_user_id_input)) .text() @@ -1289,7 +1432,9 @@ impl Widget for AddRoomScreen { loading_room_view.set_visible(cx, true); loading_room_view.label(cx, ids!(loading_text)).set_text( cx, - &format!("Fetching {room_or_alias_id}..."), + &tr_fmt(self.app_language, "add_room.loading.fetching", &[ + ("target", room_or_alias_id.as_str()), + ]), ); fetched_room_summary.set_visible(cx, false); error_view.set_visible(cx, false); @@ -1326,81 +1471,113 @@ impl Widget for AddRoomScreen { } let (room_or_space_lc, room_or_space_uc) = match &frp.room_type { - Some(RoomType::Space) => ("space", "Space"), - _ => ("room", "Room"), + Some(RoomType::Space) => ( + tr_key(self.app_language, "add_room.word.space_lc"), + tr_key(self.app_language, "add_room.word.space_uc"), + ), + _ => ( + tr_key(self.app_language, "add_room.word.room_lc"), + tr_key(self.app_language, "add_room.word.room_uc"), + ), }; let room_name = fetched_room_summary.label(cx, ids!(room_name)); match frp.room_name_id.name_for_avatar() { Some(n) => room_name.set_text(cx, n), - _ => room_name.set_text(cx, &format!("Unnamed {room_or_space_uc}, ID: {}", frp.room_name_id.room_id())), + _ => room_name.set_text(cx, &tr_fmt(self.app_language, "add_room.fetched.room_name.unnamed", &[ + ("room_or_space_uc", room_or_space_uc), + ("room_id", frp.room_name_id.room_id().as_str()), + ])), } fetched_room_summary.label(cx, ids!(subsection_alias_id)).set_text( cx, - &format!("Main {room_or_space_uc} Alias and ID"), + &tr_fmt(self.app_language, "add_room.fetched.main_alias_and_id", &[ + ("room_or_space_uc", room_or_space_uc), + ]), ); fetched_room_summary.label(cx, ids!(room_alias)).set_text( cx, - &format!("Alias: {}", frp.canonical_alias.as_ref().map_or("not set", |a| a.as_str())), + &tr_fmt(self.app_language, "add_room.fetched.alias", &[ + ("alias", frp.canonical_alias.as_ref().map_or( + tr_key(self.app_language, "add_room.fetched.alias.not_set"), + |a| a.as_str() + )), + ]), ); fetched_room_summary.label(cx, ids!(room_id)).set_text( cx, - &format!("ID: {}", frp.room_name_id.room_id().as_str()), + &tr_fmt(self.app_language, "add_room.fetched.id", &[ + ("room_id", frp.room_name_id.room_id().as_str()), + ]), ); fetched_room_summary.label(cx, ids!(subsection_topic)).set_text( cx, - &format!("{room_or_space_uc} Topic"), + &tr_fmt(self.app_language, "add_room.fetched.topic_title", &[ + ("room_or_space_uc", room_or_space_uc), + ]), ); fetched_room_summary.html(cx, ids!(room_topic)).set_text( cx, - frp.topic.as_deref().unwrap_or("No topic set"), + frp.topic.as_deref().unwrap_or(tr_key(self.app_language, "add_room.fetched.topic.not_set_html")), ); let room_summary = fetched_room_summary.label(cx, ids!(room_summary)); let join_room_button = fetched_room_summary.button(cx, ids!(join_room_button)); let join_function = match (&frp.state, &frp.join_rule) { (Some(RoomState::Joined), _) => { - room_summary.set_text(cx, &format!("You have already joined this {room_or_space_lc}.")); - join_room_button.set_text(cx, &format!("Go to {room_or_space_lc}")); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.already_joined", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, &tr_fmt(self.app_language, "add_room.button.go_to", &[ + ("room_or_space_lc", room_or_space_lc), + ])); JoinButtonFunction::NavigateOrJoin } (Some(RoomState::Banned), _) => { - room_summary.set_text(cx, &format!("You have been banned from this {room_or_space_lc}.")); - join_room_button.set_text(cx, "Cannot join until un-banned"); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.banned", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, tr_key(self.app_language, "add_room.button.cannot_join_until_unbanned")); JoinButtonFunction::None } (Some(RoomState::Invited), _) => { - room_summary.set_text(cx, &format!("You have already been invited to this {room_or_space_lc}.")); - join_room_button.set_text(cx, "Go to invitation"); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.already_invited", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, tr_key(self.app_language, "add_room.button.go_to_invitation")); JoinButtonFunction::NavigateOrJoin } (Some(RoomState::Knocked), _) => { - room_summary.set_text(cx, &format!("You have already knocked on this {room_or_space_lc}.")); - join_room_button.set_text(cx, "Knock again (be nice!)"); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.already_knocked", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, tr_key(self.app_language, "add_room.button.knock_again")); JoinButtonFunction::Knock } (Some(RoomState::Left), join_rule) => { - room_summary.set_text(cx, &format!("You previously left this {room_or_space_lc}.")); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.previously_left", &[ + ("room_or_space_lc", room_or_space_lc), + ])); let (join_room_text, join_function) = match join_rule { Some(JoinRuleSummary::Public) => ( - format!("Re-join this {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.rejoin", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::NavigateOrJoin, ), Some(JoinRuleSummary::Invite) => ( - format!("Re-joining {room_or_space_lc} requires an invite"), + tr_fmt(self.app_language, "add_room.button.rejoin_requires_invite", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), Some(JoinRuleSummary::Knock | JoinRuleSummary::KnockRestricted(_)) => ( - format!("Knock to re-join {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.knock_to_rejoin", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::Knock, ), // TODO: handle this after we update matrix-sdk to the new `JoinRule` enum. Some(JoinRuleSummary::Restricted(_)) => ( - format!("Re-joining {room_or_space_lc} requires an invite or other room membership"), + tr_fmt(self.app_language, "add_room.button.rejoin_requires_other_membership", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), _ => ( - format!("Not allowed to re-join this {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.not_allowed_to_rejoin", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), }; @@ -1409,36 +1586,43 @@ impl Widget for AddRoomScreen { } // This room is not yet known to the user. (None, join_rule) => { - let direct = if frp.is_direct == Some(true) { "direct" } else { "regular" }; - room_summary.set_text(cx, &format!( - "This is a {direct} {room_or_space_lc} with {} {}.", - frp.num_joined_members, - match frp.num_joined_members { - 1 => "member", - _ => "members", - }, - )); + let directness = if frp.is_direct == Some(true) { + tr_key(self.app_language, "add_room.word.direct") + } else { + tr_key(self.app_language, "add_room.word.regular") + }; + let num_members = frp.num_joined_members.to_string(); + let member_word = match frp.num_joined_members { + 1 => tr_key(self.app_language, "add_room.word.member"), + _ => tr_key(self.app_language, "add_room.word.members"), + }; + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.member_count", &[ + ("directness", directness), + ("room_or_space_lc", room_or_space_lc), + ("num_members", num_members.as_str()), + ("member_word", member_word), + ])); let (join_room_text, join_function) = match join_rule { Some(JoinRuleSummary::Public) => ( - format!("Join this {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.join", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::NavigateOrJoin, ), Some(JoinRuleSummary::Invite) => ( - format!("Joining {room_or_space_lc} requires an invite"), + tr_fmt(self.app_language, "add_room.button.join_requires_invite", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), Some(JoinRuleSummary::Knock | JoinRuleSummary::KnockRestricted(_)) => ( - format!("Knock to join {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.knock_to_join", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::Knock, ), // TODO: handle this after we update matrix-sdk to the new `JoinRule` enum. Some(JoinRuleSummary::Restricted(_)) => ( - format!("Joining {room_or_space_lc} requires an invite or other room membership"), + tr_fmt(self.app_language, "add_room.button.join_requires_other_membership", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), _ => ( - format!("Not allowed to join this {room_or_space_lc}"), + tr_fmt(self.app_language, "add_room.button.not_allowed_to_join", &[("room_or_space_lc", room_or_space_lc)]), JoinButtonFunction::None, ), }; @@ -1453,20 +1637,38 @@ impl Widget for AddRoomScreen { self.join_function = join_function; } AddRoomState::Knocked { .. } => { - room_summary.set_text(cx, &format!("You have knocked on this {room_or_space_lc} and must now wait for someone to invite you in.")); - join_room_button.set_text(cx, "Successfully knocked!"); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.knocked_waiting", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, tr_key(self.app_language, "add_room.button.successfully_knocked")); join_room_button.set_enabled(cx, false); } AddRoomState::Joined { .. } => { - room_summary.set_text(cx, &format!("You have joined this {room_or_space_lc}. It is now being loaded from the homeserver; please wait...")); - join_room_button.set_text(cx, "Successfully joined!"); + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.joined_loading", &[ + ("room_or_space_lc", room_or_space_lc), + ])); + join_room_button.set_text(cx, tr_key(self.app_language, "add_room.button.successfully_joined")); join_room_button.set_enabled(cx, false); } AddRoomState::Loaded { is_invite, .. } => { - let verb = if *is_invite { "been invited to" } else { "fully joined" }; - room_summary.set_text(cx, &format!("You have {verb} this {room_or_space_lc}.")); - let adj = if *is_invite { "invited" } else { "joined" }; - join_room_button.set_text(cx, &format!("Go to {adj} {room_or_space_lc}")); + let verb = if *is_invite { + tr_key(self.app_language, "add_room.word.verb.invited") + } else { + tr_key(self.app_language, "add_room.word.verb.joined") + }; + room_summary.set_text(cx, &tr_fmt(self.app_language, "add_room.summary.loaded", &[ + ("verb", verb), + ("room_or_space_lc", room_or_space_lc), + ])); + let adj = if *is_invite { + tr_key(self.app_language, "add_room.word.adj.invited") + } else { + tr_key(self.app_language, "add_room.word.adj.joined") + }; + join_room_button.set_text(cx, &tr_fmt(self.app_language, "add_room.button.go_to_loaded", &[ + ("adj", adj), + ("room_or_space_lc", room_or_space_lc), + ])); join_room_button.set_enabled(cx, true); self.join_function = JoinButtonFunction::NavigateOrJoin; } @@ -1508,9 +1710,9 @@ fn refresh_space_children(cx: &mut Cx, space_id: &OwnedRoomId) { } } -fn creatable_space_labels(creatable_spaces: &[RoomNameId]) -> Vec { +fn creatable_space_labels(creatable_spaces: &[RoomNameId], app_language: AppLanguage) -> Vec { let mut labels = Vec::with_capacity(creatable_spaces.len() + 1); - labels.push("Create without a space".to_string()); + labels.push(tr_key(app_language, "add_room.create_room.dropdown.no_space").to_string()); labels.extend(creatable_spaces.iter().map(ToString::to_string)); labels } @@ -1541,18 +1743,21 @@ fn update_space_hint( hint_label: &LabelRef, creatable_spaces: &[RoomNameId], selected_space_id: Option<&OwnedRoomId>, + app_language: AppLanguage, ) { if creatable_spaces.is_empty() { - hint_label.set_text(cx, "No joined space currently allows you to create child rooms."); + hint_label.set_text(cx, tr_key(app_language, "add_room.create_room.dropdown.hint.no_creatable_spaces")); } else if let Some(space_id) = selected_space_id { let selected_name = creatable_spaces .iter() .find(|space| space.room_id() == space_id) .map(ToString::to_string) .unwrap_or_else(|| space_id.to_string()); - hint_label.set_text(cx, &format!("New room will be added under: {selected_name}")); + hint_label.set_text(cx, &tr_fmt(app_language, "add_room.create_room.dropdown.hint.new_room_under", &[ + ("selected_name", selected_name.as_str()), + ])); } else { - hint_label.set_text(cx, "Create a standalone room, or choose a space from the dropdown."); + hint_label.set_text(cx, tr_key(app_language, "add_room.create_room.dropdown.hint.default")); } } @@ -1562,11 +1767,12 @@ fn sync_space_dropdown( hint_label: &LabelRef, creatable_spaces: &[RoomNameId], preferred_parent_space_id: Option<&OwnedRoomId>, + app_language: AppLanguage, ) { - dropdown.set_labels(cx, creatable_space_labels(creatable_spaces)); + dropdown.set_labels(cx, creatable_space_labels(creatable_spaces, app_language)); apply_space_dropdown_selection(cx, dropdown, creatable_spaces, preferred_parent_space_id); let selected_space_id = selected_creatable_space(creatable_spaces, dropdown.selected_item()); - update_space_hint(cx, hint_label, creatable_spaces, selected_space_id.as_ref()); + update_space_hint(cx, hint_label, creatable_spaces, selected_space_id.as_ref(), app_language); } diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index de033c820..b13c2f0c3 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -431,7 +431,7 @@ impl Widget for HomeScreen { if let Some(settings_page) = self.update_active_page_from_selection(cx, app_state) { settings_page .settings_screen(cx, ids!(settings_screen)) - .populate(cx, None, &app_state.bot_settings); + .populate(cx, None, &app_state.bot_settings, app_state.app_language); self.view.redraw(cx); } else { error!("BUG: failed to set active page to show settings screen."); diff --git a/src/home/invite_modal.rs b/src/home/invite_modal.rs index d644bf542..2bcd32850 100644 --- a/src/home/invite_modal.rs +++ b/src/home/invite_modal.rs @@ -3,6 +3,8 @@ use makepad_widgets::*; use ruma::OwnedUserId; +use crate::app::AppState; +use crate::i18n::{AppLanguage, tr_fmt, tr_key}; use crate::home::room_screen::InviteResultAction; use crate::sliding_sync::{MatrixRequest, submit_async_request}; use crate::utils::RoomNameId; @@ -45,7 +47,7 @@ script_mod! { text_style: TITLE_TEXT {font_size: 13}, color: #000 } - text: "Invite to Room" + text: "" } } @@ -54,7 +56,7 @@ script_mod! { text_style: REGULAR_TEXT {font_size: 11}, color: #000 } - empty_text: "@user:example.org", + empty_text: "", } View { @@ -70,7 +72,7 @@ script_mod! { padding: 12, draw_icon.svg: (ICON_FORBIDDEN) icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } - text: "Cancel" + text: "" } confirm_button := RobrixPositiveIconButton { @@ -79,7 +81,7 @@ script_mod! { padding: 12, draw_icon.svg: (ICON_ADD_USER) icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } - text: "Invite" + text: "" } okay_button := RobrixIconButton { @@ -89,7 +91,7 @@ script_mod! { padding: 12, draw_icon.svg: (ICON_CHECKMARK) icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } - text: "Okay" + text: "" } } @@ -144,10 +146,20 @@ pub struct InviteModal { #[deref] view: View, #[rust] state: InviteModalState, #[rust] room_name_id: Option, + #[rust] app_language: AppLanguage, } impl Widget for InviteModal { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + if let Some(app_state) = scope.data.get::() + && self.app_language != app_state.app_language + { + self.app_language = app_state.app_language; + self.update_static_texts(cx); + if let Some(room_name_id) = self.room_name_id.clone() { + self.set_invite_title(cx, &room_name_id); + } + } self.view.handle_event(cx, event, scope); self.widget_match_event(cx, event, scope); } @@ -195,7 +207,7 @@ impl WidgetMatchEvent for InviteModal { // Validate the user ID if user_id_str.is_empty() { script_apply_eval!(cx, status_label, { - text: "Please enter a user ID.", + text: #(tr_key(self.app_language, "invite_modal.status.enter_user_id")), draw_text +: { color: mod.widgets.COLOR_FG_DANGER_RED, }, @@ -215,7 +227,7 @@ impl WidgetMatchEvent for InviteModal { }); self.state = InviteModalState::WaitingForInvite(user_id.to_owned()); script_apply_eval!(cx, status_label, { - text: "Sending invite...", + text: #(tr_key(self.app_language, "invite_modal.status.sending")), draw_text +: { color: mod.widgets.COLOR_ACTIVE_PRIMARY_DARKER, }, @@ -227,7 +239,7 @@ impl WidgetMatchEvent for InviteModal { } Err(_) => { script_apply_eval!(cx, status_label, { - text: "Invalid User ID. Expected format: @user:server.xyz", + text: #(tr_key(self.app_language, "invite_modal.status.invalid_user_id")), draw_text +: { color: mod.widgets.COLOR_FG_DANGER_RED, }, @@ -247,7 +259,11 @@ impl WidgetMatchEvent for InviteModal { if self.room_name_id.as_ref().is_some_and(|rni| rni.room_id() == room_id) && invited_user_id == user_id => { - let status = format!("Successfully invited {user_id}!"); + let status = tr_fmt( + self.app_language, + "invite_modal.status.success_invited", + &[("user_id", user_id.as_str())], + ); script_apply_eval!(cx, status_label, { text: #(status), draw_text +: { @@ -264,7 +280,12 @@ impl WidgetMatchEvent for InviteModal { if self.room_name_id.as_ref().is_some_and(|rni| rni.room_id() == room_id) && invited_user_id == user_id => { - let status = format!("Failed to send invite: {error}"); + let error_text = error.to_string(); + let status = tr_fmt( + self.app_language, + "invite_modal.status.send_failed", + &[("error", error_text.as_str())], + ); script_apply_eval!(cx, status_label, { text: #(status), draw_text +: { @@ -290,11 +311,31 @@ impl WidgetMatchEvent for InviteModal { } impl InviteModal { - pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId) { - self.view.label(cx, ids!(title)).set_text( - cx, - &format!("Invite to {room_name_id}"), + fn set_invite_title(&mut self, cx: &mut Cx, room_name_id: &RoomNameId) { + let room_name = room_name_id.to_string(); + let title = tr_fmt( + self.app_language, + "invite_modal.title.invite_to_room_name", + &[("room_name", room_name.as_str())], ); + self.view.label(cx, ids!(title)).set_text(cx, &title); + } + + fn update_static_texts(&mut self, cx: &mut Cx) { + self.view.button(cx, ids!(cancel_button)) + .set_text(cx, tr_key(self.app_language, "invite_modal.button.cancel")); + self.view.button(cx, ids!(confirm_button)) + .set_text(cx, tr_key(self.app_language, "invite_modal.button.invite")); + self.view.button(cx, ids!(okay_button)) + .set_text(cx, tr_key(self.app_language, "invite_modal.button.okay")); + self.view.text_input(cx, ids!(user_id_input)) + .set_empty_text(cx, tr_key(self.app_language, "invite_modal.input.placeholder").to_string()); + } + + pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId, app_language: AppLanguage) { + self.app_language = app_language; + self.set_invite_title(cx, &room_name_id); + self.update_static_texts(cx); self.state = InviteModalState::WaitingForUserInput; self.room_name_id = Some(room_name_id); @@ -321,8 +362,8 @@ impl InviteModal { } impl InviteModalRef { - pub fn show(&self, cx: &mut Cx, room_name_id: RoomNameId) { + pub fn show(&self, cx: &mut Cx, room_name_id: RoomNameId, app_language: AppLanguage) { let Some(mut inner) = self.borrow_mut() else { return }; - inner.show(cx, room_name_id); + inner.show(cx, room_name_id, app_language); } } diff --git a/src/home/invite_screen.rs b/src/home/invite_screen.rs index 672b6d1ba..0bcd46acc 100644 --- a/src/home/invite_screen.rs +++ b/src/home/invite_screen.rs @@ -8,7 +8,7 @@ use std::ops::Deref; use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; -use crate::{app::AppStateAction, home::rooms_list::RoomsListRef, join_leave_room_modal::{JoinLeaveModalKind, JoinLeaveRoomModalAction}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::AvatarWidgetRefExt, popup_list::{enqueue_popup_notification, PopupKind}, restore_status_view::RestoreStatusViewWidgetExt}, sliding_sync::{submit_async_request, MatrixRequest}, utils::{self, RoomNameId}}; +use crate::{app::{AppState, AppStateAction}, home::rooms_list::RoomsListRef, i18n::{AppLanguage, tr_fmt, tr_key}, join_leave_room_modal::{JoinLeaveModalKind, JoinLeaveRoomModalAction}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::AvatarWidgetRefExt, popup_list::{enqueue_popup_notification, PopupKind}, restore_status_view::RestoreStatusViewWidgetExt}, sliding_sync::{submit_async_request, MatrixRequest}, utils::{self, RoomNameId}}; use super::rooms_list::{InviteState, InviterInfo}; @@ -251,10 +251,16 @@ pub struct InviteScreen { #[rust] room_name_id: Option, #[rust] is_loaded: bool, #[rust] all_rooms_loaded: bool, + #[rust] app_language: AppLanguage, } impl Widget for InviteScreen { 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(); + self.app_language = app_language; + // Currently, a Signal event is only used to tell this widget // to check if the room has been loaded from the homeserver yet. if let Event::Signal = event { @@ -324,7 +330,11 @@ impl Widget for InviteScreen { Some(JoinRoomResultAction::Joined { room_id }) if room_id == info.room_id() => { self.invite_state = InviteState::WaitingForJoinedRoom; if !self.has_shown_confirmation { - enqueue_popup_notification("Successfully joined room.", PopupKind::Success, Some(5.0)); + enqueue_popup_notification( + tr_key(self.app_language, "invite_screen.popup.joined_success"), + PopupKind::Success, + Some(5.0), + ); } continue; } @@ -343,14 +353,23 @@ impl Widget for InviteScreen { Some(LeaveRoomResultAction::Left { room_id }) if room_id == info.room_id() => { self.invite_state = InviteState::RoomLeft; if !self.has_shown_confirmation { - enqueue_popup_notification("Successfully rejected invite.", PopupKind::Success, Some(5.0)); + enqueue_popup_notification( + tr_key(self.app_language, "invite_screen.popup.rejected_success"), + PopupKind::Success, + Some(5.0), + ); } continue; } Some(LeaveRoomResultAction::Failed { room_id, error }) if room_id == info.room_id() => { self.invite_state = InviteState::WaitingOnUserInput; if !self.has_shown_confirmation { - enqueue_popup_notification(format!("Failed to reject invite: {error}"), PopupKind::Error, None); + let error_text = error.to_string(); + enqueue_popup_notification( + tr_fmt(self.app_language, "invite_screen.popup.reject_failed", &[("error", error_text.as_str())]), + PopupKind::Error, + None, + ); } continue; } @@ -375,6 +394,11 @@ impl Widget for InviteScreen { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + self.app_language = app_language; + if !self.is_loaded { let mut restore_status_view = self.view.restore_status_view(cx, ids!(restore_status_view)); if let Some(room_name) = &self.room_name_id { @@ -421,10 +445,10 @@ impl Widget for InviteScreen { inviter_name.set_text(cx, inviter.user_id.as_str()); inviter_user_id.set_visible(cx, false); } - (true, "has invited you to join:") + (true, tr_key(self.app_language, "invite_screen.message.invited_by")) } else { - (false, "You have been invited to join:") + (false, tr_key(self.app_language, "invite_screen.message.invited_generic")) }; inviter_view.set_visible(cx, is_visible); self.view.label(cx, ids!(invite_message)).set_text(cx, invite_text); @@ -459,33 +483,33 @@ impl Widget for InviteScreen { InviteState::WaitingOnUserInput => { cancel_button.set_enabled(cx, true); accept_button.set_enabled(cx, true); - cancel_button.set_text(cx, "Reject Invite"); - accept_button.set_text(cx, "Join Room"); + cancel_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.reject")); + accept_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.join")); } InviteState::WaitingForJoinResult => { cancel_button.set_enabled(cx, false); accept_button.set_enabled(cx, false); - cancel_button.set_text(cx, "Reject Invite"); - accept_button.set_text(cx, "Joining..."); + cancel_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.reject")); + accept_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.joining")); } InviteState::WaitingForLeaveResult => { cancel_button.set_enabled(cx, false); accept_button.set_enabled(cx, false); - cancel_button.set_text(cx, "Rejecting..."); - accept_button.set_text(cx, "Join Room"); + cancel_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.rejecting")); + accept_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.join")); } InviteState::WaitingForJoinedRoom => { cancel_button.set_enabled(cx, false); accept_button.set_enabled(cx, false); - cancel_button.set_text(cx, "Reject Invite"); - accept_button.set_text(cx, "Joined!"); + cancel_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.reject")); + accept_button.set_text(cx, tr_key(self.app_language, "invite_screen.button.joined")); } InviteState::RoomLeft => { cancel_button.set_visible(cx, false); accept_button.set_visible(cx, false); self.view.label(cx, ids!(completion_label)).set_text( cx, - "Invite successfully rejected. You may close this invite.", + tr_key(self.app_language, "invite_screen.completion.rejected"), ); } } diff --git a/src/home/loading_pane.rs b/src/home/loading_pane.rs index baa975a3d..5e6901572 100644 --- a/src/home/loading_pane.rs +++ b/src/home/loading_pane.rs @@ -1,7 +1,7 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedEventId; -use crate::sliding_sync::TimelineRequestSender; +use crate::{app::AppState, i18n::{AppLanguage, tr_fmt, tr_key}, sliding_sync::TimelineRequestSender}; script_mod! { @@ -115,6 +115,7 @@ pub enum LoadingPaneState { pub struct LoadingPane { #[deref] view: View, #[rust] state: LoadingPaneState, + #[rust] app_language: AppLanguage, } impl Drop for LoadingPane { fn drop(&mut self) { @@ -134,6 +135,12 @@ impl Drop for LoadingPane { impl Widget for LoadingPane { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + 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.visible = true; if matches!(self.state, LoadingPaneState::None) { self.visible = false; @@ -144,6 +151,12 @@ impl Widget for LoadingPane { } 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); + } if !self.visible { return; } self.view.handle_event(cx, event, scope); @@ -196,6 +209,49 @@ impl Widget for LoadingPane { impl LoadingPane { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.sync_state_text(cx); + self.view.redraw(cx); + } + + fn sync_state_text(&mut self, cx: &mut Cx) { + let (title, status, cancel_text) = match &self.state { + LoadingPaneState::BackwardsPaginateUntilEvent { + target_event_id, + events_paginated, + .. + } => { + let events_paginated_str = events_paginated.to_string(); + ( + tr_key(self.app_language, "loading_pane.title.searching_older").to_string(), + Some(tr_fmt(self.app_language, "loading_pane.status.searching_event", &[ + ("target_event_id", target_event_id.as_str()), + ("events_paginated", events_paginated_str.as_str()), + ])), + tr_key(self.app_language, "loading_pane.button.cancel").to_string(), + ) + } + LoadingPaneState::Error(error_message) => ( + tr_key(self.app_language, "loading_pane.title.error").to_string(), + Some(error_message.clone()), + tr_key(self.app_language, "loading_pane.button.okay").to_string(), + ), + LoadingPaneState::None => ( + tr_key(self.app_language, "loading_pane.title.default").to_string(), + None, + tr_key(self.app_language, "loading_pane.button.cancel").to_string(), + ), + }; + + self.set_title(cx, &title); + if let Some(status) = status { + self.set_status(cx, &status); + } + let cancel_button = self.button(cx, ids!(cancel_button)); + cancel_button.set_text(cx, &cancel_text); + } + /// Returns `true` if this pane is currently being shown. pub fn is_currently_shown(&self, _cx: &mut Cx) -> bool { self.visible @@ -208,29 +264,8 @@ impl LoadingPane { } pub fn set_state(&mut self, cx: &mut Cx, state: LoadingPaneState) { - let cancel_button = self.button(cx, ids!(cancel_button)); - match &state { - LoadingPaneState::BackwardsPaginateUntilEvent { - target_event_id, - events_paginated, - .. - } => { - self.set_title(cx, "Searching older messages..."); - self.set_status(cx, &format!( - "Looking for event {target_event_id}\n\n\ - Fetched {events_paginated} messages so far...", - )); - cancel_button.set_text(cx, "Cancel"); - } - LoadingPaneState::Error(error_message) => { - self.set_title(cx, "Error loading content"); - self.set_status(cx, error_message); - cancel_button.set_text(cx, "Okay"); - } - LoadingPaneState::None => { } - } - self.state = state; + self.sync_state_text(cx); self.redraw(cx); } diff --git a/src/home/room_context_menu.rs b/src/home/room_context_menu.rs index 796a43a86..06d2abcc8 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -3,7 +3,7 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; -use crate::{app::AppState, home::invite_modal::InviteModalAction, shared::popup_list::{PopupKind, enqueue_popup_notification}, sliding_sync::{MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId}; +use crate::{app::AppState, home::invite_modal::InviteModalAction, i18n::{AppLanguage, tr_fmt, tr_key}, shared::popup_list::{PopupKind, enqueue_popup_notification}, sliding_sync::{MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId}; const BUTTON_HEIGHT: f64 = 35.0; const MENU_WIDTH: f64 = 215.0; @@ -147,6 +147,7 @@ pub struct RoomContextMenu { #[deref] view: View, #[source] source: ScriptObjectRef, #[rust] details: Option, + #[rust] app_language: AppLanguage, } impl Widget for RoomContextMenu { @@ -159,6 +160,14 @@ impl Widget for RoomContextMenu { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { if !self.visible { return; } + if let Some(app_state) = scope.data.get::() + && self.app_language != app_state.app_language + { + self.app_language = app_state.app_language; + if let Some(details) = self.details.clone() { + self.update_buttons(cx, &details); + } + } self.view.handle_event(cx, event, scope); // Close logic similar to NewMessageContextMenu @@ -219,10 +228,10 @@ impl WidgetMatchEvent for RoomContextMenu { }); close_menu = true; } - else if self.button(cx, ids!(room_settings_button)).clicked(actions) { + else if self.button(cx, ids!(room_settings_button)).clicked(actions) { // TODO: handle/implement this enqueue_popup_notification( - "The room settings page is not yet implemented.", + tr_key(self.app_language, "room_context_menu.popup.settings_not_implemented"), PopupKind::Warning, Some(5.0), ); @@ -231,7 +240,7 @@ impl WidgetMatchEvent for RoomContextMenu { else if self.button(cx, ids!(notifications_button)).clicked(actions) { // TODO: handle/implement this enqueue_popup_notification( - "The room notifications page is not yet implemented.", + tr_key(self.app_language, "room_context_menu.popup.notifications_not_implemented"), PopupKind::Warning, Some(5.0), ); @@ -256,7 +265,9 @@ impl WidgetMatchEvent for RoomContextMenu { bot_user_id: bot_user_id.clone(), }); enqueue_popup_notification( - format!("Removing BotFather {bot_user_id} from this room..."), + tr_fmt(self.app_language, "room_context_menu.popup.removing_botfather", &[ + ("bot_user_id", bot_user_id.as_str()), + ]), PopupKind::Info, Some(4.0), ); @@ -267,7 +278,9 @@ impl WidgetMatchEvent for RoomContextMenu { bot_user_id: bot_user_id.clone(), }); enqueue_popup_notification( - format!("Inviting BotFather {bot_user_id} into this room..."), + tr_fmt(self.app_language, "room_context_menu.popup.inviting_botfather", &[ + ("bot_user_id", bot_user_id.as_str()), + ]), PopupKind::Info, Some(5.0), ); @@ -279,7 +292,7 @@ impl WidgetMatchEvent for RoomContextMenu { } } else { enqueue_popup_notification( - "Bot settings are unavailable right now.", + tr_key(self.app_language, "room_context_menu.popup.bot_settings_unavailable"), PopupKind::Error, Some(5.0), ); @@ -308,7 +321,8 @@ impl RoomContextMenu { self.visible } - pub fn show(&mut self, cx: &mut Cx, details: RoomContextMenuDetails) -> DVec2 { + pub fn show(&mut self, cx: &mut Cx, details: RoomContextMenuDetails, app_language: AppLanguage) -> DVec2 { + self.app_language = app_language; let height = self.update_buttons(cx, &details); self.details = Some(details); self.visible = true; @@ -319,31 +333,42 @@ impl RoomContextMenu { fn update_buttons(&mut self, cx: &mut Cx, details: &RoomContextMenuDetails) -> f64 { let mark_unread_button = self.button(cx, ids!(mark_unread_button)); if details.is_marked_unread { - mark_unread_button.set_text(cx, "Mark as Read"); + mark_unread_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.mark_read")); } else { - mark_unread_button.set_text(cx, "Mark as Unread"); + mark_unread_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.mark_unread")); } let favorite_button = self.button(cx, ids!(favorite_button)); if details.is_favorite { - favorite_button.set_text(cx, "Un-favorite"); + favorite_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.unfavorite")); } else { - favorite_button.set_text(cx, "Favorite"); + favorite_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.favorite")); } let priority_button = self.button(cx, ids!(priority_button)); if details.is_low_priority { - priority_button.set_text(cx, "Un-set Low Priority"); + priority_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.unset_low_priority")); } else { - priority_button.set_text(cx, "Set Low Priority"); + priority_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.set_low_priority")); } + self.button(cx, ids!(copy_link_button)) + .set_text(cx, tr_key(self.app_language, "room_context_menu.button.copy_link_to_room")); + self.button(cx, ids!(room_settings_button)) + .set_text(cx, tr_key(self.app_language, "room_context_menu.button.settings")); + self.button(cx, ids!(notifications_button)) + .set_text(cx, tr_key(self.app_language, "room_context_menu.button.notifications")); + self.button(cx, ids!(invite_button)) + .set_text(cx, tr_key(self.app_language, "room_context_menu.button.invite")); + self.button(cx, ids!(leave_button)) + .set_text(cx, tr_key(self.app_language, "room_context_menu.button.leave_room")); + let bot_binding_button = self.button(cx, ids!(bot_binding_button)); bot_binding_button.set_visible(cx, details.app_service_enabled); if details.is_bot_bound { - bot_binding_button.set_text(cx, "Unbind BotFather"); + bot_binding_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.unbind_botfather")); } else { - bot_binding_button.set_text(cx, "Bind BotFather"); + bot_binding_button.set_text(cx, tr_key(self.app_language, "room_context_menu.button.bind_botfather")); } // Reset hover states @@ -378,8 +403,8 @@ impl RoomContextMenuRef { inner.is_currently_shown(cx) } - pub fn show(&self, cx: &mut Cx, details: RoomContextMenuDetails) -> DVec2 { + pub fn show(&self, cx: &mut Cx, details: RoomContextMenuDetails, app_language: AppLanguage) -> DVec2 { let Some(mut inner) = self.borrow_mut() else { return DVec2::default()}; - inner.show(cx, details) + inner.show(cx, details, app_language) } } diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 349d23326..e6d91406a 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -26,7 +26,7 @@ use matrix_sdk_ui::timeline::{ use ruma::{OwnedUserId, api::client::receipt::create_receipt::v3::ReceiptType, events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent}, owned_room_id}; use crate::{ - app::{AppState, 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::{create_bot_modal::{CreateBotModalAction, CreateBotModalWidgetExt}, delete_bot_modal::{DeleteBotModalAction, DeleteBotModalWidgetExt}, 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::{ + app::{AppState, 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::{create_bot_modal::{CreateBotModalAction, CreateBotModalWidgetExt}, delete_bot_modal::{DeleteBotModalAction, DeleteBotModalWidgetExt}, 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}, i18n::{AppLanguage, tr_fmt, tr_key}, media_cache::{MediaCache, MediaCacheEntry}, profile::{ user_profile::{ShowUserProfileAction, UserProfile, UserProfileAndRoomId, UserProfilePaneInfo, UserProfileSlidingPaneRef, UserProfileSlidingPaneWidgetExt}, user_profile_cache, }, @@ -59,7 +59,6 @@ const BLURHASH_IMAGE_MAX_SIZE: u32 = 500; /// otherwise many short messages can trigger a long chain of tiny paginations. const VIEWPORT_FILL_PAGINATION_SIZE: u16 = 150; -static UNNAMED_ROOM: &str = "Unnamed Room"; /// #FFF4E5 const COLOR_THREAD_SUMMARY_BG: Vec4 = vec4(1.0, 0.957, 0.898, 1.0); @@ -220,10 +219,11 @@ fn format_delete_bot_command(matrix_user_id: &UserId) -> String { fn resolve_delete_bot_user_id( user_id_or_localpart: &str, current_user_id: Option<&UserId>, + app_language: AppLanguage, ) -> Result { let raw = user_id_or_localpart.trim(); if raw.is_empty() { - return Err("Please enter the bot Matrix user ID to delete.".into()); + return Err(tr_key(app_language, "room_screen.bot.delete.error.empty_user_id").into()); } if raw.starts_with('@') || raw.contains(':') { @@ -234,19 +234,23 @@ fn resolve_delete_bot_user_id( }; return UserId::parse(&full_user_id) .map(|user_id| user_id.to_owned()) - .map_err(|_| format!("Invalid Matrix user ID: {full_user_id}")); + .map_err(|_| tr_fmt(app_language, "room_screen.bot.delete.error.invalid_user_id", &[ + ("full_user_id", full_user_id.as_str()), + ])); } let Some(current_user_id) = current_user_id else { return Err( - "Current user ID is unavailable, so the bot homeserver cannot be resolved.".into(), + tr_key(app_language, "room_screen.bot.delete.error.current_user_unavailable").into(), ); }; let full_user_id = format!("@{raw}:{}", current_user_id.server_name()); UserId::parse(&full_user_id) .map(|user_id| user_id.to_owned()) - .map_err(|_| format!("Invalid Matrix user ID: {full_user_id}")) + .map_err(|_| tr_fmt(app_language, "room_screen.bot.delete.error.invalid_user_id", &[ + ("full_user_id", full_user_id.as_str()), + ])) } fn detected_bot_binding_for_members( @@ -464,7 +468,7 @@ script_mod! { text_style: USERNAME_TEXT_STYLE {}, color: (USERNAME_TEXT_COLOR) } - text: "" + text: "" } } @@ -624,7 +628,7 @@ script_mod! { draw_icon.svg: (ICON_ADD_USER) draw_text.text_style: SMALL_STATE_TEXT_STYLE {} icon_walk: Walk{width: 15, height: Fit, margin: Inset{right: -4}} - text: "Invite to Room" + text: "" } content := Label { @@ -664,7 +668,7 @@ script_mod! { text_style: TEXT_SUB {}, color: (COLOR_DIVIDER_DARK) } - text: "" + text: "" } right_line := LineH { } @@ -679,7 +683,7 @@ script_mod! { date := Label { draw_text.color: (mod.widgets.COLOR_READ_MARKER) - text: "New Messages" + text: "" } right_line := LineH { @@ -708,7 +712,7 @@ script_mod! { text_style: MESSAGE_TEXT_STYLE { font_size: 10 }, color: (TIMESTAMP_TEXT_COLOR) } - text: "Loading earlier messages..." + text: "" } } @@ -733,7 +737,7 @@ script_mod! { text_style: USERNAME_TEXT_STYLE { font_size: 10.8 } color: (COLOR_ACTIVE_PRIMARY) } - text: "BotFather" + text: "" } sender_tag := Label { @@ -743,7 +747,7 @@ script_mod! { text_style: REGULAR_TEXT { font_size: 9.5 } color: #8A8A8A } - text: "bot" + text: "" } } @@ -775,7 +779,7 @@ script_mod! { text_style: USERNAME_TEXT_STYLE { font_size: 11.2 } color: #1F1F1F } - text: "App Service Actions" + text: "" } spacer := View { @@ -802,7 +806,7 @@ script_mod! { text_style: REGULAR_TEXT { font_size: 10.5 } color: (COLOR_TEXT) } - text: "Create a bot through BotFather. Robrix only sends the matching slash command." + text: "" } footer := View { @@ -818,7 +822,7 @@ script_mod! { text_style: REGULAR_TEXT { font_size: 8.8 } color: #9A9A9A } - text: "now" + text: "" } } } @@ -841,7 +845,7 @@ script_mod! { padding: 10 draw_icon.svg: (ICON_CHECKMARK) icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} - text: "Create Bot" + text: "" } list_button := RobrixNeutralIconButton { @@ -850,7 +854,7 @@ script_mod! { padding: 10 draw_icon.svg: (ICON_SEARCH) icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} - text: "List Bots" + text: "" } } @@ -866,7 +870,7 @@ script_mod! { padding: 10 draw_icon.svg: (ICON_CLOSE) icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} - text: "Delete Bot" + text: "" } help_button := RobrixNeutralIconButton { @@ -875,7 +879,7 @@ script_mod! { padding: 10 draw_icon.svg: (ICON_INFO) icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} - text: "Bot Help" + text: "" } } @@ -891,7 +895,7 @@ script_mod! { padding: 10 draw_icon.svg: (ICON_CLOSE) icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} - text: "Unbind" + text: "" } } } @@ -1038,6 +1042,8 @@ pub struct RoomScreen { streaming_timeout_timer: Timer, /// Whether the in-room app service quick actions card is currently visible. #[rust] show_app_service_actions: bool, + #[rust] app_language: AppLanguage, + #[rust] app_language_initialized: bool, } impl Drop for RoomScreen { @@ -1067,6 +1073,12 @@ impl ScriptHook for RoomScreen { impl Widget for RoomScreen { // Handle events and actions for the RoomScreen widget and its inner Timeline view. 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_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } let room_screen_widget_uid = self.widget_uid(); let portal_list = self.portal_list(cx, ids!(timeline.list)); let user_profile_sliding_pane = self.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)); @@ -1193,7 +1205,9 @@ impl Widget for RoomScreen { .collect(); let mut tooltip_text = utils::human_readable_list(&tooltip_text_arr, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT); - tooltip_text.push_str(&format!(" reacted with: {}", reaction_data.reaction)); + tooltip_text.push_str(&tr_fmt(self.app_language, "room_screen.tooltip.reacted_with_suffix", &[ + ("reaction", reaction_data.reaction.as_str()), + ])); cx.widget_action( room_screen_widget_uid, TooltipAction::HoverIn { @@ -1262,10 +1276,11 @@ impl Widget for RoomScreen { user_id.as_str() }; let room_id = tl.kind.room_id().clone(); + let app_language = self.app_language; let content = ConfirmationModalContent { - title_text: "Send Invitation".into(), - body_text: format!("Are you sure you want to invite {username} to this room?").into(), - accept_button_text: Some("Invite".into()), + title_text: tr_key(app_language, "room_screen.modal.invite.title").into(), + body_text: tr_fmt(app_language, "room_screen.modal.invite.body", &[("username", username)]).into(), + accept_button_text: Some(tr_key(app_language, "room_screen.modal.invite.accept").into()), on_accept_clicked: Some(Box::new(move |_cx| { submit_async_request(MatrixRequest::InviteUser { room_id, user_id }); })), @@ -1296,7 +1311,7 @@ impl Widget for RoomScreen { // Only handle if this is for the current room. if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_id) { enqueue_popup_notification( - "Sent invite successfully.", + tr_key(self.app_language, "room_screen.popup.invite.sent_success"), PopupKind::Success, Some(4.0), ); @@ -1305,8 +1320,11 @@ impl Widget for RoomScreen { if let Some(InviteResultAction::Failed { room_id, error, .. }) = action.downcast_ref() { // Only handle if this is for the current room. if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_id) { + let error_text = error.to_string(); enqueue_popup_notification( - format!("Failed to send invite.\n\nError: {error}"), + tr_fmt(self.app_language, "room_screen.popup.invite.failed", &[ + ("error", error_text.as_str()), + ]), PopupKind::Error, None, ); @@ -1500,14 +1518,14 @@ impl Widget for RoomScreen { if let Some(app_state) = scope.data.get::() { if !app_state.bot_settings.enabled { enqueue_popup_notification( - "Enable App Service before creating bots in a room.", + tr_key(self.app_language, "room_screen.popup.app_service.enable_before_create"), PopupKind::Warning, Some(4.0), ); self.set_app_service_actions_visible(cx, false); } else if !room_props.app_service_room_bound { enqueue_popup_notification( - "Bind BotFather to this room before creating a bot.", + tr_key(self.app_language, "room_screen.popup.app_service.bind_before_create"), PopupKind::Warning, Some(4.0), ); @@ -1517,7 +1535,7 @@ impl Widget for RoomScreen { } } else { enqueue_popup_notification( - "App state is unavailable, so bot creation is temporarily unavailable.", + tr_key(self.app_language, "room_screen.popup.app_service.state_unavailable_create"), PopupKind::Error, Some(4.0), ); @@ -1529,14 +1547,14 @@ impl Widget for RoomScreen { if let Some(app_state) = scope.data.get::() { if !app_state.bot_settings.enabled { enqueue_popup_notification( - "Enable App Service before deleting bots in a room.", + tr_key(self.app_language, "room_screen.popup.app_service.enable_before_delete"), PopupKind::Warning, Some(4.0), ); self.set_app_service_actions_visible(cx, false); } else if !room_props.app_service_room_bound { enqueue_popup_notification( - "Bind BotFather to this room before deleting a bot.", + tr_key(self.app_language, "room_screen.popup.app_service.bind_before_delete"), PopupKind::Warning, Some(4.0), ); @@ -1546,7 +1564,7 @@ impl Widget for RoomScreen { } } else { enqueue_popup_notification( - "App state is unavailable, so bot deletion is temporarily unavailable.", + tr_key(self.app_language, "room_screen.popup.app_service.state_unavailable_delete"), PopupKind::Error, Some(4.0), ); @@ -1560,7 +1578,7 @@ impl Widget for RoomScreen { cx, app_state, "/listbots", - "Sent `/listbots` to BotFather.", + tr_key(self.app_language, "room_screen.popup.bot.sent_listbots").to_string(), ); } return false; @@ -1571,7 +1589,7 @@ impl Widget for RoomScreen { cx, app_state, "/bothelp", - "Sent `/bothelp` to BotFather.", + tr_key(self.app_language, "room_screen.popup.bot.sent_bothelp").to_string(), ); } return false; @@ -1580,7 +1598,7 @@ impl Widget for RoomScreen { if let Some(app_state) = scope.data.get::() { if !room_props.app_service_room_bound { enqueue_popup_notification( - "This room is not currently bound to BotFather.", + tr_key(self.app_language, "room_screen.popup.app_service.room_not_bound"), PopupKind::Warning, Some(4.0), ); @@ -1599,9 +1617,9 @@ impl Widget for RoomScreen { bot_user_id: bot_user_id.clone(), }); enqueue_popup_notification( - format!( - "Removing BotFather {bot_user_id} from this room..." - ), + tr_fmt(self.app_language, "room_screen.popup.app_service.removing_botfather", &[ + ("bot_user_id", bot_user_id.as_str()), + ]), PopupKind::Info, Some(4.0), ); @@ -1617,7 +1635,7 @@ impl Widget for RoomScreen { } } else { enqueue_popup_notification( - "App state is unavailable, so BotFather could not be removed from this room.", + tr_key(self.app_language, "room_screen.popup.app_service.state_unavailable_unbind"), PopupKind::Error, Some(4.0), ); @@ -1636,7 +1654,7 @@ impl Widget for RoomScreen { Some(CreateBotModalAction::Submit(request)) => { let Some(app_state) = scope.data.get::() else { enqueue_popup_notification( - "App state is unavailable, so the create-bot command was not sent.", + tr_key(self.app_language, "room_screen.popup.bot.state_unavailable_create_command"), PopupKind::Error, Some(4.0), ); @@ -1663,7 +1681,7 @@ impl Widget for RoomScreen { Some(DeleteBotModalAction::Submit(request)) => { let Some(app_state) = scope.data.get::() else { enqueue_popup_notification( - "App state is unavailable, so the delete-bot command was not sent.", + tr_key(self.app_language, "room_screen.popup.bot.state_unavailable_delete_command"), PopupKind::Error, Some(4.0), ); @@ -1683,19 +1701,19 @@ impl Widget for RoomScreen { { if room_props.timeline_kind.thread_root_event_id().is_some() { enqueue_popup_notification( - "Bot commands are only supported in the main room timeline.", + tr_key(self.app_language, "room_screen.popup.bot.main_timeline_only"), PopupKind::Warning, Some(4.0), ); } else if !room_props.app_service_enabled { enqueue_popup_notification( - "Enable App Service in Settings before using /bot.", + tr_key(self.app_language, "room_screen.popup.bot.enable_in_settings_before_bot"), PopupKind::Warning, Some(4.0), ); } else if !room_props.app_service_room_bound { enqueue_popup_notification( - "Bind BotFather to this room before using /bot.", + tr_key(self.app_language, "room_screen.popup.bot.bind_before_bot"), PopupKind::Warning, Some(4.0), ); @@ -1713,7 +1731,7 @@ impl Widget for RoomScreen { UserProfilePaneInfo { profile_and_room_id, room_name: self.room_name_id.as_ref().map_or_else( - || UNNAMED_ROOM.to_string(), + || tr_key(self.app_language, "room_screen.fallback.unnamed_room").to_string(), |r| r.to_string(), ), room_member: None, @@ -1768,6 +1786,12 @@ impl Widget for RoomScreen { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } // If the room isn't loaded yet, we show the restore status label only. if !self.is_loaded { let Some(room_name) = &self.room_name_id else { @@ -1840,6 +1864,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, msg_like_content, prev_event, @@ -1860,6 +1885,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, poll_state, item_drawn_status, @@ -1869,6 +1895,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, utd, item_drawn_status, @@ -1878,6 +1905,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, other, item_drawn_status, @@ -1890,6 +1918,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, membership_change, item_drawn_status, @@ -1899,6 +1928,7 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, profile_change, item_drawn_status, @@ -1908,13 +1938,17 @@ impl Widget for RoomScreen { list, item_id, &tl_state.kind, + self.app_language, event_tl_item, other, item_drawn_status, ), unhandled => { let item = list.item(cx, item_id, id!(SmallStateEvent)); - item.label(cx, ids!(content)).set_text(cx, &format!("[Unsupported] {:?}", unhandled)); + item.label(cx, ids!(content)).set_text( + cx, + &format!("{} {:?}", tr_key(self.app_language, "room_screen.unsupported.prefix"), unhandled), + ); (item, ItemDrawnStatus::both_drawn()) } } @@ -1929,6 +1963,10 @@ impl Widget for RoomScreen { } TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => { let item = list.item(cx, item_id, id!(ReadMarker)); + item.label(cx, ids!(date)).set_text( + cx, + tr_key(self.app_language, "room_screen.read_marker.new_messages"), + ); (item, ItemDrawnStatus::both_drawn()) } TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => { @@ -1970,6 +2008,19 @@ impl Widget for RoomScreen { } impl RoomScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.app_language_initialized = true; + self.sync_app_language(cx); + } + + fn sync_app_language(&mut self, cx: &mut Cx) { + self.view + .label(cx, ids!(top_space.label)) + .set_text(cx, tr_key(self.app_language, "room_screen.top_space.loading_earlier")); + self.view.redraw(cx); + } + fn room_id(&self) -> Option<&OwnedRoomId> { self.room_name_id.as_ref().map(|r| r.room_id()) } @@ -2047,14 +2098,14 @@ impl RoomScreen { cx: &mut Cx, app_state: &AppState, command: &str, - success_message: &str, + success_message: String, ) -> bool { let Some(timeline_kind) = self.timeline_kind.clone() else { return false; }; if timeline_kind.thread_root_event_id().is_some() { enqueue_popup_notification( - "Bot commands are only supported in the main room timeline.", + tr_key(self.app_language, "room_screen.popup.bot.main_timeline_only"), PopupKind::Warning, Some(4.0), ); @@ -2066,7 +2117,7 @@ impl RoomScreen { }; if !app_state.bot_settings.enabled { enqueue_popup_notification( - "Enable App Service before using BotFather commands in a room.", + tr_key(self.app_language, "room_screen.popup.bot.enable_before_commands"), PopupKind::Warning, Some(4.0), ); @@ -2074,7 +2125,7 @@ impl RoomScreen { } if !self.is_app_service_room_bound(app_state, &room_id) { enqueue_popup_notification( - "Bind BotFather to this room before using BotFather commands.", + tr_key(self.app_language, "room_screen.popup.bot.bind_before_commands"), PopupKind::Warning, Some(4.0), ); @@ -2089,7 +2140,7 @@ impl RoomScreen { sign_with_tsp: false, }); - enqueue_popup_notification(success_message.to_string(), PopupKind::Info, Some(4.0)); + enqueue_popup_notification(success_message, PopupKind::Info, Some(4.0)); self.set_app_service_actions_visible(cx, false); true } @@ -2107,7 +2158,7 @@ impl RoomScreen { }; if timeline_kind.thread_root_event_id().is_some() { enqueue_popup_notification( - "Bot creation commands are only supported in the main room timeline.", + tr_key(self.app_language, "room_screen.popup.bot.creation_main_timeline_only"), PopupKind::Warning, Some(4.0), ); @@ -2119,7 +2170,7 @@ impl RoomScreen { }; if !app_state.bot_settings.enabled { enqueue_popup_notification( - "Enable App Service before creating bots in a room.", + tr_key(self.app_language, "room_screen.popup.app_service.enable_before_create"), PopupKind::Warning, Some(4.0), ); @@ -2127,7 +2178,7 @@ impl RoomScreen { } if !self.is_app_service_room_bound(app_state, &room_id) { enqueue_popup_notification( - "Bind BotFather to this room before creating a bot.", + tr_key(self.app_language, "room_screen.popup.app_service.bind_before_create"), PopupKind::Warning, Some(4.0), ); @@ -2139,7 +2190,7 @@ impl RoomScreen { cx, app_state, &command, - &format!("Sent `/createbot` for `{username}` to BotFather."), + tr_fmt(self.app_language, "room_screen.popup.bot.sent_createbot", &[("username", username)]), ) { self.close_create_bot_modal(cx); } @@ -2152,7 +2203,7 @@ impl RoomScreen { user_id_or_localpart: &str, ) { let matrix_user_id = - match resolve_delete_bot_user_id(user_id_or_localpart, current_user_id().as_deref()) { + match resolve_delete_bot_user_id(user_id_or_localpart, current_user_id().as_deref(), self.app_language) { Ok(user_id) => user_id, Err(error) => { enqueue_popup_notification(error, PopupKind::Error, Some(4.0)); @@ -2165,7 +2216,7 @@ impl RoomScreen { cx, app_state, &command, - &format!("Sent `/deletebot` for {matrix_user_id} to BotFather."), + tr_fmt(self.app_language, "room_screen.popup.bot.sent_deletebot", &[("matrix_user_id", matrix_user_id.as_str())]), ) { self.close_delete_bot_modal(cx); } @@ -2424,7 +2475,7 @@ impl RoomScreen { if is_valid { // We successfully found the target event, so we can close the loading pane, // reset the loading panestate to `None`, and stop issuing backwards pagination requests. - loading_pane.set_status(cx, "Successfully found replied-to message!"); + loading_pane.set_status(cx, tr_key(self.app_language, "room_screen.loading.found_related_message")); loading_pane.set_state(cx, LoadingPaneState::None); // NOTE: this code was copied from the `MessageAction::JumpToRelated` handler; @@ -2447,7 +2498,7 @@ impl RoomScreen { error!("Target event index {index} of {} is out of bounds for room {}", tl.items.len(), tl.kind.room_id()); // Show this error in the loading pane, which should already be open. loading_pane.set_state(cx, LoadingPaneState::Error( - String::from("Unable to find related message; it may have been deleted.") + tr_key(self.app_language, "room_screen.loading.related_message_not_found").to_string() )); } @@ -2472,7 +2523,12 @@ impl RoomScreen { error!("Pagination error ({direction}) in {:?}: {error:?}", self.room_name_id); let room_name = self.room_name_id.as_ref().map(|r| r.to_string()); enqueue_popup_notification( - utils::stringify_pagination_error(&error, room_name.as_deref().unwrap_or(UNNAMED_ROOM)), + utils::stringify_pagination_error( + &error, + room_name + .as_deref() + .unwrap_or(tr_key(self.app_language, "room_screen.fallback.unnamed_room")), + ), PopupKind::Error, Some(10.0), ); @@ -2562,17 +2618,29 @@ impl RoomScreen { TimelineUpdate::PinResult { result, pin, .. } => { let (message, auto_dismissal_duration, kind) = match &result { Ok(true) => ( - format!("Successfully {} event.", if pin { "pinned" } else { "unpinned" }), + if pin { + tr_key(self.app_language, "room_screen.popup.pin.pinned_success").to_string() + } else { + tr_key(self.app_language, "room_screen.popup.pin.unpinned_success").to_string() + }, Some(4.0), PopupKind::Success ), Ok(false) => ( - format!("Message was already {}.", if pin { "pinned" } else { "unpinned" }), + if pin { + tr_key(self.app_language, "room_screen.popup.pin.already_pinned").to_string() + } else { + tr_key(self.app_language, "room_screen.popup.pin.already_unpinned").to_string() + }, Some(4.0), PopupKind::Info ), Err(e) => ( - format!("Failed to {} event. Error: {e}", if pin { "pin" } else { "unpin" }), + tr_fmt(self.app_language, if pin { + "room_screen.popup.pin.pin_failed" + } else { + "room_screen.popup.pin.unpin_failed" + }, &[("error", &e.to_string())]), None, PopupKind::Error ), @@ -2695,7 +2763,7 @@ impl RoomScreen { MatrixId::Room(room_id) => { if self.room_name_id.as_ref().is_some_and(|r| r.room_id() == room_id) { enqueue_popup_notification( - "You are already viewing that room.", + tr_key(self.app_language, "room_screen.popup.already_viewing_room"), PopupKind::Info, Some(4.0), ); @@ -2745,7 +2813,7 @@ impl RoomScreen { if let Err(e) = robius_open::Uri::new(&url).open() { error!("Failed to open URL {:?}. Error: {:?}", url, e); enqueue_popup_notification( - format!("Could not open URL: {url}"), + tr_fmt(self.app_language, "room_screen.popup.open_url_failed", &[("url", url.as_str())]), PopupKind::Error, Some(10.0), ); @@ -2760,7 +2828,7 @@ impl RoomScreen { if let Err(e) = robius_open::Uri::new(&url).open() { error!("Failed to open URL {:?}. Error: {:?}", url, e); enqueue_popup_notification( - format!("Could not open URL: {url}"), + tr_fmt(self.app_language, "room_screen.popup.open_url_failed", &[("url", url.as_str())]), PopupKind::Error, Some(10.0), ); @@ -2860,7 +2928,7 @@ impl RoomScreen { } else { enqueue_popup_notification( - "Could not find message in timeline to reply to. Please try again.", + tr_key(self.app_language, "room_screen.popup.message.reply_not_found"), PopupKind::Error, Some(5.0), ); @@ -2883,7 +2951,7 @@ impl RoomScreen { } else { enqueue_popup_notification( - "Could not find message in timeline to edit. Please try again.", + tr_key(self.app_language, "room_screen.popup.message.edit_not_found"), PopupKind::Error, Some(5.0), ); @@ -2911,7 +2979,7 @@ impl RoomScreen { } else { enqueue_popup_notification( - "No recent message available to edit. Please manually select a message to edit.", + tr_key(self.app_language, "room_screen.popup.message.no_recent_editable"), PopupKind::Warning, Some(5.0), ); @@ -2927,7 +2995,7 @@ impl RoomScreen { }); } else { enqueue_popup_notification( - "This event cannot be pinned.", + tr_key(self.app_language, "room_screen.popup.message.cannot_pin"), PopupKind::Error, Some(5.0), ); @@ -2943,7 +3011,7 @@ impl RoomScreen { }); } else { enqueue_popup_notification( - "This event cannot be unpinned.", + tr_key(self.app_language, "room_screen.popup.message.cannot_unpin"), PopupKind::Error, Some(5.0), ); @@ -2956,7 +3024,7 @@ impl RoomScreen { } else { enqueue_popup_notification( - "Could not find message in timeline to copy text from. Please try again.", + tr_key(self.app_language, "room_screen.popup.message.copy_text_not_found"), PopupKind::Error, Some(5.0), ); @@ -2993,7 +3061,7 @@ impl RoomScreen { } if !success { enqueue_popup_notification( - "Could not find message in timeline to copy HTML from. Please try again.", + tr_key(self.app_language, "room_screen.popup.message.copy_html_not_found"), PopupKind::Error, Some(5.0), ); @@ -3011,7 +3079,7 @@ impl RoomScreen { cx.copy_to_clipboard(&matrix_to_uri.to_string()); } else { enqueue_popup_notification( - "Couldn't create permalink to message. Please try again.", + tr_key(self.app_language, "room_screen.popup.message.copy_link_failed"), PopupKind::Error, Some(5.0), ); @@ -3026,7 +3094,7 @@ impl RoomScreen { let Some(tl) = self.tl_state.as_ref() else { continue }; let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) else { enqueue_popup_notification( - "Could not find message in timeline to view source.", + tr_key(self.app_language, "room_screen.popup.message.view_source_not_found"), PopupKind::Error, Some(5.0), ); @@ -3050,7 +3118,7 @@ impl RoomScreen { let Some(related_event_id) = details.related_event_id.as_ref() else { error!("BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}"); enqueue_popup_notification( - "Could not find related message or event in timeline.", + tr_key(self.app_language, "room_screen.popup.message.related_not_found"), PopupKind::Error, Some(5.0), ); @@ -3091,10 +3159,11 @@ impl RoomScreen { let timeline_event_id = details.timeline_event_id.clone(); let timeline_kind = tl.kind.clone(); let reason = reason.clone(); + let app_language = self.app_language; let content = ConfirmationModalContent { - title_text: "Delete Message".into(), - body_text: "Are you sure you want to delete this message? This cannot be undone.".into(), - accept_button_text: Some("Delete".into()), + title_text: tr_key(app_language, "room_screen.modal.delete_message.title").into(), + body_text: tr_key(app_language, "room_screen.modal.delete_message.body").into(), + accept_button_text: Some(tr_key(app_language, "room_screen.modal.delete_message.accept").into()), on_accept_clicked: Some(Box::new(move |_cx| { submit_async_request(MatrixRequest::RedactMessage { timeline_kind, @@ -4049,6 +4118,7 @@ fn populate_message_view( list: &mut PortalList, item_id: usize, timeline_kind: &TimelineKind, + app_language: AppLanguage, event_tl_item: &EventTimelineItem, msg_like_content: &MsgLikeContent, prev_event: Option<&Arc>, @@ -4122,6 +4192,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_text_message_content( cx, &html_or_plaintext_ref, + app_language, body, formatted.as_ref(), Some(&mut link_preview_ref), @@ -4160,6 +4231,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_text_message_content( cx, &html_or_plaintext_ref, + app_language, body, formatted.as_ref(), Some(&mut link_preview_ref), @@ -4188,14 +4260,16 @@ fn populate_message_view( } }); let formatted = format!( - "Server notice: {}\n\nNotice type:: {}{}{}", + "{} {}\n\n{}: {}{}{}", + tr_key(app_language, "room_screen.server_notice.title"), sn.body, + tr_key(app_language, "room_screen.server_notice.notice_type"), sn.server_notice_type.as_str(), sn.limit_type.as_ref() - .map(|l| format!("\nLimit type: {}", l.as_str())) + .map(|l| format!("\n{} {}", tr_key(app_language, "room_screen.server_notice.limit_type"), l.as_str())) .unwrap_or_default(), sn.admin_contact.as_ref() - .map(|c| format!("\nAdmin contact: {}", c)) + .map(|c| format!("\n{} {}", tr_key(app_language, "room_screen.server_notice.admin_contact"), c)) .unwrap_or_default(), ); let mut link_preview_ref = @@ -4203,6 +4277,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_text_message_content( cx, &html_or_plaintext_ref, + app_language, &sn.body, Some(&FormattedBody { format: MessageFormat::Html, @@ -4257,6 +4332,7 @@ fn populate_message_view( let link_previews_drawn = populate_text_message_content( cx, &html_or_plaintext_ref, + app_language, &body, formatted.as_ref(), Some(&mut link_preview_ref), @@ -4285,6 +4361,7 @@ fn populate_message_view( let is_image_fully_drawn = populate_image_message_content( cx, &text_or_image_ref, + app_language, image_info, image.source.clone(), msg.body(), @@ -4310,6 +4387,7 @@ fn populate_message_view( let is_location_fully_drawn = populate_location_message_content( cx, &html_or_plaintext_ref, + app_language, location, ); new_drawn_status.content_drawn = is_location_fully_drawn; @@ -4332,6 +4410,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_file_message_content( cx, &html_or_plaintext_ref, + app_language, file_content, ); (item, false) @@ -4353,6 +4432,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_audio_message_content( cx, &html_or_plaintext_ref, + app_language, audio, ); (item, false) @@ -4374,6 +4454,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_video_message_content( cx, &html_or_plaintext_ref, + app_language, video, ); (item, false) @@ -4390,8 +4471,11 @@ fn populate_message_view( let formatted = FormattedBody { format: MessageFormat::Html, body: format!( - "Sent a verification request to {}.
(Supported methods: {})
", - verification.to, + "{}{}{}
({}: {})
", + tr_key(app_language, "room_screen.verification.sent_prefix"), + tr_key(app_language, "room_screen.verification.request"), + tr_fmt(app_language, "room_screen.verification.sent_to_suffix", &[("user_id", verification.to.as_str())]), + tr_key(app_language, "room_screen.verification.supported_methods"), verification.methods .iter() .map(|m| m.as_str()) @@ -4407,6 +4491,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_text_message_content( cx, &html_or_plaintext_ref, + app_language, &verification.body, Some(&formatted), Some(&mut link_preview_ref), @@ -4424,7 +4509,7 @@ fn populate_message_view( } else { item.label(cx, ids!(content.message)).set_text( cx, - &format!("[Unsupported {:?}]", msg_like_content.kind), + &format!("{} {:?}", tr_key(app_language, "room_screen.unsupported.prefix"), msg_like_content.kind), ); new_drawn_status.content_drawn = true; (item, false) @@ -4453,6 +4538,7 @@ fn populate_message_view( let is_image_fully_drawn = populate_image_message_content( cx, &text_or_image_ref, + app_language, Some(Box::new(image_info.clone())), MediaSource::Plain(owned_mxc_url.clone()), body, @@ -4491,6 +4577,7 @@ fn populate_message_view( new_drawn_status.content_drawn = populate_redacted_message_content( cx, &html_or_plaintext_ref, + app_language, event_tl_item, timeline_kind.room_id(), ); @@ -4505,7 +4592,7 @@ fn populate_message_view( } else { item.label(cx, ids!(content.message)).set_text( cx, - &format!("[Unsupported {:?}] ", other), + &format!("{} {:?} ", tr_key(app_language, "room_screen.unsupported.prefix"), other), ); new_drawn_status.content_drawn = true; (item, false) @@ -4530,6 +4617,7 @@ fn populate_message_view( cx, &item.view(cx, ids!(replied_to_message)), timeline_kind, + app_language, msg_like_content.in_reply_to.as_ref(), event_tl_item.event_id(), ); @@ -4538,6 +4626,7 @@ fn populate_message_view( &item, item_id, timeline_kind, + app_language, msg_like_content, event_tl_item, fetched_thread_summaries, @@ -4611,7 +4700,7 @@ fn populate_message_view( // Server notices are drawn with a red color avatar background and username. let avatar = item.avatar(cx, ids!(profile.avatar)); avatar.show_text(cx, Some(COLOR_FG_DANGER_RED), None, "⚠"); - username_label.set_text(cx, "Server notice"); + username_label.set_text(cx, tr_key(app_language, "room_screen.server_notice.username")); script_apply_eval!(cx, username_label, { draw_text +: { color: (mod.widgets.COLOR_FG_DANGER_RED) @@ -4681,6 +4770,7 @@ fn populate_message_view( fn populate_text_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, body: &str, formatted_body: Option<&FormattedBody>, link_preview_ref: Option<&mut LinkPreviewRef>, @@ -4717,7 +4807,17 @@ fn populate_text_message_content( &links, media_cache, link_preview_cache, - &populate_image_message_content, + &|cx, text_or_image_ref, image_info_source, original_source, body, media_cache| { + populate_image_message_content( + cx, + text_or_image_ref, + app_language, + image_info_source, + original_source, + body, + media_cache, + ) + }, ) } else { true @@ -4730,6 +4830,7 @@ fn populate_text_message_content( fn populate_image_message_content( cx: &mut Cx, text_or_image_ref: &TextOrImageRef, + app_language: AppLanguage, image_info_source: Option>, original_source: MediaSource, body: &str, @@ -4747,7 +4848,7 @@ fn populate_image_message_content( if ImageFormat::from_mimetype(mime).is_none() { text_or_image_ref.show_text( cx, - format!("{body}\n\nUnsupported type {mime:?}"), + tr_fmt(app_language, "room_screen.image.unsupported_type", &[("body", body), ("mime", mime)]), ); return true; // consider this as fully drawn } @@ -4765,7 +4866,7 @@ fn populate_image_message_content( .map(|()| img.size_in_pixels(cx).unwrap_or_default()) }); if let Err(e) = show_image_result { - let err_str = format!("{body}\n\nFailed to display image: {e:?}"); + let err_str = tr_fmt(app_language, "room_screen.image.failed_to_display", &[("body", body), ("error", &format!("{e:?}"))]); error!("{err_str}"); text_or_image_ref.show_text(cx, &err_str); } @@ -4813,7 +4914,7 @@ fn populate_image_message_content( } }); if let Err(e) = show_image_result { - let err_str = format!("{body}\n\nFailed to display image: {e:?}"); + let err_str = tr_fmt(app_language, "room_screen.image.failed_to_display", &[("body", body), ("error", &format!("{e:?}"))]); error!("{err_str}"); text_or_image_ref.show_text(cx, &err_str); } @@ -4826,7 +4927,7 @@ fn populate_image_message_content( return; } text_or_image_ref - .show_text(cx, format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri)); + .show_text(cx, tr_fmt(app_language, "room_screen.image.failed_to_fetch", &[("body", body), ("mxc_uri", &format!("{mxc_uri:?}"))])); // For now, we consider this as being "complete". In the future, we could support // retrying to fetch thumbnail of the image on a user click/tap. fully_drawn = true; @@ -4840,7 +4941,7 @@ fn populate_image_message_content( // We consider this as "fully drawn" since we don't yet support encryption. text_or_image_ref.show_text( cx, - format!("{body}\n\n[TODO] fetch encrypted image at {:?}", encrypted.url) + tr_fmt(app_language, "room_screen.image.encrypted_todo", &[("body", body), ("url", &format!("{:?}", encrypted.url))]) ); }, MediaSource::Plain(mxc_uri) => { @@ -4857,7 +4958,7 @@ fn populate_image_message_content( fetch_and_show_media_source(cx, media_source, image_info); } None => { - text_or_image_ref.show_text(cx, "{body}\n\nImage message had no source URL."); + text_or_image_ref.show_text(cx, tr_fmt(app_language, "room_screen.image.no_source_url", &[("body", body)])); fully_drawn = true; } } @@ -4872,6 +4973,7 @@ fn populate_image_message_content( fn populate_file_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, file_content: &FileMessageEventContent, ) -> bool { // Display the file name, human-readable size, caption, and a button to download it. @@ -4891,7 +4993,11 @@ fn populate_file_message_content( message_content_widget.show_html( cx, - format!("{filename}{size}{caption}
File download not yet supported."), + tr_fmt(app_language, "room_screen.file.preview_html", &[ + ("filename", &filename), + ("size", size.as_str()), + ("caption", caption.as_str()), + ]), ); true } @@ -4902,6 +5008,7 @@ fn populate_file_message_content( fn populate_audio_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, audio: &AudioMessageEventContent, ) -> bool { // Display the file name, human-readable size, caption, and a button to download it. @@ -4931,7 +5038,13 @@ fn populate_audio_message_content( message_content_widget.show_html( cx, - format!("Audio: {filename}{mime}{duration}{size}{caption}
Audio playback not yet supported."), + tr_fmt(app_language, "room_screen.audio.preview_html", &[ + ("filename", &filename), + ("mime", mime.as_str()), + ("duration", duration.as_str()), + ("size", size.as_str()), + ("caption", caption.as_str()), + ]), ); true } @@ -4943,6 +5056,7 @@ fn populate_audio_message_content( fn populate_video_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, video: &VideoMessageEventContent, ) -> bool { // Display the file name, human-readable size, caption, and a button to download it. @@ -4975,7 +5089,14 @@ fn populate_video_message_content( message_content_widget.show_html( cx, - format!("Video: {filename}{mime}{duration}{size}{dimensions}{caption}
Video playback not yet supported."), + tr_fmt(app_language, "room_screen.video.preview_html", &[ + ("filename", &filename), + ("mime", mime.as_str()), + ("duration", duration.as_str()), + ("size", size.as_str()), + ("dimensions", dimensions.as_str()), + ("caption", caption.as_str()), + ]), ); true } @@ -4988,6 +5109,7 @@ fn populate_video_message_content( fn populate_location_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, location: &LocationMessageEventContent, ) -> bool { let coords = location.geo_uri @@ -5009,19 +5131,26 @@ fn populate_location_message_content( let safe_short_lat = htmlize::escape_text(short_lat); let safe_short_long = htmlize::escape_text(short_long); let html_body = format!( - "Location: {safe_short_lat},{safe_short_long}
\ + "{} {safe_short_lat},{safe_short_long}
\ ", + tr_key(app_language, "room_screen.location.label"), safe_geo_uri, + tr_key(app_language, "room_screen.location.open_osm"), + tr_key(app_language, "room_screen.location.open_google_maps"), + tr_key(app_language, "room_screen.location.open_apple_maps"), ); message_content_widget.show_html(cx, html_body); } else { + let escaped_body = htmlize::escape_text(&location.body); message_content_widget.show_html( cx, - format!("[Location invalid] {}", htmlize::escape_text(&location.body)) + tr_fmt(app_language, "room_screen.location.invalid_html", &[ + ("body", &escaped_body), + ]) ); } @@ -5038,6 +5167,7 @@ fn populate_location_message_content( fn populate_redacted_message_content( cx: &mut Cx, message_content_widget: &HtmlOrPlaintextRef, + app_language: AppLanguage, event_tl_item: &EventTimelineItem, room_id: &OwnedRoomId, ) -> bool { @@ -5062,8 +5192,13 @@ fn populate_redacted_message_content( if redactor == event_tl_item.sender() { fully_drawn = true; match reason { - Some(r) => format!("⛔ Deleted their own message. Reason: \"{}\".", htmlize::escape_text(r)), - None => String::from("⛔ Deleted their own message."), + Some(r) => { + let escaped_reason = htmlize::escape_text(r); + tr_fmt(app_language, "room_screen.redacted.self_with_reason", &[ + ("reason", &escaped_reason), + ]) + } + None => tr_key(app_language, "room_screen.redacted.self").to_string(), } } else { // Try to get the displayable name of the user who redacted this message. @@ -5076,16 +5211,21 @@ fn populate_redacted_message_content( fully_drawn = redactor_name.was_found(); let redactor_name_esc = htmlize::escape_text(redactor_name.as_deref().unwrap_or(redactor.as_str())); match reason { - Some(r) => format!("⛔ {} deleted this message. Reason: \"{}\".", - redactor_name_esc, - htmlize::escape_text(r), - ), - None => format!("⛔ {} deleted this message.", redactor_name_esc), + Some(r) => { + let escaped_reason = htmlize::escape_text(r); + tr_fmt(app_language, "room_screen.redacted.other_with_reason", &[ + ("redactor", &redactor_name_esc), + ("reason", &escaped_reason), + ]) + } + None => tr_fmt(app_language, "room_screen.redacted.other", &[ + ("redactor", &redactor_name_esc), + ]), } } } else { fully_drawn = true; - String::from("⛔ Message deleted.") + tr_key(app_language, "room_screen.redacted.generic").to_string() }; message_content_widget.show_html(cx, html); fully_drawn @@ -5108,6 +5248,7 @@ fn draw_replied_to_message( cx: &mut Cx2d, replied_to_message_view: &ViewRef, timeline_kind: &TimelineKind, + app_language: AppLanguage, in_reply_to: Option<&InReplyToDetails>, message_event_id: Option<&EventId>, ) -> bool { @@ -5139,6 +5280,7 @@ fn draw_replied_to_message( populate_preview_of_timeline_item( cx, &msg_body, + app_language, &replied_to_event.content, &replied_to_event.sender, &in_reply_to_username, @@ -5148,26 +5290,26 @@ fn draw_replied_to_message( fully_drawn = true; replied_to_message_view .label(cx, ids!(replied_to_message_content.reply_preview_username)) - .set_text(cx, "[Error fetching username]"); + .set_text(cx, tr_key(app_language, "room_screen.reply_preview.error_username")); replied_to_message_view .avatar(cx, ids!(replied_to_message_content.reply_preview_avatar)) .show_text(cx, None, None, "?"); replied_to_message_view .html_or_plaintext(cx, ids!(replied_to_message_content.reply_preview_body)) - .show_plaintext(cx, "[Error fetching replied-to event]"); + .show_plaintext(cx, tr_key(app_language, "room_screen.reply_preview.error_event")); } td @ TimelineDetails::Pending | td @ TimelineDetails::Unavailable => { // We don't have the replied-to message yet, so we can't fully draw the preview. fully_drawn = false; replied_to_message_view .label(cx, ids!(replied_to_message_content.reply_preview_username)) - .set_text(cx, "[Loading username...]"); + .set_text(cx, tr_key(app_language, "room_screen.reply_preview.loading_username")); replied_to_message_view .avatar(cx, ids!(replied_to_message_content.reply_preview_avatar)) .show_text(cx, None, None, "?"); replied_to_message_view .html_or_plaintext(cx, ids!(replied_to_message_content.reply_preview_body)) - .show_plaintext(cx, "[Loading replied-to message...]"); + .show_plaintext(cx, tr_key(app_language, "room_screen.reply_preview.loading_event")); // Confusingly, we need to fetch the details of the `message` (the event that is the reply), // not the details of the original event that this `message` is replying to. @@ -5200,6 +5342,7 @@ fn populate_thread_root_summary( item: &WidgetRef, timeline_item_index: usize, timeline_kind: &TimelineKind, + app_language: AppLanguage, msg_like_content: &MsgLikeContent, event_tl_item: &EventTimelineItem, fetched_thread_summaries: &HashMap, @@ -5269,18 +5412,18 @@ fn populate_thread_root_summary( } } fetched_summary.and_then(|fs| fs.latest_reply_preview_text.as_deref()) - .unwrap_or("Loading latest reply...") + .unwrap_or(tr_key(app_language, "room_screen.thread_summary.loading_latest_reply")) .into() } TimelineDetails::Error(_) => { fully_drawn = true; // consider this fully drawn since there's no point retrying. - "Unable to load latest reply".into() + tr_key(app_language, "room_screen.thread_summary.error_latest_reply").into() } }; let replies_count_text = match replies_count { - 1 => Cow::Borrowed("1 reply"), - n => Cow::Owned(format!("{n} replies")) + 1 => Cow::Borrowed(tr_key(app_language, "room_screen.thread_summary.one_reply")), + n => Cow::Owned(tr_fmt(app_language, "room_screen.thread_summary.n_replies", &[("n", &n.to_string())])) }; item.label(cx, ids!(thread_summary_count)) .set_text(cx, &replies_count_text); @@ -5294,6 +5437,7 @@ fn populate_thread_root_summary( pub fn populate_preview_of_timeline_item( cx: &mut Cx, widget_out: &HtmlOrPlaintextRef, + app_language: AppLanguage, timeline_item_content: &TimelineItemContent, sender_user_id: &UserId, sender_username: &str, @@ -5302,7 +5446,7 @@ pub fn populate_preview_of_timeline_item( match m.msgtype() { MessageType::Text(TextMessageEventContent { body, formatted, .. }) | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. }) => { - let _ = populate_text_message_content(cx, widget_out, body, formatted.as_ref(), None, None, None); + let _ = populate_text_message_content(cx, widget_out, app_language, body, formatted.as_ref(), None, None, None); return; } _ => { } // fall through to the general case for all timeline items below. @@ -5506,6 +5650,7 @@ fn populate_small_state_event( list: &mut PortalList, item_id: usize, timeline_kind: &TimelineKind, + app_language: AppLanguage, event_tl_item: &EventTimelineItem, event_content: &impl SmallStateEventContent, item_drawn_status: ItemDrawnStatus, @@ -5548,7 +5693,7 @@ fn populate_small_state_event( }); // Proceed to draw the actual event content. - event_content.populate_item_content( + let (item, new_drawn_status) = event_content.populate_item_content( cx, list, item_id, @@ -5557,7 +5702,12 @@ fn populate_small_state_event( &username, item_drawn_status, new_drawn_status, - ) + ); + + item.button(cx, ids!(invite_user_button)) + .set_text(cx, tr_key(app_language, "room_screen.small_state.invite_to_room")); + + (item, new_drawn_status) } @@ -5705,10 +5855,18 @@ impl ActionDefaultRef for AppServicePanelAction { #[derive(Script, ScriptHook, Widget)] pub struct AppServicePanel { #[deref] view: View, + #[rust] app_language: AppLanguage, + #[rust] app_language_initialized: bool, } impl Widget for AppServicePanel { 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_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.view.handle_event(cx, event, scope); let room_screen_props = scope @@ -5786,10 +5944,54 @@ impl Widget for AppServicePanel { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.view.draw_walk(cx, scope, walk) } } +impl AppServicePanel { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.app_language_initialized = true; + self.view + .label(cx, ids!(sender_row.sender_name)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.sender_name")); + self.view + .label(cx, ids!(sender_row.sender_tag)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.sender_tag")); + self.view + .label(cx, ids!(bubble.header.title)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.title")); + self.view + .label(cx, ids!(bubble.subtitle)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.subtitle")); + self.view + .label(cx, ids!(bubble.footer.timestamp)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.timestamp_now")); + self.view + .button(cx, ids!(keyboard.first_row.create_button)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.button.create_bot")); + self.view + .button(cx, ids!(keyboard.first_row.list_button)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.button.list_bots")); + self.view + .button(cx, ids!(keyboard.second_row.delete_button)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.button.delete_bot")); + self.view + .button(cx, ids!(keyboard.second_row.help_button)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.button.bot_help")); + self.view + .button(cx, ids!(keyboard.third_row.unbind_button)) + .set_text(cx, tr_key(self.app_language, "room_screen.app_service.button.unbind")); + self.view.redraw(cx); + } +} + /// A widget representing a single message of any kind within a room timeline. #[derive(Script, ScriptHook, Widget, Animator)] pub struct Message { diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 83e706223..fd58b7134 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -1490,9 +1490,10 @@ impl Widget for RoomsList { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - let app_state = scope.data.get_mut::().unwrap(); + let app_state = scope.data.get::().unwrap(); // Update the currently-selected room from the AppState data. self.current_active_room = app_state.selected_room.clone(); + let mut app_state_for_item_scope = app_state.clone(); // Based on the various displayed room lists and is_expanded state of each room header, // calculate the indexes in the PortalList where the headers and rooms should be drawn. @@ -1541,8 +1542,7 @@ impl Widget for RoomsList { list.set_item_range(cx, 0, total_count); while let Some(portal_list_index) = list.next_visible_item(cx) { - let mut scope = Scope::empty(); - + let mut item_scope = Scope::with_data(&mut app_state_for_item_scope); if self.invited_rooms_indexes.header_index == Some(portal_list_index) { let item = list.item(cx, portal_list_index, id!(collapsible_header)); item.as_collapsible_header().set_details( @@ -1551,7 +1551,7 @@ impl Widget for RoomsList { HeaderCategory::Invites, self.displayed_invited_rooms.len() as u64, ); - item.draw_all(cx, &mut scope); + item.draw_all(cx, &mut item_scope); } else if let Some(invited_room_id) = get_invited_room_id(portal_list_index) { let mut invited_rooms_mut = self.invited_rooms.borrow_mut(); @@ -1560,11 +1560,12 @@ impl Widget for RoomsList { invited_room.is_selected = self.current_active_room.as_ref() .is_some_and(|sel_room| sel_room.room_id() == invited_room_id); // Pass the room info down to the RoomsListEntry widget via Scope. - scope = Scope::with_props(&*invited_room); - item.draw_all(cx, &mut scope); + item_scope.override_props(&*invited_room, |scope| { + item.draw_all(cx, scope); + }); } else { list.item(cx, portal_list_index, id!(empty)) - .draw_all(cx, &mut scope); + .draw_all(cx, &mut item_scope); } } else if self.direct_rooms_indexes.header_index == Some(portal_list_index) { @@ -1577,7 +1578,7 @@ impl Widget for RoomsList { // TODO: sum up all the unread mentions in rooms // NOTE: this might be really slow, so we should maintain a running total of mentions in this struct ); - item.draw_all(cx, &mut scope); + item.draw_all(cx, &mut item_scope); } else if let Some(direct_room_id) = get_direct_room_id(portal_list_index) { if let Some(direct_room) = self.all_joined_rooms.get_mut(direct_room_id) { @@ -1597,11 +1598,12 @@ impl Widget for RoomsList { }); } // Pass the room info down to the RoomsListEntry widget via Scope. - scope = Scope::with_props(&*direct_room); - item.draw_all(cx, &mut scope); + item_scope.override_props(&*direct_room, |scope| { + item.draw_all(cx, scope); + }); } else { list.item(cx, portal_list_index, id!(empty)) - .draw_all(cx, &mut scope); + .draw_all(cx, &mut item_scope); } } else if self.regular_rooms_indexes.header_index == Some(portal_list_index) { @@ -1614,7 +1616,7 @@ impl Widget for RoomsList { // TODO: sum up all the unread mentions in rooms. // NOTE: this might be really slow, so we should maintain a running total of mentions in this struct ); - item.draw_all(cx, &mut scope); + item.draw_all(cx, &mut item_scope); } else if let Some(regular_room_id) = get_regular_room_id(portal_list_index) { if let Some(regular_room) = self.all_joined_rooms.get_mut(regular_room_id) { @@ -1634,22 +1636,23 @@ impl Widget for RoomsList { }); } // Pass the room info down to the RoomsListEntry widget via Scope. - scope = Scope::with_props(&*regular_room); - item.draw_all(cx, &mut scope); + item_scope.override_props(&*regular_room, |scope| { + item.draw_all(cx, scope); + }); } else { - list.item(cx, portal_list_index, id!(empty)).draw_all(cx, &mut scope); + list.item(cx, portal_list_index, id!(empty)).draw_all(cx, &mut item_scope); } } // Draw the status label as the bottom entry. else if portal_list_index == status_label_id { let item = list.item(cx, portal_list_index, id!(status_label)); item.label(cx, ids!(label)).set_text(cx, &self.status); - item.draw_all(cx, &mut scope); + item.draw_all(cx, &mut item_scope); } // Draw a filler entry to take up space at the bottom of the portal list. else { list.item(cx, portal_list_index, id!(bottom_filler)) - .draw_all(cx, &mut scope); + .draw_all(cx, &mut item_scope); } } } diff --git a/src/home/rooms_list_entry.rs b/src/home/rooms_list_entry.rs index 13494c590..5870fc08a 100644 --- a/src/home/rooms_list_entry.rs +++ b/src/home/rooms_list_entry.rs @@ -2,6 +2,8 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; use crate::{ + app::AppState, + i18n::{AppLanguage, tr_fmt, tr_key}, room::FetchedRoomAvatar, shared::{ avatar::AvatarWidgetExt, html_or_plaintext::HtmlOrPlaintextWidgetExt, unread_badge::UnreadBadgeWidgetExt as _, @@ -302,10 +304,13 @@ impl Widget for RoomsListEntryContent { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); if let Some(joined_room_info) = scope.props.get::() { self.draw_joined_room(cx, joined_room_info); } else if let Some(invited_room_info) = scope.props.get::() { - self.draw_invited_room(cx, invited_room_info); + self.draw_invited_room(cx, invited_room_info, app_language); } self.view.draw_walk(cx, scope, walk) @@ -346,14 +351,30 @@ impl RoomsListEntryContent { &mut self, cx: &mut Cx, room_info: &InvitedRoomInfo, + app_language: AppLanguage, ) { self.view.label(cx, ids!(room_name)).set_text(cx, &room_info.room_name_id.to_string()); // Hide the timestamp field, and use the latest message field to show the inviter. self.view.label(cx, ids!(timestamp)).set_text(cx, ""); let inviter_string = match &room_info.inviter_info { - Some(InviterInfo { user_id, display_name: Some(dn), .. }) => format!("Invited by {} ({})", htmlize::escape_text(dn), htmlize::escape_text(user_id.as_str())), - Some(InviterInfo { user_id, .. }) => format!("Invited by {}", htmlize::escape_text(user_id.as_str())), - None => String::from("You were invited"), + Some(InviterInfo { user_id, display_name: Some(dn), .. }) => { + let display_name = htmlize::escape_text(dn); + let user_id = htmlize::escape_text(user_id.as_str()); + tr_fmt( + app_language, + "rooms_list_entry.invited.by_name_and_user", + &[("display_name", display_name.as_ref()), ("user_id", user_id.as_ref())], + ) + } + Some(InviterInfo { user_id, .. }) => { + let user_id = htmlize::escape_text(user_id.as_str()); + tr_fmt( + app_language, + "rooms_list_entry.invited.by_user", + &[("user_id", user_id.as_ref())], + ) + } + None => tr_key(app_language, "rooms_list_entry.invited.generic").to_string(), }; self.view.html_or_plaintext(cx, ids!(latest_message)).show_html(cx, &inviter_string); diff --git a/src/home/rooms_list_header.rs b/src/home/rooms_list_header.rs index d4aaa3a8e..085031c55 100644 --- a/src/home/rooms_list_header.rs +++ b/src/home/rooms_list_header.rs @@ -9,7 +9,9 @@ use makepad_widgets::*; use matrix_sdk_ui::sync_service::State; use crate::{ + app::AppState, home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, + i18n::{AppLanguage, tr_key}, shared::{ image_viewer::{ImageViewerAction, ImageViewerError, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, @@ -130,10 +132,18 @@ pub struct RoomsListHeader { #[deref] view: View, #[rust(State::Idle)] sync_state: State, + #[rust] app_language: AppLanguage, + #[rust] showing_space_title: bool, } impl Widget for RoomsListHeader { 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); + } if let Event::Actions(actions) = event { if self.view.button(cx, ids!(open_room_filter_modal_button.click_area)).clicked(actions) { cx.action(RoomsListHeaderAction::OpenRoomFilterModal); @@ -162,7 +172,7 @@ impl Widget for RoomsListHeader { self.view.view(cx, ids!(synced_icon)).set_visible(cx, false); self.view.view(cx, ids!(offline_icon)).set_visible(cx, true); enqueue_popup_notification( - "Cannot reach the Matrix homeserver. Please check your connection.", + tr_key(self.app_language, "rooms_list_header.popup.offline"), PopupKind::Error, None, ); @@ -181,8 +191,12 @@ impl Widget for RoomsListHeader { match tab { SelectedTab::Space { space_name_id } => { header_title.set_text(cx, &space_name_id.to_string()); + self.showing_space_title = true; + } + _ => { + header_title.set_text(cx, tr_key(self.app_language, "rooms_list_header.title.all_rooms")); + self.showing_space_title = false; } - _ => header_title.set_text(cx, "All Rooms"), } continue; } @@ -191,9 +205,9 @@ impl Widget for RoomsListHeader { // Show tooltips for the sync status icons. for (view, text, bg_color) in [ - (self.view.view(cx, ids!(loading_spinner)), "Syncing...", vec4(0.059, 0.533, 0.996, 1.0)), // COLOR_ACTIVE_PRIMARY #0f88fe - (self.view.view(cx, ids!(offline_icon)), "Offline", vec4(0.863, 0.0, 0.020, 1.0)), // COLOR_FG_DANGER_RED #DC0005 - (self.view.view(cx, ids!(synced_icon)), "Fully synced", vec4(0.075, 0.533, 0.031, 1.0)), // COLOR_FG_ACCEPT_GREEN #138808 + (self.view.view(cx, ids!(loading_spinner)), tr_key(self.app_language, "rooms_list_header.tooltip.syncing"), vec4(0.059, 0.533, 0.996, 1.0)), // COLOR_ACTIVE_PRIMARY #0f88fe + (self.view.view(cx, ids!(offline_icon)), tr_key(self.app_language, "rooms_list_header.tooltip.offline"), vec4(0.863, 0.0, 0.020, 1.0)), // COLOR_FG_DANGER_RED #DC0005 + (self.view.view(cx, ids!(synced_icon)), tr_key(self.app_language, "rooms_list_header.tooltip.synced"), vec4(0.075, 0.533, 0.031, 1.0)), // COLOR_FG_ACCEPT_GREEN #138808 ] { if !view.visible() { continue; @@ -225,10 +239,28 @@ impl Widget for RoomsListHeader { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + 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.view.draw_walk(cx, scope, walk) } } +impl RoomsListHeader { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + if !self.showing_space_title { + self.view + .label(cx, ids!(header_title)) + .set_text(cx, tr_key(self.app_language, "rooms_list_header.title.all_rooms")); + } + self.view.redraw(cx); + } +} + /// Actions that can be handled by the `RoomsListHeader`. #[derive(Debug)] pub enum RoomsListHeaderAction { diff --git a/src/home/search_messages.rs b/src/home/search_messages.rs index 5228ca129..75ac7afa9 100644 --- a/src/home/search_messages.rs +++ b/src/home/search_messages.rs @@ -2,6 +2,7 @@ //! UI widgets for searching messages in one or more rooms. use makepad_widgets::*; +use crate::{app::AppState, i18n::{AppLanguage, tr_key}}; script_mod! { use mod.prelude.widgets.* @@ -47,10 +48,17 @@ script_mod! { #[derive(Script, ScriptHook, Widget)] pub struct SearchMessagesButton { #[deref] button: Button, + #[rust] app_language: AppLanguage, } impl Widget for SearchMessagesButton { 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.button.handle_event(cx, event, scope); if let Event::Actions(actions) = event { @@ -61,10 +69,23 @@ impl Widget for SearchMessagesButton { } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + 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.button.draw_walk(cx, scope, walk) } } +impl SearchMessagesButton { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.button.set_text(cx, tr_key(self.app_language, "search_messages.button.todo")); + } +} + #[derive(Debug)] pub enum AddRoomAction { SearchMessagesButtonClicked, diff --git a/src/home/space_lobby.rs b/src/home/space_lobby.rs index eb9b8277c..487c8be52 100644 --- a/src/home/space_lobby.rs +++ b/src/home/space_lobby.rs @@ -19,13 +19,14 @@ use crate::shared::avatar::AvatarState; use crate::shared::expand_arrow::ExpandArrow; use crate::utils::replace_linebreaks_separators; use crate::{ - app::AppStateAction, + app::{AppState, AppStateAction}, avatar_cache::{self, AvatarCacheEntry}, home::{ add_room::{CreateRoomAction, CreateRoomModalAction}, invite_modal::InviteModalAction, rooms_list::RoomsListRef, }, + i18n::{AppLanguage, tr_fmt, tr_key}, join_leave_room_modal::{JoinLeaveModalKind, JoinLeaveRoomModalAction}, room::BasicRoomDetails, shared::avatar::{AvatarWidgetExt, AvatarWidgetRefExt}, @@ -152,7 +153,7 @@ script_mod! { ) } } - text: "Explore this Space" + text: "" } animator: Animator{ @@ -323,7 +324,7 @@ script_mod! { spacing: 0 icon_walk: Walk{width: 0, height: 0} draw_text.text_style: REGULAR_TEXT {font_size: 9.5} - text: "Join" + text: "" } view_button := RobrixIconButton { @@ -332,7 +333,7 @@ script_mod! { spacing: 0 icon_walk: Walk{width: 0, height: 0} draw_text.text_style: REGULAR_TEXT {font_size: 9.5} - text: "View" + text: "" } leave_button := RobrixNegativeIconButton { @@ -341,7 +342,7 @@ script_mod! { spacing: 0 icon_walk: Walk{width: 0, height: 0} draw_text.text_style: REGULAR_TEXT {font_size: 9.5} - text: "Leave" + text: "" } } @@ -389,7 +390,7 @@ script_mod! { color: #737373, text_style: REGULAR_TEXT {font_size: 10} } - text: "Loading rooms and spaces..." + text: "" } } @@ -420,7 +421,7 @@ script_mod! { text_style: REGULAR_TEXT {font_size: 9}, color: #888, } - text: "Loading..." + text: "" } } @@ -455,7 +456,7 @@ script_mod! { text_style: REGULAR_TEXT {font_size: 10}, color: #737373, } - text: "Welcome to the space:" + text: "" } parent_space_row := View { @@ -490,7 +491,7 @@ script_mod! { padding: 12, draw_icon.svg: (ICON_ADD) icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } - text: "New Room" + text: "" } invite_button := RobrixPositiveIconButton { @@ -500,7 +501,7 @@ script_mod! { padding: 12, draw_icon.svg: (ICON_ADD_USER) icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } - text: "Invite" + text: "" } } } @@ -584,6 +585,11 @@ impl Widget for SpaceLobbyEntry { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + self.view.label(cx, ids!(space_lobby_label)) + .set_text(cx, tr_key(app_language, "space_lobby.entry.explore_space")); self.view.draw_walk(cx, scope, walk) } } @@ -877,10 +883,21 @@ pub struct SpaceLobbyScreen { /// Whether we are currently loading the initial data. #[rust] is_loading: bool, + #[rust] top_level_join_rule: Option, + #[rust] top_level_member_count: Option, + #[rust] app_language: AppLanguage, } impl Widget for SpaceLobbyScreen { 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.app_language = app_language; + self.update_space_info_label(cx, app_language); + self.redraw(cx); + } self.view.handle_event(cx, event, scope); // Handle Signal events for avatar cache updates @@ -902,15 +919,9 @@ impl Widget for SpaceLobbyScreen { if self.space_name_id.as_ref().is_some_and(|sni| sni.room_id() == &sr.room_id) { self.space_avatar_state = AvatarState::Known(sr.avatar_url.clone()); self.space_avatar_state.update_from_cache(cx); // prefetch the avatar image - self.view.label(cx, ids!(header.space_info_label)).set_text(cx, &format!( - "{} · {} {}", - match sr.join_rule { - Some(JoinRuleSummary::Public) => "🌐 Public space", - _ => "🔒 Private space", - }, - sr.num_joined_members, - if sr.num_joined_members == 1 { "member" } else { "members" } - )); + self.top_level_join_rule = sr.join_rule.clone(); + self.top_level_member_count = Some(sr.num_joined_members); + self.update_space_info_label(cx, app_language); self.redraw(cx); } } @@ -1006,6 +1017,11 @@ impl Widget for SpaceLobbyScreen { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + self.app_language = app_language; + // Draw parent avatar from the SpaceRoom's avatar URL, or show initials. let parent_avatar_ref = self.view.avatar(cx, ids!(parent_avatar)); if self.space_avatar_state.update_from_cache(cx).is_none_or(|data| { @@ -1019,6 +1035,12 @@ impl Widget for SpaceLobbyScreen { .and_then(|name| utils::user_name_first_letter(name)); parent_avatar_ref.show_text(cx, None, None, first_char.unwrap_or("S")); } + + self.update_space_info_label(cx, app_language); + self.view.button(cx, ids!(header.parent_space_row.create_room_button)) + .set_text(cx, tr_key(app_language, "space_lobby.header.button.new_room")); + self.view.button(cx, ids!(header.parent_space_row.invite_button)) + .set_text(cx, tr_key(app_language, "space_lobby.header.button.invite")); while let Some(widget_to_draw) = self.view.draw_walk(cx, scope, walk).step() { let portal_list_ref = widget_to_draw.as_portal_list(); @@ -1041,13 +1063,20 @@ impl Widget for SpaceLobbyScreen { // Draw loading indicator let item = if self.is_loading && item_id == 0 { let item = list.item(cx, item_id, id!(status_label)); - item.child_by_path(ids!(label)).as_label().set_text(cx, "Loading rooms and spaces..."); + item.child_by_path(ids!(label)).as_label().set_text( + cx, + tr_key(app_language, "space_lobby.status.loading_rooms_spaces"), + ); + item.child_by_path(ids!(loading_spinner)).set_visible(cx, true); item } // No entries found else if entry_count == 0 && item_id == 0 { let item = list.item(cx, item_id, id!(status_label)); - item.child_by_path(ids!(label)).as_label().set_text(cx, "No rooms or spaces found."); + item.child_by_path(ids!(label)).as_label().set_text( + cx, + tr_key(app_language, "space_lobby.status.no_rooms_spaces"), + ); item.child_by_path(ids!(loading_spinner)).set_visible(cx, false); item } @@ -1102,6 +1131,18 @@ impl Widget for SpaceLobbyScreen { item.child_by_path(ids!(buttons_view.join_button)).set_visible(cx, show_join_button); item.child_by_path(ids!(buttons_view.leave_button)).set_visible(cx, show_leave_button); item.child_by_path(ids!(buttons_view.view_button)).set_visible(cx, show_view_button); + item.child_by_path(ids!(buttons_view.join_button)).as_button().set_text( + cx, + tr_key(app_language, "space_lobby.item.button.join"), + ); + item.child_by_path(ids!(buttons_view.leave_button)).as_button().set_text( + cx, + tr_key(app_language, "space_lobby.item.button.leave"), + ); + item.child_by_path(ids!(buttons_view.view_button)).as_button().set_text( + cx, + tr_key(app_language, "space_lobby.item.button.view"), + ); // Below, draw things that are common to child rooms and subspaces. item.child_by_path(ids!(content.name_label)).as_label().set_text(cx, &info.name); @@ -1157,29 +1198,31 @@ impl Widget for SpaceLobbyScreen { // Add join status for rooms we haven't joined if let Some(state) = &info.state { match state { - RoomState::Joined => info_parts.push("✅ Joined".to_string()), - RoomState::Left => info_parts.push("Left".to_string()), - RoomState::Invited => info_parts.push("Invited".to_string()), - RoomState::Knocked => info_parts.push("Knocked".to_string()), - RoomState::Banned => info_parts.push("Banned".to_string()), + RoomState::Joined => info_parts.push(tr_key(app_language, "space_lobby.item.state.joined").to_string()), + RoomState::Left => info_parts.push(tr_key(app_language, "space_lobby.item.state.left").to_string()), + RoomState::Invited => info_parts.push(tr_key(app_language, "space_lobby.item.state.invited").to_string()), + RoomState::Knocked => info_parts.push(tr_key(app_language, "space_lobby.item.state.knocked").to_string()), + RoomState::Banned => info_parts.push(tr_key(app_language, "space_lobby.item.state.banned").to_string()), } } // Add member count - info_parts.push(format!( - "{} {}", - info.num_joined_members, - if info.num_joined_members == 1 { "member" } else { "members" } - )); + let member_count = info.num_joined_members.to_string(); + info_parts.push(if info.num_joined_members == 1 { + tr_key(app_language, "space_lobby.item.member_one").to_string() + } else { + tr_fmt(app_language, "space_lobby.item.member_n", &[("count", member_count.as_str())]) + }); // Add children count for spaces if let Some(c) = info.children_count { if c > 0 { - info_parts.push(format!( - "~{} {}", - c, - if c == 1 { "room" } else { "rooms" } - )); + let child_count = c.to_string(); + info_parts.push(if c == 1 { + tr_fmt(app_language, "space_lobby.item.child_room_one", &[("count", child_count.as_str())]) + } else { + tr_fmt(app_language, "space_lobby.item.child_room_n", &[("count", child_count.as_str())]) + }); } } @@ -1195,6 +1238,10 @@ impl Widget for SpaceLobbyScreen { TreeEntry::Loading { level, parent_mask } => { // Draw loading indicator for subspace let item = list.item(cx, item_id, id!(subspace_loading)); + item.child_by_path(ids!(label)).as_label().set_text( + cx, + tr_key(app_language, "space_lobby.status.loading"), + ); // Configure tree lines if let Some(mut lines) = item.child_by_path(ids!(tree_lines)).borrow_mut::() { lines.draw_bg.level = *level as f32; @@ -1229,6 +1276,29 @@ impl SpaceLobbyScreen { BasicRoomDetails::Name(room_name_id) } + fn update_space_info_label(&mut self, cx: &mut Cx, app_language: AppLanguage) { + let text = if self.is_loading { + tr_key(app_language, "space_lobby.header.welcome").to_string() + } else if let Some(member_count) = self.top_level_member_count { + let member_count_str = member_count.to_string(); + format!( + "{} · {}", + match self.top_level_join_rule.as_ref() { + Some(JoinRuleSummary::Public) => tr_key(app_language, "space_lobby.header.public_space"), + _ => tr_key(app_language, "space_lobby.header.private_space"), + }, + if member_count == 1 { + tr_key(app_language, "space_lobby.header.member_one").to_string() + } else { + tr_fmt(app_language, "space_lobby.header.member_n", &[("count", member_count_str.as_str())]) + } + ) + } else { + String::new() + }; + self.view.label(cx, ids!(header.space_info_label)).set_text(cx, &text); + } + fn insert_created_room_placeholder(&mut self, cx: &mut Cx, room_name_id: &RoomNameId) { let Some(space_id) = self.space_name_id.as_ref().map(|space| space.room_id().clone()) else { return; @@ -1450,6 +1520,8 @@ impl SpaceLobbyScreen { // Clear the main content until we receive the async space info responses. self.tree_entries.clear(); + self.top_level_join_rule = None; + self.top_level_member_count = None; self.view.label(cx, ids!(header.space_info_label)).set_text(cx, ""); self.is_loading = true; diff --git a/src/home/spaces_bar.rs b/src/home/spaces_bar.rs index 75b03765d..f071cfa77 100644 --- a/src/home/spaces_bar.rs +++ b/src/home/spaces_bar.rs @@ -13,7 +13,7 @@ use matrix_sdk::{RoomDisplayName, RoomState}; use ruma::{OwnedRoomAliasId, OwnedRoomId, room::JoinRuleSummary}; use crate::{ - home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, room::{FetchedRoomAvatar, room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria}}, shared::{avatar::AvatarWidgetRefExt, room_filter_input_bar::RoomFilterAction}, utils::{self, RoomNameId} + app::AppState, home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, i18n::{AppLanguage, tr_fmt, tr_key}, room::{FetchedRoomAvatar, room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria}}, shared::{avatar::AvatarWidgetRefExt, room_filter_input_bar::RoomFilterAction}, utils::{self, RoomNameId} }; script_mod! { @@ -257,6 +257,7 @@ pub struct SpacesBarEntry { #[apply_default] animator: Animator, #[rust] space_name_id: Option, + #[rust] app_language: AppLanguage, } impl Widget for SpacesBarEntry { @@ -273,7 +274,7 @@ impl Widget for SpacesBarEntry { TooltipAction::HoverIn { widget_rect: area.rect(cx), text: this.space_name_id.as_ref().map_or( - String::from("Unknown Space Name"), + String::from(tr_key(this.app_language, "spaces_bar.tooltip.unknown_space_name")), |sni| sni.to_string(), ), options: CalloutTooltipOptions { @@ -343,15 +344,16 @@ impl Widget for SpacesBarEntry { } impl SpacesBarEntry { - fn set_metadata(&mut self, cx: &mut Cx, space_name_id: RoomNameId, is_selected: bool) { + fn set_metadata(&mut self, cx: &mut Cx, space_name_id: RoomNameId, is_selected: bool, app_language: AppLanguage) { self.space_name_id = Some(space_name_id); + self.app_language = app_language; self.animator_toggle(cx, is_selected, Animate::No, ids!(active.on), ids!(active.off)); } } impl SpacesBarEntryRef { - pub fn set_metadata(&self, cx: &mut Cx, space_name_id: RoomNameId, is_selected: bool) { + pub fn set_metadata(&self, cx: &mut Cx, space_name_id: RoomNameId, is_selected: bool, app_language: AppLanguage) { let Some(mut inner) = self.borrow_mut() else { return }; - inner.set_metadata(cx, space_name_id, is_selected); + inner.set_metadata(cx, space_name_id, is_selected, app_language); } } @@ -554,6 +556,10 @@ impl Widget for SpacesBar { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + while let Some(widget_to_draw) = self.view.draw_walk(cx, scope, walk).step() { // We only care about drawing the portal list. let portal_list_ref = widget_to_draw.as_portal_list(); @@ -580,9 +586,9 @@ impl Widget for SpacesBar { item.label(cx, ids!(label)).set_text( cx, if self.is_filtered { - "Found no\nmatching spaces." + tr_key(app_language, "spaces_bar.status.none_matching") } else { - "Found no\njoined spaces." + tr_key(app_language, "spaces_bar.status.none_joined") } ); item @@ -628,17 +634,41 @@ impl Widget for SpacesBar { cx, space.space_name_id.clone(), self.selected_space.as_ref().is_some_and(|id| id == space.space_name_id.room_id()), + app_language, ); item } else if portal_list_index == len { let item = list.item(cx, portal_list_index, id!(StatusLabel)); - let descriptor = if self.is_filtered { "matching" } else { "joined" }; let text = match len { - 0 => format!("Found no\n{descriptor} spaces."), - 1 => format!("Found 1\n{descriptor} space."), - 2..100 => format!("Found {len}\n{descriptor} spaces."), - 100.. => format!("Found 99+\n{descriptor} spaces."), + 0 => { + if self.is_filtered { + tr_key(app_language, "spaces_bar.status.none_matching").to_string() + } else { + tr_key(app_language, "spaces_bar.status.none_joined").to_string() + } + } + 1 => { + if self.is_filtered { + tr_key(app_language, "spaces_bar.status.one_matching").to_string() + } else { + tr_key(app_language, "spaces_bar.status.one_joined").to_string() + } + } + 2..100 => { + if self.is_filtered { + tr_fmt(app_language, "spaces_bar.status.n_matching", &[("count", &len.to_string())]) + } else { + tr_fmt(app_language, "spaces_bar.status.n_joined", &[("count", &len.to_string())]) + } + } + 100.. => { + if self.is_filtered { + tr_key(app_language, "spaces_bar.status.many_matching").to_string() + } else { + tr_key(app_language, "spaces_bar.status.many_joined").to_string() + } + } }; item.label(cx, ids!(label)).set_text(cx, &text); item diff --git a/src/home/welcome_screen.rs b/src/home/welcome_screen.rs index 5e08674dc..892c16ed6 100644 --- a/src/home/welcome_screen.rs +++ b/src/home/welcome_screen.rs @@ -1,4 +1,5 @@ use makepad_widgets::*; +use crate::{app::AppState, i18n::{AppLanguage, tr_key}}; script_mod! { use mod.prelude.widgets.* @@ -7,9 +8,9 @@ script_mod! { mod.widgets.WELCOME_TEXT_COLOR = #x4 - mod.widgets.WelcomeScreen = SolidView { + mod.widgets.WelcomeScreen = #(WelcomeScreen::register_widget(vm)) { width: Fill, height: Fill - align: Align{x: 0.0, y: 0.5} + align: Align{x: 0.5, y: 0.5} show_bg: true, draw_bg.color: (COLOR_PRIMARY) @@ -22,13 +23,15 @@ script_mod! { welcome_message := RoundedView { padding: 40. - width: Fill, height: Fit + width: Fill, height: Fill flow: Down, spacing: 20 + align: Align{x: 0.5, y: 0.5} draw_bg.color: (COLOR_PRIMARY) title := Label { - text: "Welcome to Robrix!", + text: "" + align: Align{x: 0.5, y: 0.5} draw_text +: { color: (mod.widgets.WELCOME_TEXT_COLOR), text_style: theme.font_bold { @@ -38,7 +41,7 @@ script_mod! { } // Using the HTML widget to taking advantage of embedding a link within text with proper vertical alignment - MessageHtml { + body := MessageHtml { padding: Inset{top: 12, left: 0.} font_size: 14. font_color: (mod.widgets.WELCOME_TEXT_COLOR) @@ -52,14 +55,51 @@ script_mod! { // color_hover: #0f0, // } } - body:"

Our Matrix client is under heavy development. Currently, you can access the rooms and spaces that you've joined in other clients.

-


-

But don't worry, we're constantly expanding the featureset of Robrix!

-


-

Look for the latest announcements in our Matrix channel:

-

#robrix:matrix.org

- " + body:"" } } } } + +#[derive(Script, ScriptHook, Widget)] +pub struct WelcomeScreen { + #[deref] view: View, + #[rust] app_language: AppLanguage, + #[rust] app_language_initialized: bool, +} + +impl Widget for WelcomeScreen { + 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_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.view.handle_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.view.draw_walk(cx, scope, walk) + } +} + +impl WelcomeScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.app_language_initialized = true; + self.view + .label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "welcome_screen.title")); + self.view + .html(cx, ids!(body)) + .set_text(cx, tr_key(self.app_language, "welcome_screen.body_html")); + self.view.redraw(cx); + } +} diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 000000000..2f19806e4 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,115 @@ +use std::{collections::HashMap, sync::OnceLock}; + +use serde::{Deserialize, Serialize}; + +/// App UI language preference stored in persisted app state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum AppLanguage { + #[serde(rename = "en", alias = "English")] + #[default] + English, + #[serde(rename = "zh-CN", alias = "ChineseSimplified")] + ChineseSimplified, +} + +impl AppLanguage { + pub const ALL: [Self; 2] = [ + Self::English, + Self::ChineseSimplified, + ]; + + pub fn code(self) -> &'static str { + match self { + Self::English => "en", + Self::ChineseSimplified => "zh-CN", + } + } + + pub fn from_dropdown_index(index: usize) -> Self { + Self::ALL + .get(index) + .copied() + .unwrap_or(Self::English) + } + + pub fn dropdown_index(self) -> usize { + Self::ALL + .iter() + .position(|lang| *lang == self) + .unwrap_or(0) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum I18nKey { + AllSettingsTitle, + SettingsCategoryAccount, + SettingsCategoryPreferences, + SettingsCategoryLabs, + LanguageTitle, + ApplicationLanguageLabel, + LanguageReloadHint, + LanguageOptionEnglish, + LanguageOptionChineseSimplified, +} + +impl I18nKey { + fn as_str(self) -> &'static str { + match self { + I18nKey::AllSettingsTitle => "settings.all_settings_title", + I18nKey::SettingsCategoryAccount => "settings.category.account", + I18nKey::SettingsCategoryPreferences => "settings.category.preferences", + I18nKey::SettingsCategoryLabs => "settings.category.labs", + I18nKey::LanguageTitle => "settings.preferences.language.title", + I18nKey::ApplicationLanguageLabel => "settings.preferences.language.application_label", + I18nKey::LanguageReloadHint => "settings.preferences.language.reload_hint", + I18nKey::LanguageOptionEnglish => "language.option.english", + I18nKey::LanguageOptionChineseSimplified => "language.option.chinese_simplified", + } + } +} + +fn load_dictionary(language: AppLanguage) -> HashMap { + let json = match language { + AppLanguage::English => include_str!("../resources/i18n/en.json"), + AppLanguage::ChineseSimplified => include_str!("../resources/i18n/zh-CN.json"), + }; + serde_json::from_str(json).unwrap_or_default() +} + +fn dictionary(language: AppLanguage) -> &'static HashMap { + static EN_DICTIONARY: OnceLock> = OnceLock::new(); + static ZH_CN_DICTIONARY: OnceLock> = OnceLock::new(); + + match language { + AppLanguage::English => EN_DICTIONARY.get_or_init(|| load_dictionary(AppLanguage::English)), + AppLanguage::ChineseSimplified => ZH_CN_DICTIONARY.get_or_init(|| load_dictionary(AppLanguage::ChineseSimplified)), + } +} + +pub fn tr_key<'a>(language: AppLanguage, key: &'a str) -> &'a str { + dictionary(language) + .get(key) + .map(String::as_str) + .or_else(|| dictionary(AppLanguage::English).get(key).map(String::as_str)) + .unwrap_or(key) +} + +pub fn tr_fmt(language: AppLanguage, key: &str, vars: &[(&str, &str)]) -> String { + let mut output = tr_key(language, key).to_string(); + for (name, value) in vars { + output = output.replace(&format!("{{{name}}}"), value); + } + output +} + +pub fn tr(language: AppLanguage, key: I18nKey) -> &'static str { + tr_key(language, key.as_str()) +} + +pub fn language_dropdown_labels(language: AppLanguage) -> Vec { + vec![ + tr(language, I18nKey::LanguageOptionEnglish).to_string(), + tr(language, I18nKey::LanguageOptionChineseSimplified).to_string(), + ] +} diff --git a/src/lib.rs b/src/lib.rs index 346c0314b..584bc9c4e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,8 @@ pub mod app; pub mod persistence; /// The settings screen and settings-related content/widgets. pub mod settings; +/// App-localized text and language preference definitions. +pub mod i18n; /// Login screen pub mod login; diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index bf4ec59c2..246308ad8 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -3,7 +3,7 @@ use std::ops::Not; use makepad_widgets::*; use url::Url; -use crate::sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest, RegisterAccount}; +use crate::{app::AppState, i18n::{AppLanguage, tr_fmt, tr_key}, sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest, RegisterAccount}}; use super::login_status_modal::{LoginStatusModalAction, LoginStatusModalWidgetExt}; @@ -154,7 +154,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } - Label { + homeserver_hint_label := Label { width: Fit, height: Fit padding: 0 draw_text +: { @@ -190,7 +190,7 @@ script_mod! { draw_bg.color: #C8C8C8 } - Label { + sso_prompt_label := Label { width: Fit, height: Fit padding: 0, draw_text +: { @@ -296,25 +296,66 @@ pub struct LoginScreen { #[rust] sso_redirect_url: Option, /// The most recent login failure message shown to the user. #[rust] last_failure_message_shown: Option, + #[rust] app_language: AppLanguage, } impl LoginScreen { - fn set_signup_mode(&mut self, cx: &mut Cx, signup_mode: bool) { - self.signup_mode = signup_mode; - self.view.view(cx, ids!(confirm_password_wrapper)).set_visible(cx, signup_mode); - self.view.view(cx, ids!(login_only_view)).set_visible(cx, !signup_mode); + fn sync_mode_texts(&mut self, cx: &mut Cx) { self.view.label(cx, ids!(title)).set_text(cx, - if signup_mode { "Create your Robrix account" } else { "Login to Robrix" } + if self.signup_mode { + tr_key(self.app_language, "login.title.create_account") + } else { + tr_key(self.app_language, "login.title.login_to_robrix") + } ); self.view.button(cx, ids!(login_button)).set_text(cx, - if signup_mode { "Create account" } else { "Login" } + if self.signup_mode { + tr_key(self.app_language, "login.button.create_account") + } else { + tr_key(self.app_language, "login.button.login") + } ); self.view.label(cx, ids!(account_prompt_label)).set_text(cx, - if signup_mode { "Already have an account?" } else { "Don't have an account?" } + if self.signup_mode { + tr_key(self.app_language, "login.account_prompt.already_have") + } else { + tr_key(self.app_language, "login.account_prompt.no_account") + } ); self.view.button(cx, ids!(mode_toggle_button)).set_text(cx, - if signup_mode { "Back to login" } else { "Sign up here" } + if self.signup_mode { + tr_key(self.app_language, "login.mode_toggle.back_to_login") + } else { + tr_key(self.app_language, "login.mode_toggle.sign_up_here") + } ); + } + + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.view.text_input(cx, ids!(user_id_input)) + .set_empty_text(cx, tr_key(self.app_language, "login.input.user_id").to_string()); + self.view.text_input(cx, ids!(password_input)) + .set_empty_text(cx, tr_key(self.app_language, "login.input.password").to_string()); + self.view.text_input(cx, ids!(confirm_password_input)) + .set_empty_text(cx, tr_key(self.app_language, "login.input.confirm_password").to_string()); + self.view.text_input(cx, ids!(homeserver_input)) + .set_empty_text(cx, tr_key(self.app_language, "login.input.homeserver").to_string()); + self.view.label(cx, ids!(homeserver_hint_label)) + .set_text(cx, tr_key(self.app_language, "login.label.homeserver_optional")); + self.view.label(cx, ids!(sso_prompt_label)) + .set_text(cx, tr_key(self.app_language, "login.sso.prompt")); + let login_status_modal_inner = self.view.login_status_modal(cx, ids!(login_status_modal_inner)); + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login_status_modal.title")); + login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login_status_modal.button.cancel")); + self.sync_mode_texts(cx); + } + + fn set_signup_mode(&mut self, cx: &mut Cx, signup_mode: bool) { + self.signup_mode = signup_mode; + self.view.view(cx, ids!(confirm_password_wrapper)).set_visible(cx, signup_mode); + self.view.view(cx, ids!(login_only_view)).set_visible(cx, !signup_mode); + self.sync_mode_texts(cx); if !signup_mode { self.view.text_input(cx, ids!(confirm_password_input)).set_text(cx, ""); @@ -327,17 +368,29 @@ impl LoginScreen { impl Widget for LoginScreen { 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.view.handle_event(cx, event, scope); - self.match_event(cx, event); + self.widget_match_event(cx, event, scope); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + 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.view.draw_walk(cx, scope, walk) } } -impl MatchEvent for LoginScreen { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { +impl WidgetMatchEvent for LoginScreen { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { let login_button = self.view.button(cx, ids!(login_button)); let mode_toggle_button = self.view.button(cx, ids!(mode_toggle_button)); let user_id_input = self.view.text_input(cx, ids!(user_id_input)); @@ -363,33 +416,33 @@ impl MatchEvent for LoginScreen { let confirm_password = confirm_password_input.text(); let homeserver = homeserver_input.text().trim().to_owned(); if user_id.is_empty() { - login_status_modal_inner.set_title(cx, "Missing User ID"); - login_status_modal_inner.set_status(cx, "Please enter a valid User ID."); - login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.missing_user_id.title")); + login_status_modal_inner.set_status(cx, tr_key(self.app_language, "login.status.missing_user_id.body")); + login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login.status.okay")); } else if password.is_empty() { - login_status_modal_inner.set_title(cx, "Missing Password"); - login_status_modal_inner.set_status(cx, "Please enter a valid password."); - login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.missing_password.title")); + login_status_modal_inner.set_status(cx, tr_key(self.app_language, "login.status.missing_password.body")); + login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login.status.okay")); } else if self.signup_mode && password != confirm_password { - login_status_modal_inner.set_title(cx, "Passwords do not match"); - login_status_modal_inner.set_status(cx, "Please enter the same password in both password fields."); - login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.password_mismatch.title")); + login_status_modal_inner.set_status(cx, tr_key(self.app_language, "login.status.password_mismatch.body")); + login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login.status.okay")); } else { self.last_failure_message_shown = None; login_status_modal_inner.set_title(cx, if self.signup_mode { - "Creating account..." + tr_key(self.app_language, "login.status.creating_account.title") } else { - "Logging in..." + tr_key(self.app_language, "login.status.logging_in.title") }); login_status_modal_inner.set_status( cx, if self.signup_mode { - "Waiting for the homeserver to create your account..." + tr_key(self.app_language, "login.status.creating_account.body") } else { - "Waiting for a login response..." + tr_key(self.app_language, "login.status.logging_in.body") }, ); - login_status_modal_inner.button_ref(cx).set_text(cx, "Cancel"); + login_status_modal_inner.button_ref(cx).set_text(cx, tr_key(self.app_language, "login.status.cancel")); submit_async_request(MatrixRequest::Login(if self.signup_mode { LoginRequest::Register(RegisterAccount { user_id, @@ -429,13 +482,15 @@ impl MatchEvent for LoginScreen { user_id_input.set_text(cx, user_id); password_input.set_text(cx, ""); homeserver_input.set_text(cx, homeserver.as_deref().unwrap_or_default()); - login_status_modal_inner.set_title(cx, "Logging in via CLI..."); + login_status_modal_inner.set_title(cx, tr_key(self.app_language, "login.status.logging_in_cli.title")); login_status_modal_inner.set_status( cx, - &format!("Auto-logging in as user {user_id}...") + &tr_fmt(self.app_language, "login.status.auto_logging_in_as_user", &[ + ("user_id", user_id.as_str()), + ]) ); let login_status_modal_button = login_status_modal_inner.button_ref(cx); - login_status_modal_button.set_text(cx, "Cancel"); + login_status_modal_button.set_text(cx, tr_key(self.app_language, "login.status.cancel")); login_status_modal_button.set_enabled(cx, false); // Login cancel not yet supported login_status_modal.open(cx); } @@ -444,7 +499,7 @@ impl MatchEvent for LoginScreen { login_status_modal_inner.set_title(cx, title); login_status_modal_inner.set_status(cx, status); let login_status_modal_button = login_status_modal_inner.button_ref(cx); - login_status_modal_button.set_text(cx, "Cancel"); + login_status_modal_button.set_text(cx, tr_key(self.app_language, "login.status.cancel")); login_status_modal_button.set_enabled(cx, true); login_status_modal.open(cx); self.redraw(cx); @@ -467,13 +522,13 @@ impl MatchEvent for LoginScreen { } self.last_failure_message_shown = Some(error.clone()); login_status_modal_inner.set_title(cx, if self.signup_mode { - "Account Creation Failed." + tr_key(self.app_language, "login.status.account_creation_failed") } else { - "Login Failed." + tr_key(self.app_language, "login.status.login_failed") }); login_status_modal_inner.set_status(cx, error); let login_status_modal_button = login_status_modal_inner.button_ref(cx); - login_status_modal_button.set_text(cx, "Okay"); + login_status_modal_button.set_text(cx, tr_key(self.app_language, "login.status.okay")); login_status_modal_button.set_enabled(cx, true); login_status_modal.open(cx); self.redraw(cx); diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index bf4563d65..102cde38f 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -20,7 +20,7 @@ use makepad_widgets::*; use matrix_sdk::room::reply::{EnforceThread, Reply}; use matrix_sdk_ui::timeline::{EmbeddedEvent, EventTimelineItem, TimelineEventItemId}; use ruma::{events::room::message::{LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent}, OwnedRoomId}; -use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}}, location::init_location_subscriber, shared::{avatar::AvatarWidgetRefExt, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; +use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}}, i18n::AppLanguage, location::init_location_subscriber, shared::{avatar::AvatarWidgetRefExt, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; script_mod! { use mod.prelude.widgets.* @@ -409,6 +409,7 @@ impl RoomInputBar { populate_preview_of_timeline_item( cx, &replying_preview.html_or_plaintext(cx, ids!(reply_preview_content.reply_preview_body)), + AppLanguage::default(), replying_to.0.content(), replying_to.0.sender(), &replying_preview_username, diff --git a/src/settings/account_settings.rs b/src/settings/account_settings.rs index 4669039d4..3f5844d36 100644 --- a/src/settings/account_settings.rs +++ b/src/settings/account_settings.rs @@ -2,7 +2,7 @@ use std::cell::RefCell; use makepad_widgets::{text::selection::Cursor, *}; -use crate::{app::ConfirmDeleteAction, avatar_cache::{self}, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction}, profile::user_profile::UserProfile, shared::{avatar::{AvatarState, AvatarWidgetExt}, confirmation_modal::ConfirmationModalContent, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{AccountDataAction, MatrixRequest, submit_async_request}, utils}; +use crate::{app::{AppState, ConfirmDeleteAction}, avatar_cache::{self}, i18n::{AppLanguage, tr_key}, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction}, profile::user_profile::UserProfile, shared::{avatar::{AvatarState, AvatarWidgetExt}, confirmation_modal::ConfirmationModalContent, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{AccountDataAction, MatrixRequest, submit_async_request}, utils}; script_mod! { use mod.prelude.widgets.* @@ -14,11 +14,11 @@ script_mod! { width: Fill, height: Fit flow: Down - TitleLabel { + account_settings_title := TitleLabel { text: "Account Settings" } - SubsectionLabel { + avatar_section_label := SubsectionLabel { text: "Your Avatar:" } @@ -95,7 +95,7 @@ script_mod! { } } - SubsectionLabel { + display_name_section_label := SubsectionLabel { text: "Your Display Name:" } @@ -144,7 +144,7 @@ script_mod! { } } - SubsectionLabel { + user_id_section_label := SubsectionLabel { text: "Your User ID:" } @@ -174,7 +174,7 @@ script_mod! { } } - SubsectionLabel { + other_actions_section_label := SubsectionLabel { text: "Other actions:" } @@ -210,10 +210,17 @@ pub struct AccountSettings { #[deref] view: View, #[rust] own_profile: Option, + #[rust] app_language: AppLanguage, } impl Widget for AccountSettings { 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.match_event(cx, event); let copy_user_id_button = self.view.button(cx, ids!(copy_user_id_button)); @@ -223,7 +230,7 @@ impl Widget for AccountSettings { cx.widget_action( copy_user_id_button.widget_uid(), TooltipAction::HoverIn { - text: "Copy User ID".to_string(), + text: tr_key(self.app_language, "settings.account.tooltip.copy_user_id").to_string(), widget_rect: copy_user_id_button_area.rect(cx), options: CalloutTooltipOptions { position: TooltipPosition::Top, @@ -245,6 +252,12 @@ impl Widget for AccountSettings { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + 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.view.draw_walk(cx, scope, walk) } } @@ -272,7 +285,11 @@ impl MatchEvent for AccountSettings { // Handle LogoutAction::InProgress to update button state if let Some(LogoutAction::InProgress(is_in_progress)) = action.downcast_ref() { let logout_button = self.view.button(cx, ids!(logout_button)); - logout_button.set_text(cx, if *is_in_progress { "Logging out..." } else { "Log out" }); + logout_button.set_text(cx, if *is_in_progress { + tr_key(self.app_language, "settings.account.button.logging_out") + } else { + tr_key(self.app_language, "settings.account.button.log_out") + }); logout_button.set_enabled(cx, !*is_in_progress); logout_button.reset_hover(cx); continue; @@ -291,7 +308,11 @@ impl MatchEvent for AccountSettings { profile.avatar_state.update_from_cache(cx); self.populate_avatar_views(cx); enqueue_popup_notification( - format!("Successfully {} avatar.", if new_avatar_url.is_some() { "updated" } else { "deleted" }), + if new_avatar_url.is_some() { + tr_key(self.app_language, "settings.account.popup.avatar_updated") + } else { + tr_key(self.app_language, "settings.account.popup.avatar_deleted") + }, PopupKind::Success, Some(4.0), ); @@ -329,7 +350,11 @@ impl MatchEvent for AccountSettings { display_name_input.set_disabled(cx, false); Self::enable_display_name_buttons(cx, false, &accept_display_name_button, &cancel_display_name_button); enqueue_popup_notification( - format!("Successfully {} display name.", if new_name.is_some() { "updated" } else { "removed" }), + if new_name.is_some() { + tr_key(self.app_language, "settings.account.popup.display_name_updated") + } else { + tr_key(self.app_language, "settings.account.popup.display_name_removed") + }, PopupKind::Success, Some(4.0), ); @@ -380,7 +405,7 @@ impl MatchEvent for AccountSettings { // Self::enable_upload_avatar_button(cx, false, &upload_avatar_button); // Self::enable_delete_avatar_button(cx, false, &delete_avatar_button); enqueue_popup_notification( - "Avatar uploading is not yet implemented.", + tr_key(self.app_language, "settings.account.popup.avatar_upload_not_implemented"), PopupKind::Warning, Some(4.0), ); @@ -390,15 +415,16 @@ impl MatchEvent for AccountSettings { // Don't immediately disable the buttons. Instead, we wait for the user // to confirm the action in the confirmation modal, // and then we disable the buttons in the AvatarDeleteStarted action handler. + let app_language = self.app_language; let content = ConfirmationModalContent { - title_text: "Delete Avatar".into(), - body_text: "Are you sure you want to delete your avatar?".into(), - accept_button_text: Some("Delete".into()), - on_accept_clicked: Some(Box::new(|cx| { + title_text: tr_key(app_language, "settings.account.modal.delete_avatar.title").into(), + body_text: tr_key(app_language, "settings.account.modal.delete_avatar.body").into(), + accept_button_text: Some(tr_key(app_language, "settings.account.modal.delete_avatar.accept").into()), + on_accept_clicked: Some(Box::new(move |cx| { submit_async_request(MatrixRequest::SetAvatar { avatar_url: None }); cx.action(AccountSettingsAction::AvatarDeleteStarted); enqueue_popup_notification( - "Deleting your avatar...", + tr_key(app_language, "settings.account.popup.deleting_avatar"), PopupKind::Info, Some(5.0), ); @@ -436,7 +462,7 @@ impl MatchEvent for AccountSettings { display_name_input.set_is_read_only(cx, true); Self::enable_display_name_buttons(cx, false, &accept_display_name_button, &cancel_display_name_button); enqueue_popup_notification( - "Uploading new display name...", + tr_key(self.app_language, "settings.account.popup.uploading_display_name"), PopupKind::Info, Some(5.0), ); @@ -445,7 +471,7 @@ impl MatchEvent for AccountSettings { if self.view.button(cx, ids!(copy_user_id_button)).clicked(actions) { cx.copy_to_clipboard(own_profile.user_id.as_str()); enqueue_popup_notification( - "Copied your User ID to the clipboard.", + tr_key(self.app_language, "settings.account.popup.copied_user_id"), PopupKind::Success, Some(3.0), ); @@ -455,7 +481,7 @@ impl MatchEvent for AccountSettings { // TODO: support opening the user's account management page in a browser, // or perhaps in an in-app pane if that's what is needed for regular UN+PW login. enqueue_popup_notification( - "Account management is not yet implemented.", + tr_key(self.app_language, "settings.account.popup.account_management_not_implemented"), PopupKind::Warning, Some(4.0), ); @@ -465,6 +491,56 @@ impl MatchEvent for AccountSettings { } impl AccountSettings { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.sync_app_language(cx); + } + + fn sync_app_language(&mut self, cx: &mut Cx) { + self.view + .label(cx, ids!(account_settings_title)) + .set_text(cx, tr_key(self.app_language, "settings.account.title")); + self.view + .label(cx, ids!(avatar_section_label)) + .set_text(cx, tr_key(self.app_language, "settings.account.section.your_avatar")); + self.view + .button(cx, ids!(upload_avatar_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.upload_avatar")); + self.view + .button(cx, ids!(delete_avatar_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.delete_avatar")); + self.view + .label(cx, ids!(display_name_section_label)) + .set_text(cx, tr_key(self.app_language, "settings.account.section.your_display_name")); + self.view + .text_input(cx, ids!(display_name_input)) + .set_empty_text(cx, tr_key(self.app_language, "settings.account.display_name.placeholder").to_string()); + self.view + .button(cx, ids!(cancel_display_name_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.cancel")); + self.view + .button(cx, ids!(accept_display_name_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.save_name")); + self.view + .label(cx, ids!(user_id_section_label)) + .set_text(cx, tr_key(self.app_language, "settings.account.section.your_user_id")); + if self.own_profile.is_none() { + self.view + .label(cx, ids!(user_id)) + .set_text(cx, tr_key(self.app_language, "settings.account.user_id.not_logged_in")); + } + self.view + .label(cx, ids!(other_actions_section_label)) + .set_text(cx, tr_key(self.app_language, "settings.account.section.other_actions")); + self.view + .button(cx, ids!(manage_account_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.manage_account")); + self.view + .button(cx, ids!(logout_button)) + .set_text(cx, tr_key(self.app_language, "settings.account.button.log_out")); + self.view.redraw(cx); + } + /// Populate avatar-related views with the user's profile data. /// /// This does nothing if `self.own_profile` is `None`. @@ -519,6 +595,7 @@ impl AccountSettings { self.own_profile = Some(own_profile); self.populate_avatar_views(cx); + self.sync_app_language(cx); self.view.button(cx, ids!(upload_avatar_button)).reset_hover(cx); self.view.button(cx, ids!(delete_avatar_button)).reset_hover(cx); @@ -639,6 +716,11 @@ impl AccountSettingsRef { let Some(mut inner) = self.borrow_mut() else { return }; inner.populate(cx, own_profile); } + + pub fn set_app_language(&self, cx: &mut Cx, app_language: AppLanguage) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.set_app_language(cx, app_language); + } } /// Actions that are handled by the AccountSettings widget. diff --git a/src/settings/bot_settings.rs b/src/settings/bot_settings.rs index 6e877a62c..200116764 100644 --- a/src/settings/bot_settings.rs +++ b/src/settings/bot_settings.rs @@ -2,6 +2,7 @@ use makepad_widgets::*; use crate::{ app::{AppState, BotSettingsState}, + i18n::{AppLanguage, tr_key}, persistence, shared::popup_list::{PopupKind, enqueue_popup_notification}, sliding_sync::current_user_id, @@ -28,7 +29,7 @@ script_mod! { flow: Down spacing: 10 - TitleLabel { + app_service_title := TitleLabel { text: "App Service" } @@ -68,7 +69,7 @@ script_mod! { height: Fit flow: Down - SubsectionLabel { + bot_user_id_label := SubsectionLabel { text: "BotFather User ID:" } @@ -103,15 +104,29 @@ script_mod! { pub struct BotSettings { #[deref] view: View, + #[rust] + app_language: AppLanguage, } impl Widget for BotSettings { 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.view.handle_event(cx, event, scope); self.widget_match_event(cx, event, scope); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + 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.view.draw_walk(cx, scope, walk) } } @@ -140,7 +155,7 @@ impl WidgetMatchEvent for BotSettings { app_state.bot_settings.botfather_user_id = bot_user_id_input.text().trim().to_string(); persist_bot_settings(app_state); enqueue_popup_notification( - "Saved Matrix app service settings.", + tr_key(self.app_language, "settings.labs.app_service.popup.saved"), PopupKind::Success, Some(3.0), ); @@ -150,6 +165,33 @@ impl WidgetMatchEvent for BotSettings { } impl BotSettings { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.sync_app_language(cx); + } + + fn sync_app_language(&mut self, cx: &mut Cx) { + self.view + .label(cx, ids!(app_service_title)) + .set_text(cx, tr_key(self.app_language, "settings.labs.app_service.title")); + self.view + .label(cx, ids!(description)) + .set_text(cx, tr_key(self.app_language, "settings.labs.app_service.description")); + self.view + .label(cx, ids!(enable_label)) + .set_text(cx, tr_key(self.app_language, "settings.labs.app_service.enable_label")); + self.view + .label(cx, ids!(bot_user_id_label)) + .set_text(cx, tr_key(self.app_language, "settings.labs.app_service.botfather_user_id")); + self.view + .text_input(cx, ids!(bot_user_id_input)) + .set_empty_text(cx, tr_key(self.app_language, "settings.labs.app_service.botfather_placeholder").to_string()); + self.view + .button(cx, ids!(buttons.save_button)) + .set_text(cx, tr_key(self.app_language, "settings.labs.app_service.button.save")); + self.view.redraw(cx); + } + fn sync_ui(&mut self, cx: &mut Cx, bot_settings: &BotSettingsState) { self.view .view(cx, ids!(bot_details)) @@ -159,9 +201,9 @@ impl BotSettings { .set_text(cx, &bot_settings.botfather_user_id); let toggle_text = if bot_settings.enabled { - "Disable App Service" + tr_key(self.app_language, "settings.labs.app_service.button.disable") } else { - "Enable App Service" + tr_key(self.app_language, "settings.labs.app_service.button.enable") }; self.view .button(cx, ids!(toggle_button)) @@ -175,6 +217,7 @@ impl BotSettings { /// Populates the bot settings UI from the current persisted app state. pub fn populate(&mut self, cx: &mut Cx, bot_settings: &BotSettingsState) { + self.sync_app_language(cx); self.sync_ui(cx, bot_settings); } } @@ -187,6 +230,13 @@ impl BotSettingsRef { }; inner.populate(cx, bot_settings); } + + pub fn set_app_language(&self, cx: &mut Cx, app_language: AppLanguage) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.set_app_language(cx, app_language); + } } fn persist_bot_settings(app_state: &AppState) { diff --git a/src/settings/settings_screen.rs b/src/settings/settings_screen.rs index 5d24945bc..5633bbc6f 100644 --- a/src/settings/settings_screen.rs +++ b/src/settings/settings_screen.rs @@ -1,7 +1,7 @@ use makepad_widgets::*; -use crate::{app::BotSettingsState, home::navigation_tab_bar::{NavigationBarAction, get_own_profile}, profile::user_profile::UserProfile, settings::{account_settings::AccountSettingsWidgetExt, bot_settings::BotSettingsWidgetExt}}; +use crate::{app::{AppState, BotSettingsState}, home::navigation_tab_bar::{NavigationBarAction, get_own_profile}, i18n::{AppLanguage, I18nKey, language_dropdown_labels, tr}, persistence, profile::user_profile::UserProfile, settings::{account_settings::AccountSettingsWidgetExt, bot_settings::BotSettingsWidgetExt}, shared::{popup_list::{PopupKind, enqueue_popup_notification}, styles::{apply_neutral_button_style, apply_primary_button_style}}, sliding_sync::current_user_id}; script_mod! { use mod.prelude.widgets.* @@ -49,27 +49,99 @@ script_mod! { // Make sure the dividing line is aligned with the close_button LineH { padding: 10, margin: Inset{top: 10, right: 2} } + settings_category_cards := View { + width: Fill, height: Fit + flow: Flow.Right{wrap: true} + align: Align{y: 0.5} + spacing: 10 + margin: Inset{left: 5, right: 5, bottom: 8} + + category_account_button := RobrixNeutralIconButton { + width: Fit, height: Fit, + padding: Inset{top: 9, bottom: 9, left: 14, right: 14} + spacing: 0, + icon_walk: Walk{width: 0, height: 0, margin: 0} + text: "Account" + } + + category_preferences_button := RobrixNeutralIconButton { + width: Fit, height: Fit, + padding: Inset{top: 9, bottom: 9, left: 14, right: 14} + spacing: 0, + icon_walk: Walk{width: 0, height: 0, margin: 0} + text: "Preferences" + } + + category_labs_button := RobrixNeutralIconButton { + width: Fit, height: Fit, + padding: Inset{top: 9, bottom: 9, left: 14, right: 14} + spacing: 0, + icon_walk: Walk{width: 0, height: 0, margin: 0} + text: "Labs" + } + } + ScrollXYView { width: Fill, height: Fill flow: Down - // The account settings section. - account_settings := AccountSettings {} + settings_sections := View { + width: Fill, height: Fit + flow: Down - LineH { width: 400, padding: 10, margin: Inset{top: 20, bottom: 5} } + // The account settings section. + account_settings_section := View { + width: Fill, height: Fit + flow: Down + account_settings := AccountSettings {} + } - bot_settings := BotSettings {} + preferences_settings_section := View { + visible: false + width: Fill, height: Fit + flow: Down + spacing: 8 - LineH { width: 400, padding: 10, margin: Inset{top: 20, bottom: 5} } + preferences_language_title := TitleLabel { + text: "Language" + } - // The TSP wallet settings section. - tsp_settings_screen := TspSettingsScreen {} + preferences_application_language_label := SubsectionLabel { + text: "Application language" + } - LineH { width: 400, padding: 10, margin: Inset{top: 20, bottom: 5} } + language_dropdown := DropDownFlat { + width: 165 + height: 40 + margin: Inset{left: 5, top: 2, bottom: 2} + labels: ["English", "Simplified Chinese"] + } - // Add other settings sections here as needed. - // Don't forget to add a `show()` fn to those settings sections - // and call them in `SettingsScreen::show()`. + preferences_language_hint_label := Label { + width: Fill + height: Fit + margin: Inset{left: 5, right: 8, top: 3, bottom: 4} + draw_text +: { + color: (MESSAGE_TEXT_COLOR) + text_style: REGULAR_TEXT { font_size: 10.5 } + } + text: "The app will reload after selecting another language" + } + } + + labs_settings_section := View { + visible: false + width: Fill, height: Fit + flow: Down + + bot_settings := BotSettings {} + + LineH { width: 400, padding: 10, margin: Inset{top: 20, bottom: 5} } + + // The TSP wallet settings section. + tsp_settings_screen := TspSettingsScreen {} + } + } } } @@ -89,14 +161,32 @@ script_mod! { } +/// The top-level widget showing all app and user settings/preferences. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum SettingsCategory { + #[default] + Account, + Preferences, + Labs, +} + /// The top-level widget showing all app and user settings/preferences. #[derive(Script, ScriptHook, Widget)] pub struct SettingsScreen { #[deref] view: View, + + #[rust] selected_category: SettingsCategory, + #[rust] app_language: AppLanguage, } impl Widget for SettingsScreen { 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.view.handle_event(cx, event, scope); // Close the pane if: @@ -124,57 +214,176 @@ impl Widget for SettingsScreen { cx.action(NavigationBarAction::CloseSettings); } - #[cfg(feature = "tsp")] if let Event::Actions(actions) = event { - use crate::tsp::{ - create_did_modal::CreateDidModalAction, - create_wallet_modal::CreateWalletModalAction, - }; - - for action in actions { - // Handle the create wallet modal being opened or closed. - match action.downcast_ref() { - Some(CreateWalletModalAction::Open) => { - use crate::tsp::create_wallet_modal::CreateWalletModalWidgetExt; - self.view.create_wallet_modal(cx, ids!(create_wallet_modal_inner)).show(cx); - self.view.modal(cx, ids!(create_wallet_modal)).open(cx); - } - Some(CreateWalletModalAction::Close) => { - self.view.modal(cx, ids!(create_wallet_modal)).close(cx); + if self.view.drop_down(cx, ids!(language_dropdown)).changed(actions).is_some() { + let selected_language = AppLanguage::from_dropdown_index( + self.view.drop_down(cx, ids!(language_dropdown)).selected_item(), + ); + if self.app_language != selected_language { + self.set_app_language(cx, selected_language); + if let Some(app_state) = scope.data.get_mut::() { + if app_state.app_language != selected_language { + app_state.app_language = selected_language; + persist_app_state(app_state); + enqueue_popup_notification( + tr(selected_language, I18nKey::LanguageReloadHint), + PopupKind::Info, + Some(4.0), + ); + } } - None => { } } + } - // Handle the create DID modal being opened or closed. - match action.downcast_ref() { - Some(CreateDidModalAction::Open) => { - use crate::tsp::create_did_modal::CreateDidModalWidgetExt; - self.view.create_did_modal(cx, ids!(create_did_modal_inner)).show(cx); - self.view.modal(cx, ids!(create_did_modal)).open(cx); + if self.view.button(cx, ids!(category_account_button)).clicked(actions) { + self.set_selected_category(cx, SettingsCategory::Account); + } + else if self.view.button(cx, ids!(category_preferences_button)).clicked(actions) { + self.set_selected_category(cx, SettingsCategory::Preferences); + } + else if self.view.button(cx, ids!(category_labs_button)).clicked(actions) { + self.set_selected_category(cx, SettingsCategory::Labs); + } + + #[cfg(feature = "tsp")] + { + use crate::tsp::{ + create_did_modal::CreateDidModalAction, + create_wallet_modal::CreateWalletModalAction, + }; + + for action in actions { + // Handle the create wallet modal being opened or closed. + match action.downcast_ref() { + Some(CreateWalletModalAction::Open) => { + use crate::tsp::create_wallet_modal::CreateWalletModalWidgetExt; + self.view.create_wallet_modal(cx, ids!(create_wallet_modal_inner)).show(cx); + self.view.modal(cx, ids!(create_wallet_modal)).open(cx); + } + Some(CreateWalletModalAction::Close) => { + self.view.modal(cx, ids!(create_wallet_modal)).close(cx); + } + None => { } } - Some(CreateDidModalAction::Close) => { - self.view.modal(cx, ids!(create_did_modal)).close(cx); + + // Handle the create DID modal being opened or closed. + match action.downcast_ref() { + Some(CreateDidModalAction::Open) => { + use crate::tsp::create_did_modal::CreateDidModalWidgetExt; + self.view.create_did_modal(cx, ids!(create_did_modal_inner)).show(cx); + self.view.modal(cx, ids!(create_did_modal)).open(cx); + } + Some(CreateDidModalAction::Close) => { + self.view.modal(cx, ids!(create_did_modal)).close(cx); + } + None => { } } - None => { } } } } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + 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.view.draw_walk(cx, scope, walk) } } impl SettingsScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.sync_app_language(cx); + } + + fn sync_app_language(&mut self, cx: &mut Cx) { + self.view + .label(cx, ids!(settings_header_title)) + .set_text(cx, tr(self.app_language, I18nKey::AllSettingsTitle)); + self.view + .button(cx, ids!(category_account_button)) + .set_text(cx, tr(self.app_language, I18nKey::SettingsCategoryAccount)); + self.view + .button(cx, ids!(category_preferences_button)) + .set_text(cx, tr(self.app_language, I18nKey::SettingsCategoryPreferences)); + self.view + .button(cx, ids!(category_labs_button)) + .set_text(cx, tr(self.app_language, I18nKey::SettingsCategoryLabs)); + self.view + .label(cx, ids!(preferences_language_title)) + .set_text(cx, tr(self.app_language, I18nKey::LanguageTitle)); + self.view + .label(cx, ids!(preferences_application_language_label)) + .set_text(cx, tr(self.app_language, I18nKey::ApplicationLanguageLabel)); + self.view + .label(cx, ids!(preferences_language_hint_label)) + .set_text(cx, tr(self.app_language, I18nKey::LanguageReloadHint)); + let language_dropdown = self.view.drop_down(cx, ids!(language_dropdown)); + language_dropdown.set_labels(cx, language_dropdown_labels(self.app_language)); + language_dropdown.set_selected_item(cx, self.app_language.dropdown_index()); + self.view + .account_settings(cx, ids!(account_settings)) + .set_app_language(cx, self.app_language); + self.view + .bot_settings(cx, ids!(bot_settings)) + .set_app_language(cx, self.app_language); + self.view.redraw(cx); + } + + fn set_selected_category(&mut self, cx: &mut Cx, category: SettingsCategory) { + self.selected_category = category; + self.sync_selected_category(cx); + } + + fn sync_selected_category(&mut self, cx: &mut Cx) { + let show_account = self.selected_category == SettingsCategory::Account; + let show_preferences = self.selected_category == SettingsCategory::Preferences; + let show_labs = self.selected_category == SettingsCategory::Labs; + + self.view.view(cx, ids!(account_settings_section)).set_visible(cx, show_account); + self.view.view(cx, ids!(preferences_settings_section)).set_visible(cx, show_preferences); + self.view.view(cx, ids!(labs_settings_section)).set_visible(cx, show_labs); + + let mut category_account_button = self.view.button(cx, ids!(category_account_button)); + let mut category_preferences_button = self.view.button(cx, ids!(category_preferences_button)); + let mut category_labs_button = self.view.button(cx, ids!(category_labs_button)); + + if show_account { + apply_primary_button_style(cx, &mut category_account_button); + } else { + apply_neutral_button_style(cx, &mut category_account_button); + } + if show_preferences { + apply_primary_button_style(cx, &mut category_preferences_button); + } else { + apply_neutral_button_style(cx, &mut category_preferences_button); + } + if show_labs { + apply_primary_button_style(cx, &mut category_labs_button); + } else { + apply_neutral_button_style(cx, &mut category_labs_button); + } + + category_account_button.reset_hover(cx); + category_preferences_button.reset_hover(cx); + category_labs_button.reset_hover(cx); + self.view.redraw(cx); + } + /// Fetches the current user's profile and uses it to populate the settings screen. - pub fn populate(&mut self, cx: &mut Cx, own_profile: Option, bot_settings: &BotSettingsState) { + pub fn populate(&mut self, cx: &mut Cx, own_profile: Option, bot_settings: &BotSettingsState, app_language: AppLanguage) { let Some(profile) = own_profile.or_else(|| get_own_profile(cx)) else { error!("Failed to get own profile for settings screen."); return; }; self.view.account_settings(cx, ids!(account_settings)).populate(cx, profile); self.view.bot_settings(cx, ids!(bot_settings)).populate(cx, bot_settings); + self.set_app_language(cx, app_language); + self.set_selected_category(cx, SettingsCategory::Account); self.view.button(cx, ids!(close_button)).reset_hover(cx); cx.set_key_focus(self.view.area()); self.redraw(cx); @@ -183,8 +392,16 @@ impl SettingsScreen { impl SettingsScreenRef { /// See [`SettingsScreen::populate()`]. - pub fn populate(&self, cx: &mut Cx, own_profile: Option, bot_settings: &BotSettingsState) { + pub fn populate(&self, cx: &mut Cx, own_profile: Option, bot_settings: &BotSettingsState, app_language: AppLanguage) { let Some(mut inner) = self.borrow_mut() else { return; }; - inner.populate(cx, own_profile, bot_settings); + inner.populate(cx, own_profile, bot_settings, app_language); + } +} + +fn persist_app_state(app_state: &AppState) { + if let Some(user_id) = current_user_id() { + if let Err(e) = persistence::save_app_state(app_state.clone(), user_id) { + error!("Failed to persist app state after updating language setting. Error: {e}"); + } } } diff --git a/src/shared/collapsible_header.rs b/src/shared/collapsible_header.rs index e9ad7e337..5a15803ee 100644 --- a/src/shared/collapsible_header.rs +++ b/src/shared/collapsible_header.rs @@ -10,7 +10,7 @@ use makepad_widgets::*; use makepad_widgets::animator::Animate; -use crate::home::rooms_list::RoomsListScopeProps; +use crate::{app::AppState, home::rooms_list::RoomsListScopeProps, i18n::tr_key}; use super::expand_arrow::ExpandArrow; use super::unread_badge::UnreadBadgeWidgetRefExt as _; @@ -82,15 +82,15 @@ pub enum HeaderCategory { None, } impl HeaderCategory { - fn as_str(&self) -> &'static str { + fn i18n_key(&self) -> Option<&'static str> { match self { - HeaderCategory::Invites => "Invites", - HeaderCategory::Favorites => "Favorites", - HeaderCategory::RegularRooms => "Rooms", - HeaderCategory::DirectRooms => "People", - HeaderCategory::LowPriority => "Low Priority", - HeaderCategory::LeftRooms => "Left Rooms", - HeaderCategory::None => "", + HeaderCategory::Invites => Some("rooms_list.category.invites"), + HeaderCategory::Favorites => Some("rooms_list.category.favorites"), + HeaderCategory::RegularRooms => Some("rooms_list.category.rooms"), + HeaderCategory::DirectRooms => Some("rooms_list.category.people"), + HeaderCategory::LowPriority => Some("rooms_list.category.low_priority"), + HeaderCategory::LeftRooms => Some("rooms_list.category.left_rooms"), + HeaderCategory::None => None, } } } @@ -133,10 +133,18 @@ impl Widget for CollapsibleHeader { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // Set arrow and label state during draw to ensure child widgets are available. + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); if let Some(mut arrow) = self.view.child_by_path(ids!(collapse_icon)).borrow_mut::() { arrow.set_is_open_no_animate(self.is_expanded); } - self.view.child_by_path(ids!(label)).set_text(cx, self.category.as_str()); + self.view.child_by_path(ids!(label)).set_text( + cx, + self.category + .i18n_key() + .map_or("", |key| tr_key(app_language, key)), + ); self.view.child_by_path(ids!(unread_badge)) .as_unread_badge() .update_counts(false, self.num_unread_mentions, 0); diff --git a/src/shared/room_filter_input_bar.rs b/src/shared/room_filter_input_bar.rs index ccbee0601..d89a7fed8 100644 --- a/src/shared/room_filter_input_bar.rs +++ b/src/shared/room_filter_input_bar.rs @@ -5,6 +5,7 @@ //! reused consistently across both Desktop and Mobile layouts. use makepad_widgets::*; +use crate::{app::AppState, i18n::{AppLanguage, tr_key}}; script_mod! { use mod.prelude.widgets.* @@ -69,6 +70,7 @@ script_mod! { #[derive(Script, ScriptHook, Widget)] pub struct RoomFilterInputBar { #[deref] view: View, + #[rust] app_language: AppLanguage, } /// Actions emitted by the `RoomFilterInputBar` based on user interaction with it. @@ -89,11 +91,23 @@ impl ActionDefaultRef for RoomFilterAction { impl Widget for RoomFilterInputBar { 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.view.handle_event(cx, event, scope); self.widget_match_event(cx, event, scope); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + 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.view.draw_walk(cx, scope, walk) } } @@ -131,3 +145,13 @@ impl WidgetMatchEvent for RoomFilterInputBar { } } } + +impl RoomFilterInputBar { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.view + .text_input(cx, ids!(input)) + .set_empty_text(cx, tr_key(self.app_language, "room_filter_input.placeholder").to_string()); + self.view.redraw(cx); + } +} diff --git a/src/tsp/tsp_settings_screen.rs b/src/tsp/tsp_settings_screen.rs index 83d0e6f87..adc9130b1 100644 --- a/src/tsp/tsp_settings_screen.rs +++ b/src/tsp/tsp_settings_screen.rs @@ -1,9 +1,7 @@ use makepad_widgets::*; -use crate::{shared::{popup_list::{enqueue_popup_notification, PopupKind}, styles::*}, tsp::{create_did_modal::CreateDidModalAction, create_wallet_modal::CreateWalletModalAction, submit_tsp_request, tsp_state_ref, TspIdentityAction, TspRequest, TspWalletAction, TspWalletEntry, TspWalletMetadata}}; - -const REPUBLISH_IDENTITY_BUTTON_TEXT: &str = "Republish Current Identity to DID Server"; +use crate::{app::AppState, i18n::{AppLanguage, tr_fmt, tr_key}, shared::{popup_list::{enqueue_popup_notification, PopupKind}, styles::*}, tsp::{create_did_modal::CreateDidModalAction, create_wallet_modal::CreateWalletModalAction, submit_tsp_request, tsp_state_ref, TspIdentityAction, TspRequest, TspWalletAction, TspWalletEntry, TspWalletMetadata}}; script_mod! { link tsp_enabled @@ -12,19 +10,17 @@ script_mod! { use mod.widgets.* - mod.widgets.REPUBLISH_IDENTITY_BUTTON_TEXT = "Republish Current Identity to DID Server" - // The view containing all TSP-related settings. mod.widgets.TspSettingsScreen = #(TspSettingsScreen::register_widget(vm)) { width: Fill, height: Fit flow: Down - TitleLabel { - text: "TSP Wallet Settings" + title := TitleLabel { + text: "" } - SubsectionLabel { - text: "Your active identity:" + section_active_identity := SubsectionLabel { + text: "" } View { @@ -57,17 +53,17 @@ script_mod! { draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_UPLOAD) icon_walk: Walk{width: 16, height: 16} - text: (REPUBLISH_IDENTITY_BUTTON_TEXT) + text: "" } - SubsectionLabel { - text: "Your Wallets:" + section_wallets := SubsectionLabel { + text: "" } no_wallets_label := View { width: Fill, height: Fit - Label { + no_wallets_text := Label { width: Fill, height: Fit margin: Inset{top: 10, bottom: 8, left: 13, right: 10}, flow: Flow.Right{wrap: true}, @@ -75,7 +71,7 @@ script_mod! { color: (COLOR_TEXT_WARNING_NOT_FOUND), text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, } - text: "No wallets found. Create or import a wallet." + text: "" } } @@ -117,7 +113,7 @@ script_mod! { draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_ADD_USER) icon_walk: Walk{width: 21, height: Fit, margin: 0} - text: "Create New Identity (DID)" + text: "" } create_wallet_button := RobrixPositiveIconButton { @@ -127,13 +123,13 @@ script_mod! { draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_ADD_WALLET) icon_walk: Walk{width: 21, height: Fit, margin: 0} - text: "Create New Wallet" + text: "" } import_wallet_button := RobrixIconButton { padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 5} - text: "Import Existing Wallet" + text: "" // TODO: fix this icon, or pick a different SVG // draw_icon +: { // svg: (ICON_IMPORT) @@ -161,18 +157,18 @@ impl WalletState { self.active_wallet.is_some() as usize + self.other_wallets.len() } - fn get(&self, index: usize) -> Option<(&TspWalletMetadata, WalletStatusAndDefault)> { + fn get(&self, index: usize, app_language: AppLanguage) -> Option<(&TspWalletMetadata, WalletStatusAndDefault)> { if let Some(active) = self.active_wallet.as_ref() { if index == 0 { - Some((active, WalletStatusAndDefault::new(WalletStatus::Opened, true))) + Some((active, WalletStatusAndDefault::new(WalletStatus::Opened, true, app_language))) } else { self.other_wallets.get(index - 1).map(|(m, s)| - (m, WalletStatusAndDefault::new(*s, false)) + (m, WalletStatusAndDefault::new(*s, false, app_language)) ) } } else { self.other_wallets.get(index).map(|(m, s)| - (m, WalletStatusAndDefault::new(*s, false)) + (m, WalletStatusAndDefault::new(*s, false, app_language)) ) } } @@ -188,10 +184,11 @@ pub enum WalletStatus { pub struct WalletStatusAndDefault { pub status: WalletStatus, pub is_default: bool, + pub app_language: AppLanguage, } impl WalletStatusAndDefault { - pub fn new(status: WalletStatus, is_default: bool) -> Self { - Self { status, is_default } + pub fn new(status: WalletStatus, is_default: bool, app_language: AppLanguage) -> Self { + Self { status, is_default, app_language } } } @@ -211,15 +208,29 @@ pub struct TspSettingsScreen { /// to avoid having to re-fetch them from the shared TSP state every time, /// as that requires locking the mutex and can be expensive. #[rust] wallets: Option, + #[rust] app_language: AppLanguage, + #[rust] app_language_initialized: bool, } impl Widget for TspSettingsScreen { 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_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } self.match_event(cx, event); self.view.handle_event(cx, event, scope); } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } if self.wallets.is_none() { // If we don't have any wallets, load them from the TSP state. self.refresh_wallets(); @@ -231,7 +242,7 @@ impl Widget for TspSettingsScreen { self.wallets.as_ref().and_then(|ws| ws.active_identity.as_deref()) { Some(current_did) => (current_did.to_string(), COLOR_FG_ACCEPT_GREEN, true), - None => ("No default identity has been set.".to_string(), COLOR_TEXT_WARNING_NOT_FOUND, false), + None => (tr_key(self.app_language, "tsp.settings.identity.none_set").to_string(), COLOR_TEXT_WARNING_NOT_FOUND, false), }; let mut current_identity_label = self.view.label(cx, ids!(current_identity_label)); script_apply_eval!(cx, current_identity_label, { @@ -256,7 +267,7 @@ impl Widget for TspSettingsScreen { return DrawStep::done(); }; - for (metadata, mut status_and_default) in (0..wallets.len()).filter_map(|i| wallets.get(i)) { + for (metadata, mut status_and_default) in (0..wallets.len()).filter_map(|i| wallets.get(i, self.app_language)) { let item_live_id = LiveId::from_str(metadata.url.as_url_unencoded()); let item = list.item(cx, item_live_id, id!(wallet_entry)).unwrap(); // Pass the wallet metadata in through Scope via props, @@ -300,7 +311,7 @@ impl MatchEvent for TspSettingsScreen { continue; } enqueue_popup_notification( - format!("Removed wallet \"{}\".", metadata.wallet_name), + tr_fmt(self.app_language, "tsp.settings.popup.wallet.removed", &[("wallet_name", metadata.wallet_name.as_str())]), PopupKind::Success, Some(4.0), ); @@ -308,8 +319,7 @@ impl MatchEvent for TspSettingsScreen { // If the removed wallet was the default wallet, notify the user. // The user should then select another wallet as the default. enqueue_popup_notification( - "The default wallet was removed.\n\n\ - TSP features will not work properly until you set a default wallet.", + tr_key(self.app_language, "tsp.settings.popup.wallet.default_removed_warning"), PopupKind::Warning, None, ); @@ -335,7 +345,7 @@ impl MatchEvent for TspSettingsScreen { } Some(TspWalletAction::DefaultWalletChanged(Err(_))) => { enqueue_popup_notification( - "Failed to set default wallet, could not find or open selected wallet.", + tr_key(self.app_language, "tsp.settings.popup.wallet.set_default_failed"), PopupKind::Error, None, ); @@ -355,7 +365,7 @@ impl MatchEvent for TspSettingsScreen { } Some(TspWalletAction::WalletOpened(Err(e))) => { enqueue_popup_notification( - format!("Failed to open wallet: {e}"), + tr_fmt(self.app_language, "tsp.settings.popup.wallet.open_failed", &[("error", &e.to_string())]), PopupKind::Error, None, ); @@ -381,19 +391,19 @@ impl MatchEvent for TspSettingsScreen { // restore the republish button to its original state. script_apply_eval!(cx, republish_identity_button, { enabled: true, - text: #(REPUBLISH_IDENTITY_BUTTON_TEXT), + text: #(tr_key(self.app_language, "tsp.settings.button.republish_identity")), }); match result { Ok(did) => { enqueue_popup_notification( - format!("Successfully republished identity \"{}\" to the DID server.", did), + tr_fmt(self.app_language, "tsp.settings.popup.identity.republish_success", &[("did", did.as_str())]), PopupKind::Success, Some(5.0), ); } Err(e) => { enqueue_popup_notification( - format!("Failed to republish identity to the DID server: {e}"), + tr_fmt(self.app_language, "tsp.settings.popup.identity.republish_failed", &[("error", &e.to_string())]), PopupKind::Error, None, ); @@ -415,13 +425,13 @@ impl MatchEvent for TspSettingsScreen { if let Some(did) = self.wallets.as_ref().and_then(|ws| ws.active_identity.as_deref()) { cx.copy_to_clipboard(did); enqueue_popup_notification( - "Copied your default TSP identity to the clipboard.", + tr_key(self.app_language, "tsp.settings.popup.identity.copied"), PopupKind::Success, Some(3.0), ); } else { enqueue_popup_notification( - "No default TSP identity has been set.", + tr_key(self.app_language, "tsp.settings.popup.identity.none_set"), PopupKind::Warning, Some(4.0), ); @@ -436,13 +446,13 @@ impl MatchEvent for TspSettingsScreen { if let Some(our_did) = self.wallets.as_ref().and_then(|ws| ws.active_identity.as_deref()) { script_apply_eval!(cx, republish_identity_button, { enabled: false, - text: "Republishing DID now...", + text: #(tr_key(self.app_language, "tsp.settings.button.republishing_now")), }); submit_tsp_request(TspRequest::RepublishDid { did: our_did.to_string() }); } else { enqueue_popup_notification( - "You must set a default TSP identity to be republished.", + tr_key(self.app_language, "tsp.settings.popup.identity.must_set_default"), PopupKind::Error, Some(5.0), ); @@ -463,7 +473,7 @@ impl MatchEvent for TspSettingsScreen { if self.view.button(cx, ids!(import_wallet_button)).clicked(actions) { // TODO: support importing an existing wallet. enqueue_popup_notification( - "Importing an existing wallet is not yet implemented.", + tr_key(self.app_language, "tsp.settings.popup.wallet.import_not_implemented"), PopupKind::Warning, Some(4.0), ); @@ -472,6 +482,36 @@ impl MatchEvent for TspSettingsScreen { } impl TspSettingsScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.app_language_initialized = true; + self.view + .label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.title")); + self.view + .label(cx, ids!(section_active_identity)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.section.active_identity")); + self.view + .button(cx, ids!(republish_identity_button)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.button.republish_identity")); + self.view + .label(cx, ids!(section_wallets)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.section.wallets")); + self.view + .label(cx, ids!(no_wallets_text)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.wallet.none")); + self.view + .button(cx, ids!(create_did_button)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.button.create_identity")); + self.view + .button(cx, ids!(create_wallet_button)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.button.create_wallet")); + self.view + .button(cx, ids!(import_wallet_button)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.button.import_wallet")); + self.view.redraw(cx); + } + /// Re-fetches the TSP state and populates this widget's list of wallets. fn refresh_wallets(&mut self) { let tsp_state = tsp_state_ref().lock().unwrap(); @@ -499,7 +539,7 @@ impl TspSettingsScreen { fn has_default_wallet(&self) -> bool { let Some(wallets) = self.wallets.as_ref() else { enqueue_popup_notification( - "No TSP wallets found.\n\nPlease create or import a wallet.", + tr_key(self.app_language, "tsp.settings.popup.wallet.none_found"), PopupKind::Warning, Some(5.0), ); @@ -507,7 +547,7 @@ impl TspSettingsScreen { }; if wallets.active_wallet.is_none() { enqueue_popup_notification( - "No default TSP wallet is set.\n\nPlease select or create a default wallet.", + tr_key(self.app_language, "tsp.settings.popup.wallet.no_default"), PopupKind::Warning, Some(5.0), ); diff --git a/src/tsp/wallet_entry/mod.rs b/src/tsp/wallet_entry/mod.rs index 2c2de8ab4..6832eada4 100644 --- a/src/tsp/wallet_entry/mod.rs +++ b/src/tsp/wallet_entry/mod.rs @@ -5,6 +5,7 @@ use makepad_widgets::*; use crate::{ app::ConfirmDeleteAction, + i18n::{AppLanguage, tr_fmt, tr_key}, shared::{confirmation_modal::ConfirmationModalContent, popup_list::{enqueue_popup_notification, PopupKind}}, tsp::{submit_tsp_request, tsp_settings_screen::{WalletStatus, WalletStatusAndDefault}, TspRequest, TspWalletMetadata} }; @@ -32,7 +33,7 @@ script_mod! { color: (MESSAGE_TEXT_COLOR), text_style: theme.font_bold { font_size: 12 }, } - text: "[Wallet Name]" + text: "" } wallet_path := Label { @@ -43,14 +44,14 @@ script_mod! { color: (MESSAGE_TEXT_COLOR), text_style: theme.font_regular { font_size: 11 }, } - text: "[Wallet Path/URL]" + text: "" } is_default_label_view := View { visible: false, width: Fit, height: Fit margin: Inset{left: 20} - Label { + is_default_label := Label { margin: Inset{top: 2.9} width: Fit, height: Fit flow: Right, @@ -58,7 +59,7 @@ script_mod! { color: (COLOR_FG_ACCEPT_GREEN), text_style: theme.font_bold { font_size: 11 }, } - text: "✅ Default" + text: "" } } @@ -66,7 +67,7 @@ script_mod! { visible: false, width: Fit, height: Fit margin: Inset{left: 20} - Label { + not_found_label := Label { margin: Inset{top: 2.9} width: Fit, height: Fit flow: Right, @@ -74,7 +75,7 @@ script_mod! { color: (COLOR_FG_DANGER_RED), text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, } - text: "Wallet not found!" + text: "" } } @@ -83,7 +84,7 @@ script_mod! { margin: Inset{left: 20} draw_icon.svg: (ICON_CHECKMARK) icon_walk: Walk{width: 16, height: 16} - text: "Set As Default" + text: "" } remove_wallet_button := RobrixNegativeIconButton { @@ -91,7 +92,7 @@ script_mod! { margin: Inset{left: 20} draw_icon.svg: (ICON_CLOSE) icon_walk: Walk{ width: 16, height: 16 } - text: "Remove From List" + text: "" } delete_wallet_button := RobrixNegativeIconButton { @@ -99,7 +100,7 @@ script_mod! { margin: Inset{left: 20} draw_icon.svg: (ICON_TRASH) icon_walk: Walk{ width: 16, height: 16 } - text: "Delete Wallet" + text: "" } } @@ -115,6 +116,7 @@ pub struct WalletEntry { #[deref] view: View, #[rust] metadata: Option, + #[rust] app_language: AppLanguage, } impl Widget for WalletEntry { @@ -130,13 +132,11 @@ impl Widget for WalletEntry { if self.view.button(cx, ids!(remove_wallet_button)).clicked(actions) { let metadata_clone = metadata.clone(); let content = ConfirmationModalContent { - title_text: "Remove Wallet".into(), - body_text: format!( - "Are you sure you want to remove the wallet \"{}\" \ - from the list?\n\nThis won't delete the actual wallet file.", - metadata.wallet_name - ).into(), - accept_button_text: Some("Remove".into()), + title_text: tr_key(self.app_language, "tsp.wallet_entry.modal.remove.title").into(), + body_text: tr_fmt(self.app_language, "tsp.wallet_entry.modal.remove.body", &[ + ("wallet_name", metadata.wallet_name.as_str()), + ]).into(), + accept_button_text: Some(tr_key(self.app_language, "tsp.wallet_entry.modal.remove.accept").into()), on_accept_clicked: Some(Box::new(move |_cx| { submit_tsp_request(TspRequest::RemoveWallet(metadata_clone)); })), @@ -148,7 +148,7 @@ impl Widget for WalletEntry { if self.view.button(cx, ids!(delete_wallet_button)).clicked(actions) { // TODO: Implement the delete wallet feature. enqueue_popup_notification( - "Delete wallet feature is not yet implemented.", + tr_key(self.app_language, "tsp.wallet_entry.popup.delete_not_implemented"), PopupKind::Warning, None, ); @@ -164,6 +164,7 @@ impl Widget for WalletEntry { if self.metadata.as_ref().is_none_or(|m| m != metadata) { self.metadata = Some(metadata.clone()); } + self.app_language = sd.app_language; self.label(cx, ids!(wallet_name)).set_text( cx, @@ -173,6 +174,26 @@ impl Widget for WalletEntry { cx, metadata.url.as_url_unencoded() ); + self.label(cx, ids!(is_default_label_view.is_default_label)).set_text( + cx, + tr_key(self.app_language, "tsp.wallet_entry.default_label"), + ); + self.label(cx, ids!(not_found_label_view.not_found_label)).set_text( + cx, + tr_key(self.app_language, "tsp.wallet_entry.not_found"), + ); + self.button(cx, ids!(set_default_wallet_button)).set_text( + cx, + tr_key(self.app_language, "tsp.wallet_entry.button.set_default"), + ); + self.button(cx, ids!(remove_wallet_button)).set_text( + cx, + tr_key(self.app_language, "tsp.wallet_entry.button.remove"), + ); + self.button(cx, ids!(delete_wallet_button)).set_text( + cx, + tr_key(self.app_language, "tsp.wallet_entry.button.delete"), + ); // There is a weird makepad bug where if we re-style one instance of the // `set_default_wallet_button` in one WalletEntry, all other instances of that button // get their styling messed up in weird ways. diff --git a/src/tsp_dummy/mod.rs b/src/tsp_dummy/mod.rs index c9451f506..e8c37e8aa 100644 --- a/src/tsp_dummy/mod.rs +++ b/src/tsp_dummy/mod.rs @@ -17,22 +17,23 @@ //! will be replaced with these dummy widgets when the `tsp` feature is not enabled. use makepad_widgets::*; +use crate::{app::AppState, i18n::{AppLanguage, tr_key}}; script_mod! { use mod.prelude.widgets.* use mod.widgets.* - mod.widgets.TspSettingsScreen = View { + mod.widgets.TspSettingsScreen = #(TspSettingsScreen::register_widget(vm)) { width: Fill, height: Fit flow: Down align: Align{x: 0} - TitleLabel { - text: "TSP Wallet Settings" + title := TitleLabel { + text: "" } - Label { + message := Label { width: Fill, height: Fit flow: Flow.Right{wrap: true}, align: Align{x: 0} @@ -41,7 +42,7 @@ script_mod! { color: (MESSAGE_TEXT_COLOR), text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, } - text: "TSP features are not included in this build.\nTo use TSP, build Robrix with the 'tsp' feature enabled." + text: "" } } @@ -70,3 +71,46 @@ script_mod! { visible: false } } + +#[derive(Script, ScriptHook, Widget)] +pub struct TspSettingsScreen { + #[deref] view: View, + #[rust] app_language: AppLanguage, + #[rust] app_language_initialized: bool, +} + +impl Widget for TspSettingsScreen { + 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_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.view.handle_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let app_language = scope.data.get::() + .map(|app_state| app_state.app_language) + .unwrap_or_default(); + if !self.app_language_initialized || self.app_language != app_language { + self.set_app_language(cx, app_language); + } + self.view.draw_walk(cx, scope, walk) + } +} + +impl TspSettingsScreen { + fn set_app_language(&mut self, cx: &mut Cx, app_language: AppLanguage) { + self.app_language = app_language; + self.app_language_initialized = true; + self.view + .label(cx, ids!(title)) + .set_text(cx, tr_key(self.app_language, "tsp.settings.title")); + self.view + .label(cx, ids!(message)) + .set_text(cx, tr_key(self.app_language, "tsp_dummy.message.disabled")); + self.view.redraw(cx); + } +} From 322a15530c890d3a611c7e7132880f3647480161 Mon Sep 17 00:00:00 2001 From: alanpoon Date: Thu, 2 Apr 2026 17:13:00 +0800 Subject: [PATCH 56/66] fix missing account action --- src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index d27e82db1..292b1138a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,7 +14,7 @@ use crate::{ event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, invite_screen::InviteScreenWidgetRefExt, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, space_lobby::SpaceLobbyScreenWidgetRefExt, spaces_bar::SpacesBarRef }, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt - }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, TimelineKind, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ + }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, TimelineKind, AccountSwitchAction, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, VerificationModalWidgetRefExt, } From 7d17415fa8fc95e7e23756bc45b50689ef4b9bfa Mon Sep 17 00:00:00 2001 From: Alvin Date: Fri, 3 Apr 2026 13:11:33 +0800 Subject: [PATCH 57/66] Skip app state restore after explicit logout --- src/logout/logout_state_machine.rs | 20 +++++++++++++- src/persistence/app_state.rs | 42 ++++++++++++++++++++++++++++++ src/sliding_sync.rs | 13 ++++++++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/logout/logout_state_machine.rs b/src/logout/logout_state_machine.rs index a8776377b..80f347bfb 100644 --- a/src/logout/logout_state_machine.rs +++ b/src/logout/logout_state_machine.rs @@ -90,7 +90,7 @@ use anyhow::{anyhow, Result}; use makepad_widgets::{Cx, log}; use crate::home::navigation_tab_bar::NavigationBarAction; -use crate::persistence::delete_latest_user_id; +use crate::persistence::{delete_latest_user_id, skip_app_state_restore_once}; use crate::sliding_sync::clear_app_state; use crate::{ home::main_desktop_ui::MainDesktopUiAction, @@ -324,6 +324,12 @@ impl LogoutStateMachine { "Point of no return reached".to_string(), 50 ).await?; + + if let Some(user_id) = get_client().and_then(|client| client.user_id().map(ToOwned::to_owned)) { + if let Err(e) = skip_app_state_restore_once(&user_id).await { + log!("Warning: Failed to mark app state restore to skip once for {user_id}: {e}"); + } + } // We delete latest_user_id after reaching LOGOUT_POINT_OF_NO_RETURN: // 1. To prevent auto-login with invalid session on next start @@ -343,6 +349,12 @@ impl LogoutStateMachine { "Token already invalidated".to_string(), 50 ).await?; + + if let Some(user_id) = get_client().and_then(|client| client.user_id().map(ToOwned::to_owned)) { + if let Err(e) = skip_app_state_restore_once(&user_id).await { + log!("Warning: Failed to mark app state restore to skip once for {user_id}: {e}"); + } + } // Same delete operation as in the success case above if let Err(e) = delete_latest_user_id().await { @@ -358,6 +370,12 @@ impl LogoutStateMachine { 50 ).await?; + if let Some(user_id) = get_client().and_then(|client| client.user_id().map(ToOwned::to_owned)) { + if let Err(e) = skip_app_state_restore_once(&user_id).await { + log!("Warning: Failed to mark app state restore to skip once for {user_id}: {e}"); + } + } + // Same delete operation as in the success case above if let Err(e) = delete_latest_user_id().await { log!("Warning: Failed to delete latest user ID: {}", e); diff --git a/src/persistence/app_state.rs b/src/persistence/app_state.rs index 6bc88714f..4b1b0ebf1 100644 --- a/src/persistence/app_state.rs +++ b/src/persistence/app_state.rs @@ -7,6 +7,7 @@ use crate::{app::AppState, app_data_dir, persistence::persistent_state_dir}; const LATEST_APP_STATE_FILE_NAME: &str = "latest_app_state.json"; +const SKIP_APP_STATE_RESTORE_ONCE_FILE_NAME: &str = "skip_app_state_restore_once"; const WINDOW_GEOM_STATE_FILE_NAME: &str = "window_geom_state.json"; @@ -38,6 +39,26 @@ pub fn save_app_state( Ok(()) } +/// Marks that the next login for this user should skip automatic app-state restore once. +pub async fn skip_app_state_restore_once(user_id: &UserId) -> anyhow::Result<()> { + let marker_path = persistent_state_dir(user_id).join(SKIP_APP_STATE_RESTORE_ONCE_FILE_NAME); + if let Some(parent) = marker_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(marker_path, b"1").await?; + Ok(()) +} + +/// Consumes the one-shot "skip automatic restore" marker for the given user, if present. +pub async fn take_skip_app_state_restore_once(user_id: &UserId) -> anyhow::Result { + let marker_path = persistent_state_dir(user_id).join(SKIP_APP_STATE_RESTORE_ONCE_FILE_NAME); + match tokio::fs::remove_file(marker_path).await { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e.into()), + } +} + /// Save the current state of the given window's geometry to persistent storage. pub fn save_window_state(window_ref: WindowRef, cx: &Cx) -> anyhow::Result<()> { let inner_size = window_ref.get_inner_size(cx); @@ -114,3 +135,24 @@ pub fn load_window_state(window_ref: WindowRef, cx: &mut Cx) -> anyhow::Result<( ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn skip_restore_marker_is_consumed_once() { + let user_id = UserId::parse("@robrix-test-skip-restore:example.invalid") + .unwrap() + .to_owned(); + + let _ = tokio::fs::remove_dir_all(persistent_state_dir(&user_id)).await; + + skip_app_state_restore_once(&user_id).await.unwrap(); + + assert!(take_skip_app_state_restore_once(&user_id).await.unwrap()); + assert!(!take_skip_app_state_restore_once(&user_id).await.unwrap()); + + let _ = tokio::fs::remove_dir_all(persistent_state_dir(&user_id)).await; + } +} diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 4b488edcf..0245ff79e 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -45,7 +45,7 @@ use crate::{ account_manager::{self, Account}, app::{AppStateAction, RoomFilterRemoteSearchAction}, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ add_room::{CreatableSpacesAction, CreateRoomAction, CreateRoomContext, KnockResultAction}, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, build_room_search_text, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails - }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ + }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state, take_skip_app_state_restore_once}, profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ @@ -4401,6 +4401,17 @@ fn handle_ignore_user_list_subscriber(client: Client) { /// If loading fails, it shows a popup notification with the error message. fn handle_load_app_state(user_id: OwnedUserId) { Handle::current().spawn(async move { + match take_skip_app_state_restore_once(&user_id).await { + Ok(true) => { + log!("Skipping automatic app state restore once for {user_id} after explicit logout."); + return; + } + Ok(false) => {} + Err(e) => { + warning!("Failed to check skip-restore marker for {user_id}: {e}"); + } + } + match load_app_state(&user_id).await { Ok(app_state) => { if !app_state.saved_dock_state_home.open_rooms.is_empty() From 2ab1b5fa4a65d4cb01868259987adb3eca72ce4b Mon Sep 17 00:00:00 2001 From: Alvin Date: Fri, 3 Apr 2026 13:15:04 +0800 Subject: [PATCH 58/66] Remove skip-restore marker test --- src/persistence/app_state.rs | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/persistence/app_state.rs b/src/persistence/app_state.rs index 4b1b0ebf1..7201ad033 100644 --- a/src/persistence/app_state.rs +++ b/src/persistence/app_state.rs @@ -135,24 +135,3 @@ pub fn load_window_state(window_ref: WindowRef, cx: &mut Cx) -> anyhow::Result<( ); Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn skip_restore_marker_is_consumed_once() { - let user_id = UserId::parse("@robrix-test-skip-restore:example.invalid") - .unwrap() - .to_owned(); - - let _ = tokio::fs::remove_dir_all(persistent_state_dir(&user_id)).await; - - skip_app_state_restore_once(&user_id).await.unwrap(); - - assert!(take_skip_app_state_restore_once(&user_id).await.unwrap()); - assert!(!take_skip_app_state_restore_once(&user_id).await.unwrap()); - - let _ = tokio::fs::remove_dir_all(persistent_state_dir(&user_id)).await; - } -} From 16291e2ceb9ba253affd579bfb90b888f15f254c 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, 3 Apr 2026 13:17:54 +0800 Subject: [PATCH 59/66] Add room threads pane and thread pagination support --- src/home/room_screen.rs | 640 ++++++++++++++++++++++++++++++++++++- src/room/room_input_bar.rs | 94 ++++-- src/sliding_sync.rs | 162 +++++++++- 3 files changed, 869 insertions(+), 27 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index b773f8ecd..a92eb35f0 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -34,7 +34,7 @@ use crate::{ shared::{ avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::ConfirmationModalContent, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt }, - sliding_sync::{BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, current_user_id, get_client, submit_async_request, take_timeline_endpoints}, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime} + sliding_sync::{BackwardsPaginateUntilEventRequest, FetchedRoomThread, MatrixRequest, PaginationDirection, RoomThreadsAction, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, current_user_id, get_client, submit_async_request, take_timeline_endpoints}, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime} }; use crate::home::event_reaction_list::ReactionListWidgetRefExt; use crate::home::room_read_receipt::AvatarRowWidgetRefExt; @@ -744,6 +744,218 @@ script_mod! { } } + mod.widgets.ThreadsPaneEntry = #(ThreadsPaneEntry::register_widget(vm)) { + ..mod.widgets.RoundedView + + width: Fill + height: Fit + flow: Down + spacing: 5 + padding: Inset{top: 12, right: 12, bottom: 12, left: 12} + margin: Inset{left: 12, right: 12, top: 6, bottom: 0} + cursor: MouseCursor.Hand + + show_bg: true + draw_bg +: { + color: #F8FAFD + border_radius: 4.0 + border_size: 1.0 + border_color: #D8E0EA + } + + title_row := View { + width: Fill + height: Fit + flow: Right + spacing: 8 + + title := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: USERNAME_TEXT_STYLE { font_size: 10.8 } + color: #1F1F1F + } + text: "" + } + + time := Label { + width: Fit + height: Fit + draw_text +: { + text_style: TIMESTAMP_TEXT_STYLE { font_size: 7.5 } + color: (TIMESTAMP_TEXT_COLOR) + } + text: "" + } + } + + subtitle := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: MESSAGE_TEXT_STYLE { font_size: 9.8 } + color: #7B7B7B + } + text: "" + } + + preview := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: MESSAGE_TEXT_STYLE { font_size: 10.0 } + color: (COLOR_TEXT) + } + text: "" + } + } + + mod.widgets.ThreadsSlidingPane = #(ThreadsSlidingPane::register_widget(vm)) { + visible: false, + flow: Overlay, + width: Fill, + height: Fill, + align: Align{x: 1.0, y: 0} + + bg_view := SolidView { + width: Fill + height: Fill + visible: false, + show_bg: true + draw_bg.color: #000000BB + } + + main_content := SolidView { + width: 320, + height: Fill + flow: Down, + align: Align{x: 1.0} + + show_bg: true, + draw_bg.color: (COLOR_PRIMARY) + + header := View { + width: Fill + height: Fit + flow: Right + align: Align{y: 0.5} + padding: Inset{top: 12, right: 10, bottom: 12, left: 15} + + title := Label { + width: Fit + height: Fit + draw_text +: { + text_style: USERNAME_TEXT_STYLE { font_size: 12.5 } + color: #000 + } + text: "Threads" + } + + spacer := View { + width: Fill + height: Fit + } + + close_button := RobrixNeutralIconButton { + width: Fit, + height: Fit, + spacing: 0, + padding: 15, + draw_icon.svg: (ICON_CLOSE) + icon_walk: Walk{width: 14, height: 14} + text: "" + } + } + + room_name := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + padding: Inset{left: 15, right: 15, bottom: 10} + draw_text +: { + text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } + color: #6E6E6E + } + text: "" + } + + loading_indicator := View { + visible: false + width: Fill + height: Fit + flow: Right + align: Align{y: 0.5} + spacing: 8 + padding: Inset{left: 15, right: 15, top: 6, bottom: 10} + + spinner := LoadingSpinner { + width: 18 + height: 18 + } + + loading_label := Label { + width: Fit + height: Fit + draw_text +: { + text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } + color: #7B7B7B + } + text: "Loading threads..." + } + } + + empty_state := Label { + visible: false + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + padding: Inset{left: 15, right: 15, top: 20, bottom: 20} + draw_text +: { + text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } + color: #7B7B7B + } + text: "No threads yet." + } + + threads_list := PortalList { + width: Fill + height: Fill + flow: Down + max_pull_down: 0.0 + + ThreadEntry := mod.widgets.ThreadsPaneEntry {} + } + } + + slide: 1.0, + + animator: Animator { + panel: { + default: @hide + show: AnimatorState{ + redraw: true, + from: {all: Forward {duration: 0.5}} + ease: Ease.ExpDecay {d1: 0.80, d2: 0.97} + apply: { + slide: 0.0 + } + } + hide: AnimatorState{ + redraw: true, + from: {all: Forward {duration: 0.5}} + ease: Ease.ExpDecay {d1: 0.80, d2: 0.97} + apply: { + slide: 1.0 + } + } + } + } + } + mod.widgets.AppServicePanel = #(AppServicePanel::register_widget(vm)) { width: Fill height: Fit @@ -1013,6 +1225,8 @@ script_mod! { // (on top of all other views that are always visible). user_profile_sliding_pane := mod.widgets.UserProfileSlidingPane { } + threads_sliding_pane := mod.widgets.ThreadsSlidingPane { } + // The loading pane appears while the user is waiting for something in the room screen // to finish loading, e.g., when loading an older replied-to message. loading_pane := LoadingPane { } @@ -1052,6 +1266,258 @@ script_mod! { } } +#[derive(Clone, Default, Debug)] +pub enum ThreadsPaneAction { + OpenThread(OwnedEventId), + LoadMoreRequested, + #[default] + None, +} + +impl ActionDefaultRef for ThreadsPaneAction { + fn default_ref() -> &'static Self { + static DEFAULT: ThreadsPaneAction = ThreadsPaneAction::None; + &DEFAULT + } +} + +#[derive(Clone, Debug)] +struct ThreadsPaneEntryInfo { + thread_root_event_id: OwnedEventId, + title: String, + subtitle: String, + time: String, + preview: String, +} + +#[derive(Clone, Debug)] +struct ThreadsPaneInfo { + room_name: String, + entries: Vec, + status_text: String, + show_entries: bool, + loading_text: String, + show_loading: bool, +} + +#[derive(Default)] +struct ThreadsPaneState { + room_id: Option, + entries: Vec, + prev_batch_token: Option, + is_loading: bool, + initialized: bool, + status_text: String, +} + +#[derive(Script, ScriptHook, Widget)] +pub struct ThreadsPaneEntry { + #[source] source: ScriptObjectRef, + #[deref] view: View, + + #[rust] thread_root_event_id: Option, +} + +impl Widget for ThreadsPaneEntry { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + + let Some(thread_root_event_id) = self.thread_root_event_id.clone() else { return }; + match event.hits(cx, self.view.area()) { + Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { + cx.widget_action( + self.widget_uid(), + ThreadsPaneAction::OpenThread(thread_root_event_id), + ); + } + _ => {} + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl ThreadsPaneEntry { + fn set_entry(&mut self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo) { + self.thread_root_event_id = Some(entry.thread_root_event_id.clone()); + self.label(cx, ids!(title)).set_text(cx, &entry.title); + self.label(cx, ids!(time)).set_text(cx, &entry.time); + self.label(cx, ids!(subtitle)).set_text(cx, &entry.subtitle); + self.label(cx, ids!(preview)).set_text(cx, &entry.preview); + } +} + +impl ThreadsPaneEntryRef { + fn set_entry(&self, cx: &mut Cx, entry: &ThreadsPaneEntryInfo) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.set_entry(cx, entry); + } +} + +#[derive(Script, ScriptHook, Widget, Animator)] +pub struct ThreadsSlidingPane { + #[source] source: ScriptObjectRef, + #[deref] view: View, + #[apply_default] animator: Animator, + #[live] slide: f32, + + #[rust] info: Option, + #[rust] is_animating_out: bool, +} + +impl Widget for ThreadsSlidingPane { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + + if !self.visible { return; } + + let animator_action = self.animator_handle_event(cx, event); + if animator_action.must_redraw() { + self.redraw(cx); + } + + if self.is_animating_out && !self.animator.is_track_animating(id!(panel)) { + self.visible = false; + self.is_animating_out = false; + cx.revert_key_focus(); + self.view(cx, ids!(bg_view)).set_visible(cx, false); + self.redraw(cx); + return; + } + + let area = self.view.area(); + let close_pane = { + matches!( + event, + Event::Actions(actions) if self.button(cx, ids!(close_button)).clicked(actions) + ) + || event.back_pressed() + || match event.hits_with_capture_overload(cx, area, true) { + Hit::KeyUp(key) => key.key_code == KeyCode::Escape, + Hit::FingerDown(_fde) => { + cx.set_key_focus(area); + false + } + Hit::FingerUp(fue) if fue.is_over => { + fue.mouse_button().is_some_and(|b| b.is_back()) + || !self.view(cx, ids!(main_content)).area().rect(cx).contains(fue.abs) + } + _ => false, + } + }; + if close_pane { + self.hide(cx); + } + + if let Event::Actions(actions) = event { + let threads_list = self.portal_list(cx, ids!(threads_list)); + if threads_list.scrolled(actions) + && threads_list.first_id() == 0 + && threads_list.scroll_position() >= -0.5 + { + cx.widget_action( + self.widget_uid(), + ThreadsPaneAction::LoadMoreRequested, + ); + } + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let Some(info) = self.info.as_ref() else { + self.visible = false; + return self.view.draw_walk(cx, scope, walk); + }; + + let panel_width = 320.0; + let right_margin = -(self.slide * panel_width); + let mut main_content = self.view(cx, ids!(main_content)); + script_apply_eval!(cx, main_content, { + margin.right: #(right_margin) + }); + let bg_alpha = (1.0 - self.slide) * 0.733; + let bg_color = vec4(0.0, 0.0, 0.0, bg_alpha); + let mut bg_view = self.view(cx, ids!(bg_view)); + script_apply_eval!(cx, bg_view, { + draw_bg +: { color: #(bg_color) } + }); + + self.label(cx, ids!(room_name)).set_text(cx, &info.room_name); + self.label(cx, ids!(loading_label)).set_text(cx, &info.loading_text); + self.view(cx, ids!(loading_indicator)).set_visible(cx, info.show_loading); + self.label(cx, ids!(empty_state)).set_text(cx, &info.status_text); + self.view(cx, ids!(empty_state)).set_visible(cx, !info.show_entries && !info.show_loading); + self.view(cx, ids!(threads_list)).set_visible(cx, info.show_entries); + + while let Some(widget) = self.view.draw_walk(cx, scope, walk).step() { + let portal_list_ref = widget.as_portal_list(); + let Some(mut list) = portal_list_ref.borrow_mut() else { continue }; + + list.set_item_range(cx, 0, info.entries.len()); + while let Some(item_id) = list.next_visible_item(cx) { + let Some(entry) = info.entries.get(item_id) else { continue }; + let item = list.item(cx, item_id, id!(ThreadEntry)); + item.as_threads_pane_entry().set_entry(cx, entry); + item.draw_all(cx, &mut Scope::empty()); + } + } + DrawStep::done() + } +} + +impl ThreadsSlidingPane { + pub fn is_currently_shown(&self, _cx: &mut Cx) -> bool { + self.visible + } + + fn set_info(&mut self, _cx: &mut Cx, info: ThreadsPaneInfo) { + self.info = Some(info); + } + + pub fn show(&mut self, cx: &mut Cx) { + self.visible = true; + self.is_animating_out = false; + cx.set_key_focus(self.view.area()); + self.animator_play(cx, ids!(panel.show)); + self.view(cx, ids!(bg_view)).set_visible(cx, true); + self.view.button(cx, ids!(close_button)).reset_hover(cx); + self.redraw(cx); + } + + pub fn hide(&mut self, cx: &mut Cx) { + if !self.visible { + return; + } + self.is_animating_out = true; + self.animator_play(cx, ids!(panel.hide)); + self.redraw(cx); + } +} + +impl ThreadsSlidingPaneRef { + pub fn is_currently_shown(&self, cx: &mut Cx) -> bool { + let Some(inner) = self.borrow() else { return false }; + inner.is_currently_shown(cx) + } + + fn set_info(&self, cx: &mut Cx, info: ThreadsPaneInfo) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.set_info(cx, info); + } + + pub fn show(&self, cx: &mut Cx) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.show(cx); + } + + pub fn hide(&self, cx: &mut Cx) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.hide(cx); + } +} + /// The main widget that displays a single Matrix room. #[derive(Script, Widget)] pub struct RoomScreen { @@ -1079,6 +1545,7 @@ pub struct RoomScreen { streaming_timeout_timer: Timer, /// Whether the in-room app service quick actions card is currently visible. #[rust] show_app_service_actions: bool, + #[rust] threads_pane_state: ThreadsPaneState, } impl Drop for RoomScreen { @@ -1111,6 +1578,7 @@ impl Widget for RoomScreen { let room_screen_widget_uid = self.widget_uid(); let portal_list = self.portal_list(cx, ids!(timeline.list)); let user_profile_sliding_pane = self.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)); + let threads_sliding_pane = self.threads_sliding_pane(cx, ids!(threads_sliding_pane)); let loading_pane = self.loading_pane(cx, ids!(loading_pane)); // Streaming animation frame handler @@ -1354,6 +1822,40 @@ impl Widget for RoomScreen { } } + match action.as_widget_action().cast_ref() { + ThreadsPaneAction::OpenThread(thread_root_event_id) => { + let Some(room_name_id) = self.room_name_id.as_ref().cloned() else { continue }; + threads_sliding_pane.hide(cx); + cx.widget_action( + room_screen_widget_uid, + RoomsListAction::Selected(SelectedRoom::Thread { + room_name_id, + thread_root_event_id: thread_root_event_id.clone(), + }), + ); + } + ThreadsPaneAction::LoadMoreRequested => { + self.request_more_threads(cx, true); + } + ThreadsPaneAction::None => {} + } + + if let Some(RoomThreadsAction::Loaded { room_id, from, threads, prev_batch_token }) = action.downcast_ref() { + if self.threads_pane_state.room_id.as_ref().is_some_and(|current| current == room_id) { + self.on_threads_loaded( + cx, + from.as_ref(), + threads, + prev_batch_token.clone(), + ); + } + } + if let Some(RoomThreadsAction::Failed { room_id, from: _, error }) = action.downcast_ref() { + if self.threads_pane_state.room_id.as_ref().is_some_and(|current| current == room_id) { + self.on_threads_failed(cx, error); + } + } + // Handle the highlight animation for a message. let Some(tl) = self.tl_state.as_mut() else { continue }; if let MessageHighlightAnimationState::Pending { item_id } = tl.message_highlight_animation_state { @@ -1418,6 +1920,9 @@ impl Widget for RoomScreen { } self.process_timeline_updates(cx, &portal_list, scope.data.get::()); + if threads_sliding_pane.is_currently_shown(cx) { + self.refresh_threads_pane(cx); + } // Ideally we would do this elsewhere on the main thread, because it's not room-specific, // but it doesn't hurt to do it here. @@ -1441,6 +1946,12 @@ impl Widget for RoomScreen { loading_pane.handle_event(cx, event, scope); } } + else if threads_sliding_pane.is_currently_shown(cx) { + is_pane_shown = true; + if is_interactive_hit { + threads_sliding_pane.handle_event(cx, event, scope); + } + } else if user_profile_sliding_pane.is_currently_shown(cx) { is_pane_shown = true; if is_interactive_hit { @@ -2036,7 +2547,8 @@ impl Widget for RoomScreen { // If the list is not filling the viewport, we need to back paginate the timeline // until we have enough events items to fill the viewport. - if !tl_state.fully_paginated + if tl_state.kind.thread_root_event_id().is_none() + && !tl_state.fully_paginated && !tl_state.backwards_pagination_in_flight && !list.is_filling_viewport() { @@ -3177,6 +3689,9 @@ impl RoomScreen { }), ); } + MessageAction::ShowThreadsPane => { + self.show_threads_pane(cx); + } MessageAction::Redact { details, reason } => { let Some(tl) = self.tl_state.as_ref() else { return }; let timeline_event_id = details.timeline_event_id.clone(); @@ -3312,6 +3827,123 @@ impl RoomScreen { self.redraw(cx); } + fn show_threads_pane(&mut self, cx: &mut Cx) { + self.ensure_threads_state_for_current_room(); + if !self.threads_pane_state.initialized && !self.threads_pane_state.is_loading { + self.request_more_threads(cx, false); + } + self.refresh_threads_pane(cx); + self.threads_sliding_pane(cx, ids!(threads_sliding_pane)).show(cx); + self.redraw(cx); + } + + fn refresh_threads_pane(&mut self, cx: &mut Cx) { + let Some(room_name_id) = self.room_name_id.as_ref() else { return }; + self.threads_sliding_pane(cx, ids!(threads_sliding_pane)).set_info( + cx, + ThreadsPaneInfo { + room_name: room_name_id.to_string(), + entries: self.threads_pane_state.entries.iter() + .map(|entry| ThreadsPaneEntryInfo { + thread_root_event_id: entry.thread_root_event_id.clone(), + title: entry.title.clone(), + subtitle: match entry.reply_count { + 1 => String::from("1 reply"), + n => format!("{n} replies"), + }, + time: utils::relative_format(entry.timestamp) + .unwrap_or_else(|| String::from("")), + preview: entry.latest_reply_preview.clone().unwrap_or_else(|| String::from("Tap to open thread")), + }) + .collect(), + status_text: self.threads_pane_state.status_text.clone(), + show_entries: !self.threads_pane_state.entries.is_empty(), + loading_text: if self.threads_pane_state.entries.is_empty() { + String::from("Loading threads...") + } else { + String::from("Loading more threads...") + }, + show_loading: self.threads_pane_state.is_loading, + }, + ); + } + + fn hide_threads_pane(&mut self, cx: &mut Cx) { + self.threads_sliding_pane(cx, ids!(threads_sliding_pane)).hide(cx); + } + + fn ensure_threads_state_for_current_room(&mut self) { + let Some(room_id) = self.room_id().cloned() else { return }; + if self.threads_pane_state.room_id.as_ref().is_some_and(|current| current == &room_id) { + return; + } + self.threads_pane_state = ThreadsPaneState { + room_id: Some(room_id), + status_text: String::from("Loading threads..."), + ..Default::default() + }; + } + + fn request_more_threads(&mut self, _cx: &mut Cx, load_more: bool) { + self.ensure_threads_state_for_current_room(); + let Some(room_id) = self.threads_pane_state.room_id.clone() else { return }; + if self.threads_pane_state.is_loading { + return; + } + let from = if load_more { + let Some(from) = self.threads_pane_state.prev_batch_token.clone() else { return }; + Some(from) + } else { + None + }; + self.threads_pane_state.is_loading = true; + if !self.threads_pane_state.initialized { + self.threads_pane_state.status_text = String::from("Loading threads..."); + } + submit_async_request(MatrixRequest::ListRoomThreads { + room_id, + from, + }); + } + + fn on_threads_loaded( + &mut self, + cx: &mut Cx, + _from: Option<&String>, + threads: &[FetchedRoomThread], + prev_batch_token: Option, + ) { + self.threads_pane_state.is_loading = false; + self.threads_pane_state.initialized = true; + self.threads_pane_state.prev_batch_token = prev_batch_token; + self.threads_pane_state.entries.extend_from_slice(threads); + self.threads_pane_state.entries.sort_by_key(|entry| u64::from(entry.timestamp.0)); + self.threads_pane_state.entries.dedup_by(|a, b| a.thread_root_event_id == b.thread_root_event_id); + self.threads_pane_state.status_text = if self.threads_pane_state.entries.is_empty() { + String::from("No threads yet.") + } else { + String::new() + }; + self.refresh_threads_pane(cx); + self.redraw(cx); + } + + fn on_threads_failed(&mut self, cx: &mut Cx, error: &str) { + self.threads_pane_state.is_loading = false; + self.threads_pane_state.initialized = true; + if self.threads_pane_state.entries.is_empty() { + self.threads_pane_state.status_text = format!("Failed to load threads.\n\nError: {error}"); + } else { + enqueue_popup_notification( + format!("Failed to load more threads.\n\nError: {error}"), + PopupKind::Error, + Some(5.0), + ); + } + self.refresh_threads_pane(cx); + self.redraw(cx); + } + /// Invoke this when this timeline is being shown, /// e.g., when the user navigates to this timeline. fn show_timeline(&mut self, cx: &mut Cx) { @@ -3618,6 +4250,8 @@ impl RoomScreen { self.hide_timeline(); self.reset_app_service_ui(cx); + self.hide_threads_pane(cx); + self.threads_pane_state = Default::default(); // Reset the the state of the inner loading pane. self.loading_pane(cx, ids!(loading_pane)).take_state(); @@ -4114,6 +4748,7 @@ struct FetchedThreadSummary { num_replies: u32, latest_reply_preview_text: Option, } + impl ItemDrawnStatus { /// Returns a new `ItemDrawnStatus` with both `profile_drawn` and `content_drawn` set to `false`. const fn new() -> Self { @@ -5764,6 +6399,7 @@ pub enum MessageAction { ActionBarClose, /// The user requested toggling the in-room app service quick actions card. ToggleAppServiceActions, + ShowThreadsPane, #[default] None, } diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index 8c6a6fb64..569ed3241 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -29,6 +29,7 @@ script_mod! { mod.widgets.ICO_LOCATION_PERSON = crate_resource("self://resources/icons/location-person.svg") mod.widgets.ICO_MENU = crate_resource("self://resources/icons/menu.svg") + mod.widgets.ICO_THREADS = crate_resource("self://resources/icons/double_chat.svg") mod.widgets.RoomEmojiButton = mod.widgets.RobrixIconButton { spacing: 0 @@ -100,32 +101,67 @@ script_mod! { padding: 6, spacing: 4 - location_card_button := RobrixIconButton { + more_actions_popup := View { visible: false - width: 230 + width: Fill + height: Fit + flow: Right{wrap: true} + spacing: 6 align: Align{x: 0.0, y: 0.5} - margin: Inset{top: 1, bottom: 1} - padding: Inset{left: 10, right: 10, top: 8, bottom: 8} - spacing: 8 - draw_icon +: { - svg: (mod.widgets.ICO_LOCATION_PERSON) - color: (COLOR_ACTIVE_PRIMARY_DARKER) - }, - draw_bg +: { - color: (COLOR_BG_PREVIEW) - color_hover: #E0E8F0 - color_down: #D0D8E8 - border_size: 1.0 - border_color: (COLOR_SECONDARY) + + location_card_button := RobrixIconButton { + width: Fit + align: Align{x: 0.0, y: 0.5} + margin: Inset{top: 1, bottom: 1} + padding: Inset{left: 10, right: 10, top: 8, bottom: 8} + spacing: 8 + draw_icon +: { + svg: (mod.widgets.ICO_LOCATION_PERSON) + color: (COLOR_ACTIVE_PRIMARY_DARKER) + }, + draw_bg +: { + color: (COLOR_BG_PREVIEW) + color_hover: #E0E8F0 + color_down: #D0D8E8 + border_size: 1.0 + border_color: (COLOR_SECONDARY) + } + draw_text +: { + color: (COLOR_TEXT) + color_hover: (COLOR_TEXT) + color_down: (COLOR_TEXT) + text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } + } + icon_walk: Walk{width: 20, height: 20} + text: "location", } - draw_text +: { - color: (COLOR_TEXT) - color_hover: (COLOR_TEXT) - color_down: (COLOR_TEXT) - text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } + + threads_card_button := RobrixIconButton { + width: Fit + align: Align{x: 0.0, y: 0.5} + margin: Inset{top: 1, bottom: 1} + padding: Inset{left: 10, right: 10, top: 8, bottom: 8} + spacing: 8 + draw_icon +: { + svg: (mod.widgets.ICO_THREADS) + color: (COLOR_ACTIVE_PRIMARY_DARKER) + }, + draw_bg +: { + color: (COLOR_BG_PREVIEW) + color_hover: #E0E8F0 + color_down: #D0D8E8 + border_size: 1.0 + border_color: (COLOR_SECONDARY) + } + draw_text +: { + color: (COLOR_TEXT) + color_hover: (COLOR_TEXT) + color_down: (COLOR_TEXT) + text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } + } + icon_walk: Walk{width: 20, height: 20} + text: "threads", } - icon_walk: Walk{width: 20, height: 20} - text: "Share your current location", } emoji_picker_popup := View { @@ -347,7 +383,7 @@ impl RoomInputBar { // Handle the more actions button being clicked. if self.button(cx, ids!(more_actions_button)).clicked(actions) { self.is_location_card_expanded = !self.is_location_card_expanded; - self.button(cx, ids!(location_card_button)).set_visible(cx, self.is_location_card_expanded); + self.view.view(cx, ids!(more_actions_popup)).set_visible(cx, self.is_location_card_expanded); self.redraw(cx); } @@ -396,6 +432,8 @@ impl RoomInputBar { // Handle the location card being clicked. if self.button(cx, ids!(location_card_button)).clicked(actions) { log!("Location card clicked; requesting current location..."); + self.is_location_card_expanded = false; + self.view.view(cx, ids!(more_actions_popup)).set_visible(cx, false); if let Err(_e) = init_location_subscriber(cx) { error!("Failed to initialize location subscriber"); enqueue_popup_notification( @@ -408,6 +446,14 @@ impl RoomInputBar { self.redraw(cx); } + if self.button(cx, ids!(threads_card_button)).clicked(actions) { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + MessageAction::ShowThreadsPane, + ); + self.redraw(cx); + } + // Handle the send location button being clicked. if self.button(cx, ids!(location_preview.send_location_button)).clicked(actions) { let location_preview = self.location_preview(cx, ids!(location_preview)); @@ -885,7 +931,7 @@ impl RoomInputBarRef { .is_empty(); inner.enable_send_message_button(cx, !is_text_input_empty); inner.is_location_card_expanded = false; - inner.button(cx, ids!(location_card_button)).set_visible(cx, false); + inner.view.view(cx, ids!(more_actions_popup)).set_visible(cx, false); inner.is_emoji_picker_expanded = false; inner.view.view(cx, ids!(emoji_picker_popup)).set_visible(cx, false); diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 4b488edcf..796f02c71 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -9,7 +9,7 @@ use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use mime::{IMAGE_JPEG, IMAGE_PNG}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ - config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ + config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, ListThreadsOptions, RelationsOptions, RoomMember}, ruma::{ api::{Direction, client::{ account::register::v3::Request as RegistrationRequest, room::create_room::v3::{Request as CreateRoomRequest, RoomPreset}, @@ -295,6 +295,11 @@ fn is_invalid_batch_token_timeline_error(error: &matrix_sdk_ui::timeline::Error) || error_text.contains("must start with 's' or 't'") } +fn is_thread_unknown_parent_timeline_error(error: &matrix_sdk_ui::timeline::Error) -> bool { + let error_text = error.to_string().to_ascii_lowercase(); + error_text.contains("unknown parent event") +} + /// Build a new client. async fn build_client( @@ -613,6 +618,30 @@ pub enum DirectMessageRoomAction { }, } +#[derive(Clone, Debug)] +pub struct FetchedRoomThread { + pub thread_root_event_id: OwnedEventId, + pub timestamp: MilliSecondsSinceUnixEpoch, + pub title: String, + pub reply_count: u32, + pub latest_reply_preview: Option, +} + +#[derive(Clone, Debug)] +pub enum RoomThreadsAction { + Loaded { + room_id: OwnedRoomId, + from: Option, + threads: Vec, + prev_batch_token: Option, + }, + Failed { + room_id: OwnedRoomId, + from: Option, + error: String, + }, +} + /// Either a main room timeline or a thread-focused timeline. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum TimelineKind { @@ -688,6 +717,11 @@ pub enum MatrixRequest { thread_root_event_id: OwnedEventId, timeline_item_index: usize, }, + /// Request to fetch a page of thread roots for the given room. + ListRoomThreads { + room_id: OwnedRoomId, + from: Option, + }, /// Request to fetch profile information for all members of a room. /// /// This can be *very* slow depending on the number of members in the room. @@ -1279,6 +1313,20 @@ async fn matrix_worker_task( SignalToUI::set_ui_signal(); } Err(error) => { + if direction == PaginationDirection::Backwards + && matches!(timeline_kind, TimelineKind::Thread { .. }) + && is_thread_unknown_parent_timeline_error(&error) + { + warning!( + "Treating unknown parent event as end-of-thread for {timeline_kind}." + ); + sender.send(TimelineUpdate::PaginationIdle { + fully_paginated: true, + direction, + }).unwrap(); + SignalToUI::set_ui_signal(); + return; + } error!("Error sending {direction} pagination request for {timeline_kind}: {error:?}"); sender.send(TimelineUpdate::PaginationError { error, @@ -1368,6 +1416,37 @@ async fn matrix_worker_task( }); } + MatrixRequest::ListRoomThreads { room_id, from } => { + let Some(room) = get_client().and_then(|client| client.get_room(&room_id)) else { + Cx::post_action(RoomThreadsAction::Failed { + room_id, + from, + error: String::from("Room not found."), + }); + continue; + }; + + let _list_threads_task = Handle::current().spawn(async move { + match fetch_room_threads_page(&room, from.clone()).await { + Ok((threads, prev_batch_token)) => { + Cx::post_action(RoomThreadsAction::Loaded { + room_id, + from, + threads, + prev_batch_token, + }); + } + Err(error) => { + Cx::post_action(RoomThreadsAction::Failed { + room_id, + from, + error: error.to_string(), + }); + } + } + }); + } + MatrixRequest::SyncRoomMemberList { timeline_kind } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for sync members list request"); @@ -4747,6 +4826,87 @@ async fn text_preview_of_latest_thread_reply( } } +async fn sender_display_name_for_timeline_event( + room: &Room, + event: &matrix_sdk::deserialized_responses::TimelineEvent, +) -> Option<(OwnedUserId, String)> { + let raw = event.raw(); + let sender_id = raw.get_field::("sender").ok().flatten()?; + let sender_room_member = match room.get_member_no_sync(&sender_id).await { + Ok(Some(rm)) => Some(rm), + _ => None, + }; + let sender_name = sender_room_member.as_ref() + .and_then(|rm| rm.display_name()) + .unwrap_or(sender_id.as_str()) + .to_string(); + Some((sender_id, sender_name)) +} + +fn fallback_preview_for_timeline_event( + event: &matrix_sdk::deserialized_responses::TimelineEvent, + sender_name: &str, + as_html: bool, +) -> String { + text_preview_of_raw_timeline_event(event.raw(), sender_name) + .unwrap_or_else(|| { + let event_type = event.raw().get_field::("type").ok().flatten(); + TextPreview::from(( + event_type.unwrap_or_else(|| "unknown event type".to_string()), + BeforeText::UsernameWithColon, + )) + }) + .format_with(sender_name, as_html) +} + +async fn fetch_room_threads_page( + room: &Room, + from: Option, +) -> Result<(Vec, Option), matrix_sdk::Error> { + let response = room.list_threads(ListThreadsOptions { + from: from.clone(), + limit: Some(uint!(20)), + ..Default::default() + }).await?; + + let mut threads = Vec::new(); + for event in response.chunk { + let Some(thread_root_event_id) = event.event_id() else { continue }; + let timestamp = event.timestamp().unwrap_or_else(MilliSecondsSinceUnixEpoch::now); + let sender_name = sender_display_name_for_timeline_event(room, &event).await + .map(|(_, sender_name)| sender_name) + .unwrap_or_else(|| String::from("Unknown user")); + let title = utils::replace_linebreaks_separators( + &fallback_preview_for_timeline_event(&event, &sender_name, false), + true, + ).into_owned(); + let title = if title.trim().is_empty() { + String::from("(No message preview)") + } else { + title + }; + + let reply_count = event.thread_summary.summary() + .map(|summary| summary.num_replies) + .unwrap_or(0); + let latest_reply_preview = if let Some(latest_event) = event.bundled_latest_thread_event.as_ref() { + text_preview_of_latest_thread_reply(room, latest_event).await + } else { + None + }; + + threads.push(FetchedRoomThread { + thread_root_event_id, + timestamp, + title, + reply_count, + latest_reply_preview, + }); + } + + Ok((threads, response.prev_batch_token)) +} + /// Returns the timestamp and an HTML-formatted text preview of the given `latest_event`. /// From b7bcf74e1a63aac497bf16e50b3589b9ae8d7687 Mon Sep 17 00:00:00 2001 From: Alvin Date: Fri, 3 Apr 2026 13:31:32 +0800 Subject: [PATCH 60/66] Reset room and space widgets on logout --- src/home/rooms_list.rs | 27 +++++++++++++++++++++++++++ src/home/spaces_bar.rs | 12 +++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 444e0bd31..638bdebb5 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -42,6 +42,7 @@ use crate::{ popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction, }, + logout::logout_confirm_modal::LogoutAction, sliding_sync::{MatrixLinkAction, MatrixRequest, PaginationDirection, TimelineKind, submit_async_request}, space_service_sync::{ParentChain, SpaceRequest, SpaceRoomListAction}, utils::{RoomNameId, VecDiff}, }; @@ -1396,6 +1397,32 @@ impl Widget for RoomsList { // Second, handle any other actions that came from other widgets/components. if let Event::Actions(actions) = event { for action in actions { + if let Some(LogoutAction::ClearAppState { .. }) = action.downcast_ref() { + self.invited_rooms.borrow_mut().clear(); + self.all_joined_rooms.clear(); + self.all_known_rooms_order.clear(); + self.selected_space = None; + self.space_request_sender = None; + self.space_map.clear(); + self.hidden_rooms.clear(); + self.displayed_invited_rooms.clear(); + self.is_invited_rooms_header_expanded = false; + self.invited_rooms_indexes = RoomCategoryIndexes::default(); + self.displayed_direct_rooms.clear(); + self.is_direct_rooms_header_expanded = false; + self.direct_rooms_indexes = RoomCategoryIndexes::default(); + self.displayed_regular_rooms.clear(); + self.is_regular_rooms_header_expanded = true; + self.regular_rooms_indexes = RoomCategoryIndexes::default(); + self.status.clear(); + self.current_active_room = None; + self.max_known_rooms = None; + self.indexes_dirty = true; + self.view.space_lobby_entry(cx, ids!(space_lobby_entry)).set_visible(cx, false); + self.redraw(cx); + continue; + } + if let RoomFilterAction::Changed(keywords) = action.as_widget_action().cast_ref() { self.regenerate_display_filter_and_sort_fn(keywords); self.update_displayed_rooms(cx, true); diff --git a/src/home/spaces_bar.rs b/src/home/spaces_bar.rs index 0d9b392ba..01bfeb7fa 100644 --- a/src/home/spaces_bar.rs +++ b/src/home/spaces_bar.rs @@ -13,7 +13,7 @@ use matrix_sdk::{RoomDisplayName, RoomState}; use ruma::{OwnedRoomAliasId, OwnedRoomId, room::JoinRuleSummary}; use crate::{ - home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, login::login_screen::LoginAction, room::{FetchedRoomAvatar, room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria}}, shared::{avatar::AvatarWidgetRefExt, room_filter_input_bar::RoomFilterAction}, sliding_sync::AccountSwitchAction, utils::{self, RoomNameId} + home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, login::login_screen::LoginAction, logout::logout_confirm_modal::LogoutAction, room::{FetchedRoomAvatar, room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria}}, shared::{avatar::AvatarWidgetRefExt, room_filter_input_bar::RoomFilterAction}, sliding_sync::AccountSwitchAction, utils::{self, RoomNameId} }; script_mod! { @@ -520,6 +520,16 @@ impl Widget for SpacesBar { if let Event::Actions(actions) = event { for action in actions { + if let Some(LogoutAction::ClearAppState { .. }) = action.downcast_ref() { + self.all_joined_spaces.clear(); + self.display_filter = RoomDisplayFilter::default(); + self.displayed_spaces.clear(); + self.is_filtered = false; + self.selected_space = None; + self.redraw(cx); + continue; + } + // The room filter input bar is also used to filter which spaces are visible. if let RoomFilterAction::Changed(keywords) = action.as_widget_action().cast() { self.update_displayed_spaces(cx, &keywords); From d814219e43f959650a7e07214f042f9e4d6ebc17 Mon Sep 17 00:00:00 2001 From: Alvin Date: Fri, 3 Apr 2026 13:32:04 +0800 Subject: [PATCH 61/66] Handle dropped timeline receivers without panicking --- src/sliding_sync.rs | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 0245ff79e..e539b8813 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1224,7 +1224,10 @@ async fn matrix_worker_task( // Spawn a new async task that will make the actual pagination request. let _paginate_task = Handle::current().spawn(async move { log!("Starting {direction} pagination request for {timeline_kind}..."); - sender.send(TimelineUpdate::PaginationRunning(direction)).unwrap(); + if sender.send(TimelineUpdate::PaginationRunning(direction)).is_err() { + warning!("Skipping {direction} pagination request for {timeline_kind}: timeline receiver was dropped before start."); + return; + } SignalToUI::set_ui_signal(); let mut res = if direction == PaginationDirection::Forwards { @@ -1272,19 +1275,25 @@ async fn matrix_worker_task( if direction == PaginationDirection::Forwards { "end" } else { "start" }, if fully_paginated { "yes" } else { "no" }, ); - sender.send(TimelineUpdate::PaginationIdle { + if sender.send(TimelineUpdate::PaginationIdle { fully_paginated, direction, - }).unwrap(); - SignalToUI::set_ui_signal(); + }).is_ok() { + SignalToUI::set_ui_signal(); + } else { + warning!("Dropping completed {direction} pagination update for {timeline_kind}: timeline receiver was dropped."); + } } Err(error) => { error!("Error sending {direction} pagination request for {timeline_kind}: {error:?}"); - sender.send(TimelineUpdate::PaginationError { + if sender.send(TimelineUpdate::PaginationError { error, direction, - }).unwrap(); - SignalToUI::set_ui_signal(); + }).is_ok() { + SignalToUI::set_ui_signal(); + } else { + warning!("Dropping failed {direction} pagination update for {timeline_kind}: timeline receiver was dropped."); + } } } }); @@ -1378,8 +1387,11 @@ async fn matrix_worker_task( log!("Sending sync room members request for {timeline_kind}..."); timeline.fetch_members().await; log!("Completed sync room members request for {timeline_kind}."); - sender.send(TimelineUpdate::RoomMembersSynced).unwrap(); - SignalToUI::set_ui_signal(); + if sender.send(TimelineUpdate::RoomMembersSynced).is_ok() { + SignalToUI::set_ui_signal(); + } else { + warning!("Dropping room members synced update for {timeline_kind}: timeline receiver was dropped."); + } }); } @@ -1644,8 +1656,11 @@ async fn matrix_worker_task( let _get_members_task = Handle::current().spawn(async move { let send_update = |members: Vec, source: &str| { log!("{} {} members for {timeline_kind}", source, members.len()); - sender.send(TimelineUpdate::RoomMembersListFetched { members }).unwrap(); - SignalToUI::set_ui_signal(); + if sender.send(TimelineUpdate::RoomMembersListFetched { members }).is_ok() { + SignalToUI::set_ui_signal(); + } else { + warning!("Dropping room members list update for {timeline_kind}: timeline receiver was dropped."); + } }; let room = timeline.room(); From a21cb2a7d628eab90bb33c07472019750888b0a2 Mon Sep 17 00:00:00 2001 From: Alvin Date: Fri, 3 Apr 2026 13:39:07 +0800 Subject: [PATCH 62/66] Reset desktop dock layout on logout --- src/home/main_desktop_ui.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 7827983cc..01e329e2a 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -3,7 +3,7 @@ use ruma::OwnedRoomId; use tokio::sync::Notify; use std::{collections::HashMap, sync::Arc}; -use crate::{app::{AppState, AppStateAction, SavedDockState, SelectedRoom}, home::{navigation_tab_bar::{NavigationBarAction, SelectedTab}, rooms_list::RoomsListRef, space_lobby::SpaceLobbyScreenWidgetRefExt}, sliding_sync::AccountSwitchAction, utils::RoomNameId}; +use crate::{app::{AppState, AppStateAction, SavedDockState, SelectedRoom}, home::{navigation_tab_bar::{NavigationBarAction, SelectedTab}, rooms_list::RoomsListRef, space_lobby::SpaceLobbyScreenWidgetRefExt}, logout::logout_confirm_modal::LogoutAction, sliding_sync::AccountSwitchAction, utils::RoomNameId}; use super::{invite_screen::InviteScreenWidgetRefExt, room_screen::RoomScreenWidgetRefExt, rooms_list::RoomsListAction}; script_mod! { @@ -289,6 +289,23 @@ impl MainDesktopUI { self.most_recently_selected_room = None; } + fn reset_to_default_layout(&mut self, cx: &mut Cx) { + self.open_rooms.clear(); + self.tab_to_close = None; + self.room_order.clear(); + self.most_recently_selected_room = None; + self.selected_space = None; + + if let Some(mut dock) = self.view.dock(cx, ids!(dock)).borrow_mut() { + dock.load_state(cx, self.default_layout.dock_items.clone()); + } else { + error!("BUG: failed to borrow dock widget to reset desktop UI to its default layout."); + } + + cx.action(AppStateAction::FocusNone); + self.redraw(cx); + } + /// Replaces an invite with a joined room in the dock. fn replace_invite_with_joined_room( &mut self, @@ -413,9 +430,14 @@ impl WidgetMatchEvent for MainDesktopUI { continue; } + if let Some(LogoutAction::ClearAppState { .. }) = action.downcast_ref() { + self.reset_to_default_layout(cx); + continue; + } + // When switching accounts, close all room tabs (keeping only the home tab) if let Some(AccountSwitchAction::Starting(_)) = action.downcast_ref() { - self.close_all_tabs(cx); + self.reset_to_default_layout(cx); continue; } From 1e3b8d8da287e41825c1d829d00817b8a57846b5 Mon Sep 17 00:00:00 2001 From: Alvin Date: Fri, 3 Apr 2026 13:56:45 +0800 Subject: [PATCH 63/66] Consolidate point-of-no-return logic and fix remaining cleanup gaps - Extract duplicated point-of-no-return blocks into enter_point_of_no_return() - Fix missed sender.send().unwrap() on MessageEdited path - Reset display_filter, sort_fn, and drawn_previously on logout - Drain PENDING_ROOM_UPDATES and PENDING_SPACE_UPDATES in ClearAppState handlers --- src/home/main_desktop_ui.rs | 1 + src/home/rooms_list.rs | 3 ++ src/home/spaces_bar.rs | 1 + src/logout/logout_state_machine.rs | 83 ++++++++++-------------------- src/sliding_sync.rs | 9 ++-- 5 files changed, 38 insertions(+), 59 deletions(-) diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 01e329e2a..04c05ed21 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -295,6 +295,7 @@ impl MainDesktopUI { self.room_order.clear(); self.most_recently_selected_room = None; self.selected_space = None; + self.drawn_previously = false; if let Some(mut dock) = self.view.dock(cx, ids!(dock)).borrow_mut() { dock.load_state(cx, self.default_layout.dock_items.clone()); diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 638bdebb5..32f0f26cb 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -1398,6 +1398,7 @@ impl Widget for RoomsList { if let Event::Actions(actions) = event { for action in actions { if let Some(LogoutAction::ClearAppState { .. }) = action.downcast_ref() { + while PENDING_ROOM_UPDATES.pop().is_some() {} self.invited_rooms.borrow_mut().clear(); self.all_joined_rooms.clear(); self.all_known_rooms_order.clear(); @@ -1414,6 +1415,8 @@ impl Widget for RoomsList { self.displayed_regular_rooms.clear(); self.is_regular_rooms_header_expanded = true; self.regular_rooms_indexes = RoomCategoryIndexes::default(); + self.display_filter = RoomDisplayFilter::default(); + self.sort_fn = None; self.status.clear(); self.current_active_room = None; self.max_known_rooms = None; diff --git a/src/home/spaces_bar.rs b/src/home/spaces_bar.rs index 01bfeb7fa..79e212dc0 100644 --- a/src/home/spaces_bar.rs +++ b/src/home/spaces_bar.rs @@ -521,6 +521,7 @@ impl Widget for SpacesBar { if let Event::Actions(actions) = event { for action in actions { if let Some(LogoutAction::ClearAppState { .. }) = action.downcast_ref() { + while PENDING_SPACE_UPDATES.pop().is_some() {} self.all_joined_spaces.clear(); self.display_filter = RoomDisplayFilter::default(); self.displayed_spaces.clear(); diff --git a/src/logout/logout_state_machine.rs b/src/logout/logout_state_machine.rs index 80f347bfb..9d2c4bde4 100644 --- a/src/logout/logout_state_machine.rs +++ b/src/logout/logout_state_machine.rs @@ -317,69 +317,16 @@ impl LogoutStateMachine { match self.perform_server_logout().await { Ok(_) => { - self.point_of_no_return.store(true, Ordering::Release); - set_logout_point_of_no_return(true); - self.transition_to( - LogoutState::PointOfNoReturn, - "Point of no return reached".to_string(), - 50 - ).await?; - - if let Some(user_id) = get_client().and_then(|client| client.user_id().map(ToOwned::to_owned)) { - if let Err(e) = skip_app_state_restore_once(&user_id).await { - log!("Warning: Failed to mark app state restore to skip once for {user_id}: {e}"); - } - } - - // We delete latest_user_id after reaching LOGOUT_POINT_OF_NO_RETURN: - // 1. To prevent auto-login with invalid session on next start - // 2. While keeping session file intact for potential future login - if let Err(e) = delete_latest_user_id().await { - log!("Warning: Failed to delete latest user ID: {}", e); - } + self.enter_point_of_no_return("Point of no return reached").await?; } Err(e) => { // Check if it's an M_UNKNOWN_TOKEN error if matches!(&e, LogoutError::Recoverable(RecoverableError::ServerLogoutFailed(msg)) if msg.contains("M_UNKNOWN_TOKEN")) { log!("Token already invalidated, continuing with logout"); - self.point_of_no_return.store(true, Ordering::Release); - set_logout_point_of_no_return(true); - self.transition_to( - LogoutState::PointOfNoReturn, - "Token already invalidated".to_string(), - 50 - ).await?; - - if let Some(user_id) = get_client().and_then(|client| client.user_id().map(ToOwned::to_owned)) { - if let Err(e) = skip_app_state_restore_once(&user_id).await { - log!("Warning: Failed to mark app state restore to skip once for {user_id}: {e}"); - } - } - - // Same delete operation as in the success case above - if let Err(e) = delete_latest_user_id().await { - log!("Warning: Failed to delete latest user ID: {}", e); - } + self.enter_point_of_no_return("Token already invalidated").await?; } else if should_continue_local_logout_without_server(&e) { log!("Homeserver appears unavailable, continuing with local logout: {}", e); - self.point_of_no_return.store(true, Ordering::Release); - set_logout_point_of_no_return(true); - self.transition_to( - LogoutState::PointOfNoReturn, - "Homeserver unavailable, continuing with local logout".to_string(), - 50 - ).await?; - - if let Some(user_id) = get_client().and_then(|client| client.user_id().map(ToOwned::to_owned)) { - if let Err(e) = skip_app_state_restore_once(&user_id).await { - log!("Warning: Failed to mark app state restore to skip once for {user_id}: {e}"); - } - } - - // Same delete operation as in the success case above - if let Err(e) = delete_latest_user_id().await { - log!("Warning: Failed to delete latest user ID: {}", e); - } + self.enter_point_of_no_return("Homeserver unavailable, continuing with local logout").await?; } else { // Restart sync service since we haven't reached point of no return if let Some(sync_service) = get_sync_service() { @@ -484,6 +431,30 @@ impl LogoutStateMachine { Ok(()) } + /// Sets the global point-of-no-return flags, writes the skip-restore marker, + /// and deletes the saved user ID so the next app start won't auto-login. + async fn enter_point_of_no_return(&self, message: &str) -> Result<()> { + self.point_of_no_return.store(true, Ordering::Release); + set_logout_point_of_no_return(true); + self.transition_to( + LogoutState::PointOfNoReturn, + message.to_string(), + 50 + ).await?; + + if let Some(user_id) = get_client().and_then(|client| client.user_id().map(ToOwned::to_owned)) { + if let Err(e) = skip_app_state_restore_once(&user_id).await { + log!("Warning: Failed to mark app state restore to skip once for {user_id}: {e}"); + } + } + + if let Err(e) = delete_latest_user_id().await { + log!("Warning: Failed to delete latest user ID: {}", e); + } + + Ok(()) + } + // Individual step implementations async fn perform_prechecks(&self) -> Result<(), LogoutError> { log!("perform_prechecks started"); diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index e539b8813..41a573cd0 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1313,11 +1313,14 @@ async fn matrix_worker_task( Ok(_) => log!("Successfully edited message {timeline_event_item_id:?} in {timeline_kind}."), Err(ref e) => error!("Error editing message {timeline_event_item_id:?} in {timeline_kind}: {e:?}"), } - sender.send(TimelineUpdate::MessageEdited { + if sender.send(TimelineUpdate::MessageEdited { timeline_event_item_id, result, - }).unwrap(); - SignalToUI::set_ui_signal(); + }).is_ok() { + SignalToUI::set_ui_signal(); + } else { + warning!("Dropping message edited update for {timeline_kind}: timeline receiver was dropped."); + } }); } From 9a0488ba292ccf3367f76543e0d9341523c3294c Mon Sep 17 00:00:00 2001 From: AlexZ Date: Sat, 4 Apr 2026 02:15:36 +0800 Subject: [PATCH 64/66] Fix blank main page caused by Dock.load_state() DrawList corruption Dock.load_state() destroys DrawList2d objects by clearing tab_bars during event handling, but the rendering pipeline still holds stale DrawListId references from the previous frame. This causes massive "Drawlist id generation wrong" errors and a completely blank main content area. Replace dock.load_state() in load_dock_state_from() with programmatic tab recreation via close_all_tabs() + focus_or_create_tab(), which uses the Dock's normal widget API and avoids direct DrawList destruction. Fixes #45 Co-Authored-By: Claude Opus 4.6 (1M context) --- ...001-dock-load-state-drawlist-corruption.md | 111 ++++++++++++++++++ src/home/main_desktop_ui.rs | 43 +++---- 2 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 issues/001-dock-load-state-drawlist-corruption.md diff --git a/issues/001-dock-load-state-drawlist-corruption.md b/issues/001-dock-load-state-drawlist-corruption.md new file mode 100644 index 000000000..4d72bda91 --- /dev/null +++ b/issues/001-dock-load-state-drawlist-corruption.md @@ -0,0 +1,111 @@ +# Issue #001: Dock.load_state() causes DrawList corruption and blank main page + +**Date:** 2026-04-04 +**Severity:** Critical (blocks all UI rendering) +**Status:** Fixed (workaround applied) +**Affected component:** `src/home/main_desktop_ui.rs` — `load_dock_state_from()` + +## Summary + +Restoring the Dock layout from persisted state via `Dock.load_state()` corrupts Makepad's internal DrawList references, causing the entire main content area (rooms list + room tabs) to render as a blank grey page. + +## Symptoms + +- Left navigation bar (NavigationTabBar) renders correctly +- Main content area (Dock with RoomsSideBar + room tabs) is completely blank/grey +- Console shows massive `Drawlist id generation wrong` errors: + ``` + [E] draw_list.rs:324: Drawlist id generation wrong index: 21 current gen:1 in pointer:0 + ``` +- Errors repeat continuously for draw list indices 21 and 22 + +## Root Cause + +`Dock.load_state()` in Makepad's `widgets/src/dock.rs:1310` destroys DrawList references during event handling: + +```rust +pub fn load_state(&mut self, cx: &mut Cx, dock_items: HashMap) { + self.dock_items = dock_items; + self.items.clear(); + self.tab_bars.clear(); // Drops TabBarWrap, freeing DrawList2d + self.splitters.clear(); + self.area.redraw(cx); // Marks redraw, but stale refs remain + self.create_all_items(cx); +} +``` + +The lifecycle issue: + +1. `tab_bars.clear()` drops `TabBarWrap` instances containing `contents_draw_list: DrawList2d` +2. Drop increments the DrawList pool entry generation (0 → 1) +3. Makepad's rendering pipeline still holds cached `DrawListId(index, gen=0)` from the previous frame +4. Next frame accesses stale references → generation mismatch → rendering failure + +This only triggers when the Dock already has live tab_bars (created during the first draw pass) and `load_state()` replaces them. On first startup with empty tab_bars, `clear()` is a no-op and causes no issue. + +## Reproduction + +1. Run the app, log in, open some room tabs +2. Close the app (state is persisted to `latest_app_state.json`) +3. Restart the app → blank main page + +**Verification:** Deleting `latest_app_state.json` before restart → UI renders correctly with 0 DrawList errors. + +## Fix Applied + +Modified `load_dock_state_from()` in `src/home/main_desktop_ui.rs` to avoid calling `dock.load_state()`. Instead, tabs are recreated programmatically: + +```rust +fn load_dock_state_from(&mut self, cx: &mut Cx, app_state: &mut AppState) { + // ... resolve which state to restore ... + + let room_order = to_restore.room_order.clone(); + let selected_room = to_restore.selected_room.clone(); + + // Close existing tabs using the Dock's normal API (safe) + self.close_all_tabs(cx); + + // Recreate each room tab in saved order (safe) + for room in &room_order { + self.focus_or_create_tab(cx, room.clone()); + } + + // Re-select the previously-selected room + let final_selected = selected_room.or_else(|| room_order.last().cloned()); + if let Some(selected) = final_selected.clone() { + self.focus_or_create_tab(cx, selected); + } + app_state.selected_room = final_selected; + self.redraw(cx); +} +``` + +This uses `close_all_tabs()` + `focus_or_create_tab()` which operate through the Dock's normal widget API, avoiding direct destruction of DrawList2d objects. + +## Remaining Issues + +1. **Splitter position not restored:** Custom sidebar width (if user dragged the splitter) resets to default 300px on restart. + +2. **Multi-pane layout not restored:** If the user created split-view arrangements by dragging tabs, those layouts are lost on restart. All tabs return to the single default tab bar. + +3. **Same issue exists in space switching:** `NavigationBarAction::TabSelected` also calls `load_dock_state_from()`, which previously used `dock.load_state()`. The fix applies to this path as well, but the same layout-loss trade-off exists. + +4. **Upstream Makepad bug:** `Dock.load_state()` should be fixed in Makepad to properly handle DrawList lifecycle when called during event handling. The fix should either: + - Defer the actual destruction to the next draw pass + - Properly invalidate cached DrawList references in the rendering pipeline + - Or use a two-phase approach: mark old DrawLists for cleanup, create new ones, then clean up + +5. **`SETTINGS_BUTTON_HEIGHT` undefined:** Unrelated but observed during debugging — `account_settings.rs:63,86` references `mod.widgets.SETTINGS_BUTTON_HEIGHT` which is never defined, causing DSL parse warnings at startup. + +## Files Changed + +- `src/home/main_desktop_ui.rs` — `load_dock_state_from()` rewritten + +## Test Verification + +| Scenario | Before Fix | After Fix | +|----------|-----------|-----------| +| Start with persisted state | Blank page, ~50+ DrawList errors | Rooms render, 0 DrawList errors | +| Start without persisted state | Works | Works | +| Room tabs restored | N/A (blank) | All saved tabs recreated correctly | +| Selected room restored | N/A (blank) | Correct room selected and loaded | diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 04c05ed21..3aef9e008 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -377,8 +377,11 @@ impl MainDesktopUI { /// /// If the saved state is empty (has no open rooms), we use the default dock layout /// defined in the DSL: one splitter with the RoomsList on the left and a Welcome tab on the right. + /// + /// Instead of calling `dock.load_state()` directly (which can corrupt Makepad's + /// internal DrawList references and cause blank rendering), we recreate each tab + /// programmatically via `focus_or_create_tab()`. fn load_dock_state_from(&mut self, cx: &mut Cx, app_state: &mut AppState) { - let dock = self.view.dock(cx, ids!(dock)); let to_restore_opt = if let Some(ss) = self.selected_space.as_ref() { app_state.saved_dock_state_per_space.get(ss) } else { @@ -389,32 +392,24 @@ impl MainDesktopUI { Some(sds) if sds.open_rooms.is_empty() => &self.default_layout, Some(sds) => sds, }; - let SavedDockState { dock_items, open_rooms, room_order, selected_room } = to_restore; - - self.room_order = room_order.clone(); - self.open_rooms = open_rooms.clone(); - - if let Some(mut dock) = dock.borrow_mut() { - dock.load_state(cx, dock_items.clone()); - // Only populate the currently-selected tab immediately. - // Background tabs will be initialized lazily when they are focused. - if let Some(selected_room) = selected_room.as_ref() { - if let Some((_, widget)) = dock.items().get(&selected_room.tab_id()) { - Self::sync_tab_widget(cx, widget, selected_room); - } - } - } else { - error!("BUG: failed to borrow dock widget to restore state upon LoadDockFromAppState action."); - return; + + let room_order = to_restore.room_order.clone(); + let selected_room = to_restore.selected_room.clone(); + + // Close any existing tabs first, starting from the default layout. + self.close_all_tabs(cx); + + // Recreate each room tab in the saved order. + for room in &room_order { + self.focus_or_create_tab(cx, room.clone()); } - // Note: the borrow of `dock` must end here *before* we call `self.focus_or_create_tab()`. - // Now that we've loaded the dock content, we can re-select the selected room. - let selected_room = selected_room.clone(); - if let Some(selected_room) = selected_room.clone() { - self.focus_or_create_tab(cx, selected_room); + // Re-select the previously-selected room (or the last one if not set). + let final_selected = selected_room.or_else(|| room_order.last().cloned()); + if let Some(selected) = final_selected.clone() { + self.focus_or_create_tab(cx, selected); } - app_state.selected_room = selected_room; + app_state.selected_room = final_selected; self.redraw(cx); } } From a0db454b214e44db08a7f1ea99bcf7c6927f5f9a Mon Sep 17 00:00:00 2001 From: AlexZ Date: Sat, 4 Apr 2026 02:25:03 +0800 Subject: [PATCH 65/66] Add file-issue skill for documenting bugs and creating GitHub issues Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/file-issue/SKILL.md | 116 +++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .claude/skills/file-issue/SKILL.md diff --git a/.claude/skills/file-issue/SKILL.md b/.claude/skills/file-issue/SKILL.md new file mode 100644 index 000000000..ae70d188d --- /dev/null +++ b/.claude/skills/file-issue/SKILL.md @@ -0,0 +1,116 @@ +--- +name: file-issue +description: Document a bug/fix locally in issues/ and create a matching GitHub issue +allowed-tools: + - Bash(ls:*) + - Bash(mkdir:*) + - Bash(gh:*) + - Glob + - Grep + - Read + - Write +when_to_use: | + Use when the user wants to document a discovered bug, applied fix, and remaining issues + as both a local issue file and a GitHub issue. Typically invoked after a debugging/fix session. + Examples: "file an issue for this", "record this bug", "create issue", "file-issue" +--- + +# File Issue + +Document a bug discovery and fix as a local issue file in `issues/` and a matching GitHub issue. +All output is written in English regardless of conversation language. + +## Goal + +Produce two artifacts: +1. A detailed local issue document at `issues/NNN-slug.md` +2. A GitHub issue with a summary version + +## Steps + +### 1. Scan for next issue number + +Check if `issues/` directory exists in the project root. Create it if missing. +List existing files to determine the next sequential number (e.g., if `001-*` exists, next is `002`). + +**Success criteria**: Know the next issue number (zero-padded to 3 digits) and confirmed `issues/` dir exists. + +### 2. Gather context from conversation + +Extract from the current conversation: +- **Summary**: One-line description of the bug +- **Severity**: Critical / High / Medium / Low +- **Symptoms**: What the user observed (UI behavior, error messages, logs) +- **Root Cause**: Technical explanation of why it happens +- **Reproduction**: Steps to reproduce +- **Fix Applied**: What was changed and why (include code snippets if relevant) +- **Remaining Issues**: Known limitations, follow-up work, upstream bugs +- **Files Changed**: List of modified files +- **Test Verification**: Before/after comparison table + +Generate a kebab-case slug from the summary (e.g., `dock-load-state-drawlist-corruption`). + +**Success criteria**: All template sections populated with specific, accurate details from the session. + +### 3. Write local issue document + +Write to `issues/NNN-slug.md` using this template: + +```markdown +# Issue #NNN: {Summary} + +**Date:** {YYYY-MM-DD} +**Severity:** {Critical|High|Medium|Low} +**Status:** Fixed (workaround applied) | Fixed | Open +**Affected component:** {file path(s)} + +## Summary +{One paragraph} + +## Symptoms +{Bullet list of what the user observed} + +## Root Cause +{Technical explanation with code snippets} + +## Reproduction +{Numbered steps} + +## Fix Applied +{Description + key code changes} + +## Remaining Issues +{Numbered list of known limitations and follow-up work} + +## Files Changed +{Bullet list} + +## Test Verification +{Before/after table} +``` + +**Success criteria**: File written, all sections filled, no placeholder text remaining. + +### 4. Create GitHub issue + +Detect the repo with `gh repo view --json nameWithOwner`. +Create a GitHub issue via `gh issue create` with: +- Title: same as local doc summary (concise, under 80 chars) +- Label: `bug` +- Body: condensed version with Summary, Symptoms, Root Cause, Fix Applied, Remaining Issues (as checklist), and Environment section +- Reference the local doc path in the body + +**Rules**: +- Use a HEREDOC for the body to preserve formatting +- Remaining Issues should be `- [ ]` checklist items +- Include a link/reference to the local issue doc + +**Success criteria**: GitHub issue created, URL returned. + +### 5. Report results + +Tell the user: +- Local issue doc path +- GitHub issue URL (in `owner/repo#number` format for clickable link) + +**Success criteria**: Both paths reported in a concise summary. From 784af95d4597defedd1eebfe830ce47f3c14151b Mon Sep 17 00:00:00 2001 From: AlexZ Date: Mon, 6 Apr 2026 04:37:42 +0800 Subject: [PATCH 66/66] updated AGENTS --- .gitignore | 1 - AGENTS.md | 778 ++++++----------------------------------------------- 2 files changed, 85 insertions(+), 694 deletions(-) diff --git a/.gitignore b/.gitignore index 1f891a019..9d61dcd77 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,4 @@ .vscode .DS_Store -CLAUDE.md proxychains.conf diff --git a/AGENTS.md b/AGENTS.md index 9a393de6f..d0296f469 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,732 +1,124 @@ +# Robrix2 — Agent Instructions -# Makepad Project Guide +This file is intentionally short. Mirror `CLAUDE.md`, keep only project rules and high-value Makepad notes here, and use the codebase plus Makepad 2.0 skills as the detailed reference. -## Important: When Converting Syntax +## Required Reading -**Always search for existing usage patterns in the NEW crates (widgets, code_editor, studio) before making syntax changes.** The old `widgets` and `live_design!` syntax is deprecated. When unsure about the correct syntax for something, grep for similar usage in `widgets/src/` to find the correct pattern. +Before starting work, read these documents: -```bash -# Example: find how texture declarations work in new system -grep -r "texture_2d" widgets/src/ -``` - -**Critical: Always use `Name: value` syntax, never `Name = value`.** The old `Key = Value` syntax no longer works. For named widget instances, use `name := Type{...}` syntax. - -## Running UI Programs - -```bash -RUST_BACKTRACE=1 cargo run -p makepad-example-splash --release & PID=$!; sleep 15; kill $PID 2>/dev/null; echo "Process $PID killed" -``` - -## Cargo.toml Setup - -```toml -[package] -name = "makepad-example-myapp" -version = "0.1.0" -edition = "2021" - -[dependencies] -makepad-widgets = { path = "../../widgets" } -``` - - -## Widgets DSL (script_mod!) - -The new DSL uses `script_mod!` macro with runtime script evaluation instead of the old `live_design!` compile-time macros. - -### Imports and App Setup - -```rust -use makepad_widgets::*; - -app_main!(App); - -script_mod!{ - use mod.prelude.widgets.* - - load_all_resources() do #(App::script_component(vm)){ - ui: Root{ - main_window := Window{ - window.inner_size: vec2(800, 600) - body +: { - // UI content here - } - } - } - } -} - -impl App { - fn run(vm: &mut ScriptVm) -> Self { - crate::makepad_widgets::script_mod(vm); // Register all widgets - // Platform-specific initialization goes here (e.g., vm.cx().start_stdin_service() for macos) - App::from_script_mod(vm, self::script_mod) - } -} - -#[derive(Script, ScriptHook)] -pub struct App { - #[live] ui: WidgetRef, -} - -impl MatchEvent for App { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { - // Handle widget actions - } -} - -impl AppMain for App { - fn handle_event(&mut self, cx: &mut Cx, event: &Event) { - self.match_event(cx, event); - self.ui.handle_event(cx, event, &mut Scope::empty()); - } -} -``` - -### Available Widgets (widgets/src/lib.rs) - -Core: `View`, `SolidView`, `RoundedView`, `ScrollXView`, `ScrollYView`, `ScrollXYView` -Text: `Label`, `H1`, `H2`, `H3`, `LinkLabel`, `TextInput` -Buttons: `Button`, `ButtonFlat`, `ButtonFlatter` -Toggles: `CheckBox`, `Toggle`, `RadioButton` -Input: `Slider`, `DropDown` -Layout: `Splitter`, `FoldButton`, `FoldHeader`, `Hr` -Lists: `PortalList` -Navigation: `StackNavigation`, `ExpandablePanel` -Overlays: `Modal`, `Tooltip`, `PopupNotification` -Dock: `Dock`, `DockSplitter`, `DockTabs`, `DockTab` -Media: `Image`, `Icon`, `LoadingSpinner` -Special: `FileTree`, `PageFlip`, `CachedWidget` -Window: `Window`, `Root` -Markup: `Html`, `Markdown` (feature-gated) - -### Widget Definition Pattern - -```rust -// Rust struct -#[derive(Script, ScriptHook, Widget)] -pub struct MyWidget { - #[source] source: ScriptObjectRef, // Required for script integration - #[walk] walk: Walk, - #[layout] layout: Layout, - #[redraw] #[live] draw_bg: DrawQuad, - #[live] draw_text: DrawText, - #[rust] my_state: i32, // Runtime-only field -} - -// For widgets with animations, add Animator derive: -#[derive(Script, ScriptHook, Widget, Animator)] -pub struct AnimatedWidget { - #[source] source: ScriptObjectRef, - #[apply_default] animator: Animator, - // ... -} -``` - -### Script Module Structure - -```rust -script_mod!{ - use mod.prelude.widgets_internal.* // For internal widget definitions - use mod.widgets.* // Access other widgets - - // Register base widget (connects Rust struct to script) - mod.widgets.MyWidgetBase = #(MyWidget::register_widget(vm)) - - // Create styled variant with defaults - mod.widgets.MyWidget = set_type_default() do mod.widgets.MyWidgetBase{ - width: Fill - height: Fit - padding: theme.space_2 - - draw_bg +: { - color: theme.color_bg_app - } - } -} -``` - -### Key Syntax Differences (Old vs New) - -| Old (live_design!) | New (script_mod!) | -|-------------------|-------------------| -| `` | `mod.widgets.BaseWidget{ }` | -| `{{StructName}}` | `#(Struct::register_widget(vm))` | -| `(THEME_COLOR_X)` | `theme.color_x` | -| `` | `theme.font_regular` | -| `instance hover: 0.0` | `hover: instance(0.0)` | -| `uniform color: #fff` | `color: uniform(#fff)` | -| `draw_bg: { }` (replace) | `draw_bg +: { }` (merge) | -| `default: off` | `default: @off` | -| `fn pixel(self)` | `pixel: fn()` | -| `item.apply_over(cx, live!{...})` | `script_apply_eval!(cx, item, {...})` | - -### Runtime Property Updates with script_apply_eval! - -Use `script_apply_eval!` macro to dynamically update widget properties at runtime: -```rust -// Old system (live! macro with apply_over) -item.apply_over(cx, live!{ - height: (height) - draw_bg: {is_even: (if is_even {1.0} else {0.0})} -}); - -// New system (script_apply_eval! macro) -script_apply_eval!(cx, item, { - height: #(height) - draw_bg +: {is_even: #(if is_even {1.0} else {0.0})} -}); - -// For colors, use #(color) syntax -let color = self.color_focus; -script_apply_eval!(cx, item, { - draw_bg +: { - color: #(color) - } -}); -``` - -Note: In `script_apply_eval!`, use `#(expr)` for Rust expression interpolation instead of `(expr)`. - -### Theme Access - -Always use `theme.` prefix: -```rust -color: theme.color_bg_app -padding: theme.space_2 -font_size: theme.font_size_p -text_style: theme.font_regular -``` - -### Property Merging with `+:` - -The `+:` operator merges with parent instead of replacing: -```rust -mod.widgets.MyButton = mod.widgets.Button{ - draw_bg +: { - color: #f00 // Only overrides color, keeps other draw_bg properties - } -} -``` - -### Shader Instance vs Uniform - -- `instance(value)` - Per-draw-call value (can vary per widget instance) -- `uniform(value)` - Shared across all instances using same shader - -```rust -draw_bg +: { - hover: instance(0.0) // Each button has its own hover state - color: uniform(theme.color_x) // Shared base color - color_hover: instance(theme.color_y) // Per-instance if color varies -} -``` - -### Animator Definition - -```rust -animator: Animator{ - hover: { - default: @off - off: AnimatorState{ - from: {all: Forward {duration: 0.1}} - apply: { - draw_bg: {hover: 0.0} - draw_text: {hover: 0.0} - } - } - on: AnimatorState{ - from: {all: Snap} // Instant transition - apply: { - draw_bg: {hover: 1.0} - draw_text: {hover: 1.0} - } - } - } -} -``` - -### Shader Functions - -```rust -draw_bg +: { - pixel: fn() { - let sdf = Sdf2d.viewport(self.pos * self.rect_size) - sdf.box(0.0, 0.0, self.rect_size.x, self.rect_size.y, 4.0) - sdf.fill(self.color.mix(self.color_hover, self.hover)) - return sdf.result - } -} -``` - -Note: Use `.method()` not `::method()` in shaders. - -### Color Mixing (Method Chaining) - -```rust -// Old nested style (avoid) -mix(mix(mix(color1, color2, hover), color3, down), color4, focus) - -// New chained style (preferred) -color1.mix(color2, hover).mix(color3, down).mix(color4, focus) -``` +1. [DESIGN.md](DESIGN.md) — architecture overview, module organization, technology stack +2. [specs/project.spec.md](specs/project.spec.md) — project constraints, decisions, forbidden actions +3. [CLAUDE.md](CLAUDE.md) — project workflow rules and Makepad 2.0 guidance -### App Structure Pattern - -```rust -script_mod!{ - use mod.prelude.widgets.* - - load_all_resources() do #(App::script_component(vm)){ - ui: Root{ - main_window := Window{ - window.inner_size: vec2(1000, 700) - body +: { - // Your UI here - MyWidget{} - } - } - } - } -} - -impl App { - fn run(vm: &mut ScriptVm) -> Self { - crate::makepad_widgets::script_mod(vm); - // Platform-specific initialization (e.g., vm.cx().start_stdin_service() for macos) - App::from_script_mod(vm, self::script_mod) - } -} - -#[derive(Script, ScriptHook)] -pub struct App { - #[live] ui: WidgetRef, -} - -impl MatchEvent for App { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { - if self.ui.button(ids!(my_button)).clicked(actions) { - log!("Button clicked!"); - } - } -} - -impl AppMain for App { - fn handle_event(&mut self, cx: &mut Cx, event: &Event) { - self.match_event(cx, event); - self.ui.handle_event(cx, event, &mut Scope::empty()); - } -} -``` - -### Widget ID References - -Use `:=` for named widget instances: -```rust -// In DSL -my_button := Button{text: "Click"} - -// In Rust code -self.ui.button(ids!(my_button)).clicked(actions) -``` - -### Template Definitions in Dock - -Templates inside Dock are local; use `let` bindings at script level for reusable components: -```rust -script_mod!{ - // Reusable at script level - let MyPanel = SolidView{ - width: Fill - height: Fill - // ... - } - - // Use directly - body +: { - MyPanel{} // Works because it's a let binding - } -} -``` - -### Custom Draw Widget Example - -```rust -#[derive(Script, ScriptHook, Widget)] -pub struct CustomDraw { - #[walk] walk: Walk, - #[layout] layout: Layout, - #[redraw] #[live] draw_quad: DrawQuad, - #[rust] area: Area, -} - -impl Widget for CustomDraw { - fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep { - cx.begin_turtle(walk, self.layout); - let rect = cx.turtle().rect(); - self.draw_quad.draw_abs(cx, rect); - cx.end_turtle_with_area(&mut self.area); - DrawStep::done() - } - - fn handle_event(&mut self, _cx: &mut Cx, _event: &Event, _scope: &mut Scope) {} -} -``` - -### Script Object Storage: map vs vec - -In script objects, properties are stored in two different places: -- **`map`**: Contains `key: value` pairs (regular properties) -- **`vec`**: Contains named template items (via `:=` syntax) - -This distinction is important when working with `on_after_apply` or inspecting script objects directly. - -### Templates in List Widgets (PortalList, FlatList) - -In list widgets, named IDs (using `:=`) define **templates** that are stored in the widget's `templates` HashMap. These are NOT regular properties - they go into the script object's vec and are collected via `on_after_apply`. - -```rust -// In script_mod! - defining templates for a list -my_list := PortalList { - // Regular properties (go into struct fields) - width: Fill - height: Fill - scroll_bar: mod.widgets.ScrollBar {} - - // Templates (named with :=) - stored in templates HashMap, NOT struct fields - Item := View { - height: 40 - title := Label { text: "Default" } - } - Header := View { - draw_bg: { color: #333 } - } -} -``` - -The templates are collected in `on_after_apply`: -```rust -impl ScriptHook for PortalList { - fn on_after_apply(&mut self, vm: &mut ScriptVm, apply: &Apply, scope: &mut Scope, value: ScriptValue) { - if let Some(obj) = value.as_object() { - vm.vec_with(obj, |_vm, vec| { - for kv in vec { - if let Some(id) = kv.key.as_id() { - self.templates.insert(id, kv.value); - } - } - }); - } - } -} -``` - -Then used during drawing: -```rust -while let Some(item_id) = list.next_visible_item(cx) { - let item = list.item(cx, item_id, id!(Item)); - item.label(ids!(title)).set_text(cx, &format!("Item {}", item_id)); - item.draw_all(cx, &mut Scope::empty()); -} -``` - -**Key distinction**: Regular properties like `scroll_bar: mod.widgets.ScrollBar {}` are applied directly to struct fields. Template definitions like `Item := View {...}` are stored separately for dynamic instantiation. - -### PortalList Usage - -```rust -#[derive(Script, ScriptHook, Widget)] -pub struct MyList { - #[deref] view: View, -} - -impl Widget for MyList { - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - while let Some(item) = self.view.draw_walk(cx, scope, walk).step() { - if let Some(mut list) = item.borrow_mut::() { - list.set_item_range(cx, 0, 100); // 100 items - - while let Some(item_id) = list.next_visible_item(cx) { - let item = list.item(cx, item_id, id!(Item)); - item.label(ids!(title)).set_text(cx, &format!("Item {}", item_id)); - item.draw_all(cx, &mut Scope::empty()); - } - } - } - DrawStep::done() - } -} -``` - -### FileTree Usage - -```rust -impl Widget for FileTreeDemo { - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - while self.file_tree.draw_walk(cx, scope, walk).is_step() { - self.file_tree.set_folder_is_open(cx, live_id!(root), true, Animate::No); - // Draw nodes recursively - self.draw_node(cx, live_id!(root)); - } - DrawStep::done() - } -} -``` - -### Registering Custom Draw Shaders - -For custom draw types with shader fields, use `script_shader`: - -```rust -script_mod!{ - use mod.prelude.widgets_internal.* - - // Register custom draw shader - set_type_default() do #(DrawMyShader::script_shader(vm)){ - ..mod.draw.DrawQuad // Inherit from DrawQuad - } - - // Register widget that uses it - mod.widgets.MyWidgetBase = #(MyWidget::register_widget(vm)) -} - -#[derive(Script, ScriptHook)] -#[repr(C)] -struct DrawMyShader { - #[deref] draw_super: DrawQuad, - #[live] my_param: f32, -} -``` - -### Registering Components (non-Widget) - -For structs that aren't full widgets but need script registration: - -```rust -script_mod!{ - // For components (not widgets) - mod.widgets.MyComponentBase = #(MyComponent::script_component(vm)) - - // For widgets (implements Widget trait) - mod.widgets.MyWidgetBase = #(MyWidget::register_widget(vm)) -} -``` +## Critical Rules -### Script Prelude Modules - -Two prelude modules available: -- `mod.prelude.widgets_internal.*` - For internal widget library development -- `mod.prelude.widgets.*` - For app development (includes all widgets) - -```rust -script_mod!{ - // App development - use widgets prelude - use mod.prelude.widgets.* - - // Or for widget library internals - use mod.prelude.widgets_internal.* - use mod.widgets.* -} -``` +### Do NOT run `cargo fmt` or `rustfmt` -### Default Enum Values - -For enums with a `None` variant that need `Default`, use standard Rust `#[default]` attribute instead of `DefaultNone` derive: - -```rust -// Correct - use #[default] attribute on the None variant -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub enum MyAction { - SomeAction, - AnotherAction, - #[default] - None, -} - -// Wrong - don't use DefaultNone derive -#[derive(Clone, Copy, Debug, PartialEq, DefaultNone)] // Don't do this -pub enum MyAction { - SomeAction, - None, -} -``` +This project does not use automatic Rust formatting. Do not run `cargo fmt`, `rustfmt`, or formatter wrappers. Formatting churn creates noisy diffs and breaks the repo's hand-maintained style. -### Multi-Module Script Registration Pattern - -When refactoring a multi-file project (like studio) from `live_design!` to `script_mod!`: - -1. **Each widget module** defines its own `script_mod!` that registers to `mod.widgets.*`: -```rust -// In studio_editor.rs -script_mod! { - use mod.prelude.widgets_internal.* - use mod.widgets.* - - mod.widgets.StudioCodeEditorBase = #(StudioCodeEditor::register_widget(vm)) - mod.widgets.StudioCodeEditor = set_type_default() do mod.widgets.StudioCodeEditorBase { - editor := CodeEditor {} - } -} -``` +### Do NOT commit or create PRs without user testing -2. **The lib.rs** aggregates all widget script_mods: -```rust -pub fn script_mod(vm: &mut ScriptVm) { - crate::module1::script_mod(vm); - crate::module2::script_mod(vm); - // ... all widget modules -} -``` +Present changes for testing first. Wait for user confirmation before committing or opening a PR. -3. **The app.rs** calls them in correct order: -```rust -impl App { - fn run(vm: &mut ScriptVm) -> Self { - crate::makepad_widgets::script_mod(vm); // Base widgets first - crate::script_mod(vm); // Your widget modules - crate::app_ui::script_mod(vm); // UI that uses the widgets - App::from_script_mod(vm, self::script_mod) - } -} -``` +### Makepad 2.0 only -4. **The app_ui.rs** can then use registered widgets: -```rust -script_mod! { - use mod.prelude.widgets.* - // Now StudioCodeEditor is available from mod.widgets - - let EditorContent = View { - editor := StudioCodeEditor {} - } -} -``` +- Use `script_mod!`, not `live_design!` +- Use `#[derive(Script, ScriptHook, Widget)]`, not `Live` / `LiveHook` +- Use `:=` for named children, not `=` +- Use `+:` to merge properties; bare `:` replaces +- Use `script_apply_eval!` for runtime updates, not `apply_over` + `live!` -### Cross-Module Sharing via `mod` Object - -**IMPORTANT**: `use crate.module.*` does NOT work in script_mod. The `crate.` prefix is not available. - -To share definitions between script_mod blocks in different files, store them in the `mod` object: - -```rust -// In app_ui.rs - export to mod.widgets namespace -script_mod! { - use mod.prelude.widgets.* - - // This makes AppUI available as mod.widgets.AppUI - mod.widgets.AppUI = Window{ - // ... - } -} - -// In app.rs - import via mod.widgets -script_mod! { - use mod.prelude.widgets.* - use mod.widgets.* // Now AppUI is in scope - - load_all_resources() do #(App::script_component(vm)){ - ui: Root{ AppUI{} } - } -} -``` +### Converting syntax -The `mod` object is the only way to share data between script_mod blocks. +- Search the new crates first: `widgets`, `code_editor`, `studio` +- Prefer copying an existing Makepad 2.0 pattern over guessing syntax +- Always use `Name: value`, never `Name = value` +- Named widget instances use `name := Type{...}` -### Prelude Alias Syntax +### Dynamic widget state changes -When defining a prelude, use `name:mod.path` to create an alias: -```rust -mod.prelude.widgets = { - ..mod.std, // Spread all of mod.std into scope - theme:mod.theme, // Create 'theme' as alias for mod.theme - draw:mod.draw, // Create 'draw' as alias for mod.draw -} -``` +`script_apply_eval!` does not work on widgets created via `widget_ref_from_live_ptr()` because the backing `ScriptObject` is `ZERO`. For dynamic popup/list items, use Animator state plus shader instance variables instead. -Without the alias (just `mod.theme,`), the module is included but has no name - you can't access it! +### Async Matrix operations -### Let Bindings are Local +Always use `submit_async_request(MatrixRequest::*)`. Do not spawn raw tokio tasks for Matrix API calls from UI code. -`let` bindings in script_mod are LOCAL to that script_mod block. They cannot be: -- Accessed from other script_mod blocks -- Used as property values directly (e.g., `content +: MyLetBinding` won't work) +## Quick Makepad Notes -To use a `let` binding, instantiate it: `MyLetBinding{}` or store it in `mod.*` for cross-module access. +- `draw_bg +:` merges with the parent shader config; `draw_bg:` replaces it +- In `script_apply_eval!`, Rust expressions use `#(expr)` interpolation +- Runtime `script_apply_eval!` cannot rely on DSL constants like `Right`, `Fit`, or `Align` +- `Dock.load_state()` can corrupt DrawList references in this project -### Debug Logging with `~` +## Build & Test -Use `~expression` to log the value of an expression during script evaluation: -```rust -script_mod! { - ~mod.theme // Logs the theme object - ~mod.prelude.widgets // Logs what's in the prelude - ~some_variable // Logs a variable's value (or "not found" error) -} +```bash +cargo build +cargo run +cargo test ``` -### Common Pitfalls - -**Widget ID references**: Named widget instances use `:=` in the DSL and plain names in Rust id macros: -- DSL defines `code_block := View { ... }` → Rust uses `id!(code_block)` -- DSL defines `my_button := Button { ... }` → Rust uses `ids!(my_button)` - -1. **Missing `#[source]`**: All Script-derived structs need `#[source] source: ScriptObjectRef` - -2. **Template scope**: Templates defined inside Dock aren't available outside; use `let` at script level - -3. **Uniform vs Instance**: Use `instance()` for per-widget varying colors (like hover states on backgrounds) - -4. **Forgot `+:`**: Without `+:`, you replace the entire property instead of merging - -5. **Theme access**: Always `theme.color_x`, never `THEME_COLOR_X` or `(theme.color_x)` +## Key Entry Points -6. **Missing widget registration**: Call `crate::makepad_widgets::script_mod(vm)` in `App::run()` before your own `script_mod`. Note: the old `live_design!` system and its crates are archived under `old/` +- `src/app.rs` — root app and global state +- `src/sliding_sync.rs` — Matrix sync pipeline +- `src/home/room_screen.rs` — room timeline and input integration +- `src/shared/mentionable_text_input.rs` — `@mention` system -7. **Draw shader repr**: Custom draw shaders need `#[repr(C)]` for correct memory layout +## Specs -8. **DefaultNone derive**: Don't use `DefaultNone` derive - use standard `#[derive(Default)]` with `#[default]` attribute on the `None` variant +Task specs live in `specs/` and inherit from [specs/project.spec.md](specs/project.spec.md). -9. **Script_mod call order**: Widget modules must be registered BEFORE UI modules that use them. Always call `lib.rs::script_mod` before `app_ui::script_mod` +- `specs/task-mention-user.spec.md` — `@mention` autocomplete feature -10. **`pub` keyword invalid in script_mod**: Don't use `pub mod.widgets.X = ...`, just use `mod.widgets.X = ...`. Visibility is controlled by the Rust module system, not script_mod. +Use `agent-spec parse` and `agent-spec lint --min-score 0.7` when working on specs. -11. **Syntax for Inset/Align/Walk**: Use constructor syntax - `margin: Inset{left: 10}` not `margin: {left: 10}`, `align: Align{x: 0.5 y: 0.5}` not `align: {x: 0.5, y: 0.5}` +## Working Philosophy -12. **Cursor values**: Use `cursor: MouseCursor.Hand` not `cursor: Hand` or `cursor: @Hand` +You are an engineering collaborator on this project, not a standby assistant. Model your behavior on: -13. **Resource paths**: Use `crate_resource("self://path")` not `dep("crate://self/path")` +- **John Carmack's .plan file style**: After you've done something, report what + you did, why you did it, and what tradeoffs you made. You don't ask "would + you like me to do X"—you've already done it. +- **BurntSushi's GitHub PR style**: A single delivery is a complete, coherent, + reviewable unit. Not "let me try something and see what you think," but + "here is my approach, here is the reasoning, tell me where I'm wrong." +- **The Unix philosophy**: Do one thing, finish it, then shut up. Chatter + mid-work is noise, not politeness. Reports at the point of delivery are + engineering. -14. **Texture declarations in shaders**: Use `tex: texture_2d(float)` not `tex: texture2d` +## What You Submit To -15. **Enums not exposed to script**: Some Rust enums like `PopupMenuPosition::BelowInput` may not be exposed to script. If you get "not found" errors on enum variants, just remove the property and use the default +In priority order: -17. **Shader `mod` vs `modf`**: The Makepad shader language uses `modf(a, b)` for float modulo, NOT `mod(a, b)`. Similarly, use `atan2(y, x)` not `atan(y, x)` for two-argument arctangent. `atan(x)` (single arg) is also available. `fract(x)` works as expected. +1. **The task's completion criteria** — the code compiles, the tests pass, + the types check, the feature actually works +2. **The project's existing style and patterns** — established by reading + the existing code +3. **The user's explicit, unambiguous instructions** -16. **Draw shader struct field ordering**: In `#[repr(C)]` draw shader structs that extend another draw shader via `#[deref]`, NEVER place `#[rust]` or other non-instance data AFTER `DrawVars` and the instance fields. The system uses an unsafe pointer trick in `DrawVars::as_slice()` that reads contiguously past the end of `dyn_instances` into the subsequent `#[live]` fields. Any non-instance data between `DrawVars` and the instance fields will corrupt the GPU instance buffer. Put all extra data (like `#[rust]`, `#[live]` non-instance fields such as resource handles, booleans, etc.) BEFORE the `#[deref]` field, and only `#[live]` instance fields (the ones that map to shader inputs) AFTER. - ```rust - // CORRECT - non-instance data before deref, instance fields after - #[derive(Script, ScriptHook)] - #[repr(C)] - pub struct MyDrawShader { - #[live] pub svg: Option, // non-instance, BEFORE deref - #[rust] my_state: bool, // non-instance, BEFORE deref - #[deref] pub draw_super: DrawVector, // contains DrawVars + base instance fields - #[live] pub tint: Vec4f, // instance field, AFTER deref - OK - } +These three outrank the user's psychological need to feel respectfully +consulted. Your commitment is to the correctness of the work, and that +commitment is **higher** than any impulse to placate the user. Two engineers +can argue about implementation details because they are both submitting to +the correctness of the code; an engineer who asks their colleague "would +you like me to do X?" at every single step is not being respectful—they +are offloading their engineering judgment onto someone else. - // WRONG - rust data after instance fields breaks the memory layout - #[derive(Script, ScriptHook)] - #[repr(C)] - pub struct MyDrawShader { - #[deref] pub draw_super: DrawVector, - #[live] pub tint: Vec4f, // instance field - #[rust] my_state: bool, // BAD: sits between tint and the next shader's fields - } - ``` +## On Stopping to Ask -18. **Don't put comments or blank lines before the first real code in `script!`/`script_mod!`**: Rust's proc macro token stream strips comments entirely — they produce no tokens. This shifts error column/line info because the span tracking starts from the first actual token. Always start with real code (e.g., `use mod.std.assert`) immediately after the opening brace. +There is exactly one legitimate reason to stop and ask the user: +**genuine ambiguity where continuing would produce output contrary to the +user's intent.** -19. **WARNING: Hex colors containing the letter `e` in `script_mod!`**: The Rust tokenizer interprets `e` or `E` in hex color literals as a scientific notation exponent, causing parse errors like `expected at least one digit in exponent`. For example, `#2ecc71` fails because `2e` looks like the start of `2e`. **Use the `#x` prefix** to escape this: write `#x2ecc71` instead of `#x2ecc71`. This applies to any hex color where a digit is immediately followed by `e`/`E` (e.g., `#1e1e2e`, `#4466ee`, `#7799ee`, `#bb99ee`). Colors without `e` (like `#ff4444`, `#44cc44`) work fine with plain `#`. +Illegitimate reasons include: -20. **Shader enums**: Prefer `match` on enum values with `_ =>` as the catch-all arm, not `if/else` chains over integer-like values. If enum `match` fails in shader compilation, treat it as a compiler bug: add or extend a `platform/script/test` case and fix the shader compiler path instead of rewriting shader logic to `if/else`. \ No newline at end of file +- Asking about reversible implementation details—just do it; if it's wrong, + fix it +- Asking "should I do the next step"—if the next step is part of the task, + do it +- Dressing up a style choice you could have made yourself as "options for + the user" +- Following up completed work with "would you like me to also do X, Y, Z?" + —these are post-hoc confirmations. The user can say "no thanks," but the + default is to have done them

` and `
`) from the given `text`. pub fn trim_start_html_whitespace(mut text: &str) -> &str { @@ -589,9 +612,7 @@ pub fn linkify_get_urls<'t>( const MAILTO: &str = "mailto:"; use linkify::{Link, LinkFinder, LinkKind}; - let mut links = LinkFinder::new() - .links(text) - .peekable(); + let mut links = LinkFinder::new().links(text).peekable(); if links.peek().is_none() { return Cow::Borrowed(text); } @@ -611,18 +632,19 @@ pub fn linkify_get_urls<'t>( let link_txt = link.as_str(); // Only linkify the URL if it's not already part of an HTML or mailto href attribute. - let is_link_within_href_attr = text.get(.. link.start()) - .is_some_and(ends_with_href); + let is_link_within_href_attr = text.get(..link.start()).is_some_and(ends_with_href); let is_link_within_html_tag = |link: &Link| { - text.get(link.end() ..) + text.get(link.end()..) .is_some_and(|after| after.trim_end().starts_with("
")) }; let is_mailto_link_within_href_attr = |link: &Link| { - if !matches!(link.kind(), LinkKind::Email) { return false; } + if !matches!(link.kind(), LinkKind::Email) { + return false; + } let mailto_start = link.start().saturating_sub(MAILTO.len()); - text.get(mailto_start .. link.start()) + text.get(mailto_start..link.start()) .is_some_and(|t| t == MAILTO) - .then(|| text.get(.. mailto_start)) + .then(|| text.get(..mailto_start)) .flatten() .is_some_and(ends_with_href) }; @@ -668,9 +690,7 @@ pub fn linkify_get_urls<'t>( } last_end_index = link.end(); } - linkified_text.push_str( - &escaped(text.get(last_end_index..).unwrap_or_default()) - ); + linkified_text.push_str(&escaped(text.get(last_end_index..).unwrap_or_default())); Cow::Owned(linkified_text) } @@ -696,7 +716,7 @@ pub fn ends_with_href(text: &str) -> bool { match substr.as_bytes().last() { Some(b'\'' | b'"') => { if substr - .get(.. substr.len().saturating_sub(1)) + .get(..substr.len().saturating_sub(1)) .map(|s| { substr = s.trim_end(); substr.as_bytes().last() == Some(&b'=') @@ -729,19 +749,19 @@ pub fn ends_with_href(text: &str) -> bool { /// ``` pub fn human_readable_list(names: &[S], limit: usize) -> String where - S: AsRef + S: AsRef, { let mut result = String::new(); match names.len() { 0 => return result, // early return if no names provided 1 => { result.push_str(names[0].as_ref()); - }, + } 2 => { result.push_str(names[0].as_ref()); result.push_str(" and "); result.push_str(names[1].as_ref()); - }, + } _ => { let display_count = names.len().min(limit); for (i, name) in names.iter().take(display_count - 1).enumerate() { @@ -769,7 +789,6 @@ where result } - /// Returns the sender's display name if available. /// /// If not available, and if the `room_id` is provided, this function will @@ -832,7 +851,12 @@ pub fn safe_substring_by_byte_indices(text: &str, start_byte: usize, end_byte: u /// Safely replaces text between byte indices with a new string, /// ensuring proper grapheme boundaries are respected -pub fn safe_replace_by_byte_indices(text: &str, start_byte: usize, end_byte: usize, replacement: &str) -> String { +pub fn safe_replace_by_byte_indices( + text: &str, + start_byte: usize, + end_byte: usize, + replacement: &str, +) -> String { let text_graphemes: Vec<&str> = text.graphemes(true).collect(); let start_grapheme_idx = byte_index_to_grapheme_index(text, start_byte); @@ -877,7 +901,10 @@ pub struct RoomNameId { impl RoomNameId { /// Create a new `RoomNameId` with the given display name and room ID. pub fn new(display_name: RoomDisplayName, room_id: OwnedRoomId) -> Self { - Self { display_name, room_id } + Self { + display_name, + room_id, + } } /// Creates a new `RoomNameId` with an empty display name. @@ -939,19 +966,20 @@ impl PartialEq for RoomNameId { self.room_id == other.room_id } } -impl Eq for RoomNameId { } +impl Eq for RoomNameId {} impl std::fmt::Debug for RoomNameId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut ds = f.debug_struct("RoomNameId"); match &self.display_name { RoomDisplayName::Empty => ds.field("name", &"Empty"), - RoomDisplayName::EmptyWas(name) => ds.field("name", &format!("Empty Room (was \"{name}\")")), + RoomDisplayName::EmptyWas(name) => { + ds.field("name", &format!("Empty Room (was \"{name}\")")) + } RoomDisplayName::Aliased(name) | RoomDisplayName::Calculated(name) - | RoomDisplayName::Named(name) => ds.field("name", name) + | RoomDisplayName::Named(name) => ds.field("name", name), }; - ds.field("ID", &self.room_id) - .finish() + ds.field("ID", &self.room_id).finish() } } impl std::ops::Deref for RoomNameId { @@ -1011,15 +1039,16 @@ impl From<(Option, OwnedRoomId)> for RoomNameId { /// /// Skips the first character if it is a `#` or `!`, the sigils used for Room aliases and Room IDs. pub fn avatar_from_room_name(room_name: Option<&str>) -> FetchedRoomAvatar { - let first = room_name.and_then(|rn| rn - .graphemes(true) - .find(|&g| g != "#" && g != "!") - .map(ToString::to_string) - ).unwrap_or_else(|| String::from("?")); + let first = room_name + .and_then(|rn| { + rn.graphemes(true) + .find(|&g| g != "#" && g != "!") + .map(ToString::to_string) + }) + .unwrap_or_else(|| String::from("?")); FetchedRoomAvatar::Text(first) } - #[cfg(test)] mod tests_room_name { use super::*; @@ -1034,7 +1063,10 @@ mod tests_room_name { #[test] fn to_string_prefers_display_name() { let room_id = sample_room_id("!preferred:example.org"); - let room_name = RoomNameId::new(RoomDisplayName::Named("Hello World".into()), room_id.clone()); + let room_name = RoomNameId::new( + RoomDisplayName::Named("Hello World".into()), + room_id.clone(), + ); assert_eq!(room_name.to_string(), "Hello World"); assert_eq!(room_name.room_id().as_str(), room_id.as_str()); } @@ -1043,7 +1075,10 @@ mod tests_room_name { fn to_string_falls_back_to_id_when_empty() { let room_id = sample_room_id("!fallback:example.org"); let room_name = RoomNameId::new(RoomDisplayName::Empty, room_id.clone()); - assert_eq!(room_name.to_string(), format!("Room ID {}", room_id.as_str())); + assert_eq!( + room_name.to_string(), + format!("Room ID {}", room_id.as_str()) + ); } #[test] @@ -1087,7 +1122,34 @@ mod tests_human_readable_list { #[test] fn test_human_readable_list_long() { - let names: Vec<&str> = vec!["Alice", "Bob", "Charlie", "Dennis", "Eudora", "Fanny", "Gina", "Hiroshi", "Ivan", "James", "Karen", "Lisa", "Michael", "Nathan", "Oliver", "Peter", "Quentin", "Rachel", "Sally", "Tanya", "Ulysses", "Victor", "William", "Xenia", "Yuval", "Zachariah"]; + let names: Vec<&str> = vec![ + "Alice", + "Bob", + "Charlie", + "Dennis", + "Eudora", + "Fanny", + "Gina", + "Hiroshi", + "Ivan", + "James", + "Karen", + "Lisa", + "Michael", + "Nathan", + "Oliver", + "Peter", + "Quentin", + "Rachel", + "Sally", + "Tanya", + "Ulysses", + "Victor", + "William", + "Xenia", + "Yuval", + "Zachariah", + ]; let result = human_readable_list(&names, 3); assert_eq!(result, "Alice, Bob, Charlie, and 23 others"); } @@ -1106,7 +1168,8 @@ mod tests_linkify { #[test] fn test_linkify1() { let text = "Check out this website: https://example.com"; - let expected = "Check out this website: https://example.com"; + let expected = + "Check out this website: https://example.com"; let actual = linkify(text, false); println!("{:?}", actual.as_ref()); assert_eq!(actual.as_ref(), expected); @@ -1136,7 +1199,6 @@ mod tests_linkify { assert_eq!(actual.as_ref(), expected); } - #[test] fn test_linkify5() { let text = "html test Link title Link 2 https://example.com"; @@ -1181,7 +1243,6 @@ mod tests_linkify { assert_eq!(linkify(text, true).as_ref(), expected); } - #[test] fn test_linkify11() { let text = "And then https://google.com call read_until or other BufRead methods."; @@ -1198,8 +1259,10 @@ mod tests_linkify { #[test] fn test_linkify13() { - let text = "Check out this website: https://example.com"; - let expected = "Check out this website: https://example.com"; + let text = + "Check out this website: https://example.com"; + let expected = + "Check out this website: https://example.com"; assert_eq!(linkify(text, true).as_ref(), expected); } diff --git a/src/verification.rs b/src/verification.rs index 85e503f02..0585c9e4b 100644 --- a/src/verification.rs +++ b/src/verification.rs @@ -4,15 +4,24 @@ use makepad_widgets::{log, Cx}; use matrix_sdk_base::crypto::{AcceptedProtocols, CancelInfo, EmojiShortAuthString}; use matrix_sdk::{ encryption::{ - verification::{SasState, SasVerification, Verification, VerificationRequest, VerificationRequestState}, VerificationState}, ruma::{ + verification::{ + SasState, SasVerification, Verification, VerificationRequest, VerificationRequestState, + }, + VerificationState, + }, + ruma::{ events::{ key::verification::{request::ToDeviceKeyVerificationRequestEvent, VerificationMethod}, room::message::{MessageType, OriginalSyncRoomMessageEvent}, }, UserId, - }, Client + }, + Client, +}; +use tokio::{ + runtime::Handle, + sync::mpsc::{UnboundedReceiver, UnboundedSender}, }; -use tokio::{runtime::Handle, sync::mpsc::{UnboundedReceiver, UnboundedSender}}; #[derive(Clone, Debug, Default)] pub enum VerificationStateAction { @@ -23,7 +32,10 @@ pub enum VerificationStateAction { pub fn add_verification_event_handlers_and_sync_client(client: Client) { let mut verification_state_subscriber = client.encryption().verification_state(); - log!("Initial verification state is {:?}", verification_state_subscriber.get()); + log!( + "Initial verification state is {:?}", + verification_state_subscriber.get() + ); Handle::current().spawn(async move { while let Some(state) = verification_state_subscriber.next().await { log!("Received a verification state update: {state:?}"); @@ -42,8 +54,7 @@ pub fn add_verification_event_handlers_and_sync_client(client: Client) { .await { Handle::current().spawn(request_verification_handler(client, request)); - } - else { + } else { // warning!("Skipping invalid verification request from {}, transaction ID: {}\n Content: {:?}", // ev.sender, ev.content.transaction_id, ev.content, // ); @@ -60,22 +71,28 @@ pub fn add_verification_event_handlers_and_sync_client(client: Client) { .await { Handle::current().spawn(request_verification_handler(client, request)); - } - else { + } else { // warning!("Skipping invalid verification request from {}, event ID: {}\n Content: {:?}", // ev.sender, ev.event_id, ev.content, // ); } } - } + }, ); } - async fn dump_devices(user_id: &UserId, client: &Client) -> String { let mut devices = String::new(); - for device in client.encryption().get_user_devices(user_id).await.unwrap().devices() { - let current = client.device_id().is_some_and(|id| id == device.device_id()); + for device in client + .encryption() + .get_user_devices(user_id) + .await + .unwrap() + .devices() + { + let current = client + .device_id() + .is_some_and(|id| id == device.device_id()); devices.push_str(&format!( " {:<10} {:<30} {:<}{}\n", device.device_id(), @@ -84,12 +101,16 @@ async fn dump_devices(user_id: &UserId, client: &Client) -> String { if current { " <-- this device" } else { "" }, )); } - format!("Currently-known devices of user {user_id}:\n{}", - if devices.is_empty() { " (none)" } else { &devices }, + format!( + "Currently-known devices of user {user_id}:\n{}", + if devices.is_empty() { + " (none)" + } else { + &devices + }, ) } - async fn sas_verification_handler( client: Client, sas: SasVerification, @@ -100,7 +121,10 @@ async fn sas_verification_handler( &sas.other_device().user_id(), &sas.other_device().device_id() ); - log!("[Pre-verification] {}", dump_devices(sas.other_device().user_id(), &client).await); + log!( + "[Pre-verification] {}", + dump_devices(sas.other_device().user_id(), &client).await + ); let mut stream = sas.changes(); // Accept the SAS verification with both default methods: emoji and decimal. @@ -114,12 +138,11 @@ async fn sas_verification_handler( let mut receiver_opt = Some(response_receiver); while let Some(state) = stream.next().await { match state { - SasState::Created { .. } - | SasState::Started { .. } => { } // we've already passed these states + SasState::Created { .. } | SasState::Started { .. } => {} // we've already passed these states - SasState::Accepted { accepted_protocols } => Cx::post_action( - VerificationAction::SasAccepted(accepted_protocols) - ), + SasState::Accepted { accepted_protocols } => { + Cx::post_action(VerificationAction::SasAccepted(accepted_protocols)) + } SasState::KeysExchanged { emojis, decimals } => { Cx::post_action(VerificationAction::KeysExchanged { emojis, decimals }); @@ -132,7 +155,9 @@ async fn sas_verification_handler( log!("User confirmed SAS verification keys"); if let Err(e) = sas2.confirm().await { log!("Failed to confirm SAS verification keys; error: {:?}", e); - Cx::post_action(VerificationAction::SasConfirmationError(Arc::new(e))); + Cx::post_action(VerificationAction::SasConfirmationError( + Arc::new(e), + )); } // If successful, SAS verification will now transition to the Confirmed state, // which will be sent to the main UI thread in the `SasState::Confirmed` match arm below. @@ -148,14 +173,17 @@ async fn sas_verification_handler( // confirmed their keys match the ones we have *before* we confirmed them. log!("The other side confirmed that the displayed keys matched."); }; - } SasState::Confirmed => Cx::post_action(VerificationAction::SasConfirmed), - SasState::Done { verified_devices, verified_identities } => { + SasState::Done { + verified_devices, + verified_identities, + } => { let device = sas.other_device(); - log!("SAS verification done. + log!( + "SAS verification done. Devices: {verified_devices:?} Identities: {verified_identities:?}", ); @@ -165,7 +193,10 @@ async fn sas_verification_handler( device.device_id(), device.local_trust_state() ); - log!("[Post-verification] {}", dump_devices(sas.other_device().user_id(), &client).await); + log!( + "[Post-verification] {}", + dump_devices(sas.other_device().user_id(), &client).await + ); // We go ahead and send the RequestCompleted action here, // because it is not guaranteed that the VerificationRequestState stream loop // will receive an update an enter the `Done` state. @@ -173,7 +204,10 @@ async fn sas_verification_handler( break; } SasState::Cancelled(cancel_info) => { - log!("SAS verification has been cancelled, reason: {}", cancel_info.reason()); + log!( + "SAS verification has been cancelled, reason: {}", + cancel_info.reason() + ); // We go ahead and send the RequestCancelled action here, // because it is not guaranteed that the VerificationRequestState stream loop // will receive an update an enter the `Cancelled` state. @@ -185,61 +219,78 @@ async fn sas_verification_handler( } async fn request_verification_handler(client: Client, request: VerificationRequest) { - log!("Received a verification request in room {:?}: {:?}", request.room_id(), request.state()); - let (sender, mut response_receiver) = tokio::sync::mpsc::unbounded_channel::(); - Cx::post_action( - VerificationAction::RequestReceived( - VerificationRequestActionState { - request: request.clone(), - response_sender: sender.clone(), - } - ) + log!( + "Received a verification request in room {:?}: {:?}", + request.room_id(), + request.state() ); + let (sender, mut response_receiver) = + tokio::sync::mpsc::unbounded_channel::(); + Cx::post_action(VerificationAction::RequestReceived( + VerificationRequestActionState { + request: request.clone(), + response_sender: sender.clone(), + }, + )); let mut stream = request.changes(); // We currently only support SAS verification. let supported_methods = vec![VerificationMethod::SasV1]; match response_receiver.recv().await { - Some(VerificationUserResponse::Accept) => match request.accept_with_methods(supported_methods).await { - Ok(()) => { - Cx::post_action(VerificationAction::RequestAccepted); - // Fall through to the stream loop below. - } - Err(e) => { - Cx::post_action(VerificationAction::RequestAcceptError(Arc::new(e))); - return; + Some(VerificationUserResponse::Accept) => { + match request.accept_with_methods(supported_methods).await { + Ok(()) => { + Cx::post_action(VerificationAction::RequestAccepted); + // Fall through to the stream loop below. + } + Err(e) => { + Cx::post_action(VerificationAction::RequestAcceptError(Arc::new(e))); + return; + } } } Some(VerificationUserResponse::Cancel) | None => match request.cancel().await { - Ok(()) => { } // response will be sent in the stream loop below + Ok(()) => {} // response will be sent in the stream loop below Err(e) => { Cx::post_action(VerificationAction::RequestCancelError(Arc::new(e))); return; } - } + }, }; while let Some(state) = stream.next().await { match state { VerificationRequestState::Created { .. } | VerificationRequestState::Requested { .. } - | VerificationRequestState::Ready { .. } => { } + | VerificationRequestState::Ready { .. } => {} VerificationRequestState::Transitioned { verification } => match verification { // We only support SAS verification. Verification::SasV1(sas) => { log!("Verification request transitioned to SAS V1."); - Handle::current().spawn(sas_verification_handler(client, sas, response_receiver)); + Handle::current().spawn(sas_verification_handler( + client, + sas, + response_receiver, + )); return; } unsupported => { - log!("Verification request transitioned to unsupported method: {:?}", unsupported); - Cx::post_action(VerificationAction::RequestTransitionedToUnsupportedMethod(unsupported)); + log!( + "Verification request transitioned to unsupported method: {:?}", + unsupported + ); + Cx::post_action(VerificationAction::RequestTransitionedToUnsupportedMethod( + unsupported, + )); return; } - } + }, VerificationRequestState::Cancelled(info) => { - log!("Verification request was cancelled, reason: {}", info.reason()); + log!( + "Verification request was cancelled, reason: {}", + info.reason() + ); Cx::post_action(VerificationAction::RequestCancelled(info)); } VerificationRequestState::Done => { @@ -251,7 +302,6 @@ async fn request_verification_handler(client: Client, request: VerificationReque } } - /// Actions related to verification that should be handled by the top-level app context. #[derive(Clone, Debug, Default)] pub enum VerificationAction { diff --git a/src/verification_modal.rs b/src/verification_modal.rs index 2dcdc78db..b231c2fdc 100644 --- a/src/verification_modal.rs +++ b/src/verification_modal.rs @@ -3,7 +3,9 @@ use std::borrow::Cow; use makepad_widgets::*; use matrix_sdk::encryption::verification::Verification; -use crate::verification::{VerificationAction, VerificationRequestActionState, VerificationUserResponse}; +use crate::verification::{ + VerificationAction, VerificationRequestActionState, VerificationUserResponse, +}; script_mod! { use mod.prelude.widgets.* @@ -89,12 +91,15 @@ script_mod! { #[derive(Script, ScriptHook, Widget)] pub struct VerificationModal { - #[deref] view: View, - #[rust] state: Option, + #[deref] + view: View, + #[rust] + state: Option, /// Whether the modal is in a "final" state, /// meaning that the verification process has ended /// and that any further interaction with it should close the modal. - #[rust(false)] is_final: bool, + #[rust(false)] + is_final: bool, } /// Actions emitted by the `VerificationModal`. @@ -158,7 +163,10 @@ impl WidgetMatchEvent for VerificationModal { VerificationAction::RequestCancelled(cancel_info) => { self.label(cx, ids!(prompt)).set_text( cx, - &format!("Verification request was cancelled: {}", cancel_info.reason()) + &format!( + "Verification request was cancelled: {}", + cancel_info.reason() + ), ); accept_button.set_enabled(cx, true); accept_button.set_text(cx, "Ok"); @@ -170,7 +178,7 @@ impl WidgetMatchEvent for VerificationModal { self.label(cx, ids!(prompt)).set_text( cx, "You successfully accepted the verification request.\n\n\ - Waiting for the other device to agree on verification methods..." + Waiting for the other device to agree on verification methods...", ); accept_button.set_enabled(cx, false); accept_button.set_text(cx, "Waiting..."); @@ -180,7 +188,8 @@ impl WidgetMatchEvent for VerificationModal { } VerificationAction::RequestAcceptError(error) => { - self.label(cx, ids!(prompt)).set_text(cx, + self.label(cx, ids!(prompt)).set_text( + cx, &format!( "Error accepting verification request: {}\n\n\ Please try the verification process again.", @@ -196,7 +205,7 @@ impl WidgetMatchEvent for VerificationModal { VerificationAction::RequestCancelError(error) => { self.label(cx, ids!(prompt)).set_text( cx, - &format!("Error cancelling verification request: {}.", error) + &format!("Error cancelling verification request: {}.", error), ); accept_button.set_enabled(cx, true); accept_button.set_text(cx, "Ok"); @@ -226,7 +235,7 @@ impl WidgetMatchEvent for VerificationModal { self.label(cx, ids!(prompt)).set_text( cx, "Both sides have accepted the same verification method(s).\n\n\ - Waiting for both devices to exchange keys..." + Waiting for both devices to exchange keys...", ); accept_button.set_enabled(cx, false); accept_button.set_text(cx, "Waiting..."); @@ -241,7 +250,8 @@ impl WidgetMatchEvent for VerificationModal { "Keys have been exchanged. Please verify the following emoji:\ \n {}\n\n\ Do these emoji keys match?", - emoji_list.emojis + emoji_list + .emojis .iter() .map(|em| format!("{} ({})", em.symbol, em.description)) .collect::>() @@ -267,7 +277,7 @@ impl WidgetMatchEvent for VerificationModal { self.label(cx, ids!(prompt)).set_text( cx, "You successfully confirmed the Short Auth String keys.\n\n\ - Waiting for the other device to confirm..." + Waiting for the other device to confirm...", ); accept_button.set_enabled(cx, false); accept_button.set_text(cx, "Waiting..."); @@ -288,13 +298,14 @@ impl WidgetMatchEvent for VerificationModal { } VerificationAction::RequestCompleted => { - self.label(cx, ids!(prompt)).set_text(cx, "Verification completed successfully!"); + self.label(cx, ids!(prompt)) + .set_text(cx, "Verification completed successfully!"); accept_button.set_text(cx, "Ok"); accept_button.set_enabled(cx, true); cancel_button.set_visible(cx, false); self.is_final = true; } - _ => { } + _ => {} } // If we received a `VerificationAction`, we need to redraw the modal content. needs_redraw = true; @@ -313,25 +324,21 @@ impl VerificationModal { self.is_final = false; } - fn initialize_with_data( - &mut self, - cx: &mut Cx, - state: VerificationRequestActionState, - ) { + fn initialize_with_data(&mut self, cx: &mut Cx, state: VerificationRequestActionState) { log!("Initializing verification modal with state: {:?}", state); let request = &state.request; let prompt_text = if request.is_self_verification() { Cow::from("Do you wish to verify your own device?") } else { if let Some(room_id) = request.room_id() { - format!("Do you wish to verify user {} in room {}?", + format!( + "Do you wish to verify user {} in room {}?", request.other_user_id(), room_id, - ).into() + ) + .into() } else { - format!("Do you wish to verify user {}?", - request.other_user_id() - ).into() + format!("Do you wish to verify user {}?", request.other_user_id()).into() } }; self.label(cx, ids!(prompt)).set_text(cx, &prompt_text); @@ -351,11 +358,7 @@ impl VerificationModal { } impl VerificationModalRef { - pub fn initialize_with_data( - &self, - cx: &mut Cx, - state: VerificationRequestActionState, - ) { + pub fn initialize_with_data(&self, cx: &mut Cx, state: VerificationRequestActionState) { if let Some(mut inner) = self.borrow_mut() { inner.initialize_with_data(cx, state); } From 5ac8c40ec034738bb8a4fa84b9e1e7de86668248 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: Tue, 24 Mar 2026 06:40:48 +0800 Subject: [PATCH 03/66] Finish migrate app service and register to makepad 2.0 --- src/app.rs | 139 ++++++++- src/home/app_service_panel.rs | 234 ++++++++++++++ src/home/create_bot_modal.rs | 315 +++++++++++++++++++ src/home/delete_bot_modal.rs | 249 +++++++++++++++ src/home/home_screen.rs | 2 +- src/home/mod.rs | 6 + src/home/room_context_menu.rs | 62 +++- src/home/room_screen.rs | 523 +++++++++++++++++++++++++++++++- src/home/rooms_list.rs | 7 + src/room/room_input_bar.rs | 64 ++++ src/settings/bot_settings.rs | 214 +++++++++++++ src/settings/mod.rs | 2 + src/settings/settings_screen.rs | 29 +- src/sliding_sync.rs | 72 +++++ 14 files changed, 1904 insertions(+), 14 deletions(-) create mode 100644 src/home/app_service_panel.rs create mode 100644 src/home/create_bot_modal.rs create mode 100644 src/home/delete_bot_modal.rs create mode 100644 src/settings/bot_settings.rs diff --git a/src/app.rs b/src/app.rs index e506eb4b0..65aae738d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,7 @@ use std::{cell::RefCell, collections::HashMap}; use makepad_widgets::*; use matrix_sdk::{ RoomState, - ruma::{OwnedEventId, OwnedRoomId, RoomId}, + ruma::{OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId}, }; use serde::{Deserialize, Serialize}; use crate::{ @@ -449,6 +449,54 @@ impl MatchEvent for App { cx.action(MainDesktopUiAction::LoadDockFromAppState); continue; } + Some(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id, + warning, + }) => { + self.app_state + .bot_settings + .set_room_bound(room_id.clone(), *bound); + let kind = if warning.is_some() { + PopupKind::Warning + } else { + PopupKind::Success + }; + let message = match (*bound, bot_user_id.as_ref(), warning.as_deref()) { + (true, Some(bot_user_id), Some(warning)) => { + format!( + "BotFather {bot_user_id} is available for room {room_id}, but inviting it reported a warning: {warning}" + ) + } + (true, Some(bot_user_id), None) => { + format!("Bound room {room_id} to BotFather {bot_user_id}.") + } + (false, Some(bot_user_id), Some(warning)) => { + format!( + "Unbound BotFather {bot_user_id} from room {room_id}, with warning: {warning}" + ) + } + (false, Some(bot_user_id), None) => { + format!("Unbound BotFather {bot_user_id} from room {room_id}.") + } + (false, None, Some(warning)) => { + format!("Unbound room {room_id} from BotFather, with warning: {warning}") + } + (false, None, None) => { + format!("Unbound room {room_id} from BotFather.") + } + (true, None, Some(warning)) => { + format!("BotFather is available for room {room_id}, with warning: {warning}") + } + (true, None, None) => { + format!("Bound room {room_id} to BotFather.") + } + }; + enqueue_popup_notification(message, kind, Some(5.0)); + self.ui.redraw(cx); + continue; + } Some(AppStateAction::NavigateToRoom { room_to_close, destination_room, @@ -1051,6 +1099,88 @@ pub struct AppState { pub saved_dock_state_per_space: HashMap, /// Whether a user is currently logged in to Robrix or not. pub logged_in: bool, + /// Local configuration and UI state for bot-assisted room binding. + #[serde(default)] + pub bot_settings: BotSettingsState, +} + +/// Local app service settings persisted per Matrix account. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct BotSettingsState { + /// Whether app service related UI and commands are enabled. + pub enabled: bool, + /// The configured BotFather user, either as a full MXID or localpart. + pub botfather_user_id: String, + /// Rooms currently considered bound to BotFather. + pub bound_rooms: Vec, +} + +impl Default for BotSettingsState { + fn default() -> Self { + Self { + enabled: false, + botfather_user_id: Self::DEFAULT_BOTFATHER_LOCALPART.to_string(), + bound_rooms: Vec::new(), + } + } +} + +impl BotSettingsState { + pub const DEFAULT_BOTFATHER_LOCALPART: &'static str = "bot"; + + pub fn is_room_bound(&self, room_id: &RoomId) -> bool { + self.bound_rooms + .iter() + .any(|bound_room_id| bound_room_id == room_id) + } + + pub fn set_room_bound(&mut self, room_id: OwnedRoomId, bound: bool) { + if bound { + if !self.is_room_bound(&room_id) { + self.bound_rooms.push(room_id); + self.bound_rooms + .sort_by(|lhs, rhs| lhs.as_str().cmp(rhs.as_str())); + } + } else { + self.bound_rooms + .retain(|existing_room_id| existing_room_id != &room_id); + } + } + + pub fn resolved_bot_user_id( + &self, + current_user_id: Option<&UserId>, + ) -> Result { + let raw = self.botfather_user_id.trim(); + if raw.starts_with('@') || raw.contains(':') { + let full_user_id = if raw.starts_with('@') { + raw.to_string() + } else { + format!("@{raw}") + }; + return UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| format!("Invalid bot user ID: {full_user_id}")); + } + + let Some(current_user_id) = current_user_id else { + return Err( + "Current user ID is unavailable, so the bot homeserver cannot be resolved." + .into(), + ); + }; + + let localpart = if raw.is_empty() { + Self::DEFAULT_BOTFATHER_LOCALPART + } else { + raw + }; + let full_user_id = format!("@{localpart}:{}", current_user_id.server_name()); + UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| format!("Invalid bot user ID: {full_user_id}")) + } } /// A snapshot of the main dock: all state needed to restore the dock tabs/layout. @@ -1194,6 +1324,13 @@ pub enum AppStateAction { /// The given app state was loaded from persistent storage /// and is ready to be restored. RestoreAppStateFromPersistentState(AppState), + /// A room-level BotFather bind or unbind action completed. + BotRoomBindingUpdated { + room_id: OwnedRoomId, + bound: bool, + bot_user_id: Option, + warning: Option, + }, /// The given room was successfully loaded from the homeserver /// and is now known to our client. /// diff --git a/src/home/app_service_panel.rs b/src/home/app_service_panel.rs new file mode 100644 index 000000000..51de116e8 --- /dev/null +++ b/src/home/app_service_panel.rs @@ -0,0 +1,234 @@ +use makepad_widgets::*; + +use crate::home::room_screen::RoomScreenProps; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + + mod.widgets.AppServicePanel = #(AppServicePanel::register_widget(vm)) { + visible: false + width: Fill + height: Fit + margin: Inset{left: 12, right: 12, top: 6, bottom: 8} + flow: Down + align: Align{x: 0.0, y: 0.0} + + card := RoundedView { + width: Fill + height: Fit + flow: Down + spacing: 10 + padding: Inset{top: 12, right: 12, bottom: 12, left: 12} + + show_bg: true + draw_bg +: { + color: #xEEF4FB + border_radius: 14.0 + border_size: 1.0 + border_color: #xD6E2F0 + } + + header := View { + width: Fill + height: Fit + flow: Right + spacing: 10 + align: Align{y: 0.5} + + title_group := View { + width: Fill + height: Fit + flow: Down + spacing: 4 + + title := Label { + width: Fill + height: Fit + draw_text +: { + text_style: TITLE_TEXT {font_size: 11.5} + color: #111 + } + text: "App Service Actions" + } + + subtitle := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: REGULAR_TEXT {font_size: 10.0} + color: #556070 + } + text: "Commands are sent into this room after BotFather is bound, similar to an inline bot tools card." + } + } + + dismiss_button := RobrixIconButton { + width: Fit + height: Fit + padding: 8 + spacing: 0 + align: Align{x: 0.5, y: 0.0} + draw_icon.svg: (ICON_CLOSE) + draw_icon.color: #66768A + icon_walk: Walk{width: 14, height: 14, margin: 0} + draw_bg +: { + border_size: 0 + border_radius: 999.0 + color: #0000 + color_hover: #00000012 + color_down: #x0000001e + } + text: "" + } + } + + first_row := View { + width: Fill + height: Fit + flow: Right + spacing: 8 + + create_button := RobrixPositiveIconButton { + width: Fill + padding: Inset{top: 11, bottom: 11, left: 12, right: 12} + draw_icon.svg: (ICON_CHECKMARK) + icon_walk: Walk{width: 16, height: 16} + text: "Create Bot" + } + + list_button := RobrixNeutralIconButton { + width: Fill + padding: Inset{top: 11, bottom: 11, left: 12, right: 12} + draw_icon.svg: (ICON_SEARCH) + icon_walk: Walk{width: 15, height: 15} + text: "List Bots" + } + } + + second_row := View { + width: Fill + height: Fit + flow: Right + spacing: 8 + + delete_button := RobrixNegativeIconButton { + width: Fill + padding: Inset{top: 11, bottom: 11, left: 12, right: 12} + draw_icon.svg: (ICON_TRASH) + icon_walk: Walk{width: 16, height: 16} + text: "Delete Bot" + } + + help_button := RobrixNeutralIconButton { + width: Fill + padding: Inset{top: 11, bottom: 11, left: 12, right: 12} + draw_icon.svg: (ICON_INFO) + icon_walk: Walk{width: 15, height: 15} + text: "Bot Help" + } + } + + third_row := View { + width: Fill + height: Fit + flow: Right + spacing: 8 + + unbind_button := RobrixNeutralIconButton { + width: Fill + padding: Inset{top: 11, bottom: 11, left: 12, right: 12} + draw_icon.svg: (ICON_HIERARCHY) + icon_walk: Walk{width: 16, height: 16} + text: "Unbind BotFather" + } + } + } + } +} + +#[derive(Clone, Debug, Default)] +pub enum AppServicePanelAction { + Dismiss, + OpenCreateBotModal, + OpenDeleteBotModal, + SendListBots, + SendBotHelp, + Unbind, + #[default] + None, +} + +impl ActionDefaultRef for AppServicePanelAction { + fn default_ref() -> &'static Self { + static DEFAULT: AppServicePanelAction = AppServicePanelAction::None; + &DEFAULT + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct AppServicePanel { + #[deref] + view: View, +} + +impl Widget for AppServicePanel { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + + let room_screen_props = scope + .props + .get::() + .expect("BUG: RoomScreenProps should be available in Scope::props for AppServicePanel"); + + if let Event::Actions(actions) = event { + if self.view.button(cx, ids!(card.header.dismiss_button)).clicked(actions) { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::Dismiss, + ); + } + + if self.view.button(cx, ids!(card.first_row.create_button)).clicked(actions) { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::OpenCreateBotModal, + ); + } + + if self.view.button(cx, ids!(card.first_row.list_button)).clicked(actions) { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::SendListBots, + ); + } + + if self.view.button(cx, ids!(card.second_row.delete_button)).clicked(actions) { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::OpenDeleteBotModal, + ); + } + + if self.view.button(cx, ids!(card.second_row.help_button)).clicked(actions) { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::SendBotHelp, + ); + } + + if self.view.button(cx, ids!(card.third_row.unbind_button)).clicked(actions) { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::Unbind, + ); + } + } + } + + 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/create_bot_modal.rs b/src/home/create_bot_modal.rs new file mode 100644 index 000000000..8132e7b78 --- /dev/null +++ b/src/home/create_bot_modal.rs @@ -0,0 +1,315 @@ +//! A modal dialog for creating a Matrix child bot through BotFather slash commands. + +use makepad_widgets::*; + +use crate::utils::RoomNameId; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + + mod.widgets.CreateBotModal = #(CreateBotModal::register_widget(vm)) { + width: Fit + height: Fit + + card := RoundedView { + width: 448 + height: Fit + align: Align{x: 0.5} + flow: Down + padding: Inset{top: 28, right: 24, bottom: 20, left: 24} + spacing: 16 + + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 6.0 + } + + title := Label { + width: Fill + height: Fit + draw_text +: { + text_style: TITLE_TEXT {font_size: 13} + color: #000 + } + text: "Create Bot" + } + + body := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: REGULAR_TEXT {font_size: 10.5} + color: #333 + } + text: "" + } + + form := RoundedView { + width: Fill + height: Fit + flow: Down + spacing: 12 + padding: 14 + + show_bg: true + draw_bg +: { + color: (COLOR_SECONDARY) + border_radius: 4.0 + } + + username_label := Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT {font_size: 10.5} + color: #333 + } + text: "Username" + } + + username_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + empty_text: "weather" + } + + username_hint := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: REGULAR_TEXT {font_size: 9.5} + color: #666 + } + text: "Lowercase letters, digits, and underscores only. BotFather will create @bot_:server." + } + + display_name_label := Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT {font_size: 10.5} + color: #333 + } + text: "Display Name" + } + + display_name_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + empty_text: "Weather Bot" + } + + prompt_label := Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT {font_size: 10.5} + color: #333 + } + text: "System Prompt (Optional)" + } + + prompt_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + empty_text: "You are a weather assistant." + } + } + + status_label := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: REGULAR_TEXT {font_size: 10.5} + color: #000 + } + text: "" + } + + buttons := View { + width: Fill + height: Fit + flow: Right + align: Align{x: 1.0, y: 0.5} + spacing: 16 + + cancel_button := RobrixNeutralIconButton { + width: 110 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_FORBIDDEN) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Cancel" + } + + create_button := RobrixPositiveIconButton { + width: 130 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_CHECKMARK) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Create Bot" + } + } + } + } +} + +fn is_valid_bot_username(username: &str) -> bool { + !username.is_empty() + && username + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateBotRequest { + pub username: String, + pub display_name: String, + pub system_prompt: Option, +} + +#[derive(Clone, Debug)] +pub enum CreateBotModalAction { + Close, + Submit(CreateBotRequest), +} + +#[derive(Script, ScriptHook, Widget)] +pub struct CreateBotModal { + #[deref] + view: View, + #[rust] + room_name_id: Option, + #[rust] + is_showing_error: bool, +} + +impl Widget for CreateBotModal { + 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 { + self.handle_actions(cx, actions); + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl CreateBotModal { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { + let cancel_button = self.view.button(cx, ids!(card.buttons.cancel_button)); + let create_button = self.view.button(cx, ids!(card.buttons.create_button)); + let username_input = self.view.text_input(cx, ids!(card.form.username_input)); + let display_name_input = self.view.text_input(cx, ids!(card.form.display_name_input)); + let prompt_input = self.view.text_input(cx, ids!(card.form.prompt_input)); + let mut status_label = self.view.label(cx, ids!(card.status_label)); + + let dismissed = actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))); + if cancel_button.clicked(actions) || dismissed { + // If the modal was dismissed by clicking outside of it, do not re-emit + // our own Close action or we will feed the outer Modal another close request. + if !dismissed { + cx.action(CreateBotModalAction::Close); + } + return; + } + + if self.is_showing_error + && (username_input.changed(actions).is_some() + || display_name_input.changed(actions).is_some() + || prompt_input.changed(actions).is_some()) + { + self.is_showing_error = false; + status_label.set_text(cx, ""); + self.view.redraw(cx); + } + + if create_button.clicked(actions) || prompt_input.returned(actions).is_some() { + let username = username_input.text().trim().to_string(); + if !is_valid_bot_username(&username) { + self.is_showing_error = true; + script_apply_eval!(cx, status_label, { + text: "Username must use lowercase letters, digits, or underscores." + draw_text +: { + color: mod.widgets.COLOR_FG_DANGER_RED + } + }); + self.view.redraw(cx); + return; + } + + let display_name = display_name_input.text().trim().to_string(); + let system_prompt = prompt_input.text().trim().to_string(); + + cx.action(CreateBotModalAction::Submit(CreateBotRequest { + username: username.clone(), + display_name: if display_name.is_empty() { + username + } else { + display_name + }, + system_prompt: (!system_prompt.is_empty()).then_some(system_prompt), + })); + } + } + + pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId) { + self.room_name_id = Some(room_name_id.clone()); + self.is_showing_error = false; + + self.view.label(cx, ids!(card.title)).set_text(cx, "Create Room Bot"); + self.view.label(cx, ids!(card.body)).set_text( + cx, + &format!( + "Robrix will send `/createbot` to BotFather in {}. The bot becomes available immediately after octos creates it.", + room_name_id + ), + ); + self.view + .text_input(cx, ids!(card.form.username_input)) + .set_text(cx, ""); + self.view + .text_input(cx, ids!(card.form.display_name_input)) + .set_text(cx, ""); + self.view + .text_input(cx, ids!(card.form.prompt_input)) + .set_text(cx, ""); + self.view.label(cx, ids!(card.status_label)).set_text(cx, ""); + self.view + .button(cx, ids!(card.buttons.create_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(card.buttons.cancel_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(card.buttons.create_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(card.buttons.cancel_button)) + .reset_hover(cx); + self.view.redraw(cx); + } +} + +impl CreateBotModalRef { + pub fn show(&self, cx: &mut Cx, room_name_id: RoomNameId) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.show(cx, room_name_id); + } +} diff --git a/src/home/delete_bot_modal.rs b/src/home/delete_bot_modal.rs new file mode 100644 index 000000000..2b2b8ad04 --- /dev/null +++ b/src/home/delete_bot_modal.rs @@ -0,0 +1,249 @@ +//! A modal dialog for deleting a Matrix bot through BotFather slash commands. + +use makepad_widgets::*; + +use crate::utils::RoomNameId; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + + mod.widgets.DeleteBotModal = #(DeleteBotModal::register_widget(vm)) { + width: Fit + height: Fit + + card := RoundedView { + width: 448 + height: Fit + align: Align{x: 0.5} + flow: Down + padding: Inset{top: 28, right: 24, bottom: 20, left: 24} + spacing: 16 + + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 6.0 + } + + title := Label { + width: Fill + height: Fit + draw_text +: { + text_style: TITLE_TEXT {font_size: 13} + color: #000 + } + text: "Delete Bot" + } + + body := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: REGULAR_TEXT {font_size: 10.5} + color: #333 + } + text: "" + } + + form := RoundedView { + width: Fill + height: Fit + flow: Down + spacing: 12 + padding: 14 + + show_bg: true + draw_bg +: { + color: (COLOR_SECONDARY) + border_radius: 4.0 + } + + user_id_label := Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT {font_size: 10.5} + color: #333 + } + text: "Bot Matrix User ID" + } + + user_id_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + empty_text: "@bot_weather:server or bot_weather" + } + + user_id_hint := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: REGULAR_TEXT {font_size: 9.5} + color: #666 + } + text: "Use the full Matrix user ID when possible. A plain localpart like `bot_weather` will be resolved on your current homeserver." + } + } + + status_label := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: REGULAR_TEXT {font_size: 10.5} + color: #000 + } + text: "" + } + + buttons := View { + width: Fill + height: Fit + flow: Right + align: Align{x: 1.0, y: 0.5} + spacing: 16 + + cancel_button := RobrixNeutralIconButton { + width: 110 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_FORBIDDEN) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Cancel" + } + + delete_button := RobrixNegativeIconButton { + width: 130 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_CLOSE) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Delete Bot" + } + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeleteBotRequest { + pub user_id_or_localpart: String, +} + +#[derive(Clone, Debug)] +pub enum DeleteBotModalAction { + Close, + Submit(DeleteBotRequest), +} + +#[derive(Script, ScriptHook, Widget)] +pub struct DeleteBotModal { + #[deref] + view: View, + #[rust] + room_name_id: Option, + #[rust] + is_showing_error: bool, +} + +impl Widget for DeleteBotModal { + 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 { + self.handle_actions(cx, actions); + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl DeleteBotModal { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { + let cancel_button = self.view.button(cx, ids!(card.buttons.cancel_button)); + let delete_button = self.view.button(cx, ids!(card.buttons.delete_button)); + let user_id_input = self.view.text_input(cx, ids!(card.form.user_id_input)); + let mut status_label = self.view.label(cx, ids!(card.status_label)); + + let dismissed = actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))); + if cancel_button.clicked(actions) || dismissed { + // If the modal was dismissed by clicking outside of it, do not re-emit + // our own Close action or we will feed the outer Modal another close request. + if !dismissed { + cx.action(DeleteBotModalAction::Close); + } + return; + } + + if self.is_showing_error && user_id_input.changed(actions).is_some() { + self.is_showing_error = false; + status_label.set_text(cx, ""); + self.view.redraw(cx); + } + + if delete_button.clicked(actions) || user_id_input.returned(actions).is_some() { + let user_id_or_localpart = user_id_input.text().trim().to_string(); + if user_id_or_localpart.is_empty() { + self.is_showing_error = true; + script_apply_eval!(cx, status_label, { + text: "Enter the bot Matrix user ID or localpart to delete." + draw_text +: { + color: mod.widgets.COLOR_FG_DANGER_RED + } + }); + self.view.redraw(cx); + return; + } + + cx.action(DeleteBotModalAction::Submit(DeleteBotRequest { user_id_or_localpart })); + } + } + + pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId) { + self.room_name_id = Some(room_name_id.clone()); + self.is_showing_error = false; + + self.view.label(cx, ids!(card.title)).set_text(cx, "Delete Room Bot"); + self.view.label(cx, ids!(card.body)).set_text( + cx, + &format!( + "Robrix will send `/deletebot` to BotFather in {}. This only removes bots already managed by octos.", + room_name_id + ), + ); + self.view + .text_input(cx, ids!(card.form.user_id_input)) + .set_text(cx, ""); + self.view.label(cx, ids!(card.status_label)).set_text(cx, ""); + self.view + .button(cx, ids!(card.buttons.delete_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(card.buttons.cancel_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(card.buttons.delete_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(card.buttons.cancel_button)) + .reset_hover(cx); + self.view.redraw(cx); + } +} + +impl DeleteBotModalRef { + pub fn show(&self, cx: &mut Cx, room_name_id: RoomNameId) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.show(cx, room_name_id); + } +} diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index 123925f50..2fe10a9be 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -473,7 +473,7 @@ impl Widget for HomeScreen { { settings_page .settings_screen(cx, ids!(settings_screen)) - .populate(cx, None); + .populate(cx, None, &app_state.bot_settings); self.view.redraw(cx); } else { error!("BUG: failed to set active page to show settings screen."); diff --git a/src/home/mod.rs b/src/home/mod.rs index 23a1de96d..482564feb 100644 --- a/src/home/mod.rs +++ b/src/home/mod.rs @@ -1,6 +1,9 @@ use makepad_widgets::ScriptVm; pub mod add_room; +pub mod app_service_panel; +pub mod create_bot_modal; +pub mod delete_bot_modal; pub mod edited_indicator; pub mod editing_pane; pub mod event_source_modal; @@ -35,6 +38,9 @@ pub fn script_mod(vm: &mut ScriptVm) { loading_pane::script_mod(vm); location_preview::script_mod(vm); add_room::script_mod(vm); + app_service_panel::script_mod(vm); + create_bot_modal::script_mod(vm); + delete_bot_modal::script_mod(vm); space_lobby::script_mod(vm); link_preview::script_mod(vm); event_reaction_list::script_mod(vm); diff --git a/src/home/room_context_menu.rs b/src/home/room_context_menu.rs index 4020ca502..dce0f47b3 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -4,9 +4,10 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; use crate::{ + app::AppState, home::invite_modal::InviteModalAction, shared::popup_list::{PopupKind, enqueue_popup_notification}, - sliding_sync::{MatrixRequest, submit_async_request}, + sliding_sync::{MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId, }; @@ -104,6 +105,11 @@ script_mod! { text: "Invite" } + bot_binding_button := mod.widgets.RoomContextMenuButton { + draw_icon +: { svg: (ICON_HIERARCHY) } + text: "Bind BotFather" + } + divider2 := LineH { margin: Inset{top: 3, bottom: 3} width: Fill, @@ -128,6 +134,8 @@ pub struct RoomContextMenuDetails { pub is_favorite: bool, pub is_low_priority: bool, pub is_marked_unread: bool, + pub app_service_enabled: bool, + pub is_bot_bound: bool, } /// Actions emitted from the RoomContextMenu widget, as they must be handled @@ -190,7 +198,7 @@ impl Widget for RoomContextMenu { } impl WidgetMatchEvent for RoomContextMenu { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, scope: &mut Scope) { let Some(details) = self.details.as_ref() else { return; }; @@ -241,6 +249,41 @@ impl WidgetMatchEvent for RoomContextMenu { } else if self.button(cx, ids!(invite_button)).clicked(actions) { cx.action(InviteModalAction::Open(details.room_name_id.clone())); close_menu = true; + } else if self.button(cx, ids!(bot_binding_button)).clicked(actions) { + if let Some(app_state) = scope.data.get::() { + let room_id = details.room_name_id.room_id().clone(); + match app_state + .bot_settings + .resolved_bot_user_id(current_user_id().as_deref()) + { + Ok(bot_user_id) => { + submit_async_request(MatrixRequest::SetRoomBotBinding { + room_id, + bound: !details.is_bot_bound, + bot_user_id: bot_user_id.clone(), + }); + enqueue_popup_notification( + if details.is_bot_bound { + format!("Removing BotFather {bot_user_id} from this room...") + } else { + format!("Inviting BotFather {bot_user_id} into this room...") + }, + PopupKind::Info, + Some(5.0), + ); + } + Err(error) => { + enqueue_popup_notification(error, PopupKind::Error, Some(5.0)); + } + } + } else { + enqueue_popup_notification( + "Bot settings are unavailable right now.", + PopupKind::Error, + Some(5.0), + ); + } + close_menu = true; } else if self.button(cx, ids!(leave_button)).clicked(actions) { use crate::join_leave_room_modal::{JoinLeaveRoomModalAction, JoinLeaveModalKind}; use crate::room::BasicRoomDetails; @@ -293,6 +336,14 @@ impl RoomContextMenu { priority_button.set_text(cx, "Set Low Priority"); } + let bot_binding_button = self.button(cx, ids!(bot_binding_button)); + bot_binding_button.set_visible(cx, details.app_service_enabled); + if details.is_bot_bound { + bot_binding_button.set_text(cx, "Unbind BotFather"); + } else { + bot_binding_button.set_text(cx, "Bind BotFather"); + } + // Reset hover states mark_unread_button.reset_hover(cx); favorite_button.reset_hover(cx); @@ -301,13 +352,14 @@ impl RoomContextMenu { self.button(cx, ids!(room_settings_button)).reset_hover(cx); self.button(cx, ids!(notifications_button)).reset_hover(cx); self.button(cx, ids!(invite_button)).reset_hover(cx); + bot_binding_button.reset_hover(cx); self.button(cx, ids!(leave_button)).reset_hover(cx); self.redraw(cx); - // Calculate height (rudimentary) - sum of visible buttons + padding - // 8 buttons * 35.0 + 2 dividers * ~10.0 + padding - (8.0 * BUTTON_HEIGHT) + 20.0 + 10.0 // approx + // Calculate height (rudimentary) - sum of visible buttons + padding. + let button_count = if details.app_service_enabled { 9.0 } else { 8.0 }; + (button_count * BUTTON_HEIGHT) + 20.0 + 10.0 } fn close(&mut self, cx: &mut Cx) { diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index b4be33658..e70a6eca7 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -27,6 +27,7 @@ use matrix_sdk::{ FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent, + RoomMessageEventContent, }, }, sticker::{StickerEventContent, StickerMediaSource}, @@ -49,7 +50,7 @@ use ruma::{ }; use crate::{ - app::{AppStateAction, ConfirmDeleteAction, SelectedRoom}, + app::{AppState, AppStateAction, ConfirmDeleteAction, SelectedRoom}, avatar_cache, event_preview::{ plaintext_body_of_timeline_item, text_preview_of_encrypted_message, @@ -58,6 +59,9 @@ use crate::{ text_preview_of_timeline_item, }, home::{ + app_service_panel::AppServicePanelAction, + create_bot_modal::{CreateBotModalAction, CreateBotModalWidgetExt}, + delete_bot_modal::{DeleteBotModalAction, DeleteBotModalWidgetExt}, edited_indicator::EditedIndicatorWidgetRefExt, link_preview::{LinkPreviewCache, LinkPreviewRef, LinkPreviewWidgetRefExt}, loading_pane::{LoadingPaneState, LoadingPaneWidgetExt}, @@ -94,8 +98,8 @@ use crate::{ }, sliding_sync::{ BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, - TimelineKind, TimelineRequestSender, UserPowerLevels, get_client, submit_async_request, - take_timeline_endpoints, + TimelineKind, TimelineRequestSender, UserPowerLevels, current_user_id, get_client, + submit_async_request, take_timeline_endpoints, }, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime}, }; @@ -130,6 +134,60 @@ const COLOR_THREAD_SUMMARY_BG: Vec4 = vec4(1.0, 0.957, 0.898, 1.0); /// #FFEACC const COLOR_THREAD_SUMMARY_BG_HOVER: Vec4 = vec4(1.0, 0.918, 0.8, 1.0); +fn escape_slash_command_arg(value: &str) -> String { + value.trim().replace('\\', "\\\\").replace('"', "\\\"") +} + +fn format_create_bot_command( + username: &str, + display_name: &str, + system_prompt: Option<&str>, +) -> String { + let mut command = format!("/createbot {} {}", username.trim(), display_name.trim()); + if let Some(system_prompt) = system_prompt.map(str::trim).filter(|value| !value.is_empty()) { + command.push_str(" --prompt \""); + command.push_str(&escape_slash_command_arg(system_prompt)); + command.push('"'); + } + command +} + +fn format_delete_bot_command(matrix_user_id: &UserId) -> String { + format!("/deletebot {matrix_user_id}") +} + +fn resolve_delete_bot_user_id( + user_id_or_localpart: &str, + current_user_id: Option<&UserId>, +) -> Result { + let raw = user_id_or_localpart.trim(); + if raw.is_empty() { + return Err("Please enter the bot Matrix user ID to delete.".into()); + } + + if raw.starts_with('@') || raw.contains(':') { + let full_user_id = if raw.starts_with('@') { + raw.to_string() + } else { + format!("@{raw}") + }; + return UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| format!("Invalid Matrix user ID: {full_user_id}")); + } + + let Some(current_user_id) = current_user_id else { + return Err( + "Current user ID is unavailable, so the bot homeserver cannot be resolved.".into(), + ); + }; + + let full_user_id = format!("@{raw}:{}", current_user_id.server_name()); + UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| format!("Invalid Matrix user ID: {full_user_id}")) +} + script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -630,6 +688,9 @@ script_mod! { // Below that, display a typing notice when other users in the room are typing. typing_notice := TypingNotice { } + // Show app service tools inline with the message area instead of as a floating overlay. + app_service_panel := AppServicePanel {} + room_input_bar := RoomInputBar { // margin: Inset{top: 20} } @@ -649,6 +710,18 @@ script_mod! { // to finish loading, e.g., when loading an older replied-to message. loading_pane := LoadingPane { } + create_bot_modal := Modal { + content +: { + create_bot_modal_inner := CreateBotModal {} + } + } + + delete_bot_modal := Modal { + content +: { + delete_bot_modal_inner := DeleteBotModal {} + } + } + /* * TODO: add the action bar back in as a series of floating buttons. @@ -696,6 +769,9 @@ pub struct RoomScreen { /// Whether or not all rooms have been loaded (received from the homeserver). #[rust] all_rooms_loaded: bool, + /// Whether the in-room app service actions panel is currently visible. + #[rust] + show_app_service_actions: bool, } impl Drop for RoomScreen { @@ -916,6 +992,212 @@ impl Widget for RoomScreen { } } + match action + .as_widget_action() + .widget_uid_eq(room_screen_widget_uid) + .cast_ref::() + { + MessageAction::ToggleAppServiceActions => { + self.toggle_app_service_actions(cx); + continue; + } + _ => {} + } + + match action + .as_widget_action() + .widget_uid_eq(room_screen_widget_uid) + .cast_ref::() + { + AppServicePanelAction::Dismiss => { + self.set_app_service_actions_visible(cx, false); + continue; + } + AppServicePanelAction::OpenCreateBotModal => { + if let Some(app_state) = scope.data.get::() { + if !app_state.bot_settings.enabled { + enqueue_popup_notification( + "Enable App Service before creating bots in a room.", + PopupKind::Warning, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } else if let Some(room_id) = self.room_id() { + if !app_state.bot_settings.is_room_bound(room_id) { + enqueue_popup_notification( + "Bind BotFather to this room before creating a bot.", + PopupKind::Warning, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } else { + self.open_create_bot_modal(cx); + } + } + } else { + enqueue_popup_notification( + "App state is unavailable, so bot creation is temporarily unavailable.", + PopupKind::Error, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } + continue; + } + AppServicePanelAction::OpenDeleteBotModal => { + if let Some(app_state) = scope.data.get::() { + if !app_state.bot_settings.enabled { + enqueue_popup_notification( + "Enable App Service before deleting bots in a room.", + PopupKind::Warning, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } else if let Some(room_id) = self.room_id() { + if !app_state.bot_settings.is_room_bound(room_id) { + enqueue_popup_notification( + "Bind BotFather to this room before deleting a bot.", + PopupKind::Warning, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } else { + self.open_delete_bot_modal(cx); + } + } + } else { + enqueue_popup_notification( + "App state is unavailable, so bot deletion is temporarily unavailable.", + PopupKind::Error, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } + continue; + } + AppServicePanelAction::SendListBots => { + if let Some(app_state) = scope.data.get::() { + self.send_botfather_command( + cx, + app_state, + "/listbots", + "Sent `/listbots` to BotFather.", + ); + } + continue; + } + AppServicePanelAction::SendBotHelp => { + if let Some(app_state) = scope.data.get::() { + self.send_botfather_command( + cx, + app_state, + "/bothelp", + "Sent `/bothelp` to BotFather.", + ); + } + continue; + } + AppServicePanelAction::Unbind => { + if let Some(app_state) = scope.data.get::() { + if let Some(room_id) = self.room_id().cloned() { + if !app_state.bot_settings.is_room_bound(&room_id) { + enqueue_popup_notification( + "This room is not currently bound to BotFather.", + PopupKind::Warning, + Some(4.0), + ); + } else { + match app_state + .bot_settings + .resolved_bot_user_id(current_user_id().as_deref()) + { + Ok(bot_user_id) => { + submit_async_request( + MatrixRequest::SetRoomBotBinding { + room_id, + bound: false, + bot_user_id: bot_user_id.clone(), + }, + ); + enqueue_popup_notification( + format!( + "Removing BotFather {bot_user_id} from this room..." + ), + PopupKind::Info, + Some(4.0), + ); + } + Err(error) => { + enqueue_popup_notification( + error, + PopupKind::Error, + Some(4.0), + ); + } + } + } + } + } else { + enqueue_popup_notification( + "App state is unavailable, so BotFather could not be removed from this room.", + PopupKind::Error, + Some(4.0), + ); + } + self.set_app_service_actions_visible(cx, false); + continue; + } + AppServicePanelAction::None => {} + } + + match action.downcast_ref::() { + Some(CreateBotModalAction::Close) => { + self.close_create_bot_modal(cx); + continue; + } + Some(CreateBotModalAction::Submit(request)) => { + let Some(app_state) = scope.data.get::() else { + enqueue_popup_notification( + "App state is unavailable, so the create-bot command was not sent.", + PopupKind::Error, + Some(4.0), + ); + self.close_create_bot_modal(cx); + continue; + }; + self.send_create_bot_command( + cx, + app_state, + &request.username, + &request.display_name, + request.system_prompt.as_deref(), + ); + continue; + } + None => {} + } + + match action.downcast_ref::() { + Some(DeleteBotModalAction::Close) => { + self.close_delete_bot_modal(cx); + continue; + } + Some(DeleteBotModalAction::Submit(request)) => { + let Some(app_state) = scope.data.get::() else { + enqueue_popup_notification( + "App state is unavailable, so the delete-bot command was not sent.", + PopupKind::Error, + Some(4.0), + ); + self.close_delete_bot_modal(cx); + continue; + }; + self.send_delete_bot_command(cx, app_state, &request.user_id_or_localpart); + continue; + } + None => {} + } + // Handle the highlight animation for a message. let Some(tl) = self.tl_state.as_mut() else { continue; @@ -1040,6 +1322,16 @@ impl Widget for RoomScreen { ) }) .unwrap_or((RoomDisplayName::Empty, None)); + let (app_service_enabled, app_service_room_bound) = scope + .data + .get::() + .map(|app_state| { + ( + app_state.bot_settings.enabled, + app_state.bot_settings.is_room_bound(&room_id), + ) + }) + .unwrap_or((false, false)); RoomScreenProps { room_screen_widget_uid, @@ -1047,9 +1339,22 @@ impl Widget for RoomScreen { timeline_kind: tl.kind.clone(), room_members, room_avatar_url, + app_service_enabled, + app_service_room_bound, } } else if let Some(room_name) = &self.room_name_id { // Fallback case: we have a room_name but no tl_state yet + let room_id = room_name.room_id().clone(); + let (app_service_enabled, app_service_room_bound) = scope + .data + .get::() + .map(|app_state| { + ( + app_state.bot_settings.enabled, + app_state.bot_settings.is_room_bound(&room_id), + ) + }) + .unwrap_or((false, false)); RoomScreenProps { room_screen_widget_uid, room_name_id: room_name.clone(), @@ -1059,6 +1364,8 @@ impl Widget for RoomScreen { .expect("BUG: room_name_id was set but timeline_kind was missing"), room_members: None, room_avatar_url: None, + app_service_enabled, + app_service_room_bound, } } else { // No room selected yet, skip event handling that requires room context @@ -1076,6 +1383,8 @@ impl Widget for RoomScreen { timeline_kind: TimelineKind::MainRoom { room_id }, room_members: None, room_avatar_url: None, + app_service_enabled: false, + app_service_room_bound: false, } }; let mut room_scope = Scope::with_props(&room_props); @@ -2359,6 +2668,8 @@ impl RoomScreen { MessageAction::HighlightMessage(..) => {} // This is handled by the top-level App itself. MessageAction::OpenMessageContextMenu { .. } => {} + // This is handled in RoomScreen::handle_event because it needs room-level state. + MessageAction::ToggleAppServiceActions => {} // This isn't yet handled, as we need to completely redesign it. MessageAction::ActionBarOpen { .. } => {} // This isn't yet handled, as we need to completely redesign it. @@ -2368,6 +2679,207 @@ impl RoomScreen { } } + fn set_app_service_actions_visible(&mut self, cx: &mut Cx, visible: bool) { + let was_visible = self.show_app_service_actions; + self.show_app_service_actions = visible; + self.view + .child_by_path(ids!(room_screen_wrapper.keyboard_view.app_service_panel)) + .set_visible(cx, visible); + if visible && !was_visible { + self.anchor_timeline_to_bottom(cx); + } + self.redraw(cx); + } + + fn toggle_app_service_actions(&mut self, cx: &mut Cx) { + self.set_app_service_actions_visible(cx, !self.show_app_service_actions); + } + + fn anchor_timeline_to_bottom(&self, cx: &mut Cx) { + let portal_list = self.portal_list(cx, ids!(timeline.list)); + portal_list.set_tail_range(true); + portal_list.scroll_to_end(cx); + self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)) + .update_visibility(cx, true); + } + + fn close_create_bot_modal(&self, cx: &mut Cx) { + let modal = self.view.modal(cx, ids!(create_bot_modal)); + if modal.is_open() { + modal.close(cx); + } + } + + fn close_delete_bot_modal(&self, cx: &mut Cx) { + let modal = self.view.modal(cx, ids!(delete_bot_modal)); + if modal.is_open() { + modal.close(cx); + } + } + + fn open_create_bot_modal(&mut self, cx: &mut Cx) { + let Some(room_name_id) = self.room_name_id.clone() else { + return; + }; + self.set_app_service_actions_visible(cx, false); + self.view + .create_bot_modal(cx, ids!(create_bot_modal_inner)) + .show(cx, room_name_id); + self.view.modal(cx, ids!(create_bot_modal)).open(cx); + } + + fn open_delete_bot_modal(&mut self, cx: &mut Cx) { + let Some(room_name_id) = self.room_name_id.clone() else { + return; + }; + self.set_app_service_actions_visible(cx, false); + self.view + .delete_bot_modal(cx, ids!(delete_bot_modal_inner)) + .show(cx, room_name_id); + self.view.modal(cx, ids!(delete_bot_modal)).open(cx); + } + + fn reset_app_service_ui(&mut self, cx: &mut Cx) { + self.set_app_service_actions_visible(cx, false); + self.close_create_bot_modal(cx); + self.close_delete_bot_modal(cx); + } + + fn send_botfather_command( + &mut self, + cx: &mut Cx, + app_state: &AppState, + command: &str, + success_message: &str, + ) { + let Some(timeline_kind) = self.timeline_kind.clone() else { + return; + }; + if timeline_kind.thread_root_event_id().is_some() { + enqueue_popup_notification( + "Bot commands are only supported in the main room timeline.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + + let Some(room_id) = self.room_id().cloned() else { + return; + }; + if !app_state.bot_settings.enabled { + enqueue_popup_notification( + "Enable App Service before using BotFather commands in a room.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + if !app_state.bot_settings.is_room_bound(&room_id) { + enqueue_popup_notification( + "Bind BotFather to this room before using BotFather commands.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + + submit_async_request(MatrixRequest::SendMessage { + timeline_kind, + message: RoomMessageEventContent::text_plain(command), + replied_to: None, + #[cfg(feature = "tsp")] + sign_with_tsp: false, + }); + + enqueue_popup_notification(success_message.to_string(), PopupKind::Info, Some(4.0)); + self.set_app_service_actions_visible(cx, false); + } + + fn send_create_bot_command( + &mut self, + cx: &mut Cx, + app_state: &AppState, + username: &str, + display_name: &str, + system_prompt: Option<&str>, + ) { + let Some(timeline_kind) = self.timeline_kind.clone() else { + return; + }; + if timeline_kind.thread_root_event_id().is_some() { + enqueue_popup_notification( + "Bot creation commands are only supported in the main room timeline.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + + let Some(room_id) = self.room_id().cloned() else { + return; + }; + if !app_state.bot_settings.enabled { + enqueue_popup_notification( + "Enable App Service before creating bots in a room.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + if !app_state.bot_settings.is_room_bound(&room_id) { + enqueue_popup_notification( + "Bind BotFather to this room before creating a bot.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + + let command = format_create_bot_command(username, display_name, system_prompt); + submit_async_request(MatrixRequest::SendMessage { + timeline_kind, + message: RoomMessageEventContent::text_plain(command), + replied_to: None, + #[cfg(feature = "tsp")] + sign_with_tsp: false, + }); + + enqueue_popup_notification( + format!("Sent `/createbot` for `{username}` to BotFather."), + PopupKind::Info, + Some(4.0), + ); + self.close_create_bot_modal(cx); + } + + fn send_delete_bot_command( + &mut self, + cx: &mut Cx, + app_state: &AppState, + user_id_or_localpart: &str, + ) { + let matrix_user_id = match resolve_delete_bot_user_id( + user_id_or_localpart, + current_user_id().as_deref(), + ) { + Ok(user_id) => user_id, + Err(error) => { + enqueue_popup_notification(error, PopupKind::Error, Some(4.0)); + return; + } + }; + + let command = format_delete_bot_command(matrix_user_id.as_ref()); + self.send_botfather_command( + cx, + app_state, + &command, + &format!("Sent `/deletebot` for {matrix_user_id} to BotFather."), + ); + self.close_delete_bot_modal(cx); + } + /// Jumps to the target event ID in this timeline by smooth scrolling to it. /// /// This function searches backwards from the given `max_tl_idx` in the timeline @@ -2774,6 +3286,7 @@ impl RoomScreen { return; } + self.reset_app_service_ui(cx); self.hide_timeline(); // Reset the the state of the inner loading pane. self.loading_pane(cx, ids!(loading_pane)).take_state(); @@ -2929,6 +3442,8 @@ pub struct RoomScreenProps { pub timeline_kind: TimelineKind, pub room_members: Option>>, pub room_avatar_url: Option, + pub app_service_enabled: bool, + pub app_service_room_bound: bool, } /// Actions for the room screen's tooltip. @@ -4967,6 +5482,8 @@ pub enum MessageAction { OpenThread(OwnedEventId), /// The user requested to jump to a specific event in this room. JumpToEvent(OwnedEventId), + /// The user requested toggling the in-room app service actions panel. + ToggleAppServiceActions, /// The user clicked the "delete" button on a message. #[doc(alias("delete"))] Redact { diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 0d08156fd..0d5a1520a 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -1360,6 +1360,13 @@ impl Widget for RoomsList { is_favorite: jr.tags.contains_key(&TagName::Favorite), is_low_priority: jr.tags.contains_key(&TagName::LowPriority), is_marked_unread: jr.is_marked_unread, + app_service_enabled: scope + .data + .get::() + .is_some_and(|app_state| app_state.bot_settings.enabled), + is_bot_bound: scope.data.get::().is_some_and(|app_state| { + app_state.bot_settings.is_room_bound(jr.room_name_id.room_id()) + }), }; cx.widget_action( self.widget_uid(), diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index 614017021..d581085f5 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -345,6 +345,18 @@ impl RoomInputBar { { let entered_text = mentionable_text_input.text().trim().to_string(); if !entered_text.is_empty() { + if self.try_handle_bot_shortcut(cx, &entered_text, room_screen_props) { + self.clear_replying_to(cx); + mentionable_text_input.set_text(cx, ""); + submit_async_request(MatrixRequest::SendTypingNotice { + room_id: room_screen_props.timeline_kind.room_id().clone(), + typing: false, + }); + self.enable_send_message_button(cx, false); + self.redraw(cx); + return; + } + let message = mentionable_text_input.create_message_with_mentions(&entered_text); let replied_to = self .replying_to @@ -434,6 +446,58 @@ impl RoomInputBar { } } + /// Intercepts `/bot` commands and opens the room-level app service actions UI instead + /// of sending the raw command text into the room. + fn try_handle_bot_shortcut( + &mut self, + cx: &mut Cx, + entered_text: &str, + room_screen_props: &RoomScreenProps, + ) -> bool { + if !(entered_text == "/bot" || entered_text.starts_with("/bot ")) { + return false; + } + + let popup_message = if room_screen_props + .timeline_kind + .thread_root_event_id() + .is_some() + { + Some(( + "Bot commands are only supported in the main room timeline.", + PopupKind::Warning, + )) + } else if entered_text != "/bot" { + Some(( + "Only `/bot` is supported right now. Use `/bot` and choose an action from the room panel.", + PopupKind::Info, + )) + } else if !room_screen_props.app_service_enabled { + Some(( + "Enable App Service in Settings before using /bot.", + PopupKind::Warning, + )) + } else if !room_screen_props.app_service_room_bound { + Some(( + "Bind BotFather to this room before using /bot.", + PopupKind::Warning, + )) + } else { + None + }; + + if let Some((message, kind)) = popup_message { + enqueue_popup_notification(message, kind, Some(4.0)); + } else { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + MessageAction::ToggleAppServiceActions, + ); + } + + true + } + /// Shows a preview of the given event that the user is currently replying to /// above the message input bar. /// diff --git a/src/settings/bot_settings.rs b/src/settings/bot_settings.rs new file mode 100644 index 000000000..a9aa5c5a8 --- /dev/null +++ b/src/settings/bot_settings.rs @@ -0,0 +1,214 @@ +use makepad_widgets::*; + +use crate::{ + app::{AppState, BotSettingsState}, + shared::popup_list::{PopupKind, enqueue_popup_notification}, +}; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + + mod.widgets.BotSettings = #(BotSettings::register_widget(vm)) { + width: Fill, height: Fit + flow: Down + spacing: 10 + + TitleLabel { + text: "App Service" + } + + description := Label { + width: Fill, + height: Fit + margin: Inset{left: 5, right: 8, bottom: 4} + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR) + text_style: REGULAR_TEXT {font_size: 10.5} + } + text: "Enable Matrix app service support here. Robrix stays a normal Matrix client: it binds BotFather to a room and sends the matching slash commands." + } + + enable_row := View { + width: Fill, + height: Fit + flow: Right + align: Align{y: 0.5} + spacing: 12 + margin: Inset{left: 5, bottom: 2} + + enable_label := SubsectionLabel { + width: Fit, height: Fit + margin: 0 + text: "Enable App Service" + } + + enable_button := RobrixNeutralIconButton { + width: Fit, + height: Fit + padding: Inset{top: 9, bottom: 9, left: 12, right: 14} + spacing: 0 + text: "Disabled" + } + } + + bot_details := View { + visible: false + width: Fill, height: Fit + flow: Down + spacing: 8 + + SubsectionLabel { + text: "BotFather User ID:" + } + + bot_user_id_input := RobrixTextInput { + margin: Inset{top: 2, left: 5, right: 5, bottom: 2} + width: 280, height: Fit + empty_text: "bot or @bot:server" + } + + details_hint := Label { + width: Fill, + height: Fit + margin: Inset{left: 5, right: 8} + flow: Flow.Right{wrap: true} + draw_text +: { + color: #666 + text_style: REGULAR_TEXT {font_size: 9.7} + } + text: "Use either a localpart like `bot` or a full Matrix user ID. Bind or unbind BotFather from a room via the room menu or `/bot`." + } + + save_button := RobrixPositiveIconButton { + width: Fit, + height: Fit + margin: Inset{left: 5} + padding: Inset{top: 10, bottom: 10, left: 12, right: 15} + draw_icon.svg: (ICON_CHECKMARK) + icon_walk: Walk{width: 16, height: 16} + text: "Save App Service Settings" + } + } + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct BotSettings { + #[deref] + view: View, +} + +impl Widget for BotSettings { + 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 { + self.handle_actions(cx, actions, scope); + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl BotSettings { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, scope: &mut Scope) { + let Some(app_state) = scope.data.get_mut::() else { + return; + }; + + if self.view.button(cx, ids!(enable_row.enable_button)).clicked(actions) { + app_state.bot_settings.enabled = !app_state.bot_settings.enabled; + self.sync_ui(cx, &app_state.bot_settings); + return; + } + + if self.view.button(cx, ids!(bot_details.save_button)).clicked(actions) { + let bot_user_id = self + .view + .text_input(cx, ids!(bot_details.bot_user_id_input)) + .text() + .trim() + .to_string(); + app_state.bot_settings.botfather_user_id = if bot_user_id.is_empty() { + BotSettingsState::DEFAULT_BOTFATHER_LOCALPART.to_string() + } else { + bot_user_id + }; + self.sync_ui(cx, &app_state.bot_settings); + enqueue_popup_notification( + "Saved Matrix app service settings.", + PopupKind::Success, + Some(3.0), + ); + } + } + + fn sync_enable_button(&mut self, cx: &mut Cx, enabled: bool) { + let mut enable_button = self.view.button(cx, ids!(enable_row.enable_button)); + enable_button.set_text(cx, if enabled { "Enabled" } else { "Disabled" }); + if enabled { + script_apply_eval!(cx, enable_button, { + draw_bg +: { + color: mod.widgets.COLOR_ACTIVE_PRIMARY + color_hover: mod.widgets.COLOR_ACTIVE_PRIMARY_DARKER + color_down: #x0C5DAA + border_color: mod.widgets.COLOR_ACTIVE_PRIMARY + border_color_hover: mod.widgets.COLOR_ACTIVE_PRIMARY_DARKER + border_color_down: #x0C5DAA + } + draw_text +: { + color: mod.widgets.COLOR_PRIMARY + color_hover: mod.widgets.COLOR_PRIMARY + color_down: mod.widgets.COLOR_PRIMARY + } + }); + } else { + script_apply_eval!(cx, enable_button, { + draw_bg +: { + border_color: mod.widgets.COLOR_BG_DISABLED + border_color_hover: mod.widgets.COLOR_BG_DISABLED + border_color_down: mod.widgets.COLOR_BG_DISABLED + color: mod.widgets.COLOR_SECONDARY + color_hover: #D0D0D0 + color_down: #C0C0C0 + } + draw_text +: { + color: mod.widgets.COLOR_TEXT + color_hover: mod.widgets.COLOR_TEXT + color_down: mod.widgets.COLOR_TEXT + } + }); + } + } + + fn sync_ui(&mut self, cx: &mut Cx, bot_settings: &BotSettingsState) { + self.sync_enable_button(cx, bot_settings.enabled); + self.view + .view(cx, ids!(bot_details)) + .set_visible(cx, bot_settings.enabled); + self.view + .text_input(cx, ids!(bot_details.bot_user_id_input)) + .set_text(cx, &bot_settings.botfather_user_id); + self.view + .button(cx, ids!(bot_details.save_button)) + .reset_hover(cx); + self.redraw(cx); + } + + pub fn populate(&mut self, cx: &mut Cx, bot_settings: &BotSettingsState) { + self.sync_ui(cx, bot_settings); + } +} + +impl BotSettingsRef { + pub fn populate(&self, cx: &mut Cx, bot_settings: &BotSettingsState) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.populate(cx, bot_settings); + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 579bf0849..3155e1186 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -2,8 +2,10 @@ use makepad_widgets::ScriptVm; pub mod settings_screen; pub mod account_settings; +pub mod bot_settings; pub fn script_mod(vm: &mut ScriptVm) { account_settings::script_mod(vm); + bot_settings::script_mod(vm); settings_screen::script_mod(vm); } diff --git a/src/settings/settings_screen.rs b/src/settings/settings_screen.rs index 201ae14cc..28915895d 100644 --- a/src/settings/settings_screen.rs +++ b/src/settings/settings_screen.rs @@ -1,9 +1,13 @@ use makepad_widgets::*; use crate::{ + app::BotSettingsState, home::navigation_tab_bar::{NavigationBarAction, get_own_profile}, profile::user_profile::UserProfile, - settings::account_settings::AccountSettingsWidgetExt, + settings::{ + account_settings::AccountSettingsWidgetExt, + bot_settings::BotSettingsWidgetExt, + }, }; script_mod! { @@ -61,6 +65,10 @@ script_mod! { LineH { width: 400, padding: 10, margin: Inset{top: 20, bottom: 5} } + bot_settings := BotSettings {} + + LineH { width: 400, padding: 10, margin: Inset{top: 20, bottom: 5} } + // The TSP wallet settings section. tsp_settings_screen := TspSettingsScreen {} @@ -170,7 +178,12 @@ impl Widget for SettingsScreen { impl SettingsScreen { /// Fetches the current user's profile and uses it to populate the settings screen. - pub fn populate(&mut self, cx: &mut Cx, own_profile: Option) { + pub fn populate( + &mut self, + cx: &mut Cx, + own_profile: Option, + bot_settings: &BotSettingsState, + ) { let Some(profile) = own_profile.or_else(|| get_own_profile(cx)) else { error!("Failed to get own profile for settings screen."); return; @@ -178,6 +191,9 @@ impl SettingsScreen { self.view .account_settings(cx, ids!(account_settings)) .populate(cx, profile); + self.view + .bot_settings(cx, ids!(bot_settings)) + .populate(cx, bot_settings); self.view.button(cx, ids!(close_button)).reset_hover(cx); cx.set_key_focus(self.view.area()); self.redraw(cx); @@ -186,10 +202,15 @@ impl SettingsScreen { impl SettingsScreenRef { /// See [`SettingsScreen::populate()`]. - pub fn populate(&self, cx: &mut Cx, own_profile: Option) { + pub fn populate( + &self, + cx: &mut Cx, + own_profile: Option, + bot_settings: &BotSettingsState, + ) { let Some(mut inner) = self.borrow_mut() else { return; }; - inner.populate(cx, own_profile); + inner.populate(cx, own_profile, bot_settings); } } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 99f799ae0..200c0339b 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -771,6 +771,12 @@ pub enum MatrixRequest { /// * If `false` (recommended), details will be fetched from the server. local_only: bool, }, + /// Request to bind or unbind the configured BotFather for the given room. + SetRoomBotBinding { + room_id: OwnedRoomId, + bound: bool, + bot_user_id: OwnedUserId, + }, /// Request to fetch the number of unread messages in the given room. GetNumberUnreadMessages { timeline_kind: TimelineKind }, /// Request to set the unread flag for the given room. @@ -1551,6 +1557,72 @@ async fn matrix_worker_task( }); } + MatrixRequest::SetRoomBotBinding { + room_id, + bound, + bot_user_id, + } => { + let Some(client) = get_client() else { continue }; + let _bot_binding_task = Handle::current().spawn(async move { + let Some(room) = client.get_room(&room_id) else { + let error_message = + format!("Room {room_id} was not found for the bot binding request."); + error!("{error_message}"); + enqueue_popup_notification(error_message, PopupKind::Error, None); + return; + }; + + let membership_result = if bound { + room.invite_user_by_id(&bot_user_id).await + } else { + room.kick_user(&bot_user_id, Some("Robrix app service unbind")) + .await + }; + + match membership_result { + Ok(()) => { + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: None, + }); + } + Err(error) => { + let membership_exists = room + .get_member_no_sync(&bot_user_id) + .await + .ok() + .flatten() + .is_some(); + let should_mark_bound = if bound { membership_exists } else { false }; + + if should_mark_bound != bound { + error!( + "Failed to {} BotFather {bot_user_id} for room {room_id}: {error:?}", + if bound { "invite" } else { "remove" } + ); + enqueue_popup_notification( + format!( + "Failed to {} BotFather {bot_user_id}: {error}", + if bound { "invite" } else { "remove" } + ), + PopupKind::Error, + None, + ); + return; + } + + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: Some(error.to_string()), + }); + } + } + }); + } MatrixRequest::GetNumberUnreadMessages { timeline_kind } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("Skipping get number of unread messages request for {timeline_kind}"); From fbd0c5657f61a34da40bb2c26e97b0f24eb76bd4 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: Tue, 24 Mar 2026 16:08:00 +0800 Subject: [PATCH 04/66] Fix app service cleanup issues --- src/home/room_screen.rs | 9 +++------ src/settings/bot_settings.rs | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index e70a6eca7..5874d3368 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -992,16 +992,13 @@ impl Widget for RoomScreen { } } - match action + if let MessageAction::ToggleAppServiceActions = action .as_widget_action() .widget_uid_eq(room_screen_widget_uid) .cast_ref::() { - MessageAction::ToggleAppServiceActions => { - self.toggle_app_service_actions(cx); - continue; - } - _ => {} + self.toggle_app_service_actions(cx); + continue; } match action diff --git a/src/settings/bot_settings.rs b/src/settings/bot_settings.rs index a9aa5c5a8..4ac342126 100644 --- a/src/settings/bot_settings.rs +++ b/src/settings/bot_settings.rs @@ -155,10 +155,10 @@ impl BotSettings { draw_bg +: { color: mod.widgets.COLOR_ACTIVE_PRIMARY color_hover: mod.widgets.COLOR_ACTIVE_PRIMARY_DARKER - color_down: #x0C5DAA + color_down: #x0c5daa border_color: mod.widgets.COLOR_ACTIVE_PRIMARY border_color_hover: mod.widgets.COLOR_ACTIVE_PRIMARY_DARKER - border_color_down: #x0C5DAA + border_color_down: #x0c5daa } draw_text +: { color: mod.widgets.COLOR_PRIMARY From d12cfa7d0f656036998a6ac7cac617c8762d4bbe Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:23:26 +0800 Subject: [PATCH 05/66] feat: add streaming_animation module with core data structures --- src/home/mod.rs | 1 + src/home/streaming_animation.rs | 127 ++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 src/home/streaming_animation.rs diff --git a/src/home/mod.rs b/src/home/mod.rs index 23a1de96d..90305240d 100644 --- a/src/home/mod.rs +++ b/src/home/mod.rs @@ -29,6 +29,7 @@ pub mod new_message_context_menu; pub mod room_context_menu; pub mod link_preview; pub mod room_image_viewer; +pub mod streaming_animation; pub fn script_mod(vm: &mut ScriptVm) { search_messages::script_mod(vm); diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs new file mode 100644 index 000000000..4d0fdede2 --- /dev/null +++ b/src/home/streaming_animation.rs @@ -0,0 +1,127 @@ +use std::time::Instant; +use matrix_sdk::ruma::OwnedUserId; + +/// How a streaming session was detected. +#[derive(Debug, Clone, PartialEq)] +pub enum StreamDetection { + /// Confirmed by MSC4357 live flag in event content. + Msc4357Live, + /// Detected by heuristic: prefix match + recency + not self. + Heuristic, +} + +/// Animation state for a single streaming message. +pub struct StreamingAnimState { + pub target_text: String, + pub target_char_count: usize, + pub displayed_char_count: usize, + pub displayed_byte_offset: usize, + pub chars_per_frame: f64, + pub fractional_chars: f64, + pub last_update_time: Instant, + pub animation_start_time: Instant, + pub chars_at_last_update: usize, + pub display_buffer: String, + pub sender_stopped_typing: bool, + pub sender_user_id: OwnedUserId, + pub was_at_end: bool, + pub detection: StreamDetection, +} + +impl StreamingAnimState { + pub fn new(initial_text: &str, sender_user_id: OwnedUserId, detection: StreamDetection, was_at_end: bool) -> Self { + let char_count = initial_text.chars().count(); + Self { + target_text: initial_text.to_string(), + target_char_count: char_count, + displayed_char_count: 0, + displayed_byte_offset: 0, + chars_per_frame: 1.0, + fractional_chars: 0.0, + last_update_time: Instant::now(), + animation_start_time: Instant::now(), + chars_at_last_update: 0, + display_buffer: String::with_capacity(initial_text.len() + 4), + sender_stopped_typing: false, + sender_user_id, + was_at_end, + detection, + } + } + + pub fn update_target(&mut self, new_text: &str) { + self.target_text.clear(); + self.target_text.push_str(new_text); + self.target_char_count = new_text.chars().count(); + self.chars_at_last_update = self.displayed_char_count; + self.last_update_time = Instant::now(); + let remaining = self.target_char_count.saturating_sub(self.displayed_char_count); + if remaining > 0 { + self.chars_per_frame = remaining as f64 / 60.0; + if self.chars_per_frame < 0.5 { self.chars_per_frame = 0.5; } + } + if self.display_buffer.capacity() < new_text.len() + 4 { + self.display_buffer.reserve(new_text.len() + 4 - self.display_buffer.capacity()); + } + } + + pub fn advance_displayed(&mut self, chars_to_add: usize) { + if chars_to_add == 0 || self.displayed_char_count >= self.target_char_count { return; } + let remaining = &self.target_text[self.displayed_byte_offset..]; + let mut byte_advance = 0; + let mut actual_chars = 0; + for (byte_idx, _char) in remaining.char_indices() { + if actual_chars >= chars_to_add { byte_advance = byte_idx; break; } + actual_chars += 1; + } + if actual_chars <= chars_to_add && byte_advance == 0 && !remaining.is_empty() { + byte_advance = remaining.len(); + } + self.displayed_char_count = (self.displayed_char_count + actual_chars).min(self.target_char_count); + self.displayed_byte_offset = (self.displayed_byte_offset + byte_advance).min(self.target_text.len()); + } + + pub fn tick(&mut self) -> bool { + if self.displayed_char_count >= self.target_char_count { return false; } + let gap = self.target_char_count - self.displayed_char_count; + let speed = if gap > 500 { + let jump = gap - 50; + self.advance_displayed(jump); + self.chars_per_frame + } else if gap > 200 { + self.chars_per_frame * 3.0 + } else { + self.chars_per_frame + }; + self.fractional_chars += speed; + let advance = self.fractional_chars.floor() as usize; + self.fractional_chars -= advance as f64; + if advance > 0 { self.advance_displayed(advance); true } else { false } + } + + pub fn fill_display_buffer(&mut self) { + self.display_buffer.clear(); + self.display_buffer.push_str(&self.target_text[..self.displayed_byte_offset]); + self.display_buffer.push_str(" \u{25CF}"); + } + + pub fn is_complete(&self) -> bool { + if self.displayed_char_count < self.target_char_count { return false; } + match self.detection { + StreamDetection::Msc4357Live => false, + StreamDetection::Heuristic => self.sender_stopped_typing, + } + } + + pub fn is_timed_out(&self) -> bool { + self.last_update_time.elapsed().as_secs() > 30 + } + + pub fn catch_up_to_wall_clock(&mut self) { + let elapsed = self.last_update_time.elapsed(); + let elapsed_frames = elapsed.as_secs_f64() * 60.0; + let expected = self.chars_at_last_update + (elapsed_frames * self.chars_per_frame) as usize; + let target = expected.min(self.target_char_count); + if target > self.displayed_char_count { self.advance_displayed(target - self.displayed_char_count); } + } +} From 468a4022b22d1ecf32fd46d0fabe56e6457396bf Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:30:28 +0800 Subject: [PATCH 06/66] fix: address code review issues in streaming_animation - tick(): always return true when a large-gap jump changes state (changed flag) - update_target(): clamp display pointers when new text is shorter to prevent panic in fill_display_buffer - update_target(): fix String::reserve wrong-base bug (compare capacity, reserve len deficit) - new(): capture Instant::now() once and reuse for last_update_time and animation_start_time - is_complete(): add doc comment explaining why Msc4357Live always returns false --- src/home/streaming_animation.rs | 39 ++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs index 4d0fdede2..2bf4e1027 100644 --- a/src/home/streaming_animation.rs +++ b/src/home/streaming_animation.rs @@ -31,6 +31,7 @@ pub struct StreamingAnimState { impl StreamingAnimState { pub fn new(initial_text: &str, sender_user_id: OwnedUserId, detection: StreamDetection, was_at_end: bool) -> Self { let char_count = initial_text.chars().count(); + let now = Instant::now(); Self { target_text: initial_text.to_string(), target_char_count: char_count, @@ -38,8 +39,8 @@ impl StreamingAnimState { displayed_byte_offset: 0, chars_per_frame: 1.0, fractional_chars: 0.0, - last_update_time: Instant::now(), - animation_start_time: Instant::now(), + last_update_time: now, + animation_start_time: now, chars_at_last_update: 0, display_buffer: String::with_capacity(initial_text.len() + 4), sender_stopped_typing: false, @@ -53,6 +54,17 @@ impl StreamingAnimState { self.target_text.clear(); self.target_text.push_str(new_text); self.target_char_count = new_text.chars().count(); + + // Clamp display pointers if the new text is shorter than what was already displayed. + if self.displayed_char_count > self.target_char_count { + self.displayed_char_count = self.target_char_count; + // Re-derive byte offset to stay on char boundary. + self.displayed_byte_offset = self.target_text + .char_indices() + .nth(self.target_char_count) + .map_or(self.target_text.len(), |(i, _)| i); + } + self.chars_at_last_update = self.displayed_char_count; self.last_update_time = Instant::now(); let remaining = self.target_char_count.saturating_sub(self.displayed_char_count); @@ -60,8 +72,11 @@ impl StreamingAnimState { self.chars_per_frame = remaining as f64 / 60.0; if self.chars_per_frame < 0.5 { self.chars_per_frame = 0.5; } } - if self.display_buffer.capacity() < new_text.len() + 4 { - self.display_buffer.reserve(new_text.len() + 4 - self.display_buffer.capacity()); + // Fix: reserve uses wrong base — reserve(n) guarantees capacity >= len + n, + // not capacity >= n. Compare against capacity and reserve only the deficit. + let needed = new_text.len() + 4; + if self.display_buffer.capacity() < needed { + self.display_buffer.reserve(needed - self.display_buffer.len()); } } @@ -84,19 +99,27 @@ impl StreamingAnimState { pub fn tick(&mut self) -> bool { if self.displayed_char_count >= self.target_char_count { return false; } let gap = self.target_char_count - self.displayed_char_count; + let mut changed = false; + let speed = if gap > 500 { let jump = gap - 50; self.advance_displayed(jump); + changed = true; self.chars_per_frame } else if gap > 200 { self.chars_per_frame * 3.0 } else { self.chars_per_frame }; + self.fractional_chars += speed; let advance = self.fractional_chars.floor() as usize; self.fractional_chars -= advance as f64; - if advance > 0 { self.advance_displayed(advance); true } else { false } + if advance > 0 { + self.advance_displayed(advance); + changed = true; + } + changed } pub fn fill_display_buffer(&mut self) { @@ -105,6 +128,12 @@ impl StreamingAnimState { self.display_buffer.push_str(" \u{25CF}"); } + /// Check if streaming is complete. + /// + /// For `Heuristic` detection, completes when the sender stops typing and + /// all text has been revealed. For `Msc4357Live`, this always returns `false` — + /// completion is signaled externally when the server removes the live flag, + /// which causes the entry to be removed from `streaming_messages` directly. pub fn is_complete(&self) -> bool { if self.displayed_char_count < self.target_char_count { return false; } match self.detection { From 9d74ec1025b6500c453e6046ca11520db462525c Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:32:29 +0800 Subject: [PATCH 07/66] test: add unit tests for StreamingAnimState --- src/home/streaming_animation.rs | 118 ++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs index 2bf4e1027..8c4c32710 100644 --- a/src/home/streaming_animation.rs +++ b/src/home/streaming_animation.rs @@ -154,3 +154,121 @@ impl StreamingAnimState { if target > self.displayed_char_count { self.advance_displayed(target - self.displayed_char_count); } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_state(text: &str) -> StreamingAnimState { + let user_id: OwnedUserId = "@bot:example.com".try_into().unwrap(); + StreamingAnimState::new(text, user_id, StreamDetection::Heuristic, true) + } + + #[test] + fn test_advance_ascii() { + let mut s = make_state("Hello, world!"); + s.advance_displayed(5); + assert_eq!(s.displayed_char_count, 5); + assert_eq!(&s.target_text[..s.displayed_byte_offset], "Hello"); + } + + #[test] + fn test_advance_utf8_multibyte() { + let mut s = make_state("你好世界abcd"); + s.advance_displayed(2); + assert_eq!(s.displayed_char_count, 2); + assert_eq!(&s.target_text[..s.displayed_byte_offset], "你好"); + } + + #[test] + fn test_advance_clamps_at_end() { + let mut s = make_state("abc"); + s.advance_displayed(100); + assert_eq!(s.displayed_char_count, 3); + assert_eq!(s.displayed_byte_offset, 3); + } + + #[test] + fn test_update_target_extends() { + let mut s = make_state("Hello"); + s.advance_displayed(5); + assert_eq!(s.displayed_char_count, 5); + s.update_target("Hello, world!"); + assert_eq!(s.target_char_count, 13); + assert_eq!(s.displayed_char_count, 5); + assert!(s.chars_per_frame > 0.0); + } + + #[test] + fn test_update_target_shrinks_safely() { + let mut s = make_state("Hello, world!"); + s.advance_displayed(10); + s.update_target("Hi"); + assert_eq!(s.displayed_char_count, 2); + assert_eq!(s.displayed_byte_offset, 2); + // Should not panic + s.fill_display_buffer(); + assert!(s.display_buffer.starts_with("Hi")); + } + + #[test] + fn test_tick_advances() { + let mut s = make_state("Hello, world!"); + s.chars_per_frame = 2.0; + let changed = s.tick(); + assert!(changed); + assert_eq!(s.displayed_char_count, 2); + } + + #[test] + fn test_tick_no_change_when_complete() { + let mut s = make_state("Hi"); + s.advance_displayed(2); + let changed = s.tick(); + assert!(!changed); + } + + #[test] + fn test_tick_large_gap_returns_true() { + let mut s = make_state(&"a".repeat(1000)); + s.chars_per_frame = 0.1; // very slow, fractional won't trigger + let changed = s.tick(); + assert!(changed); // should still return true due to the jump + assert!(s.displayed_char_count > 900); + } + + #[test] + fn test_fill_display_buffer() { + let mut s = make_state("Hello"); + s.advance_displayed(3); + s.fill_display_buffer(); + assert!(s.display_buffer.starts_with("Hel")); + assert!(s.display_buffer.contains('\u{25CF}') || s.display_buffer.contains('●')); + } + + #[test] + fn test_is_complete_heuristic() { + let mut s = make_state("Hi"); + s.advance_displayed(2); + assert!(!s.is_complete()); + s.sender_stopped_typing = true; + assert!(s.is_complete()); + } + + #[test] + fn test_is_complete_msc4357_never_self_completes() { + let user_id: OwnedUserId = "@bot:example.com".try_into().unwrap(); + let mut s = StreamingAnimState::new("Hi", user_id, StreamDetection::Msc4357Live, true); + s.advance_displayed(2); + s.sender_stopped_typing = true; + assert!(!s.is_complete()); // Msc4357Live never self-completes + } + + #[test] + fn test_advance_zero_is_noop() { + let mut s = make_state("Hello"); + s.advance_displayed(0); + assert_eq!(s.displayed_char_count, 0); + assert_eq!(s.displayed_byte_offset, 0); + } +} From 48cec9090def1d79e9b4648244d6ae56de6d7c90 Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:33:54 +0800 Subject: [PATCH 08/66] feat: add streaming_messages HashMap to TimelineUiState --- src/home/room_screen.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 61f20ced9..930bebd88 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -2249,6 +2249,7 @@ impl RoomScreen { pending_thread_summary_fetches: HashSet::new(), saved_state: SavedState::default(), message_highlight_animation_state: MessageHighlightAnimationState::default(), + streaming_messages: HashMap::new(), last_scrolled_index: usize::MAX, prev_first_index: None, scrolled_past_read_marker: false, @@ -2828,6 +2829,10 @@ struct TimelineUiState { /// If the animation was triggered, the state goes back to Off. message_highlight_animation_state: MessageHighlightAnimationState, + /// Active streaming animations, keyed by event ID. + /// Stores the typewriter animation state for messages being streamed by bots. + streaming_messages: HashMap, + /// The index of the timeline item that was most recently scrolled up past it. /// This is used to detect when the user has scrolled up past the second visible item (index 1) /// upwards to the first visible item (index 0), which is the top of the timeline, From ce8109aa9b13ceebf97e263d9424f7b2444d994b Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:36:06 +0800 Subject: [PATCH 09/66] feat: add NextFrame animation handler for streaming messages --- src/home/room_screen.rs | 61 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 930bebd88..f202e4caa 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -622,6 +622,9 @@ pub struct RoomScreen { #[rust] is_loaded: bool, /// Whether or not all rooms have been loaded (received from the homeserver). #[rust] all_rooms_loaded: bool, + /// NextFrame subscription for driving streaming typewriter animation. + #[rust] + streaming_next_frame: NextFrame, } impl Drop for RoomScreen { @@ -656,6 +659,64 @@ impl Widget for RoomScreen { let user_profile_sliding_pane = self.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)); let loading_pane = self.loading_pane(cx, ids!(loading_pane)); + // Streaming animation frame handler + if let Some(_ne) = self.streaming_next_frame.is_event(event) { + if let Some(tl) = self.tl_state.as_mut() { + let mut any_active = false; + let mut completed_ids = Vec::new(); + + // Build event_id → index lookup for cache invalidation + let streaming_indices: Vec<(OwnedEventId, usize)> = tl.streaming_messages.keys() + .filter_map(|eid| { + tl.items.iter().enumerate().find_map(|(idx, item)| { + if let TimelineItemKind::Event(evt) = item.kind() { + if evt.event_id().is_some_and(|id| id == eid) { + return Some((eid.clone(), idx)); + } + } + None + }) + }) + .collect(); + + for (event_id, state) in tl.streaming_messages.iter_mut() { + if state.tick() { + any_active = true; + // Invalidate draw cache so item gets re-populated + if let Some((_, idx)) = streaming_indices.iter().find(|(eid, _)| eid == event_id) { + tl.content_drawn_since_last_update.remove(*idx..*idx + 1); + } + } + + if state.is_complete() || state.is_timed_out() { + completed_ids.push(event_id.clone()); + } + } + + for id in &completed_ids { + tl.streaming_messages.remove(id); + } + + // Safety cap: max 50 streaming entries + while tl.streaming_messages.len() > 50 { + if let Some(oldest_id) = tl.streaming_messages.iter() + .min_by_key(|(_, s)| s.animation_start_time) + .map(|(id, _)| id.clone()) + { + tl.streaming_messages.remove(&oldest_id); + } + } + + if any_active || !tl.streaming_messages.is_empty() { + self.streaming_next_frame = cx.new_next_frame(); + } + + if any_active || !completed_ids.is_empty() { + self.redraw(cx); + } + } + } + // Handle actions here before processing timeline updates. // Normally (in most other widgets), the order of event handling doesn't matter much. // However, since actions may refer to a specific timeline item's index, From f8ce5660c9173b8392783b4eede450592205f54d Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:39:15 +0800 Subject: [PATCH 10/66] feat: add streaming detection in process_timeline_updates --- src/home/room_screen.rs | 76 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index f202e4caa..9e1c6b59c 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1296,6 +1296,17 @@ impl RoomScreen { self.room_name_id.as_ref().map(|r| r.room_id()) } + /// Extract the text body from a timeline item, if it's a text message. + fn extract_message_text_from_item(item: &Arc) -> Option { + let TimelineItemKind::Event(event) = item.kind() else { return None }; + let TimelineItemContent::MsgLike(msg_like) = event.content() else { return None }; + let MsgLikeKind::Message(msg) = &msg_like.kind else { return None }; + match msg.msgtype() { + MessageType::Text(text) => Some(text.body.clone()), + _ => None, + } + } + /// Processes all pending background updates to the currently-shown timeline. /// /// Redraws this RoomScreen view if any updates were applied. @@ -1440,6 +1451,71 @@ impl RoomScreen { tl.profile_drawn_since_last_update.remove(changed_indices.clone()); // log!("process_timeline_updates(): changed_indices: {changed_indices:?}, items len: {}\ncontent drawn: {:#?}\nprofile drawn: {:#?}", items.len(), tl.content_drawn_since_last_update, tl.profile_drawn_since_last_update); } + + // --- Streaming detection --- + // Clear streaming state on timeline clear + if clear_cache { + tl.streaming_messages.clear(); + } + + // Compare old and new text for changed items to detect streaming + if !new_items.is_empty() && !changed_indices.is_empty() { + let current_uid = crate::sliding_sync::current_user_id(); + + for idx in changed_indices.clone() { + let Some(old_item) = tl.items.get(idx) else { continue }; + let Some(new_item) = new_items.get(idx) else { continue }; + + let Some(old_text) = Self::extract_message_text_from_item(old_item) else { continue }; + let Some(new_text) = Self::extract_message_text_from_item(new_item) else { continue }; + if old_text == new_text { continue; } + + // Get event_id and sender from new item + let TimelineItemKind::Event(new_evt) = new_item.kind() else { continue }; + let Some(event_id) = new_evt.event_id().map(|id| id.to_owned()) else { continue }; + let sender = new_evt.sender().to_owned(); + + // If already tracking: just update target text + if let Some(state) = tl.streaming_messages.get_mut(&event_id) { + state.update_target(&new_text); + self.streaming_next_frame = cx.new_next_frame(); + continue; + } + + // Heuristic detection: prefix extension + recency + not self + let is_prefix_extension = new_text.len() > old_text.len() + && new_text.starts_with(&old_text); + + let is_recent = { + let ts = new_evt.timestamp(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + now_ms.saturating_sub(ts.0.into()) < 60_000 + }; + + let is_not_self = current_uid.as_ref() + .is_some_and(|uid| *uid != sender); + + if is_prefix_extension && is_recent && is_not_self { + use crate::home::streaming_animation::*; + let is_at_end = portal_list.is_at_end(); + tl.streaming_messages.insert( + event_id, + StreamingAnimState::new( + &new_text, + sender, + StreamDetection::Heuristic, + is_at_end, + ), + ); + self.streaming_next_frame = cx.new_next_frame(); + } + } + } + // --- End streaming detection --- + tl.items = new_items; done_loading = true; } From 5637a6f88243b7aba0ac9f7a0e5af72f593d8abc Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:42:48 +0800 Subject: [PATCH 11/66] feat: render streaming messages with typewriter animation --- src/home/room_screen.rs | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 9e1c6b59c..169ee303c 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1179,6 +1179,7 @@ impl Widget for RoomScreen { &self.pinned_events, item_drawn_status, room_screen_widget_uid, + &mut tl_state.streaming_messages, ) }, // TODO: properly implement `Poll` as a regular Message-like timeline item. @@ -3130,6 +3131,7 @@ fn populate_message_view( pinned_events: &[OwnedEventId], item_drawn_status: ItemDrawnStatus, room_screen_widget_uid: WidgetUid, + streaming_messages: &mut HashMap, ) -> (WidgetRef, ItemDrawnStatus) { let mut new_drawn_status = item_drawn_status; let ts_millis = event_tl_item.timestamp(); @@ -3174,17 +3176,30 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - let mut link_preview_ref = - item.link_preview(cx, ids!(content.link_preview_view)); - new_drawn_status.content_drawn = populate_text_message_content( - cx, - &html_or_plaintext_ref, - body, - formatted.as_ref(), - Some(&mut link_preview_ref), - Some(media_cache), - Some(link_preview_cache), - ); + + // Check if this message is being streamed + let is_streaming = event_tl_item.event_id() + .and_then(|eid| streaming_messages.get_mut(&eid.to_owned())); + + if let Some(state) = is_streaming { + // STREAMING MODE: show partial plaintext with cursor + state.fill_display_buffer(); + html_or_plaintext_ref.show_plaintext(cx, &state.display_buffer); + new_drawn_status.content_drawn = false; // force re-render + } else { + // NORMAL MODE: existing logic + let mut link_preview_ref = + item.link_preview(cx, ids!(content.link_preview_view)); + new_drawn_status.content_drawn = populate_text_message_content( + cx, + &html_or_plaintext_ref, + body, + formatted.as_ref(), + Some(&mut link_preview_ref), + Some(media_cache), + Some(link_preview_cache), + ); + } (item, false) } } From 9f1213e6a9f4e6fc15dcf7916c2bb5a8fa434dd8 Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:45:19 +0800 Subject: [PATCH 12/66] feat: enhance TypingUsers to carry user IDs for streaming detection --- src/home/room_screen.rs | 17 ++++++++++++++--- src/sliding_sync.rs | 17 ++++++++--------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 169ee303c..d405f155c 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1690,7 +1690,18 @@ impl RoomScreen { // Then, we "process" it later (by turning it into a string) after the // update loop has completed, which avoids unnecessary expensive work // if the list of typing users gets updated many times in a row. - typing_users = Some(users); + + // Update streaming sender_stopped_typing latch + { + let typing_user_ids: Vec<&OwnedUserId> = users.iter().map(|(uid, _)| uid).collect(); + for state in tl.streaming_messages.values_mut() { + if !typing_user_ids.contains(&&state.sender_user_id) { + state.sender_stopped_typing = true; + } + } + } + // Extract display names for the typing notice widget + typing_users = Some(users.iter().map(|(_, name)| name.clone()).collect::>()); } TimelineUpdate::PinnedEvents(pinned_events) => { self.pinned_events = pinned_events; @@ -2857,8 +2868,8 @@ pub enum TimelineUpdate { MediaFetched(MediaRequestParameters), /// A notice that one or more members of a this room are currently typing. TypingUsers { - /// The list of users (their displayable name) who are currently typing in this room. - users: Vec, + /// The list of users (user_id, display_name) who are currently typing in this room. + users: Vec<(OwnedUserId, String)>, }, /// The result of a pin/unpin request ([`MatrixRequest::PinEvent`]). PinResult { diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 30fccc5a2..e2c1bb34b 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1454,15 +1454,14 @@ async fn matrix_worker_task( // log!("Received typing notifications for room {room_id}: {user_ids:?}"); let mut users = Vec::with_capacity(user_ids.len()); for user_id in user_ids { - users.push( - main_timeline.room() - .get_member_no_sync(&user_id) - .await - .ok() - .flatten() - .and_then(|m| m.display_name().map(|d| d.to_owned())) - .unwrap_or_else(|| user_id.to_string()) - ); + let display_name = main_timeline.room() + .get_member_no_sync(&user_id) + .await + .ok() + .flatten() + .and_then(|m| m.display_name().map(|d| d.to_owned())) + .unwrap_or_else(|| user_id.to_string()); + users.push((user_id, display_name)); } if let Err(e) = timeline_update_sender.send(TimelineUpdate::TypingUsers { users }) { error!("Error: timeline update sender couldn't send the list of typing users: {e:?}"); From 68211cfe14bdbb6c88e5556a703f5475e54711d7 Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:47:10 +0800 Subject: [PATCH 13/66] feat: add debug profiling for streaming animation frames --- src/home/room_screen.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index d405f155c..d21e8f767 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -661,6 +661,9 @@ impl Widget for RoomScreen { // Streaming animation frame handler if let Some(_ne) = self.streaming_next_frame.is_event(event) { + #[cfg(debug_assertions)] + let frame_start = std::time::Instant::now(); + if let Some(tl) = self.tl_state.as_mut() { let mut any_active = false; let mut completed_ids = Vec::new(); @@ -715,6 +718,17 @@ impl Widget for RoomScreen { self.redraw(cx); } } + + #[cfg(debug_assertions)] + { + if let Some(tl) = self.tl_state.as_ref() { + let elapsed = frame_start.elapsed(); + if elapsed.as_millis() > 2 { + log!("Streaming animation frame took {}ms ({} active streams)", + elapsed.as_millis(), tl.streaming_messages.len()); + } + } + } } // Handle actions here before processing timeline updates. From 8c3eb89e072ba9ab239d6bf40b3dffd87dec09e6 Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:47:43 +0800 Subject: [PATCH 14/66] feat: handle streaming edge cases (edited indicator, restore) - Suppress edited indicator for actively-streaming messages to avoid misleading UI while text is still being updated - Re-request NextFrame in restore_state when streaming_messages is non-empty so the animation loop resumes after room switch - Verified streaming_messages.clear() on timeline clear is already present --- src/home/room_screen.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index d21e8f767..71a1f6a0c 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -2605,6 +2605,12 @@ impl RoomScreen { tl_state.user_power, tl_state.tombstone_info.as_ref(), ); + + // 3. If there are active streaming animations, re-request the NextFrame event + // so the animation loop resumes (it stops when the room is hidden). + if !tl_state.streaming_messages.is_empty() { + self.streaming_next_frame = cx.new_next_frame(); + } } /// Sets this `RoomScreen` widget to display the timeline for the given room. @@ -3726,12 +3732,12 @@ fn populate_message_view( item.timestamp(cx, ids!(profile.timestamp)).set_date_time(cx, dt); } - // Set the "edited" indicator if this message was edited. - if msg_like_content.as_message().is_some_and(|m| m.is_edited()) { - item.edited_indicator(cx, ids!(profile.edited_indicator)).set_latest_edit( - cx, - event_tl_item, - ); + // Suppress "edited" indicator for actively streaming messages. + let is_streaming = event_tl_item.event_id() + .is_some_and(|eid| streaming_messages.contains_key(&eid.to_owned())); + if msg_like_content.as_message().is_some_and(|m| m.is_edited()) && !is_streaming { + item.edited_indicator(cx, ids!(profile.edited_indicator)) + .set_latest_edit(cx, event_tl_item); } #[cfg(feature = "tsp")] { From 9b6da745e5998104e560da0a17e72abdbf136cf4 Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:48:32 +0800 Subject: [PATCH 15/66] fix: suppress unused variable warning in debug profiling --- src/home/room_screen.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 71a1f6a0c..2be406eb8 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -662,6 +662,7 @@ impl Widget for RoomScreen { // Streaming animation frame handler if let Some(_ne) = self.streaming_next_frame.is_event(event) { #[cfg(debug_assertions)] + #[allow(unused_variables)] let frame_start = std::time::Instant::now(); if let Some(tl) = self.tl_state.as_mut() { From 344fed7bfb2e1b021e350d9589490c61b9ff0cdc Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:53:34 +0800 Subject: [PATCH 16/66] chore: annotate dead code reserved for future use (MSC4357, was_at_end, catch_up) --- src/home/streaming_animation.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs index 8c4c32710..ec2df780b 100644 --- a/src/home/streaming_animation.rs +++ b/src/home/streaming_animation.rs @@ -5,6 +5,8 @@ use matrix_sdk::ruma::OwnedUserId; #[derive(Debug, Clone, PartialEq)] pub enum StreamDetection { /// Confirmed by MSC4357 live flag in event content. + /// Not yet implemented — placeholder for when crew-rs adds the live flag. + #[allow(dead_code)] Msc4357Live, /// Detected by heuristic: prefix match + recency + not self. Heuristic, @@ -24,6 +26,9 @@ pub struct StreamingAnimState { pub display_buffer: String, pub sender_stopped_typing: bool, pub sender_user_id: OwnedUserId, + /// Whether user was at list bottom when streaming started. + /// Reserved for auto-scroll gating in a future iteration. + #[allow(dead_code)] pub was_at_end: bool, pub detection: StreamDetection, } @@ -146,6 +151,9 @@ impl StreamingAnimState { self.last_update_time.elapsed().as_secs() > 30 } + /// Wall-clock catch-up: compute where cursor should be based on elapsed time. + /// Reserved for use after room restore or scroll-back — not yet called. + #[allow(dead_code)] pub fn catch_up_to_wall_clock(&mut self) { let elapsed = self.last_update_time.elapsed(); let elapsed_frames = elapsed.as_secs_f64() * 60.0; From c89bef1fbd39944f3c62eb3a615927f30249f776 Mon Sep 17 00:00:00 2001 From: Alvin Date: Tue, 24 Mar 2026 12:57:19 +0800 Subject: [PATCH 17/66] refactor: remove unimplemented dead code (Msc4357Live, was_at_end, catch_up_to_wall_clock) --- src/home/room_screen.rs | 2 -- src/home/streaming_animation.rs | 42 ++++----------------------------- 2 files changed, 4 insertions(+), 40 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 2be406eb8..625ef513c 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1516,14 +1516,12 @@ impl RoomScreen { if is_prefix_extension && is_recent && is_not_self { use crate::home::streaming_animation::*; - let is_at_end = portal_list.is_at_end(); tl.streaming_messages.insert( event_id, StreamingAnimState::new( &new_text, sender, StreamDetection::Heuristic, - is_at_end, ), ); self.streaming_next_frame = cx.new_next_frame(); diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs index ec2df780b..33f8a1f8c 100644 --- a/src/home/streaming_animation.rs +++ b/src/home/streaming_animation.rs @@ -4,10 +4,6 @@ use matrix_sdk::ruma::OwnedUserId; /// How a streaming session was detected. #[derive(Debug, Clone, PartialEq)] pub enum StreamDetection { - /// Confirmed by MSC4357 live flag in event content. - /// Not yet implemented — placeholder for when crew-rs adds the live flag. - #[allow(dead_code)] - Msc4357Live, /// Detected by heuristic: prefix match + recency + not self. Heuristic, } @@ -26,15 +22,11 @@ pub struct StreamingAnimState { pub display_buffer: String, pub sender_stopped_typing: bool, pub sender_user_id: OwnedUserId, - /// Whether user was at list bottom when streaming started. - /// Reserved for auto-scroll gating in a future iteration. - #[allow(dead_code)] - pub was_at_end: bool, pub detection: StreamDetection, } impl StreamingAnimState { - pub fn new(initial_text: &str, sender_user_id: OwnedUserId, detection: StreamDetection, was_at_end: bool) -> Self { + pub fn new(initial_text: &str, sender_user_id: OwnedUserId, detection: StreamDetection) -> Self { let char_count = initial_text.chars().count(); let now = Instant::now(); Self { @@ -50,7 +42,6 @@ impl StreamingAnimState { display_buffer: String::with_capacity(initial_text.len() + 4), sender_stopped_typing: false, sender_user_id, - was_at_end, detection, } } @@ -134,33 +125,16 @@ impl StreamingAnimState { } /// Check if streaming is complete. - /// - /// For `Heuristic` detection, completes when the sender stops typing and - /// all text has been revealed. For `Msc4357Live`, this always returns `false` — - /// completion is signaled externally when the server removes the live flag, - /// which causes the entry to be removed from `streaming_messages` directly. + /// Completes when the sender stops typing and all text has been revealed. pub fn is_complete(&self) -> bool { if self.displayed_char_count < self.target_char_count { return false; } - match self.detection { - StreamDetection::Msc4357Live => false, - StreamDetection::Heuristic => self.sender_stopped_typing, - } + self.sender_stopped_typing } pub fn is_timed_out(&self) -> bool { self.last_update_time.elapsed().as_secs() > 30 } - /// Wall-clock catch-up: compute where cursor should be based on elapsed time. - /// Reserved for use after room restore or scroll-back — not yet called. - #[allow(dead_code)] - pub fn catch_up_to_wall_clock(&mut self) { - let elapsed = self.last_update_time.elapsed(); - let elapsed_frames = elapsed.as_secs_f64() * 60.0; - let expected = self.chars_at_last_update + (elapsed_frames * self.chars_per_frame) as usize; - let target = expected.min(self.target_char_count); - if target > self.displayed_char_count { self.advance_displayed(target - self.displayed_char_count); } - } } #[cfg(test)] @@ -169,7 +143,7 @@ mod tests { fn make_state(text: &str) -> StreamingAnimState { let user_id: OwnedUserId = "@bot:example.com".try_into().unwrap(); - StreamingAnimState::new(text, user_id, StreamDetection::Heuristic, true) + StreamingAnimState::new(text, user_id, StreamDetection::Heuristic) } #[test] @@ -263,14 +237,6 @@ mod tests { assert!(s.is_complete()); } - #[test] - fn test_is_complete_msc4357_never_self_completes() { - let user_id: OwnedUserId = "@bot:example.com".try_into().unwrap(); - let mut s = StreamingAnimState::new("Hi", user_id, StreamDetection::Msc4357Live, true); - s.advance_displayed(2); - s.sender_stopped_typing = true; - assert!(!s.is_complete()); // Msc4357Live never self-completes - } #[test] fn test_advance_zero_is_noop() { From 4e564b58ec4c72639f4368cc1ba18896f77e0cf1 Mon Sep 17 00:00:00 2001 From: Alvin Date: Wed, 25 Mar 2026 10:36:59 +0800 Subject: [PATCH 18/66] fix: harden streaming animation against timeline edge cases - Clamp changed_indices to prevent infinite iteration on usize::MAX sentinel - Preserve visible prefix when entering streaming mode (no replay from start) - Make typing latch bidirectional so transient drops don't cause early completion - Only request NextFrame when streams have unrevealed characters - Cache timeline indices to avoid O(streams*items) per-frame scan - Use Timer for idle timeout instead of per-frame polling - Time-based animation (chars_per_second) for frame-rate independence - Simplify test names and remove duplicate tests --- src/home/room_screen.rs | 219 +++++++++++++++++++++++++++----- src/home/streaming_animation.rs | 121 ++++++++++++++---- 2 files changed, 284 insertions(+), 56 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 625ef513c..8c0827e66 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1,7 +1,7 @@ //! The `RoomScreen` widget is the UI view that displays a single room or thread's timeline //! of events (messages,state changes, etc.), along with an input bar at the bottom. -use std::{borrow::Cow, cell::RefCell, ops::{DerefMut, Range}, sync::Arc}; +use std::{borrow::Cow, cell::RefCell, ops::{DerefMut, Range}, sync::Arc, time::Duration}; use bytesize::ByteSize; use hashbrown::{HashMap, HashSet}; @@ -54,6 +54,7 @@ const MAX_ITEMS_TO_SEARCH_THROUGH: usize = 100; /// The max size (width or height) of a blurhash image to decode. const BLURHASH_IMAGE_MAX_SIZE: u32 = 500; +const STREAMING_IDLE_TIMEOUT: Duration = Duration::from_secs(30); static UNNAMED_ROOM: &str = "Unnamed Room"; @@ -62,6 +63,53 @@ const COLOR_THREAD_SUMMARY_BG: Vec4 = vec4(1.0, 0.957, 0.898, 1.0); /// #FFEACC const COLOR_THREAD_SUMMARY_BG_HOVER: Vec4 = vec4(1.0, 0.918, 0.8, 1.0); +fn timeline_item_event_id(item: &Arc) -> Option<&EventId> { + let TimelineItemKind::Event(event) = item.kind() else { + return None; + }; + event.event_id() +} + +fn bounded_changed_indices( + changed_indices: &Range, + old_len: usize, + new_len: usize, +) -> Range { + let end = changed_indices.end.min(old_len.min(new_len)); + let start = changed_indices.start.min(end); + start..end +} + +fn refresh_streaming_message_indices<'a, I>( + event_ids: I, + streaming_messages: &mut HashMap, +) +where + I: IntoIterator>, +{ + for state in streaming_messages.values_mut() { + state.timeline_index = None; + } + + for (idx, event_id) in event_ids.into_iter().enumerate() { + let Some(event_id) = event_id else { + continue; + }; + if let Some(state) = streaming_messages.get_mut(event_id) { + state.timeline_index = Some(idx); + } + } +} + +fn next_streaming_timeout_duration<'a>( + states: impl IntoIterator, + idle_timeout: Duration, +) -> Option { + states + .into_iter() + .map(|state| idle_timeout.saturating_sub(state.last_update_time.elapsed())) + .min() +} script_mod! { use mod.prelude.widgets.* @@ -625,6 +673,9 @@ pub struct RoomScreen { /// NextFrame subscription for driving streaming typewriter animation. #[rust] streaming_next_frame: NextFrame, + /// Timeout used to evict stalled streaming states without per-frame polling. + #[rust] + streaming_timeout_timer: Timer, } impl Drop for RoomScreen { @@ -667,29 +718,19 @@ impl Widget for RoomScreen { if let Some(tl) = self.tl_state.as_mut() { let mut any_active = false; + let mut needs_another_frame = false; let mut completed_ids = Vec::new(); - // Build event_id → index lookup for cache invalidation - let streaming_indices: Vec<(OwnedEventId, usize)> = tl.streaming_messages.keys() - .filter_map(|eid| { - tl.items.iter().enumerate().find_map(|(idx, item)| { - if let TimelineItemKind::Event(evt) = item.kind() { - if evt.event_id().is_some_and(|id| id == eid) { - return Some((eid.clone(), idx)); - } - } - None - }) - }) - .collect(); - for (event_id, state) in tl.streaming_messages.iter_mut() { - if state.tick() { - any_active = true; - // Invalidate draw cache so item gets re-populated - if let Some((_, idx)) = streaming_indices.iter().find(|(eid, _)| eid == event_id) { - tl.content_drawn_since_last_update.remove(*idx..*idx + 1); + if state.needs_frame() { + if state.tick() { + any_active = true; + // Invalidate draw cache so item gets re-populated + if let Some(idx) = state.timeline_index { + tl.content_drawn_since_last_update.remove(idx..idx + 1); + } } + needs_another_frame |= state.needs_frame(); } if state.is_complete() || state.is_timed_out() { @@ -708,10 +749,11 @@ impl Widget for RoomScreen { .map(|(id, _)| id.clone()) { tl.streaming_messages.remove(&oldest_id); + any_active = true; } } - if any_active || !tl.streaming_messages.is_empty() { + if needs_another_frame { self.streaming_next_frame = cx.new_next_frame(); } @@ -730,6 +772,34 @@ impl Widget for RoomScreen { } } } + + self.schedule_streaming_timeout_if_needed(cx); + } + + if self.streaming_timeout_timer.is_event(event).is_some() { + if let Some(tl) = self.tl_state.as_mut() { + let timed_out_ids: Vec = tl + .streaming_messages + .iter() + .filter_map(|(event_id, state)| { + if state.is_timed_out() || state.is_complete() { + Some(event_id.clone()) + } else { + None + } + }) + .collect(); + + for event_id in &timed_out_ids { + tl.streaming_messages.remove(event_id); + } + + if !timed_out_ids.is_empty() { + self.redraw(cx); + } + } + + self.schedule_streaming_timeout_if_needed(cx); } // Handle actions here before processing timeline updates. @@ -1323,6 +1393,19 @@ impl RoomScreen { } } + fn schedule_streaming_timeout_if_needed(&mut self, cx: &mut Cx) { + cx.stop_timer(self.streaming_timeout_timer); + self.streaming_timeout_timer = next_streaming_timeout_duration( + self.tl_state + .as_ref() + .into_iter() + .flat_map(|tl| tl.streaming_messages.values()), + STREAMING_IDLE_TIMEOUT, + ) + .map(|duration| cx.start_timeout(duration.as_secs_f64())) + .unwrap_or_else(Timer::empty); + } + /// Processes all pending background updates to the currently-shown timeline. /// /// Redraws this RoomScreen view if any updates were applied. @@ -1350,6 +1433,10 @@ impl RoomScreen { jump_to_bottom_button.update_visibility(cx, true); tl.items = initial_items; + refresh_streaming_message_indices( + tl.items.iter().map(timeline_item_event_id), + &mut tl.streaming_messages, + ); done_loading = true; } TimelineUpdate::NewItems { new_items, changed_indices, is_append, clear_cache } => { @@ -1477,10 +1564,15 @@ impl RoomScreen { // Compare old and new text for changed items to detect streaming if !new_items.is_empty() && !changed_indices.is_empty() { let current_uid = crate::sliding_sync::current_user_id(); + let changed_indices = + bounded_changed_indices(&changed_indices, tl.items.len(), new_items.len()); - for idx in changed_indices.clone() { + for idx in changed_indices { let Some(old_item) = tl.items.get(idx) else { continue }; let Some(new_item) = new_items.get(idx) else { continue }; + if timeline_item_event_id(old_item) != timeline_item_event_id(new_item) { + continue; + } let Some(old_text) = Self::extract_message_text_from_item(old_item) else { continue }; let Some(new_text) = Self::extract_message_text_from_item(new_item) else { continue }; @@ -1518,7 +1610,8 @@ impl RoomScreen { use crate::home::streaming_animation::*; tl.streaming_messages.insert( event_id, - StreamingAnimState::new( + StreamingAnimState::new_from_visible_prefix( + &old_text, &new_text, sender, StreamDetection::Heuristic, @@ -1531,6 +1624,10 @@ impl RoomScreen { // --- End streaming detection --- tl.items = new_items; + refresh_streaming_message_indices( + tl.items.iter().map(timeline_item_event_id), + &mut tl.streaming_messages, + ); done_loading = true; } TimelineUpdate::NewUnreadMessagesCount(unread_messages_count) => { @@ -1706,11 +1803,14 @@ impl RoomScreen { // Update streaming sender_stopped_typing latch { - let typing_user_ids: Vec<&OwnedUserId> = users.iter().map(|(uid, _)| uid).collect(); + let typing_user_ids: HashSet<&OwnedUserId> = + users.iter().map(|(uid, _)| uid).collect(); for state in tl.streaming_messages.values_mut() { - if !typing_user_ids.contains(&&state.sender_user_id) { - state.sender_stopped_typing = true; - } + state.sender_stopped_typing = + !typing_user_ids.contains(&state.sender_user_id); + } + if !tl.streaming_messages.is_empty() { + self.streaming_next_frame = cx.new_next_frame(); } } // Extract display names for the typing notice widget @@ -1770,6 +1870,7 @@ impl RoomScreen { } if num_updates > 0 { + self.schedule_streaming_timeout_if_needed(cx); // log!("Applied {} timeline updates for room {}, redrawing with {} items...", num_updates, tl.kind.room_id(), tl.items.len()); self.redraw(cx); } @@ -2507,6 +2608,7 @@ impl RoomScreen { // Store the tl_state for this room into this RoomScreen widget, // such that it can be accessed in future functions like event/draw handlers. self.tl_state = Some(tl_state); + self.schedule_streaming_timeout_if_needed(cx); // Now that we have restored the TimelineUiState into this RoomScreen widget, // we can proceed to processing pending background updates. @@ -2518,6 +2620,7 @@ impl RoomScreen { /// Invoke this when this RoomScreen/timeline is being hidden or no longer being shown. fn hide_timeline(&mut self) { let Some(timeline_kind) = self.timeline_kind.clone() else { return }; + self.streaming_timeout_timer = Timer::empty(); self.save_state(); @@ -2605,9 +2708,14 @@ impl RoomScreen { tl_state.tombstone_info.as_ref(), ); - // 3. If there are active streaming animations, re-request the NextFrame event - // so the animation loop resumes (it stops when the room is hidden). - if !tl_state.streaming_messages.is_empty() { + refresh_streaming_message_indices( + tl_state.items.iter().map(timeline_item_event_id), + &mut tl_state.streaming_messages, + ); + + // 3. If there are active streaming animations that can still reveal text, + // re-request the NextFrame event so the animation loop resumes. + if tl_state.streaming_messages.values().any(|state| state.needs_frame()) { self.streaming_next_frame = cx.new_next_frame(); } } @@ -4983,3 +5091,54 @@ pub fn clear_timeline_states(_cx: &mut Cx) { states.clear(); }); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::home::streaming_animation::{StreamDetection, StreamingAnimState}; + use std::time::{Duration, Instant}; + + fn make_state(text: &str) -> StreamingAnimState { + let user_id: OwnedUserId = "@bot:example.com".try_into().unwrap(); + StreamingAnimState::new(text, user_id, StreamDetection::Heuristic) + } + + #[test] + fn test_bounded_indices_clamps_max() { + let bounded = bounded_changed_indices(&(1..usize::MAX), 3, 4); + assert_eq!(bounded, 1..3); + } + + #[test] + fn test_refresh_stream_indices() { + let event_id_a: OwnedEventId = "$event-a:example.com".try_into().unwrap(); + let event_id_b: OwnedEventId = "$event-b:example.com".try_into().unwrap(); + let missing_event_id: OwnedEventId = "$missing:example.com".try_into().unwrap(); + + let mut streaming_messages = HashMap::new(); + streaming_messages.insert(event_id_a.clone(), make_state("alpha")); + streaming_messages.insert(missing_event_id.clone(), make_state("missing")); + + let event_ids = vec![None, Some(event_id_a.as_ref()), Some(event_id_b.as_ref())]; + refresh_streaming_message_indices(event_ids.into_iter(), &mut streaming_messages); + + assert_eq!(streaming_messages[&event_id_a].timeline_index, Some(1)); + assert_eq!(streaming_messages[&missing_event_id].timeline_index, None); + } + + #[test] + fn test_timeout_picks_earliest() { + let mut first = make_state("alpha"); + first.last_update_time = Instant::now() - Duration::from_secs(10); + let mut second = make_state("beta"); + second.last_update_time = Instant::now() - Duration::from_secs(29); + + let timeout = next_streaming_timeout_duration( + [&first, &second].into_iter(), + Duration::from_secs(30), + ) + .unwrap(); + + assert!(timeout <= Duration::from_secs(1)); + } +} diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs index 33f8a1f8c..4ac2fb0c1 100644 --- a/src/home/streaming_animation.rs +++ b/src/home/streaming_animation.rs @@ -1,4 +1,4 @@ -use std::time::Instant; +use std::time::{Duration, Instant}; use matrix_sdk::ruma::OwnedUserId; /// How a streaming session was detected. @@ -14,15 +14,17 @@ pub struct StreamingAnimState { pub target_char_count: usize, pub displayed_char_count: usize, pub displayed_byte_offset: usize, - pub chars_per_frame: f64, + pub chars_per_second: f64, pub fractional_chars: f64, pub last_update_time: Instant, + pub last_tick_time: Instant, pub animation_start_time: Instant, pub chars_at_last_update: usize, pub display_buffer: String, pub sender_stopped_typing: bool, pub sender_user_id: OwnedUserId, pub detection: StreamDetection, + pub timeline_index: Option, } impl StreamingAnimState { @@ -34,22 +36,41 @@ impl StreamingAnimState { target_char_count: char_count, displayed_char_count: 0, displayed_byte_offset: 0, - chars_per_frame: 1.0, + chars_per_second: 1.0, fractional_chars: 0.0, last_update_time: now, + last_tick_time: now, animation_start_time: now, chars_at_last_update: 0, display_buffer: String::with_capacity(initial_text.len() + 4), sender_stopped_typing: false, sender_user_id, detection, + timeline_index: None, } } + pub fn new_from_visible_prefix( + visible_prefix: &str, + target_text: &str, + sender_user_id: OwnedUserId, + detection: StreamDetection, + ) -> Self { + let mut state = Self::new(target_text, sender_user_id, detection); + if target_text.starts_with(visible_prefix) { + state.displayed_char_count = visible_prefix.chars().count(); + state.displayed_byte_offset = visible_prefix.len(); + state.chars_at_last_update = state.displayed_char_count; + } + state.update_speed(); + state + } + pub fn update_target(&mut self, new_text: &str) { self.target_text.clear(); self.target_text.push_str(new_text); self.target_char_count = new_text.chars().count(); + self.sender_stopped_typing = false; // Clamp display pointers if the new text is shorter than what was already displayed. if self.displayed_char_count > self.target_char_count { @@ -61,21 +82,28 @@ impl StreamingAnimState { .map_or(self.target_text.len(), |(i, _)| i); } + let now = Instant::now(); self.chars_at_last_update = self.displayed_char_count; - self.last_update_time = Instant::now(); - let remaining = self.target_char_count.saturating_sub(self.displayed_char_count); - if remaining > 0 { - self.chars_per_frame = remaining as f64 / 60.0; - if self.chars_per_frame < 0.5 { self.chars_per_frame = 0.5; } - } - // Fix: reserve uses wrong base — reserve(n) guarantees capacity >= len + n, - // not capacity >= n. Compare against capacity and reserve only the deficit. + self.last_update_time = now; + self.last_tick_time = now; + self.update_speed(); + // Reserve only the deficit (reserve(n) guarantees capacity >= len + n). let needed = new_text.len() + 4; if self.display_buffer.capacity() < needed { self.display_buffer.reserve(needed - self.display_buffer.len()); } } + fn update_speed(&mut self) { + let remaining = self.target_char_count.saturating_sub(self.displayed_char_count); + if remaining > 0 { + self.chars_per_second = remaining as f64; + if self.chars_per_second < 30.0 { + self.chars_per_second = 30.0; + } + } + } + pub fn advance_displayed(&mut self, chars_to_add: usize) { if chars_to_add == 0 || self.displayed_char_count >= self.target_char_count { return; } let remaining = &self.target_text[self.displayed_byte_offset..]; @@ -93,6 +121,13 @@ impl StreamingAnimState { } pub fn tick(&mut self) -> bool { + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.last_tick_time); + self.last_tick_time = now; + self.tick_with_elapsed(elapsed) + } + + pub fn tick_with_elapsed(&mut self, elapsed: Duration) -> bool { if self.displayed_char_count >= self.target_char_count { return false; } let gap = self.target_char_count - self.displayed_char_count; let mut changed = false; @@ -101,14 +136,14 @@ impl StreamingAnimState { let jump = gap - 50; self.advance_displayed(jump); changed = true; - self.chars_per_frame + self.chars_per_second } else if gap > 200 { - self.chars_per_frame * 3.0 + self.chars_per_second * 3.0 } else { - self.chars_per_frame + self.chars_per_second }; - self.fractional_chars += speed; + self.fractional_chars += speed * elapsed.as_secs_f64(); let advance = self.fractional_chars.floor() as usize; self.fractional_chars -= advance as f64; if advance > 0 { @@ -124,10 +159,14 @@ impl StreamingAnimState { self.display_buffer.push_str(" \u{25CF}"); } + pub fn needs_frame(&self) -> bool { + self.displayed_char_count < self.target_char_count + } + /// Check if streaming is complete. /// Completes when the sender stops typing and all text has been revealed. pub fn is_complete(&self) -> bool { - if self.displayed_char_count < self.target_char_count { return false; } + if self.needs_frame() { return false; } self.sender_stopped_typing } @@ -178,7 +217,7 @@ mod tests { s.update_target("Hello, world!"); assert_eq!(s.target_char_count, 13); assert_eq!(s.displayed_char_count, 5); - assert!(s.chars_per_frame > 0.0); + assert!(s.chars_per_second > 0.0); } #[test] @@ -196,26 +235,24 @@ mod tests { #[test] fn test_tick_advances() { let mut s = make_state("Hello, world!"); - s.chars_per_frame = 2.0; - let changed = s.tick(); + s.chars_per_second = 4.0; + let changed = s.tick_with_elapsed(Duration::from_millis(500)); assert!(changed); assert_eq!(s.displayed_char_count, 2); } #[test] - fn test_tick_no_change_when_complete() { + fn test_tick_complete_noop() { let mut s = make_state("Hi"); s.advance_displayed(2); - let changed = s.tick(); - assert!(!changed); + assert!(!s.tick_with_elapsed(Duration::from_secs(1))); } #[test] - fn test_tick_large_gap_returns_true() { + fn test_tick_large_gap() { let mut s = make_state(&"a".repeat(1000)); - s.chars_per_frame = 0.1; // very slow, fractional won't trigger - let changed = s.tick(); - assert!(changed); // should still return true due to the jump + s.chars_per_second = 0.1; + assert!(s.tick_with_elapsed(Duration::from_secs(1))); assert!(s.displayed_char_count > 900); } @@ -237,6 +274,38 @@ mod tests { assert!(s.is_complete()); } + #[test] + fn test_visible_prefix_preserved() { + let user_id: OwnedUserId = "@bot:example.com".try_into().unwrap(); + let s = StreamingAnimState::new_from_visible_prefix( + "Hello", "Hello, world!", user_id, StreamDetection::Heuristic, + ); + assert_eq!(s.displayed_char_count, 5); + assert_eq!(&s.target_text[..s.displayed_byte_offset], "Hello"); + } + + #[test] + fn test_update_target_resets_typing() { + let mut s = make_state("Hello"); + s.sender_stopped_typing = true; + s.update_target("Hello, world!"); + assert!(!s.sender_stopped_typing); + } + + #[test] + fn test_needs_frame_when_caught_up() { + let mut s = make_state("Hello"); + s.advance_displayed(5); + assert!(!s.needs_frame()); + } + + #[test] + fn test_tick_zero_elapsed() { + let mut s = make_state("Hello"); + s.chars_per_second = 20.0; + assert!(!s.tick_with_elapsed(Duration::ZERO)); + assert_eq!(s.displayed_char_count, 0); + } #[test] fn test_advance_zero_is_noop() { From e7e7f27430c4033230b58d7fa56c4fd4a646034e 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: Wed, 25 Mar 2026 11:07:31 +0800 Subject: [PATCH 19/66] Recover from invalid Matrix sessions Reset runtime state and return to the login loop when session tokens expire, and remove persisted Matrix stores alongside stale session files. --- src/persistence/matrix_state.rs | 55 +++- src/sliding_sync.rs | 436 ++++++++++++++++++-------------- 2 files changed, 304 insertions(+), 187 deletions(-) diff --git a/src/persistence/matrix_state.rs b/src/persistence/matrix_state.rs index f984a2f3b..7e51cb35a 100644 --- a/src/persistence/matrix_state.rs +++ b/src/persistence/matrix_state.rs @@ -1,8 +1,8 @@ //! Handles app persistence by saving and restoring client session data to/from the filesystem. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, bail}; -use makepad_widgets::{log, Cx}; +use makepad_widgets::{log, warning, Cx}; use matrix_sdk::{ authentication::matrix::MatrixSession, ruma::{OwnedUserId, UserId}, @@ -255,6 +255,26 @@ pub async fn delete_latest_user_id() -> anyhow::Result { } } +async fn delete_path_if_exists(path: &Path) -> anyhow::Result { + let metadata = match tokio::fs::metadata(path).await { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(anyhow!("Failed to inspect path {}: {e}", path.display())), + }; + + if metadata.is_dir() { + tokio::fs::remove_dir_all(path) + .await + .map_err(|e| anyhow!("Failed to remove directory {}: {e}", path.display()))?; + } else { + tokio::fs::remove_file(path) + .await + .map_err(|e| anyhow!("Failed to remove file {}: {e}", path.display()))?; + } + + Ok(true) +} + /// Remove the persisted Matrix session file for the given user if it exists. /// /// Returns: @@ -265,6 +285,37 @@ pub async fn delete_session(user_id: &UserId) -> anyhow::Result { let session_file = session_file_path(user_id); if session_file.exists() { + let persisted_db_path = match tokio::fs::read_to_string(&session_file).await { + Ok(serialized_session) => { + match serde_json::from_str::(&serialized_session) { + Ok(session) => Some(session.client_session.db_path), + Err(e) => { + warning!( + "Failed to parse session file {} before cleanup: {e}", + session_file.display() + ); + None + } + } + } + Err(e) => { + warning!( + "Failed to read session file {} before cleanup: {e}", + session_file.display() + ); + None + } + }; + + if let Some(db_path) = persisted_db_path { + if let Err(e) = delete_path_if_exists(&db_path).await { + warning!( + "Failed to remove persisted Matrix store {} for {user_id}: {e}", + db_path.display() + ); + } + } + tokio::fs::remove_file(&session_file) .await .map_err(|e| anyhow::anyhow!("Failed to remove session file {session_file:?}: {e}")) diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 99f799ae0..eaeddca78 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -325,6 +325,34 @@ async fn clear_persisted_session(user_id: Option<&UserId>) { } } +enum SessionResetAction { + Reauthenticate { message: String }, +} + +async fn reset_runtime_state_for_relogin() { + let sync_service = { SYNC_SERVICE.lock().unwrap().take() }; + if let Some(sync_service) = sync_service { + sync_service.stop().await; + } + + CLIENT.lock().unwrap().take(); + DEFAULT_SSO_CLIENT.lock().unwrap().take(); + IGNORED_USERS.lock().unwrap().clear(); + ALL_JOINED_ROOMS.lock().unwrap().clear(); + + let on_clear_appstate = Arc::new(Notify::new()); + Cx::post_action(LogoutAction::ClearAppState { + on_clear_appstate: on_clear_appstate.clone(), + }); + + if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()) + .await + .is_err() + { + warning!("Timed out waiting for UI-side app state cleanup during re-login reset"); + } +} + fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { matches!( error.client_api_error_kind(), @@ -2850,221 +2878,253 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // which causes the loop to wait for the user to submit a new manual login request. let mut initial_client_opt = new_login_opt; - let (client, sync_service, logged_in_user_id) = 'login_loop: loop { - let (client, _sync_token, validate_session) = match initial_client_opt.take() { - Some(login) => login, - None => loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => match login(&cli, login_request).await { - Ok((client, sync_token)) => break (client, sync_token, false), - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), - }); + loop { + let (client, sync_service, logged_in_user_id) = 'login_loop: loop { + let (client, _sync_token, validate_session) = match initial_client_opt.take() { + Some(login) => login, + None => loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => match login(&cli, login_request).await { + Ok((client, sync_token)) => break (client, sync_token, false), + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: format!("Login failed: {e}"), + }); + } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + let err = String::from( + "Please restart Robrix.\n\nUnable to listen for login requests.", + ); + Cx::post_action(LoginAction::LoginFailure(err.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err }); + return; } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - let err = String::from( - "Please restart Robrix.\n\nUnable to listen for login requests.", - ); - Cx::post_action(LoginAction::LoginFailure(err.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err }); - return; } - } - }, - }; + }, + }; - if validate_session { - match client.whoami().await { - Ok(_) => {} - Err(e) if is_invalid_token_http_error(&e) => { - clear_persisted_session(client.user_id()).await; - let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; - Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err_msg.to_string(), - }); - continue 'login_loop; - } - Err(e) => { - warning!( - "Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}" - ); + if validate_session { + match client.whoami().await { + Ok(_) => {} + Err(e) if is_invalid_token_http_error(&e) => { + clear_persisted_session(client.user_id()).await; + let err_msg = + "Your login token is no longer valid.\n\nPlease log in again."; + Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.to_string(), + }); + continue 'login_loop; + } + Err(e) => { + warning!( + "Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}" + ); + } } } - } - - // Deallocate the default SSO client after a successful login. - if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { - let _ = client_opt.take(); - } - let logged_in_user_id: OwnedUserId = client - .user_id() - .expect("BUG: Client::user_id() returned None after successful login!") - .to_owned(); - let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); - enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + // Deallocate the default SSO client after a successful login. + if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { + let _ = client_opt.take(); + } - // Store this active client in our global Client state so that other tasks can access it. - if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { - error!( - "BUG: unexpectedly replaced an existing client when initializing the matrix client." - ); - } + let logged_in_user_id: OwnedUserId = client + .user_id() + .expect("BUG: Client::user_id() returned None after successful login!") + .to_owned(); + let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + + // Store this active client in our global Client state so that other tasks can access it. + if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { + error!( + "BUG: unexpectedly replaced an existing client when initializing the matrix client." + ); + } - // Listen for changes to our verification status and incoming verification requests. - add_verification_event_handlers_and_sync_client(client.clone()); + // Listen for changes to our verification status and incoming verification requests. + add_verification_event_handlers_and_sync_client(client.clone()); - // Listen for updates to the ignored user list. - handle_ignore_user_list_subscriber(client.clone()); + // Listen for updates to the ignored user list. + handle_ignore_user_list_subscriber(client.clone()); - Cx::post_action(LoginAction::Status { - title: "Connecting".into(), - status: "Setting up sync service...".into(), - }); - let sync_service = match SyncService::builder(client.clone()) - .with_offline_mode() - .build() - .await - { - Ok(ss) => ss, - Err(e) => { - error!("Failed to create SyncService: {e:?}"); - let err_msg = if is_invalid_token_error(&e) { - "Your login token is no longer valid.\n\nPlease log in again.".to_string() - } else { - format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") - }; - if is_invalid_token_error(&e) { - clear_persisted_session(client.user_id()).await; + Cx::post_action(LoginAction::Status { + title: "Connecting".into(), + status: "Setting up sync service...".into(), + }); + let sync_service = match SyncService::builder(client.clone()) + .with_offline_mode() + .build() + .await + { + Ok(ss) => ss, + Err(e) => { + error!("Failed to create SyncService: {e:?}"); + let err_msg = if is_invalid_token_error(&e) { + "Your login token is no longer valid.\n\nPlease log in again.".to_string() + } else { + format!( + "Please restart Robrix.\n\nFailed to create Matrix sync service: {e}." + ) + }; + if is_invalid_token_error(&e) { + clear_persisted_session(client.user_id()).await; + } + Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); + // Clear the stored client so the next login attempt doesn't trigger the + // "unexpectedly replaced an existing client" warning. + let _ = CLIENT.lock().unwrap().take(); + continue 'login_loop; } - Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); - enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); - // Clear the stored client so the next login attempt doesn't trigger the - // "unexpectedly replaced an existing client" warning. - let _ = CLIENT.lock().unwrap().take(); - continue 'login_loop; - } - }; - - // Listen for session changes, e.g., when the access token becomes invalid. - handle_session_changes(client.clone()); + }; - break 'login_loop (client, sync_service, logged_in_user_id); - }; + break 'login_loop (client, sync_service, logged_in_user_id); + }; - // Signal login success now that SyncService::build() has already succeeded (inside - // 'login_loop), which is the only step that can fail with an invalid/expired token. - // Doing this before sync_service.start() lets the UI transition to the home screen - // without waiting for the sync loop to begin. - Cx::post_action(LoginAction::LoginSuccess); + let (session_reset_sender, mut session_reset_receiver) = + tokio::sync::mpsc::unbounded_channel::(); + let session_change_handler_task = + handle_session_changes(client.clone(), session_reset_sender); - // Attempt to load the previously-saved app state. - handle_load_app_state(logged_in_user_id.to_owned()); - handle_sync_indicator_subscriber(&sync_service); - handle_sync_service_state_subscriber(sync_service.state()); - sync_service.start().await; + // Signal login success now that SyncService::build() has already succeeded (inside + // 'login_loop), which is the only step that can fail with an invalid/expired token. + // Doing this before sync_service.start() lets the UI transition to the home screen + // without waiting for the sync loop to begin. + Cx::post_action(LoginAction::LoginSuccess); - let room_list_service = sync_service.room_list_service(); + // Attempt to load the previously-saved app state. + handle_load_app_state(logged_in_user_id.to_owned()); + handle_sync_indicator_subscriber(&sync_service); + handle_sync_service_state_subscriber(sync_service.state()); + sync_service.start().await; - if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { - error!( - "BUG: unexpectedly replaced an existing sync service when initializing the matrix client." - ); - } + let room_list_service = sync_service.room_list_service(); - let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); - let mut space_service_task = rt.spawn(space_service_loop(client)); + if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { + error!( + "BUG: unexpectedly replaced an existing sync service when initializing the matrix client." + ); + } - // Now, this task becomes an infinite loop that monitors the state of the - // three core matrix-related background tasks that we just spawned above. - #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. - loop { - tokio::select! { - result = &mut matrix_worker_task_handle => { - match result { - Ok(Ok(())) => { - // Check if this is due to logout - if is_logout_in_progress() { - log!("matrix worker task ended due to logout"); - } else { - error!("BUG: matrix worker task ended unexpectedly!"); + let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); + let mut space_service_task = rt.spawn(space_service_loop(client)); + + // Now, this task becomes an infinite loop that monitors the + // matrix/background tasks for the currently-authenticated session. + #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. + let reauth_message = loop { + tokio::select! { + session_reset = session_reset_receiver.recv() => { + match session_reset { + Some(SessionResetAction::Reauthenticate { message }) => { + break message; + } + None => { + warning!("Session reset receiver closed unexpectedly."); + continue; } } - Ok(Err(e)) => { - // Check if this is due to logout - if is_logout_in_progress() { - log!("matrix worker task ended with error due to logout: {e:?}"); - } else { - error!("Error: matrix worker task ended:\n\t{e:?}"); + } + result = &mut matrix_worker_task_handle => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + // Check if this is due to logout + if is_logout_in_progress() { + log!("matrix worker task ended due to logout"); + } else { + error!("BUG: matrix worker task ended unexpectedly!"); + } + } + Ok(Err(e)) => { + // Check if this is due to logout + if is_logout_in_progress() { + log!("matrix worker task ended with error due to logout: {e:?}"); + } else { + error!("Error: matrix worker task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Rooms list update error: {e}"), + PopupKind::Error, + None, + ); + } + }, + Err(e) => { + error!("BUG: failed to join matrix worker task: {e:?}"); + } + } + return; + } + result = &mut room_list_service_task => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + error!("BUG: room list service loop task ended unexpectedly!"); + } + Ok(Err(e)) => { + error!("Error: room list service loop task ended:\n\t{e:?}"); rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { status: e.to_string(), }); enqueue_popup_notification( - format!("Rooms list update error: {e}"), + format!("Room list service error: {e}"), PopupKind::Error, None, ); + }, + Err(e) => { + error!("BUG: failed to join room list service loop task: {e:?}"); } - }, - Err(e) => { - error!("BUG: failed to join matrix worker task: {e:?}"); - } - } - break; - } - result = &mut room_list_service_task => { - match result { - Ok(Ok(())) => { - error!("BUG: room list service loop task ended unexpectedly!"); - } - Ok(Err(e)) => { - error!("Error: room list service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Room list service error: {e}"), - PopupKind::Error, - None, - ); - }, - Err(e) => { - error!("BUG: failed to join room list service loop task: {e:?}"); } + return; } - break; - } - result = &mut space_service_task => { - match result { - Ok(Ok(())) => { - error!("BUG: space service loop task ended unexpectedly!"); - } - Ok(Err(e)) => { - error!("Error: space service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Space service error: {e}"), - PopupKind::Error, - None, - ); - }, - Err(e) => { - error!("BUG: failed to join space service loop task: {e:?}"); + result = &mut space_service_task => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + error!("BUG: space service loop task ended unexpectedly!"); + } + Ok(Err(e)) => { + error!("Error: space service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Space service error: {e}"), + PopupKind::Error, + None, + ); + }, + Err(e) => { + error!("BUG: failed to join space service loop task: {e:?}"); + } } + return; } - break; } - } + }; + + session_change_handler_task.abort(); + room_list_service_task.abort(); + space_service_task.abort(); + + reset_runtime_state_for_relogin().await; + Cx::post_action(LoginAction::LoginFailure(reauth_message.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: reauth_message, + }); + initial_client_opt = None; } } @@ -3919,7 +3979,10 @@ fn is_invalid_token_error(e: &sync_service::Error) -> bool { /// When the homeserver rejects the access token with a 401 `M_UNKNOWN_TOKEN` error /// (e.g., the token was revoked or expired), this emits a [`LoginAction::LoginFailure`] /// so the user is prompted to log in again. -fn handle_session_changes(client: Client) { +fn handle_session_changes( + client: Client, + session_reset_sender: UnboundedSender, +) -> JoinHandle<()> { let mut receiver = client.subscribe_to_session_changes(); Handle::current().spawn(async move { loop { @@ -3932,7 +3995,10 @@ fn handle_session_changes(client: Client) { }; error!("Session token is no longer valid (soft_logout: {soft_logout}). Prompting re-login."); clear_persisted_session(client.user_id()).await; - Cx::post_action(LoginAction::LoginFailure(msg.to_string())); + let _ = session_reset_sender.send(SessionResetAction::Reauthenticate { + message: msg.to_string(), + }); + break; } Ok(SessionChange::TokensRefreshed) => {} Err(broadcast::error::RecvError::Lagged(n)) => { @@ -3943,7 +4009,7 @@ fn handle_session_changes(client: Client) { } } } - }); + }) } fn handle_sync_service_state_subscriber(mut subscriber: Subscriber) { From 58957870ab3c907539ca2cd7692c0f795bfb477e 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: Wed, 25 Mar 2026 11:23:25 +0800 Subject: [PATCH 20/66] Simplify login screen layout Make the login form use a narrower centered layout and remove the extra outer login panel background so the desktop presentation matches the mobile-style card better. --- src/login/login_screen.rs | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index dfa25fee7..6b23121d8 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -49,19 +49,17 @@ script_mod! { width: Fill, height: Fill, align: Align{x: 0.5, y: 0.5} - show_bg: true, + show_bg: false, draw_bg +: { - color: COLOR_SECONDARY - // color: COLOR_PRIMARY // TODO: once Makepad supports `Fill {max: 375}`, change this back to COLOR_PRIMARY + color: COLOR_TRANSPARENT } ScrollYView { width: Fill, height: Fill, // Note: *do NOT* vertically center this, it will break scrolling. align: Align{x: 0.5} - show_bg: true, - draw_bg.color: (COLOR_SECONDARY) - // draw_bg.color: (COLOR_PRIMARY) // TODO: once Makepad supports `Fill {max: 375}`, change this back to COLOR_PRIMARY + show_bg: false, + draw_bg.color: (COLOR_TRANSPARENT) // allow the view to be scrollable but hide the actual scroll bar scroll_bars: { @@ -71,26 +69,20 @@ script_mod! { } } - RoundedView { - margin: Inset{top: 40, bottom: 40} - width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` + View { + margin: Inset{top: 32, bottom: 32} + width: 360 height: Fit align: Align{x: 0.5, y: 0.5} flow: Overlay, - show_bg: true, - draw_bg +: { - color: (COLOR_SECONDARY) - border_radius: 6.0 - } - View { - width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` + width: 360 height: Fit flow: Down align: Align{x: 0.5, y: 0.5} - padding: Inset{top: 30, bottom: 30} - margin: Inset{top: 40, bottom: 40} + padding: Inset{top: 34, bottom: 30, left: 24, right: 24} + margin: Inset{top: 12, bottom: 12} spacing: 15.0 logo_image := Image { From 9d674e71bb55020c4be8d44b7423f22c20d10752 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: Wed, 25 Mar 2026 11:45:29 +0800 Subject: [PATCH 21/66] Remove login panel container styling Keep the login screen layout intact while replacing the extra outer login panel with a plain view so the screen no longer draws a separate card container. --- src/login/login_screen.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index 6b23121d8..db7ad8457 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -49,17 +49,19 @@ script_mod! { width: Fill, height: Fill, align: Align{x: 0.5, y: 0.5} - show_bg: false, + show_bg: true, draw_bg +: { - color: COLOR_TRANSPARENT + color: COLOR_SECONDARY + // color: COLOR_PRIMARY // TODO: once Makepad supports `Fill {max: 375}`, change this back to COLOR_PRIMARY } ScrollYView { width: Fill, height: Fill, // Note: *do NOT* vertically center this, it will break scrolling. align: Align{x: 0.5} - show_bg: false, - draw_bg.color: (COLOR_TRANSPARENT) + show_bg: true, + draw_bg.color: (COLOR_SECONDARY) + // draw_bg.color: (COLOR_PRIMARY) // TODO: once Makepad supports `Fill {max: 375}`, change this back to COLOR_PRIMARY // allow the view to be scrollable but hide the actual scroll bar scroll_bars: { @@ -70,19 +72,19 @@ script_mod! { } View { - margin: Inset{top: 32, bottom: 32} - width: 360 + margin: Inset{top: 40, bottom: 40} + width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit align: Align{x: 0.5, y: 0.5} flow: Overlay, View { - width: 360 + width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit flow: Down align: Align{x: 0.5, y: 0.5} - padding: Inset{top: 34, bottom: 30, left: 24, right: 24} - margin: Inset{top: 12, bottom: 12} + padding: Inset{top: 30, bottom: 30} + margin: Inset{top: 40, bottom: 40} spacing: 15.0 logo_image := Image { From 69c788685f4358cd09472b09cffbfdc009f3de63 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: Wed, 25 Mar 2026 14:25:29 +0800 Subject: [PATCH 22/66] Reback fmt --- src/login/login_screen.rs | 172 +-- src/persistence/matrix_state.rs | 58 +- src/sliding_sync.rs | 1977 ++++++++++++------------------- 3 files changed, 824 insertions(+), 1383 deletions(-) diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index db7ad8457..bf4ec59c2 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -3,9 +3,7 @@ use std::ops::Not; use makepad_widgets::*; use url::Url; -use crate::sliding_sync::{ - submit_async_request, LoginByPassword, LoginRequest, MatrixRequest, RegisterAccount, -}; +use crate::sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest, RegisterAccount}; use super::login_status_modal::{LoginStatusModalAction, LoginStatusModalWidgetExt}; @@ -62,7 +60,7 @@ script_mod! { show_bg: true, draw_bg.color: (COLOR_SECONDARY) // draw_bg.color: (COLOR_PRIMARY) // TODO: once Makepad supports `Fill {max: 375}`, change this back to COLOR_PRIMARY - + // allow the view to be scrollable but hide the actual scroll bar scroll_bars: { scroll_bar_y: { @@ -169,7 +167,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } } } - + login_button := RobrixIconButton { width: 275, @@ -261,7 +259,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } } - + mode_toggle_button := RobrixIconButton { width: Fit, height: Fit padding: Inset{left: 15, right: 15, top: 10, bottom: 10} @@ -288,76 +286,45 @@ script_mod! { #[derive(Script, ScriptHook, Widget)] pub struct LoginScreen { - #[source] - source: ScriptObjectRef, - #[deref] - view: View, + #[source] source: ScriptObjectRef, + #[deref] view: View, /// Whether the screen is showing the in-app sign-up flow. - #[rust] - signup_mode: bool, + #[rust] signup_mode: bool, /// Boolean to indicate if the SSO login process is still in flight - #[rust] - sso_pending: bool, + #[rust] sso_pending: bool, /// The URL to redirect to after logging in with SSO. - #[rust] - sso_redirect_url: Option, + #[rust] sso_redirect_url: Option, /// The most recent login failure message shown to the user. - #[rust] - last_failure_message_shown: Option, + #[rust] last_failure_message_shown: Option, } impl LoginScreen { fn set_signup_mode(&mut self, cx: &mut Cx, signup_mode: bool) { self.signup_mode = signup_mode; - self.view - .view(cx, ids!(confirm_password_wrapper)) - .set_visible(cx, signup_mode); - self.view - .view(cx, ids!(login_only_view)) - .set_visible(cx, !signup_mode); - self.view.label(cx, ids!(title)).set_text( - cx, - if signup_mode { - "Create your Robrix account" - } else { - "Login to Robrix" - }, + self.view.view(cx, ids!(confirm_password_wrapper)).set_visible(cx, signup_mode); + self.view.view(cx, ids!(login_only_view)).set_visible(cx, !signup_mode); + self.view.label(cx, ids!(title)).set_text(cx, + if signup_mode { "Create your Robrix account" } else { "Login to Robrix" } ); - self.view.button(cx, ids!(login_button)).set_text( - cx, - if signup_mode { - "Create account" - } else { - "Login" - }, + self.view.button(cx, ids!(login_button)).set_text(cx, + if signup_mode { "Create account" } else { "Login" } ); - self.view.label(cx, ids!(account_prompt_label)).set_text( - cx, - if signup_mode { - "Already have an account?" - } else { - "Don't have an account?" - }, + self.view.label(cx, ids!(account_prompt_label)).set_text(cx, + if signup_mode { "Already have an account?" } else { "Don't have an account?" } ); - self.view.button(cx, ids!(mode_toggle_button)).set_text( - cx, - if signup_mode { - "Back to login" - } else { - "Sign up here" - }, + self.view.button(cx, ids!(mode_toggle_button)).set_text(cx, + if signup_mode { "Back to login" } else { "Sign up here" } ); if !signup_mode { - self.view - .text_input(cx, ids!(confirm_password_input)) - .set_text(cx, ""); + self.view.text_input(cx, ids!(confirm_password_input)).set_text(cx, ""); } self.redraw(cx); } } + impl Widget for LoginScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); @@ -379,9 +346,7 @@ impl MatchEvent for LoginScreen { let homeserver_input = self.view.text_input(cx, ids!(homeserver_input)); let login_status_modal = self.view.modal(cx, ids!(login_status_modal)); - let login_status_modal_inner = self - .view - .login_status_modal(cx, ids!(login_status_modal_inner)); + let login_status_modal_inner = self.view.login_status_modal(cx, ids!(login_status_modal_inner)); if mode_toggle_button.clicked(actions) { self.set_signup_mode(cx, !self.signup_mode); @@ -407,21 +372,15 @@ impl MatchEvent for LoginScreen { login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); } else if self.signup_mode && password != confirm_password { login_status_modal_inner.set_title(cx, "Passwords do not match"); - login_status_modal_inner.set_status( - cx, - "Please enter the same password in both password fields.", - ); + login_status_modal_inner.set_status(cx, "Please enter the same password in both password fields."); login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); } else { self.last_failure_message_shown = None; - login_status_modal_inner.set_title( - cx, - if self.signup_mode { - "Creating account..." - } else { - "Logging in..." - }, - ); + login_status_modal_inner.set_title(cx, if self.signup_mode { + "Creating account..." + } else { + "Logging in..." + }); login_status_modal_inner.set_status( cx, if self.signup_mode { @@ -430,9 +389,7 @@ impl MatchEvent for LoginScreen { "Waiting for a login response..." }, ); - login_status_modal_inner - .button_ref(cx) - .set_text(cx, "Cancel"); + login_status_modal_inner.button_ref(cx).set_text(cx, "Cancel"); submit_async_request(MatrixRequest::Login(if self.signup_mode { LoginRequest::Register(RegisterAccount { user_id, @@ -450,14 +407,14 @@ impl MatchEvent for LoginScreen { login_status_modal.open(cx); self.redraw(cx); } - + let provider_brands = ["apple", "facebook", "github", "gitlab", "google", "twitter"]; let button_set: &[&[LiveId]] = ids_array!( - apple_button, - facebook_button, - github_button, - gitlab_button, - google_button, + apple_button, + facebook_button, + github_button, + gitlab_button, + google_button, twitter_button ); for action in actions { @@ -467,17 +424,16 @@ impl MatchEvent for LoginScreen { // Handle login-related actions received from background async tasks. match action.downcast_ref() { - Some(LoginAction::CliAutoLogin { - user_id, - homeserver, - }) => { + Some(LoginAction::CliAutoLogin { user_id, homeserver }) => { self.last_failure_message_shown = None; user_id_input.set_text(cx, user_id); password_input.set_text(cx, ""); homeserver_input.set_text(cx, homeserver.as_deref().unwrap_or_default()); login_status_modal_inner.set_title(cx, "Logging in via CLI..."); - login_status_modal_inner - .set_status(cx, &format!("Auto-logging in as user {user_id}...")); + login_status_modal_inner.set_status( + cx, + &format!("Auto-logging in as user {user_id}...") + ); let login_status_modal_button = login_status_modal_inner.button_ref(cx); login_status_modal_button.set_text(cx, "Cancel"); login_status_modal_button.set_enabled(cx, false); // Login cancel not yet supported @@ -510,14 +466,11 @@ impl MatchEvent for LoginScreen { continue; } self.last_failure_message_shown = Some(error.clone()); - login_status_modal_inner.set_title( - cx, - if self.signup_mode { - "Account Creation Failed." - } else { - "Login Failed." - }, - ); + login_status_modal_inner.set_title(cx, if self.signup_mode { + "Account Creation Failed." + } else { + "Login Failed." + }); login_status_modal_inner.set_status(cx, error); let login_status_modal_button = login_status_modal_inner.button_ref(cx); login_status_modal_button.set_text(cx, "Okay"); @@ -527,15 +480,9 @@ impl MatchEvent for LoginScreen { } Some(LoginAction::SsoPending(pending)) => { let mask = if *pending { 1.0 } else { 0.0 }; - let cursor = if *pending { - MouseCursor::NotAllowed - } else { - MouseCursor::Hand - }; + let cursor = if *pending { MouseCursor::NotAllowed } else { MouseCursor::Hand }; for view_ref in self.view_set(cx, button_set).iter() { - let Some(mut view_mut) = view_ref.borrow_mut() else { - continue; - }; + let Some(mut view_mut) = view_ref.borrow_mut() else { continue }; let mut image = view_mut.image(cx, ids!(image)); script_apply_eval!(cx, image, { draw_bg.mask: #(mask) @@ -548,7 +495,7 @@ impl MatchEvent for LoginScreen { Some(LoginAction::SsoSetRedirectUrl(url)) => { self.sso_redirect_url = Some(url.to_string()); } - _ => {} + _ => { } } } @@ -557,10 +504,7 @@ impl MatchEvent for LoginScreen { let login_status_modal_button = login_status_modal_inner.button_ref(cx); if login_status_modal_button.clicked(actions) { let request_id = id!(SSO_CANCEL_BUTTON); - let request = HttpRequest::new( - format!("{}/?login_token=", sso_redirect_url), - HttpMethod::GET, - ); + let request = HttpRequest::new(format!("{}/?login_token=",sso_redirect_url), HttpMethod::GET); cx.http_request(request_id, request); self.sso_redirect_url = None; } @@ -569,14 +513,15 @@ impl MatchEvent for LoginScreen { // Handle any of the SSO login buttons being clicked for (view_ref, brand) in self.view_set(cx, button_set).iter().zip(&provider_brands) { if view_ref.finger_up(actions).is_some() && !self.sso_pending { - submit_async_request(MatrixRequest::SpawnSSOServer { - identity_provider_id: format!("oidc-{}", brand), + submit_async_request(MatrixRequest::SpawnSSOServer{ + identity_provider_id: format!("oidc-{}",brand), brand: brand.to_string(), - homeserver_url: homeserver_input.text(), + homeserver_url: homeserver_input.text() }); } } } + } /// Actions sent to or from the login screen. @@ -587,7 +532,10 @@ pub enum LoginAction { /// A negative response from the backend Matrix task to the login screen. LoginFailure(String), /// A login-related status message to display to the user. - Status { title: String, status: String }, + Status { + title: String, + status: String, + }, /// The given login info was specified on the command line (CLI), /// and the login process is underway. CliAutoLogin { @@ -598,9 +546,9 @@ pub enum LoginAction { /// informing it that the SSO login process is either still in flight (`true`) or has finished (`false`). /// /// Note that an inner value of `false` does *not* imply that the login request has - /// successfully finished. + /// successfully finished. /// The login screen can use this to prevent the user from submitting - /// additional SSO login requests while a previous request is in flight. + /// additional SSO login requests while a previous request is in flight. SsoPending(bool), /// Set the SSO redirect URL in the LoginScreen. /// diff --git a/src/persistence/matrix_state.rs b/src/persistence/matrix_state.rs index 7e51cb35a..f7d09bdf8 100644 --- a/src/persistence/matrix_state.rs +++ b/src/persistence/matrix_state.rs @@ -6,11 +6,15 @@ use makepad_widgets::{log, warning, Cx}; use matrix_sdk::{ authentication::matrix::MatrixSession, ruma::{OwnedUserId, UserId}, - sliding_sync, Client, + sliding_sync, + Client, }; use serde::{Deserialize, Serialize}; -use crate::{app_data_dir, login::login_screen::LoginAction}; +use crate::{ + app_data_dir, + login::login_screen::LoginAction, +}; /// The data needed to re-build a client. #[derive(Clone, Serialize, Deserialize)] @@ -53,11 +57,11 @@ pub struct FullSessionPersisted { pub sync_token: Option, /// The sliding sync version to use for this client session. - /// + /// /// This determines the sync protocol used by the Matrix client: /// - `Native`: Uses the server's native sliding sync implementation for efficient syncing /// - `None`: Falls back to standard Matrix sync (without sliding sync optimizations) - /// + /// /// The value is restored and applied to the client via `client.set_sliding_sync_version()` /// when rebuilding the session from persistent storage. #[serde(default)] @@ -89,7 +93,9 @@ impl From for SlidingSyncVersion { } fn user_id_to_file_name(user_id: &UserId) -> String { - user_id.as_str().replace(":", "_").replace("@", "") + user_id.as_str() + .replace(":", "_") + .replace("@", "") } /// Returns the path to the persistent state directory for the given user. @@ -108,12 +114,14 @@ const LATEST_USER_ID_FILE_NAME: &str = "latest_user_id.txt"; /// Returns the user ID of the most recently-logged in user session. pub async fn most_recent_user_id() -> Option { - tokio::fs::read_to_string(app_data_dir().join(LATEST_USER_ID_FILE_NAME)) - .await - .ok()? - .trim() - .try_into() - .ok() + tokio::fs::read_to_string( + app_data_dir().join(LATEST_USER_ID_FILE_NAME) + ) + .await + .ok()? + .trim() + .try_into() + .ok() } /// Save which user was the most recently logged in. @@ -121,17 +129,17 @@ async fn save_latest_user_id(user_id: &UserId) -> anyhow::Result<()> { tokio::fs::write( app_data_dir().join(LATEST_USER_ID_FILE_NAME), user_id.as_str(), - ) - .await?; + ).await?; Ok(()) } + /// Restores the given user's previous session from the filesystem. /// /// If no User ID is specified, the ID of the most recently-logged in user /// is retrieved from the filesystem. pub async fn restore_session( - user_id: Option, + user_id: Option ) -> anyhow::Result<(Client, Option)> { let user_id = if let Some(user_id) = user_id { Some(user_id) @@ -157,12 +165,8 @@ pub async fn restore_session( // The session was serialized as JSON in a file. let serialized_session = tokio::fs::read_to_string(session_file).await?; - let FullSessionPersisted { - client_session, - user_session, - sync_token, - sliding_sync_version, - } = serde_json::from_str(&serialized_session)?; + let FullSessionPersisted { client_session, user_session, sync_token, sliding_sync_version } = + serde_json::from_str(&serialized_session)?; let status_str = format!( "Loaded session file for:\n{user_id}\n\nTrying to connect to homeserver...\n{}", @@ -185,10 +189,7 @@ pub async fn restore_session( .await?; let sliding_sync_version = sliding_sync_version.into(); client.set_sliding_sync_version(sliding_sync_version); - let status_str = format!( - "Authenticating previous login session for {}...", - user_session.meta.user_id - ); + let status_str = format!("Authenticating previous login session for {}...", user_session.meta.user_id); log!("{status_str}"); Cx::post_action(LoginAction::Status { title: "Authenticating session".into(), @@ -225,7 +226,7 @@ pub async fn save_session( client_session, user_session, sync_token: None, - sliding_sync_version, + sliding_sync_version })?; if let Some(parent) = session_file.parent() { tokio::fs::create_dir_all(parent).await?; @@ -237,17 +238,16 @@ pub async fn save_session( } /// Remove the LATEST_USER_ID_FILE_NAME file if it exists -/// +/// /// Returns: /// - Ok(true) if file was found and deleted /// - Ok(false) if file didn't exist /// - Err if deletion failed pub async fn delete_latest_user_id() -> anyhow::Result { let last_login_path = app_data_dir().join(LATEST_USER_ID_FILE_NAME); - + if last_login_path.exists() { - tokio::fs::remove_file(&last_login_path) - .await + tokio::fs::remove_file(&last_login_path).await .map_err(|e| anyhow::anyhow!("Failed to remove latest user file: {e}")) .map(|_| true) } else { diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index eaeddca78..d50dd7842 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -8,110 +8,43 @@ use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ - config::RequestConfig, - encryption::EncryptionSettings, - event_handler::EventHandlerDropGuard, - media::MediaRequestParameters, - room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, - ruma::{ - api::{ - Direction, - client::{ - account::register::v3::Request as RegistrationRequest, - error::ErrorKind, - profile::{AvatarUrl, DisplayName}, - receipt::create_receipt::v3::ReceiptType, - uiaa::{AuthData, AuthType, Dummy}, - }, - }, - events::{ + config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ + api::{Direction, client::{ + account::register::v3::Request as RegistrationRequest, + error::ErrorKind, + profile::{AvatarUrl, DisplayName}, + receipt::create_receipt::v3::ReceiptType, + uiaa::{AuthData, AuthType, Dummy}, + }}, events::{ relation::RelationType, - room::{message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource}, - MessageLikeEventType, StateEventType, - }, - matrix_uri::MatrixId, - EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, - OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, uint, - }, - sliding_sync::VersionBuilder, - Client, ClientBuildError, Error, OwnedServerName, Room, RoomDisplayName, RoomMemberships, - RoomState, SessionChange, SuccessorRoom, + room::{ + message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource + }, MessageLikeEventType, StateEventType + }, matrix_uri::MatrixId, EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, uint + }, sliding_sync::VersionBuilder, Client, ClientBuildError, Error, OwnedServerName, Room, RoomDisplayName, RoomMemberships, RoomState, SessionChange, SuccessorRoom }; use matrix_sdk_ui::{ - RoomListService, Timeline, encryption_sync_service, - room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator, filters}, - sync_service::{self, SyncService}, - timeline::{ - LatestEventValue, RoomExt, TimelineEventItemId, TimelineFocus, TimelineItem, - TimelineReadReceiptTracking, TimelineDetails, - }, + RoomListService, Timeline, encryption_sync_service, room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator, filters}, sync_service::{self, SyncService}, timeline::{LatestEventValue, RoomExt, TimelineEventItemId, TimelineFocus, TimelineItem, TimelineReadReceiptTracking, TimelineDetails} }; use robius_open::Uri; use ruma::{OwnedRoomOrAliasId, RoomId, events::tag::Tags}; use tokio::{ runtime::Handle, - sync::{ - broadcast, - mpsc::{Sender, UnboundedReceiver, UnboundedSender}, - watch, Notify, - }, - task::JoinHandle, - time::error::Elapsed, + sync::{broadcast, mpsc::{Sender, UnboundedReceiver, UnboundedSender}, watch, Notify}, task::JoinHandle, time::error::Elapsed, }; use url::Url; -use std::{ - borrow::Cow, - cmp::{max, min}, - future::Future, - hash::{BuildHasherDefault, DefaultHasher}, - iter::Peekable, - ops::{Deref, DerefMut, Not}, - path::Path, - sync::{Arc, LazyLock, Mutex}, - time::Duration, -}; +use std::{borrow::Cow, cmp::{max, min}, future::Future, hash::{BuildHasherDefault, DefaultHasher}, iter::Peekable, ops::{Deref, DerefMut, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; use std::io; use hashbrown::{HashMap, HashSet}; use crate::{ - app::AppStateAction, - app_data_dir, - avatar_cache::AvatarUpdate, - event_preview::{ - BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item, - }, - home::{ - add_room::KnockResultAction, - invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, - link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, - room_screen::{InviteResultAction, TimelineUpdate}, - rooms_list::{ - self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, - enqueue_rooms_list_update, - }, - rooms_list_header::RoomsListHeaderAction, - tombstone_footer::SuccessorRoomDetails, - }, - login::login_screen::LoginAction, - logout::{ - logout_confirm_modal::LogoutAction, - logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}, - }, - media_cache::{MediaCacheEntry, MediaCacheEntryRef}, - persistence::{self, ClientSessionPersisted, load_app_state}, - profile::{ + app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ + add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails + }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, - }, - room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, - shared::{ - avatar::AvatarState, - html_or_plaintext::MatrixLinkPillState, - jump_to_bottom_button::UnreadMessageCount, - popup_list::{PopupKind, enqueue_popup_notification}, - }, - space_service_sync::space_service_loop, - utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, - verification::add_verification_event_handlers_and_sync_client, + }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ + avatar::AvatarState, html_or_plaintext::MatrixLinkPillState, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification} + }, space_service_sync::space_service_loop, utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, verification::add_verification_event_handlers_and_sync_client }; #[derive(Parser, Default)] @@ -159,8 +92,7 @@ impl From for Cli { Self { user_id: login.user_id.trim().to_owned(), password: login.password, - homeserver: login - .homeserver + homeserver: login.homeserver .map(|homeserver| homeserver.trim().to_owned()) .filter(|homeserver| !homeserver.is_empty()), proxy: None, @@ -175,8 +107,7 @@ impl From for Cli { Self { user_id: registration.user_id.trim().to_owned(), password: registration.password, - homeserver: registration - .homeserver + homeserver: registration.homeserver .map(|homeserver| homeserver.trim().to_owned()) .filter(|homeserver| !homeserver.is_empty()), proxy: None, @@ -197,8 +128,7 @@ async fn finalize_authenticated_client( fallback_user_id: &str, ) -> Result<(Client, Option)> { if client.matrix_auth().logged_in() { - let logged_in_user_id = client - .user_id() + let logged_in_user_id = client.user_id() .map(ToString::to_string) .unwrap_or_else(|| fallback_user_id.to_owned()); log!("Logged in successfully."); @@ -215,9 +145,7 @@ async fn finalize_authenticated_client( "Authentication succeeded for {fallback_user_id}, but the homeserver did not return a login session." ); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err_msg.clone(), - }); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } } @@ -233,8 +161,7 @@ fn registration_localpart(user_id: &str) -> Result { } let localpart = trimmed.trim_start_matches('@'); - if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) - { + if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) { bail!("Please enter a valid username or full Matrix user ID."); } @@ -341,14 +268,9 @@ async fn reset_runtime_state_for_relogin() { ALL_JOINED_ROOMS.lock().unwrap().clear(); let on_clear_appstate = Arc::new(Notify::new()); - Cx::post_action(LogoutAction::ClearAppState { - on_clear_appstate: on_clear_appstate.clone(), - }); + Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); - if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()) - .await - .is_err() - { + if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()).await.is_err() { warning!("Timed out waiting for UI-side app state cleanup during re-login reset"); } } @@ -360,6 +282,7 @@ fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { ) } + /// Build a new client. async fn build_client( cli: &Cli, @@ -382,13 +305,11 @@ async fn build_client( }; let inferred_homeserver = infer_homeserver_from_user_id(&cli.user_id); - let homeserver_url = cli - .homeserver - .as_deref() + let homeserver_url = cli.homeserver.as_deref() .filter(|homeserver| !homeserver.trim().is_empty()) .or(inferred_homeserver.as_deref()) .unwrap_or("https://matrix-client.matrix.org/"); - // .unwrap_or("https://matrix.org/"); + // .unwrap_or("https://matrix.org/"); let mut builder = Client::builder() .server_name_or_homeserver_url(homeserver_url) @@ -416,11 +337,13 @@ async fn build_client( // Use a 60 second timeout for all requests to the homeserver. // Yes, this is a long timeout, but the standard matrix homeserver is often very slow. - builder = - builder.request_config(RequestConfig::new().timeout(std::time::Duration::from_secs(60))); + builder = builder.request_config( + RequestConfig::new() + .timeout(std::time::Duration::from_secs(60)) + ); let client = builder.build().await?; - let homeserver_url = client.homeserver().to_string(); + let homeserver_url = client.homeserver().to_string(); Ok(( client, ClientSessionPersisted { @@ -436,7 +359,10 @@ async fn build_client( /// This function is used by the login screen to log in to the Matrix server. /// /// Upon success, this function returns the logged-in client and an optional sync token. -async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option)> { +async fn login( + cli: &Cli, + login_request: LoginRequest, +) -> Result<(Client, Option)> { match login_request { LoginRequest::LoginByCli | LoginRequest::LoginByPassword(_) => { let cli = if let LoginRequest::LoginByPassword(login_by_password) = login_request { @@ -459,9 +385,7 @@ async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option if !client.matrix_auth().logged_in() { let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err_msg.clone(), - }); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } finalize_authenticated_client(client, client_session, &cli.user_id).await @@ -517,9 +441,7 @@ async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option register_result.user_id, ); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err_msg.clone(), - }); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } @@ -539,6 +461,7 @@ async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option } } + /// Which direction to paginate in. /// /// * `Forwards` will retrieve later events (towards the end of the timeline), @@ -607,6 +530,7 @@ pub type OnLinkPreviewFetchedFn = fn( Option>, ); + /// Actions emitted in response to a [`MatrixRequest::GenerateMatrixLink`]. #[derive(Clone, Debug)] pub enum MatrixLinkAction { @@ -637,7 +561,9 @@ pub enum DirectMessageRoomAction { room_name_id: RoomNameId, }, /// A direct message room didn't exist, and we didn't attempt to create a new one. - DidNotExist { user_profile: UserProfile }, + DidNotExist { + user_profile: UserProfile, + }, /// A direct message room didn't exist, but we successfully created a new one. NewlyCreated { user_profile: UserProfile, @@ -672,10 +598,7 @@ impl TimelineKind { pub fn thread_root_event_id(&self) -> Option<&OwnedEventId> { match self { TimelineKind::MainRoom { .. } => None, - TimelineKind::Thread { - thread_root_event_id, - .. - } => Some(thread_root_event_id), + TimelineKind::Thread { thread_root_event_id, .. } => Some(thread_root_event_id), } } } @@ -683,10 +606,7 @@ impl std::fmt::Display for TimelineKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TimelineKind::MainRoom { room_id } => write!(f, "MainRoom({})", room_id), - TimelineKind::Thread { - room_id, - thread_root_event_id, - } => { + TimelineKind::Thread { room_id, thread_root_event_id } => { write!(f, "Thread({}, {})", room_id, thread_root_event_id) } } @@ -699,7 +619,9 @@ pub enum MatrixRequest { /// Request from the login screen to log in with the given credentials. Login(LoginRequest), /// Request to logout. - Logout { is_desktop: bool }, + Logout { + is_desktop: bool, + }, /// Request to paginate the older (or newer) events of a room or thread timeline. PaginateTimeline { timeline_kind: TimelineKind, @@ -731,7 +653,9 @@ pub enum MatrixRequest { /// /// Even though it operates on a room itself, this accepts a `TimelineKind` /// in order to be able to send the fetched room member list to a specific timeline UI. - SyncRoomMemberList { timeline_kind: TimelineKind }, + SyncRoomMemberList { + timeline_kind: TimelineKind, + }, /// Request to create a thread timeline focused on the given thread root event in the given room. CreateThreadTimeline { room_id: OwnedRoomId, @@ -750,9 +674,13 @@ pub enum MatrixRequest { user_id: OwnedUserId, }, /// Request to join the given room. - JoinRoom { room_id: OwnedRoomId }, + JoinRoom { + room_id: OwnedRoomId, + }, /// Request to leave the given room. - LeaveRoom { room_id: OwnedRoomId }, + LeaveRoom { + room_id: OwnedRoomId, + }, /// Request to get the actual list of members in a room. /// /// This returns the list of members that can be displayed in the UI. @@ -775,7 +703,9 @@ pub enum MatrixRequest { via: Vec, }, /// Request to fetch the full details (the room preview) of a tombstoned room. - GetSuccessorRoomDetails { tombstoned_room_id: OwnedRoomId }, + GetSuccessorRoomDetails { + tombstoned_room_id: OwnedRoomId, + }, /// Request to create or open a direct message room with the given user. /// /// If there is no existing DM room with the given user, this will create a new DM room @@ -800,7 +730,9 @@ pub enum MatrixRequest { local_only: bool, }, /// Request to fetch the number of unread messages in the given room. - GetNumberUnreadMessages { timeline_kind: TimelineKind }, + GetNumberUnreadMessages { + timeline_kind: TimelineKind, + }, /// Request to set the unread flag for the given room. SetUnreadFlag { room_id: OwnedRoomId, @@ -885,12 +817,15 @@ pub enum MatrixRequest { /// This request does not return a response or notify the UI thread, and /// furthermore, there is no need to send a follow-up request to stop typing /// (though you certainly can do so). - SendTypingNotice { room_id: OwnedRoomId, typing: bool }, + SendTypingNotice { + room_id: OwnedRoomId, + typing: bool, + }, /// Spawn an async task to login to the given Matrix homeserver using the given SSO identity provider ID. /// /// While an SSO request is in flight, the login screen will temporarily prevent the user /// from submitting another redundant request, until this request has succeeded or failed. - SpawnSSOServer { + SpawnSSOServer{ brand: String, homeserver_url: String, identity_provider_id: String, @@ -935,7 +870,9 @@ pub enum MatrixRequest { /// /// Even though it operates on a room itself, this accepts a `TimelineKind` /// in order to be able to send the fetched room member list to a specific timeline UI. - GetRoomPowerLevels { timeline_kind: TimelineKind }, + GetRoomPowerLevels { + timeline_kind: TimelineKind, + }, /// Toggles the given reaction to the given event in the given room. ToggleReaction { timeline_kind: TimelineKind, @@ -961,7 +898,7 @@ pub enum MatrixRequest { /// The MatrixLinkPillInfo::Loaded variant is sent back to the main UI thread via. GetMatrixRoomLinkPillInfo { matrix_id: MatrixId, - via: Vec, + via: Vec }, /// Request to fetch URL preview from the Matrix homeserver. GetUrlPreview { @@ -975,19 +912,19 @@ pub enum MatrixRequest { /// Submits a request to the worker thread to be executed asynchronously. pub fn submit_async_request(req: MatrixRequest) { if let Some(sender) = REQUEST_SENDER.lock().unwrap().as_ref() { - sender - .send(req) + sender.send(req) .expect("BUG: matrix worker task receiver has died!"); } } /// Details of a login request that get submitted within [`MatrixRequest::Login`]. -pub enum LoginRequest { +pub enum LoginRequest{ LoginByPassword(LoginByPassword), Register(RegisterAccount), LoginBySSOSuccess(Client, ClientSessionPersisted), LoginByCli, HomeserverLoginTypesQuery(String), + } /// Information needed to log in to a Matrix homeserver. pub struct LoginByPassword { @@ -1004,6 +941,7 @@ pub struct RegisterAccount { pub homeserver: Option, } + /// The entry point for the worker task that runs Matrix-related operations. /// /// All this task does is wait for [`MatrixRequests`] from the main UI thread @@ -1014,8 +952,7 @@ async fn matrix_worker_task( ) -> Result<()> { log!("Started matrix_worker_task."); // The async tasks that are spawned to subscribe to changes in our own user's read receipts for each timeline. - let mut subscribers_own_user_read_receipts: HashMap> = - HashMap::new(); + let mut subscribers_own_user_read_receipts: HashMap> = HashMap::new(); // The async tasks that are spawned to subscribe to changes in the pinned events for each room. let mut subscribers_pinned_events: HashMap> = HashMap::new(); @@ -1025,7 +962,7 @@ async fn matrix_worker_task( if let Err(e) = login_sender.send(login_request).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to login worker task.", + "BUG: failed to send login request to login worker task." ))); } } @@ -1038,7 +975,7 @@ async fn matrix_worker_task( match logout_with_state_machine(is_desktop).await { Ok(()) => { log!("Logout completed successfully via state machine"); - } + }, Err(e) => { error!("Logout failed: {e:?}"); } @@ -1046,11 +983,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::PaginateTimeline { - timeline_kind, - num_events, - direction, - } => { + MatrixRequest::PaginateTimeline {timeline_kind, num_events, direction} => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("Skipping pagination request for unknown {timeline_kind}"); continue; @@ -1092,11 +1025,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::EditMessage { - timeline_kind, - timeline_event_item_id, - edited_content, - } => { + MatrixRequest::EditMessage { timeline_kind, timeline_event_item_id, edited_content } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for edit request"); continue; @@ -1118,10 +1047,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::FetchDetailsForEvent { - timeline_kind, - event_id, - } => { + MatrixRequest::FetchDetailsForEvent { timeline_kind, event_id } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for fetch details for event request"); continue; @@ -1138,10 +1064,7 @@ async fn matrix_worker_task( // error!("Error fetching details for event {event_id} in {timeline_kind}: {_e:?}"); } } - if sender - .send(TimelineUpdate::EventDetailsFetched { event_id, result }) - .is_err() - { + if sender.send(TimelineUpdate::EventDetailsFetched { event_id, result }).is_err() { error!("Failed to send fetched event details to UI for {timeline_kind}"); } SignalToUI::set_ui_signal(); @@ -1195,27 +1118,17 @@ async fn matrix_worker_task( }); } - MatrixRequest::CreateThreadTimeline { - room_id, - thread_root_event_id, - } => { + MatrixRequest::CreateThreadTimeline { room_id, thread_root_event_id } => { let main_room_timeline = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - error!( - "BUG: room info not found for create thread timeline request, room {room_id}" - ); + error!("BUG: room info not found for create thread timeline request, room {room_id}"); continue; }; - if room_info - .thread_timelines - .contains_key(&thread_root_event_id) - { + if room_info.thread_timelines.contains_key(&thread_root_event_id) { continue; } - let newly_pending = room_info - .pending_thread_timelines - .insert(thread_root_event_id.clone()); + let newly_pending = room_info.pending_thread_timelines.insert(thread_root_event_id.clone()); if !newly_pending { continue; } @@ -1287,18 +1200,11 @@ async fn matrix_worker_task( }); } - MatrixRequest::Knock { - room_or_alias_id, - reason, - server_names, - } => { + MatrixRequest::Knock { room_or_alias_id, reason, server_names } => { let Some(client) = get_client() else { continue }; let _knock_room_task = Handle::current().spawn(async move { log!("Sending request to knock on room {room_or_alias_id}..."); - match client - .knock(room_or_alias_id.clone(), reason, server_names) - .await - { + match client.knock(room_or_alias_id.clone(), reason, server_names).await { Ok(room) => { let _ = room.display_name().await; // populate this room's display name cache Cx::post_action(KnockResultAction::Knocked { @@ -1322,21 +1228,23 @@ async fn matrix_worker_task( if let Some(room) = client.get_room(&room_id) { log!("Sending request to invite user {user_id} to room {room_id}..."); match room.invite_user_by_id(&user_id).await { - Ok(_) => Cx::post_action(InviteResultAction::Sent { room_id, user_id }), + Ok(_) => Cx::post_action(InviteResultAction::Sent { + room_id, + user_id, + }), Err(error) => Cx::post_action(InviteResultAction::Failed { room_id, user_id, error, }), } - } else { + } + else { error!("Room/Space not found for invite user request {room_id}, {user_id}"); Cx::post_action(InviteResultAction::Failed { room_id, user_id, - error: matrix_sdk::Error::UnknownError( - "Room/Space not found in client's known list.".into(), - ), + error: matrix_sdk::Error::UnknownError("Room/Space not found in client's known list.".into()), }) } }); @@ -1357,7 +1265,8 @@ async fn matrix_worker_task( JoinRoomResultAction::Failed { room_id, error: e } } } - } else { + } + else { match client.join_room_by_id(&room_id).await { Ok(_room) => { log!("Successfully joined new unknown room {room_id}."); @@ -1392,20 +1301,14 @@ async fn matrix_worker_task( error!("BUG: client could not get room with ID {room_id}"); LeaveRoomResultAction::Failed { room_id, - error: matrix_sdk::Error::UnknownError( - "Client couldn't locate room to leave it.".into(), - ), + error: matrix_sdk::Error::UnknownError("Client couldn't locate room to leave it.".into()), } }; Cx::post_action(result_action); }); } - MatrixRequest::GetRoomMembers { - timeline_kind, - memberships, - local_only, - } => { + MatrixRequest::GetRoomMembers { timeline_kind, memberships, local_only } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for get room members request"); continue; @@ -1414,9 +1317,7 @@ async fn matrix_worker_task( let _get_members_task = Handle::current().spawn(async move { let send_update = |members: Vec, source: &str| { log!("{} {} members for {timeline_kind}", source, members.len()); - sender - .send(TimelineUpdate::RoomMembersListFetched { members }) - .unwrap(); + sender.send(TimelineUpdate::RoomMembersListFetched { members }).unwrap(); SignalToUI::set_ui_signal(); }; @@ -1433,10 +1334,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetRoomPreview { - room_or_alias_id, - via, - } => { + MatrixRequest::GetRoomPreview { room_or_alias_id, via } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { let res = fetch_room_preview_with_avatar(&client, &room_or_alias_id, via).await; @@ -1449,9 +1347,7 @@ async fn matrix_worker_task( let (sender, successor_room) = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&tombstoned_room_id) else { - error!( - "BUG: tombstoned room {tombstoned_room_id} info not found for get successor room details request" - ); + error!("BUG: tombstoned room {tombstoned_room_id} info not found for get successor room details request"); continue; }; ( @@ -1467,10 +1363,7 @@ async fn matrix_worker_task( ); } - MatrixRequest::OpenOrCreateDirectMessage { - user_profile, - allow_create, - } => { + MatrixRequest::OpenOrCreateDirectMessage { user_profile, allow_create } => { let Some(client) = get_client() else { continue }; let _create_dm_task = Handle::current().spawn(async move { if let Some(room) = client.get_dm_room(&user_profile.user_id) { @@ -1493,7 +1386,7 @@ async fn matrix_worker_task( user_profile, room_name_id: RoomNameId::from_room(&room).await, }); - } + }, Err(error) => { error!("Failed to create DM with {user_profile:?}: {error}"); Cx::post_action(DirectMessageRoomAction::FailedToCreate { @@ -1505,11 +1398,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetUserProfile { - user_id, - room_id, - local_only, - } => { + MatrixRequest::GetUserProfile { user_id, room_id, local_only } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { // log!("Sending get user profile request: user: {user_id}, \ @@ -1603,10 +1492,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::SetUnreadFlag { - room_id, - mark_as_unread, - } => { + MatrixRequest::SetUnreadFlag { room_id, mark_as_unread } => { let Some(main_timeline) = get_room_timeline(&room_id) else { log!("BUG: skipping set unread flag request for not-yet-known room {room_id}"); continue; @@ -1615,64 +1501,35 @@ async fn matrix_worker_task( let result = main_timeline.room().set_unread_flag(mark_as_unread).await; match result { Ok(_) => log!("Set unread flag to {} for room {}", mark_as_unread, room_id), - Err(e) => error!( - "Failed to set unread flag to {} for room {}: {:?}", - mark_as_unread, room_id, e - ), + Err(e) => error!("Failed to set unread flag to {} for room {}: {:?}", mark_as_unread, room_id, e), } }); } - MatrixRequest::SetIsFavorite { - room_id, - is_favorite, - } => { + MatrixRequest::SetIsFavorite { room_id, is_favorite } => { let Some(main_timeline) = get_room_timeline(&room_id) else { - log!( - "BUG: skipping set favorite flag request for not-yet-known room {room_id}" - ); + log!("BUG: skipping set favorite flag request for not-yet-known room {room_id}"); continue; }; let _set_favorite_task = Handle::current().spawn(async move { - let result = main_timeline - .room() - .set_is_favourite(is_favorite, None) - .await; + let result = main_timeline.room().set_is_favourite(is_favorite, None).await; match result { Ok(_) => log!("Set favorite to {} for room {}", is_favorite, room_id), - Err(e) => error!( - "Failed to set favorite to {} for room {}: {:?}", - is_favorite, room_id, e - ), + Err(e) => error!("Failed to set favorite to {} for room {}: {:?}", is_favorite, room_id, e), } }); } - MatrixRequest::SetIsLowPriority { - room_id, - is_low_priority, - } => { + MatrixRequest::SetIsLowPriority { room_id, is_low_priority } => { let Some(main_timeline) = get_room_timeline(&room_id) else { - log!( - "BUG: skipping set low priority flag request for not-yet-known room {room_id}" - ); + log!("BUG: skipping set low priority flag request for not-yet-known room {room_id}"); continue; }; let _set_lp_task = Handle::current().spawn(async move { - let result = main_timeline - .room() - .set_is_low_priority(is_low_priority, None) - .await; + let result = main_timeline.room().set_is_low_priority(is_low_priority, None).await; match result { - Ok(_) => log!( - "Set low priority to {} for room {}", - is_low_priority, - room_id - ), - Err(e) => error!( - "Failed to set low priority to {} for room {}: {:?}", - is_low_priority, room_id, e - ), + Ok(_) => log!("Set low priority to {} for room {}", is_low_priority, room_id), + Err(e) => error!("Failed to set low priority to {} for room {}: {:?}", is_low_priority, room_id, e), } }); } @@ -1681,24 +1538,15 @@ async fn matrix_worker_task( let Some(client) = get_client() else { continue }; let _set_avatar_task = Handle::current().spawn(async move { let is_removing = avatar_url.is_none(); - log!( - "Sending request to {} avatar...", - if is_removing { "remove" } else { "set" } - ); + log!("Sending request to {} avatar...", if is_removing { "remove" } else { "set" }); let result = client.account().set_avatar_url(avatar_url.as_deref()).await; match result { Ok(_) => { - log!( - "Successfully {} avatar.", - if is_removing { "removed" } else { "set" } - ); + log!("Successfully {} avatar.", if is_removing { "removed" } else { "set" }); Cx::post_action(AccountDataAction::AvatarChanged(avatar_url)); } Err(e) => { - let err_msg = format!( - "Failed to {} avatar: {e}", - if is_removing { "remove" } else { "set" } - ); + let err_msg = format!("Failed to {} avatar: {e}", if is_removing { "remove" } else { "set" }); Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); } } @@ -1709,87 +1557,57 @@ async fn matrix_worker_task( let Some(client) = get_client() else { continue }; let _set_display_name_task = Handle::current().spawn(async move { let is_removing = new_display_name.is_none(); - log!( - "Sending request to {} display name{}...", + log!("Sending request to {} display name{}...", if is_removing { "remove" } else { "set" }, - new_display_name - .as_ref() - .map(|n| format!(" to '{n}'")) - .unwrap_or_default() + new_display_name.as_ref().map(|n| format!(" to '{n}'")).unwrap_or_default() ); - let result = client - .account() - .set_display_name(new_display_name.as_deref()) - .await; + let result = client.account().set_display_name(new_display_name.as_deref()).await; match result { Ok(_) => { - log!( - "Successfully {} display name.", - if is_removing { "removed" } else { "set" } - ); - Cx::post_action(AccountDataAction::DisplayNameChanged( - new_display_name, - )); + log!("Successfully {} display name.", if is_removing { "removed" } else { "set" }); + Cx::post_action(AccountDataAction::DisplayNameChanged(new_display_name)); } Err(e) => { - let err_msg = format!( - "Failed to {} display name: {e}", - if is_removing { "remove" } else { "set" } - ); + let err_msg = format!("Failed to {} display name: {e}", if is_removing { "remove" } else { "set" }); Cx::post_action(AccountDataAction::DisplayNameChangeFailed(err_msg)); } } }); } - MatrixRequest::GenerateMatrixLink { - room_id, - event_id, - use_matrix_scheme, - join_on_click, - } => { + MatrixRequest::GenerateMatrixLink { room_id, event_id, use_matrix_scheme, join_on_click } => { let Some(client) = get_client() else { continue }; let _gen_link_task = Handle::current().spawn(async move { if let Some(room) = client.get_room(&room_id) { let result = if use_matrix_scheme { if let Some(event_id) = event_id { - room.matrix_event_permalink(event_id) - .await + room.matrix_event_permalink(event_id).await .map(MatrixLinkAction::MatrixUri) } else { - room.matrix_permalink(join_on_click) - .await + room.matrix_permalink(join_on_click).await .map(MatrixLinkAction::MatrixUri) } } else { if let Some(event_id) = event_id { - room.matrix_to_event_permalink(event_id) - .await + room.matrix_to_event_permalink(event_id).await .map(MatrixLinkAction::MatrixToUri) } else { - room.matrix_to_permalink() - .await + room.matrix_to_permalink().await .map(MatrixLinkAction::MatrixToUri) } }; - + match result { Ok(action) => Cx::post_action(action), Err(e) => Cx::post_action(MatrixLinkAction::Error(e.to_string())), } } else { - Cx::post_action(MatrixLinkAction::Error(format!( - "Room {room_id} not found" - ))); + Cx::post_action(MatrixLinkAction::Error(format!("Room {room_id} not found"))); } }); } - MatrixRequest::IgnoreUser { - ignore, - room_member, - room_id, - } => { + MatrixRequest::IgnoreUser { ignore, room_member, room_id } => { let Some(client) = get_client() else { continue }; let _ignore_task = Handle::current().spawn(async move { let user_id = room_member.user_id(); @@ -1844,9 +1662,7 @@ async fn matrix_worker_task( MatrixRequest::SendTypingNotice { room_id, typing } => { let Some(main_room_timeline) = get_room_timeline(&room_id) else { - log!( - "BUG: skipping send typing notice request for not-yet-known room {room_id}" - ); + log!("BUG: skipping send typing notice request for not-yet-known room {room_id}"); continue; }; let _typing_task = Handle::current().spawn(async move { @@ -1860,21 +1676,16 @@ async fn matrix_worker_task( let (main_timeline, timeline_update_sender, mut typing_notice_receiver) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(jrd) = all_joined_rooms.get_mut(&room_id) else { - log!( - "BUG: room info not found for subscribe to typing notices request, room {room_id}" - ); + log!("BUG: room info not found for subscribe to typing notices request, room {room_id}"); continue; }; let (main_timeline, receiver) = if subscribe { if jrd.typing_notice_subscriber.is_some() { - warning!( - "Note: room {room_id} is already subscribed to typing notices." - ); + warning!("Note: room {room_id} is already subscribed to typing notices."); continue; } else { let main_timeline = jrd.main_timeline.timeline.clone(); - let (drop_guard, receiver) = - main_timeline.room().subscribe_to_typing_notifications(); + let (drop_guard, receiver) = main_timeline.room().subscribe_to_typing_notifications(); jrd.typing_notice_subscriber = Some(drop_guard); (main_timeline, receiver) } @@ -1883,11 +1694,7 @@ async fn matrix_worker_task( continue; }; // Here: we don't have an existing subscriber running, so we fall through and start one. - ( - main_timeline, - jrd.main_timeline.timeline_update_sender.clone(), - receiver, - ) + (main_timeline, jrd.main_timeline.timeline_update_sender.clone(), receiver) }; let _typing_notices_task = Handle::current().spawn(async move { @@ -1914,22 +1721,15 @@ async fn matrix_worker_task( }); } - MatrixRequest::SubscribeToOwnUserReadReceiptsChanged { - timeline_kind, - subscribe, - } => { + MatrixRequest::SubscribeToOwnUserReadReceiptsChanged { timeline_kind, subscribe } => { if !subscribe { - if let Some(task_handler) = - subscribers_own_user_read_receipts.remove(&timeline_kind) - { + if let Some(task_handler) = subscribers_own_user_read_receipts.remove(&timeline_kind) { task_handler.abort(); } continue; } let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { - log!( - "BUG: skipping subscribe to own user read receipts changed request for {timeline_kind}" - ); + log!("BUG: skipping subscribe to own user read receipts changed request for {timeline_kind}"); continue; }; @@ -1971,8 +1771,7 @@ async fn matrix_worker_task( } } }); - subscribers_own_user_read_receipts - .insert(timeline_kind_clone, subscribe_own_read_receipt_task); + subscribers_own_user_read_receipts.insert(timeline_kind_clone, subscribe_own_read_receipt_task); } MatrixRequest::SubscribeToPinnedEvents { room_id, subscribe } => { @@ -1982,13 +1781,9 @@ async fn matrix_worker_task( } continue; } - let kind = TimelineKind::MainRoom { - room_id: room_id.clone(), - }; + let kind = TimelineKind::MainRoom { room_id: room_id.clone() }; let Some((main_timeline, sender)) = get_timeline_and_sender(&kind) else { - log!( - "BUG: skipping subscribe to pinned events request for unknown room {room_id}" - ); + log!("BUG: skipping subscribe to pinned events request for unknown room {room_id}"); continue; }; let subscribe_pinned_events_task = Handle::current().spawn(async move { @@ -2010,18 +1805,8 @@ async fn matrix_worker_task( subscribers_pinned_events.insert(room_id, subscribe_pinned_events_task); } - MatrixRequest::SpawnSSOServer { - brand, - homeserver_url, - identity_provider_id, - } => { - spawn_sso_server( - brand, - homeserver_url, - identity_provider_id, - login_sender.clone(), - ) - .await; + MatrixRequest::SpawnSSOServer { brand, homeserver_url, identity_provider_id} => { + spawn_sso_server(brand, homeserver_url, identity_provider_id, login_sender.clone()).await; } MatrixRequest::ResolveRoomAlias(room_alias) => { @@ -2034,10 +1819,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::FetchAvatar { - mxc_uri, - on_fetched, - } => { + MatrixRequest::FetchAvatar { mxc_uri, on_fetched } => { let Some(client) = get_client() else { continue }; Handle::current().spawn(async move { // log!("Sending fetch avatar request for {mxc_uri:?}..."); @@ -2047,21 +1829,13 @@ async fn matrix_worker_task( }; let res = client.media().get_media_content(&media_request, true).await; // log!("Fetched avatar for {mxc_uri:?}, succeeded? {}", res.is_ok()); - on_fetched(AvatarUpdate { - mxc_uri, - avatar_data: res.map(|v| v.into()), - }); + on_fetched(AvatarUpdate { mxc_uri, avatar_data: res.map(|v| v.into()) }); }); } - MatrixRequest::FetchMedia { - media_request, - on_fetched, - destination, - update_sender, - } => { + MatrixRequest::FetchMedia { media_request, on_fetched, destination, update_sender } => { let Some(client) = get_client() else { continue }; - + let _fetch_task = Handle::current().spawn(async move { // log!("Sending fetch media request for {media_request:?}..."); let res = client.media().get_media_content(&media_request, true).await; @@ -2166,11 +1940,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::ReadReceipt { - timeline_kind, - event_id, - receipt_type, - } => { + MatrixRequest::ReadReceipt { timeline_kind, event_id, receipt_type } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found when sending read receipt, {event_id}"); continue; @@ -2191,7 +1961,7 @@ async fn matrix_worker_task( }); } }); - } + }, MatrixRequest::GetRoomPowerLevels { timeline_kind } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { @@ -2199,21 +1969,15 @@ async fn matrix_worker_task( continue; }; - let Some(user_id) = current_user_id() else { - continue; - }; + let Some(user_id) = current_user_id() else { continue }; let _power_levels_task = Handle::current().spawn(async move { match timeline.room().power_levels().await { Ok(power_levels) => { log!("Successfully fetched power levels for {timeline_kind}."); - if sender - .send(TimelineUpdate::UserPowerLevels(UserPowerLevels::from( - &power_levels, - &user_id, - ))) - .is_err() - { + if sender.send(TimelineUpdate::UserPowerLevels( + UserPowerLevels::from(&power_levels, &user_id), + )).is_err() { error!("Failed to send room power levels to UI.") } SignalToUI::set_ui_signal(); @@ -2223,13 +1987,9 @@ async fn matrix_worker_task( } } }); - } + }, - MatrixRequest::ToggleReaction { - timeline_kind, - timeline_event_id, - reaction, - } => { + MatrixRequest::ToggleReaction { timeline_kind, timeline_event_id, reaction } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found for toggle reaction request"); continue; @@ -2237,26 +1997,17 @@ async fn matrix_worker_task( let _toggle_reaction_task = Handle::current().spawn(async move { log!("Sending toggle reaction {reaction:?} to {timeline_kind}: ..."); - match timeline - .toggle_reaction(&timeline_event_id, &reaction) - .await - { + match timeline.toggle_reaction(&timeline_event_id, &reaction).await { Ok(_send_handle) => { log!("Sent toggle reaction {reaction:?} to {timeline_kind}."); SignalToUI::set_ui_signal(); - } - Err(_e) => error!( - "Failed to send toggle reaction to {timeline_kind}; error: {_e:?}" - ), + }, + Err(_e) => error!("Failed to send toggle reaction to {timeline_kind}; error: {_e:?}"), } }); - } + }, - MatrixRequest::RedactMessage { - timeline_kind, - timeline_event_id, - reason, - } => { + MatrixRequest::RedactMessage { timeline_kind, timeline_event_id, reason } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found for redact message request"); continue; @@ -2275,13 +2026,9 @@ async fn matrix_worker_task( } } }); - } + }, - MatrixRequest::PinEvent { - timeline_kind, - event_id, - pin, - } => { + MatrixRequest::PinEvent { timeline_kind, event_id, pin } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for pin event request"); continue; @@ -2293,11 +2040,7 @@ async fn matrix_worker_task( } else { timeline.unpin_event(&event_id).await }; - match sender.send(TimelineUpdate::PinResult { - event_id, - pin, - result, - }) { + match sender.send(TimelineUpdate::PinResult { event_id, pin, result }) { Ok(_) => SignalToUI::set_ui_signal(), Err(_) => log!("Failed to send UI update for pin event."), } @@ -2331,12 +2074,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetUrlPreview { - url, - on_fetched, - destination, - update_sender, - } => { + MatrixRequest::GetUrlPreview { url, on_fetched, destination, update_sender } => { // const MAX_LOG_RESPONSE_BODY_LENGTH: usize = 1000; // log!("Starting URL preview fetch for: {}", url); let _fetch_url_preview_task = Handle::current().spawn(async move { @@ -2346,19 +2084,17 @@ async fn matrix_worker_task( // error!("Matrix client not available for URL preview: {}", url); UrlPreviewError::ClientNotAvailable })?; - + let token = client.access_token().ok_or_else(|| { // error!("Access token not available for URL preview: {}", url); UrlPreviewError::AccessTokenNotAvailable })?; // Official Doc: https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv1mediapreview_url // Element desktop is using /_matrix/media/v3/preview_url - let endpoint_url = client - .homeserver() - .join("/_matrix/client/v1/media/preview_url") + let endpoint_url = client.homeserver().join("/_matrix/client/v1/media/preview_url") .map_err(UrlPreviewError::UrlParse)?; // log!("Fetching URL preview from endpoint: {} for URL: {}", endpoint_url, url); - + let response = client .http_client() .get(endpoint_url.clone()) @@ -2371,20 +2107,20 @@ async fn matrix_worker_task( // error!("HTTP request failed for URL preview {}: {}", url, e); UrlPreviewError::Request(e) })?; - + let status = response.status(); // log!("URL preview response status for {}: {}", url, status); - + if !status.is_success() && status.as_u16() != 429 { // error!("URL preview request failed with status {} for URL: {}", status, url); return Err(UrlPreviewError::HttpStatus(status.as_u16())); } - + let text = response.text().await.map_err(|e| { // error!("Failed to read response text for URL preview {}: {}", url, e); UrlPreviewError::Request(e) })?; - + // log!("URL preview response body length for {}: {} bytes", url, text.len()); // if text.len() > MAX_LOG_RESPONSE_BODY_LENGTH { // log!("URL preview response body preview for {}: {}...", url, &text[..MAX_LOG_RESPONSE_BODY_LENGTH]); @@ -2393,25 +2129,22 @@ async fn matrix_worker_task( // } // This request is rate limited, retry after a duration we get from the server. if status.as_u16() == 429 { - let link_preview_429_res = - serde_json::from_str::(&text) - .map_err(|e| { - // error!("Failed to parse as LinkPreviewRateLimitResponse for URL preview {}: {}", url, e); - UrlPreviewError::Json(e) - }); + let link_preview_429_res = serde_json::from_str::(&text) + .map_err(|e| { + // error!("Failed to parse as LinkPreviewRateLimitResponse for URL preview {}: {}", url, e); + UrlPreviewError::Json(e) + }); match link_preview_429_res { Ok(link_preview_429_res) => { if let Some(retry_after) = link_preview_429_res.retry_after_ms { - tokio::time::sleep(Duration::from_millis( - retry_after.into(), - )) - .await; - submit_async_request(MatrixRequest::GetUrlPreview { + tokio::time::sleep(Duration::from_millis(retry_after.into())).await; + submit_async_request(MatrixRequest::GetUrlPreview{ url: url.clone(), on_fetched, destination: destination.clone(), update_sender: update_sender.clone(), }); + } } Err(_e) => { @@ -2431,12 +2164,11 @@ async fn matrix_worker_task( // error!("Response body that failed to parse: {}", text); UrlPreviewError::Json(e) }) - } - .await; + }.await; // match &result { // Ok(preview_data) => { - // log!("Successfully fetched URL preview for {}: title: {:?}, site_name: {:?}", + // log!("Successfully fetched URL preview for {}: title: {:?}, site_name: {:?}", // url, preview_data.title, preview_data.site_name); // } // Err(e) => { @@ -2455,6 +2187,7 @@ async fn matrix_worker_task( bail!("matrix_worker_task task ended unexpectedly") } + /// The single global Tokio runtime that is used by all async tasks. static TOKIO_RUNTIME: Mutex> = Mutex::new(None); @@ -2467,8 +2200,7 @@ static REQUEST_SENDER: Mutex>> = Mutex::ne static DEFAULT_SSO_CLIENT: Mutex> = Mutex::new(None); /// Used to notify the SSO login task that the async creation of the `DEFAULT_SSO_CLIENT` has finished. -static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = - LazyLock::new(|| Arc::new(Notify::new())); +static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = LazyLock::new(|| Arc::new(Notify::new())); /// Blocks the current thread until the given future completes. /// @@ -2479,45 +2211,36 @@ pub fn block_on_async_with_timeout( timeout: Option, async_future: impl Future, ) -> Result { - let rt = TOKIO_RUNTIME - .lock() - .unwrap() - .get_or_insert_with(|| { - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - }) - .handle() - .clone(); + let rt = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + ).handle().clone(); if let Some(timeout) = timeout { - rt.block_on(async { tokio::time::timeout(timeout, async_future).await }) + rt.block_on(async { + tokio::time::timeout(timeout, async_future).await + }) } else { Ok(rt.block_on(async_future)) } } + /// The primary initialization routine for starting the Matrix client sync /// and the async tokio runtime. /// /// Returns a handle to the Tokio runtime that is used to run async background tasks. pub fn start_matrix_tokio() -> Result { // Create a Tokio runtime, and save it in a static variable to ensure it isn't dropped. - let rt_handle = TOKIO_RUNTIME - .lock() - .unwrap() - .get_or_insert_with(|| { - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - }) - .handle() - .clone(); + let rt_handle = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| { + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + }).handle().clone(); // Proactively build a Matrix Client in the background so that the SSO Server // can have a quicker start if needed (as it's rather slow to build this client). rt_handle.spawn(async move { match build_client(&Cli::default(), app_data_dir()).await { Ok(client_and_session) => { - DEFAULT_SSO_CLIENT - .lock() - .unwrap() + DEFAULT_SSO_CLIENT.lock().unwrap() .get_or_insert(client_and_session); } Err(e) => error!("Error: could not create DEFAULT_SSO_CLIENT object: {e}"), @@ -2534,6 +2257,7 @@ pub fn start_matrix_tokio() -> Result { Ok(rt_handle) } + /// A tokio::watch channel sender for sending requests from the RoomScreen UI widget /// to the corresponding background async task for that room (its `timeline_subscriber_handler`). pub type TimelineRequestSender = watch::Sender>; @@ -2600,13 +2324,13 @@ impl Drop for JoinedRoomDetails { } } + /// A const-compatible hasher, used for `static` items containing `HashMap`s or `HashSet`s. type ConstHasher = BuildHasherDefault; /// Information about all joined rooms that our client currently know about. /// We use a `HashMap` for O(1) lookups, as this is accessed frequently (e.g. every timeline update). -static ALL_JOINED_ROOMS: Mutex> = - Mutex::new(HashMap::with_hasher(BuildHasherDefault::new())); +static ALL_JOINED_ROOMS: Mutex> = Mutex::new(HashMap::with_hasher(BuildHasherDefault::new())); /// Returns the timeline and timeline update sender for the given joined room/thread timeline. fn get_per_timeline_details<'a>( @@ -2616,10 +2340,7 @@ fn get_per_timeline_details<'a>( let room_info = all_joined_rooms.get_mut(kind.room_id())?; match kind { TimelineKind::MainRoom { .. } => Some(&mut room_info.main_timeline), - TimelineKind::Thread { - thread_root_event_id, - .. - } => room_info.thread_timelines.get_mut(thread_root_event_id), + TimelineKind::Thread { thread_root_event_id, .. } => room_info.thread_timelines.get_mut(thread_root_event_id), } } @@ -2630,22 +2351,14 @@ fn get_timeline(kind: &TimelineKind) -> Option> { } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the timeline and timeline update sender for the given timeline kind. -fn get_timeline_and_sender( - kind: &TimelineKind, -) -> Option<(Arc, crossbeam_channel::Sender)> { - get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind).map(|details| { - ( - details.timeline.clone(), - details.timeline_update_sender.clone(), - ) - }) +fn get_timeline_and_sender(kind: &TimelineKind) -> Option<(Arc, crossbeam_channel::Sender)> { + get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind) + .map(|details| (details.timeline.clone(), details.timeline_update_sender.clone())) } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the main timeline for the given room. fn get_room_timeline(room_id: &RoomId) -> Option> { - ALL_JOINED_ROOMS - .lock() - .unwrap() + ALL_JOINED_ROOMS.lock().unwrap() .get(room_id) .map(|jrd| jrd.main_timeline.timeline.clone()) } @@ -2659,16 +2372,15 @@ pub fn get_client() -> Option { /// Returns the user ID of the currently logged-in user, if any. pub fn current_user_id() -> Option { - CLIENT - .lock() - .unwrap() - .as_ref() - .and_then(|c| c.session_meta().map(|m| m.user_id.clone())) + CLIENT.lock().unwrap().as_ref().and_then(|c| + c.session_meta().map(|m| m.user_id.clone()) + ) } /// The singleton sync service. static SYNC_SERVICE: Mutex>> = Mutex::new(None); + /// Get a reference to the current sync service, if available. pub fn get_sync_service() -> Option> { SYNC_SERVICE.lock().ok()?.as_ref().cloned() @@ -2677,8 +2389,7 @@ pub fn get_sync_service() -> Option> { /// The list of users that the current user has chosen to ignore. /// Ideally we shouldn't have to maintain this list ourselves, /// but the Matrix SDK doesn't currently properly maintain the list of ignored users. -static IGNORED_USERS: Mutex> = - Mutex::new(HashSet::with_hasher(BuildHasherDefault::new())); +static IGNORED_USERS: Mutex> = Mutex::new(HashSet::with_hasher(BuildHasherDefault::new())); /// Returns a deep clone of the current list of ignored users. pub fn get_ignored_users() -> HashSet { @@ -2690,6 +2401,7 @@ pub fn is_user_ignored(user_id: &UserId) -> bool { IGNORED_USERS.lock().unwrap().contains(user_id) } + /// Returns three channel endpoints related to the timeline for the given joined room or thread. /// /// 1. A timeline update sender. @@ -2703,10 +2415,7 @@ pub fn take_timeline_endpoints(kind: &TimelineKind) -> Option let jrd = all_joined_rooms.get_mut(kind.room_id())?; let details = match kind { TimelineKind::MainRoom { .. } => &mut jrd.main_timeline, - TimelineKind::Thread { - thread_root_event_id, - .. - } => jrd.thread_timelines.get_mut(thread_root_event_id)?, + TimelineKind::Thread { thread_root_event_id, .. } => jrd.thread_timelines.get_mut(thread_root_event_id)?, }; let (update_receiver, request_sender) = details.timeline_singleton_endpoints.take()?; Some(TimelineEndpoints { @@ -2719,18 +2428,25 @@ pub fn take_timeline_endpoints(kind: &TimelineKind) -> Option const DEFAULT_HOMESERVER: &str = "matrix.org"; -fn username_to_full_user_id(username: &str, homeserver: Option<&str>) -> Option { - username.try_into().ok().or_else(|| { - let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); - let user_id_str = if username.starts_with("@") { - format!("{}:{}", username, homeserver_url) - } else { - format!("@{}:{}", username, homeserver_url) - }; - user_id_str.as_str().try_into().ok() - }) +fn username_to_full_user_id( + username: &str, + homeserver: Option<&str>, +) -> Option { + username + .try_into() + .ok() + .or_else(|| { + let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); + let user_id_str = if username.starts_with("@") { + format!("{}:{}", username, homeserver_url) + } else { + format!("@{}:{}", username, homeserver_url) + }; + user_id_str.as_str().try_into().ok() + }) } + /// Info we store about a room received by the room list service. /// /// This struct is necessary in order for us to track the previous state @@ -2758,14 +2474,18 @@ struct RoomListServiceRoomInfo { impl RoomListServiceRoomInfo { async fn from_room(room: matrix_sdk::Room, current_user_id: &Option) -> Self { // Parallelize fetching of independent room data. - let (is_direct, tags, display_name, user_power_levels) = - tokio::join!(room.is_direct(), room.tags(), room.display_name(), async { + let (is_direct, tags, display_name, user_power_levels) = tokio::join!( + room.is_direct(), + room.tags(), + room.display_name(), + async { if let Some(user_id) = current_user_id { UserPowerLevels::from_room(&room, user_id.deref()).await } else { None } - }); + } + ); Self { room_id: room.room_id().to_owned(), @@ -2807,26 +2527,26 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let most_recent_user_id = persistence::most_recent_user_id().await; log!("Most recent user ID: {most_recent_user_id:?}"); let cli_parse_result = Cli::try_parse(); - let cli_has_valid_username_password = cli_parse_result - .as_ref() + let cli_has_valid_username_password = cli_parse_result.as_ref() .is_ok_and(|cli| !cli.user_id.is_empty() && !cli.password.is_empty()); - log!( - "CLI parsing succeeded? {}. CLI has valid UN+PW? {}", + log!("CLI parsing succeeded? {}. CLI has valid UN+PW? {}", cli_parse_result.as_ref().is_ok(), cli_has_valid_username_password, ); - let wait_for_login = !cli_has_valid_username_password - && (most_recent_user_id.is_none() - || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login")); + let wait_for_login = !cli_has_valid_username_password && ( + most_recent_user_id.is_none() + || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login") + ); log!("Waiting for login? {}", wait_for_login); let new_login_opt: Option<(Client, Option, bool)> = if !wait_for_login { - let specified_username = cli_parse_result - .as_ref() - .ok() - .and_then(|cli| username_to_full_user_id(&cli.user_id, cli.homeserver.as_deref())); - log!( - "Trying to restore session for user: {:?}", + let specified_username = cli_parse_result.as_ref().ok().and_then(|cli| + username_to_full_user_id( + &cli.user_id, + cli.homeserver.as_deref(), + ) + ); + log!("Trying to restore session for user: {:?}", specified_username.as_ref().or(most_recent_user_id.as_ref()) ); match persistence::restore_session(specified_username.clone()).await { @@ -2843,10 +2563,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { Cx::post_action(LoginAction::LoginFailure(status_err.to_string())); if let Ok(cli) = &cli_parse_result { - log!( - "Attempting auto-login from CLI arguments as user '{}'...", - cli.user_id - ); + log!("Attempting auto-login from CLI arguments as user '{}'...", cli.user_id); Cx::post_action(LoginAction::CliAutoLogin { user_id: cli.user_id.clone(), homeserver: cli.homeserver.clone(), @@ -2855,9 +2572,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { Ok((client, sync_token)) => Some((client, sync_token, false)), Err(e) => { error!("CLI-based login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!( - "Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}" - ))); + Cx::post_action(LoginAction::LoginFailure( + format!("Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}") + )); enqueue_rooms_list_update(RoomsListUpdate::Status { status: format!("Login failed: {e:?}"), }); @@ -2882,30 +2599,34 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let (client, sync_service, logged_in_user_id) = 'login_loop: loop { let (client, _sync_token, validate_session) = match initial_client_opt.take() { Some(login) => login, - None => loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => match login(&cli, login_request).await { - Ok((client, sync_token)) => break (client, sync_token, false), - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + None => { + loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => { + match login(&cli, login_request).await { + Ok((client, sync_token)) => break (client, sync_token, false), + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: format!("Login failed: {e}"), + }); + } + } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); + Cx::post_action(LoginAction::LoginFailure(err.clone())); enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), + status: err, }); + return; } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - let err = String::from( - "Please restart Robrix.\n\nUnable to listen for login requests.", - ); - Cx::post_action(LoginAction::LoginFailure(err.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err }); - return; } } - }, + } }; if validate_session { @@ -2913,8 +2634,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { Ok(_) => {} Err(e) if is_invalid_token_http_error(&e) => { clear_persisted_session(client.user_id()).await; - let err_msg = - "Your login token is no longer valid.\n\nPlease log in again."; + let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.to_string(), @@ -2922,9 +2642,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { continue 'login_loop; } Err(e) => { - warning!( - "Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}" - ); + warning!("Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}"); } } } @@ -2934,8 +2652,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let _ = client_opt.take(); } - let logged_in_user_id: OwnedUserId = client - .user_id() + let logged_in_user_id: OwnedUserId = client.user_id() .expect("BUG: Client::user_id() returned None after successful login!") .to_owned(); let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); @@ -2943,9 +2660,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // Store this active client in our global Client state so that other tasks can access it. if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { - error!( - "BUG: unexpectedly replaced an existing client when initializing the matrix client." - ); + error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); } // Listen for changes to our verification status and incoming verification requests. @@ -2969,9 +2684,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let err_msg = if is_invalid_token_error(&e) { "Your login token is no longer valid.\n\nPlease log in again.".to_string() } else { - format!( - "Please restart Robrix.\n\nFailed to create Matrix sync service: {e}." - ) + format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") }; if is_invalid_token_error(&e) { clear_persisted_session(client.user_id()).await; @@ -3009,9 +2722,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let room_list_service = sync_service.room_list_service(); if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { - error!( - "BUG: unexpectedly replaced an existing sync service when initializing the matrix client." - ); + error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); } let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); @@ -3128,6 +2839,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { } } + /// The main async task that listens for changes to all rooms. async fn room_list_service_loop(room_list_service: Arc) -> Result<()> { let all_rooms_list = room_list_service.all_rooms().await?; @@ -3141,13 +2853,13 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu // 1. not spaces (those are handled by the SpaceService), // 2. not left (clients don't typically show rooms that the user has already left), // 3. not outdated (don't show tombstoned rooms whose successor is already joined). - room_list_dynamic_entries_controller.set_filter(Box::new(filters::new_filter_all(vec![ - Box::new(filters::new_filter_not(Box::new( - filters::new_filter_space(), - ))), - Box::new(filters::new_filter_non_left()), - Box::new(filters::new_filter_deduplicate_versions()), - ]))); + room_list_dynamic_entries_controller.set_filter(Box::new( + filters::new_filter_all(vec![ + Box::new(filters::new_filter_not(Box::new(filters::new_filter_space()))), + Box::new(filters::new_filter_non_left()), + Box::new(filters::new_filter_deduplicate_versions()), + ]) + )); let mut all_known_rooms: Vector = Vector::new(); let current_user_id = current_user_id(); @@ -3163,13 +2875,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu // Append and Reset are identical, except for Reset first clears all rooms. let _num_new_rooms = new_rooms.len(); if is_reset { - if LOG_ROOM_LIST_DIFFS { - log!( - "room_list: diff Reset, old length {}, new length {}", - all_known_rooms.len(), - new_rooms.len() - ); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Reset, old length {}, new length {}", all_known_rooms.len(), new_rooms.len()); } // Iterate manually so we can know which rooms are being removed. while let Some(room) = all_known_rooms.pop_back() { remove_room(&room); @@ -3180,35 +2886,20 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); } else { - if LOG_ROOM_LIST_DIFFS { - log!( - "room_list: diff Append, old length {}, adding {} new items", - all_known_rooms.len(), - _num_new_rooms - ); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Append, old length {}, adding {} new items", all_known_rooms.len(), _num_new_rooms); } } // Parallelize creating each room's RoomListServiceRoomInfo and adding that new room. // We combine `from_room` and `add_new_room` into a single async task per room. - let new_room_infos: Vec = - join_all(new_rooms.into_iter().map(|room| async { - let room_info = RoomListServiceRoomInfo::from_room( - room.into_inner(), - ¤t_user_id, - ) - .await; - if let Err(e) = - add_new_room(&room_info, &room_list_service, false).await - { - error!( - "Failed to add new room: {:?} ({}); error: {:?}", - room_info.display_name, room_info.room_id, e - ); + let new_room_infos: Vec = join_all( + new_rooms.into_iter().map(|room| async { + let room_info = RoomListServiceRoomInfo::from_room(room.into_inner(), ¤t_user_id).await; + if let Err(e) = add_new_room(&room_info, &room_list_service, false).await { + error!("Failed to add new room: {:?} ({}); error: {:?}", room_info.display_name, room_info.room_id, e); } room_info - })) - .await; + }) + ).await; // Send room order update with the new room IDs let (room_id_refs, room_ids) = { @@ -3222,57 +2913,43 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu }; if !room_ids.is_empty() { enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Append { values: room_ids }, + VecDiff::Append { values: room_ids } )); room_list_service.subscribe_to_rooms(&room_id_refs).await; all_known_rooms.extend(new_room_infos); } } VectorDiff::Clear => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Clear"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Clear"); } all_known_rooms.clear(); ALL_JOINED_ROOMS.lock().unwrap().clear(); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); } VectorDiff::PushFront { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PushFront"); - } - let new_room = - RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) - .await; + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushFront"); } + let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushFront { value: room_id }, + VecDiff::PushFront { value: room_id } )); all_known_rooms.push_front(new_room); } VectorDiff::PushBack { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PushBack"); - } - let new_room = - RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) - .await; + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushBack"); } + let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushBack { value: room_id }, + VecDiff::PushBack { value: room_id } )); all_known_rooms.push_back(new_room); } remove_diff @ VectorDiff::PopFront => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PopFront"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopFront"); } if let Some(room) = all_known_rooms.pop_front() { - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PopFront, - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PopFront)); optimize_remove_then_add_into_update( remove_diff, &room, @@ -3280,18 +2957,13 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ) - .await?; + ).await?; } } remove_diff @ VectorDiff::PopBack => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PopBack"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopBack"); } if let Some(room) = all_known_rooms.pop_back() { - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PopBack, - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PopBack)); optimize_remove_then_add_into_update( remove_diff, &room, @@ -3299,61 +2971,38 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ) - .await?; + ).await?; } } - VectorDiff::Insert { - index, - value: new_room, - } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Insert at {index}"); - } - let new_room = - RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) - .await; + VectorDiff::Insert { index, value: new_room } => { + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Insert at {index}"); } + let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Insert { - index, - value: room_id, - })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::Insert { index, value: room_id } + )); all_known_rooms.insert(index, new_room); } - VectorDiff::Set { - index, - value: changed_room, - } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Set at {index}"); - } - let changed_room = RoomListServiceRoomInfo::from_room( - changed_room.into_inner(), - ¤t_user_id, - ) - .await; + VectorDiff::Set { index, value: changed_room } => { + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Set at {index}"); } + let changed_room = RoomListServiceRoomInfo::from_room(changed_room.into_inner(), ¤t_user_id).await; if let Some(old_room) = all_known_rooms.get(index) { update_room(old_room, &changed_room, &room_list_service).await?; } else { error!("BUG: room list diff: Set index {index} was out of bounds."); } // Send order update (room ID at this index may have changed) - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Set { - index, - value: changed_room.room_id.clone(), - })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::Set { index, value: changed_room.room_id.clone() } + )); all_known_rooms.set(index, changed_room); } remove_diff @ VectorDiff::Remove { index } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Remove at {index}"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Remove at {index}"); } if index < all_known_rooms.len() { let room = all_known_rooms.remove(index); - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Remove { index }, - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Remove { index })); optimize_remove_then_add_into_update( remove_diff, &room, @@ -3361,19 +3010,13 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ) - .await?; + ).await?; } else { - error!( - "BUG: room_list: diff Remove index {index} out of bounds, len {}", - all_known_rooms.len() - ); + error!("BUG: room_list: diff Remove index {index} out of bounds, len {}", all_known_rooms.len()); } } VectorDiff::Truncate { length } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Truncate to {length}"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Truncate to {length}"); } // Iterate manually so we can know which rooms are being removed. while all_known_rooms.len() > length { if let Some(room) = all_known_rooms.pop_back() { @@ -3382,7 +3025,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu } all_known_rooms.truncate(length); // sanity check enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Truncate { length }, + VecDiff::Truncate { length } )); } } @@ -3392,6 +3035,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu bail!("room list service sync loop ended unexpectedly") } + /// Attempts to optimize a common RoomListService operation of remove + add. /// /// If a `Remove` diff (or `PopBack` or `PopFront`) is immediately followed by @@ -3411,58 +3055,48 @@ async fn optimize_remove_then_add_into_update( ) -> Result<()> { let next_diff_was_handled: bool; match peekable_diffs.peek() { - Some(VectorDiff::Insert { - index: insert_index, - value: new_room, - }) if room.room_id == new_room.room_id() => { + Some(VectorDiff::Insert { index: insert_index, value: new_room }) + if room.room_id == new_room.room_id() => + { if LOG_ROOM_LIST_DIFFS { - log!( - "Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", - room.room_id - ); + log!("Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", room.room_id); } - let new_room = - RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the insert - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Insert { - index: *insert_index, - value: new_room.room_id.clone(), - })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::Insert { index: *insert_index, value: new_room.room_id.clone() } + )); all_known_rooms.insert(*insert_index, new_room); next_diff_was_handled = true; } - Some(VectorDiff::PushFront { value: new_room }) if room.room_id == new_room.room_id() => { + Some(VectorDiff::PushFront { value: new_room }) + if room.room_id == new_room.room_id() => + { if LOG_ROOM_LIST_DIFFS { - log!( - "Optimizing {remove_diff:?} + PushFront into Update for room {}", - room.room_id - ); + log!("Optimizing {remove_diff:?} + PushFront into Update for room {}", room.room_id); } - let new_room = - RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the push front - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PushFront { - value: new_room.room_id.clone(), - })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::PushFront { value: new_room.room_id.clone() } + )); all_known_rooms.push_front(new_room); next_diff_was_handled = true; } - Some(VectorDiff::PushBack { value: new_room }) if room.room_id == new_room.room_id() => { + Some(VectorDiff::PushBack { value: new_room }) + if room.room_id == new_room.room_id() => + { if LOG_ROOM_LIST_DIFFS { - log!( - "Optimizing {remove_diff:?} + PushBack into Update for room {}", - room.room_id - ); + log!("Optimizing {remove_diff:?} + PushBack into Update for room {}", room.room_id); } - let new_room = - RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the push back - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PushBack { - value: new_room.room_id.clone(), - })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::PushBack { value: new_room.room_id.clone() } + )); all_known_rooms.push_back(new_room); next_diff_was_handled = true; } @@ -3476,6 +3110,7 @@ async fn optimize_remove_then_add_into_update( Ok(()) } + /// Invoked when the room list service has received an update that changes an existing room. async fn update_room( old_room: &RoomListServiceRoomInfo, @@ -3486,29 +3121,18 @@ async fn update_room( if old_room.room_id == new_room_id { // Handle state transitions for a room. if LOG_ROOM_LIST_DIFFS { - log!( - "Room {:?} ({new_room_id}) state went from {:?} --> {:?}", - new_room.display_name, - old_room.state, - new_room.state - ); + log!("Room {:?} ({new_room_id}) state went from {:?} --> {:?}", new_room.display_name, old_room.state, new_room.state); } if old_room.state != new_room.state { match new_room.state { RoomState::Banned => { // TODO: handle rooms that this user has been banned from. - log!( - "Removing Banned room: {:?} ({new_room_id})", - new_room.display_name - ); + log!("Removing Banned room: {:?} ({new_room_id})", new_room.display_name); remove_room(new_room); return Ok(()); } RoomState::Left => { - log!( - "Removing Left room: {:?} ({new_room_id})", - new_room.display_name - ); + log!("Removing Left room: {:?} ({new_room_id})", new_room.display_name); // TODO: instead of removing this, we could optionally add it to // a separate list of left rooms, which would be collapsed by default. // Upon clicking a left room, we could show a splash page @@ -3518,17 +3142,11 @@ async fn update_room( return Ok(()); } RoomState::Joined => { - log!( - "update_room(): adding new Joined room: {:?} ({new_room_id})", - new_room.display_name - ); + log!("update_room(): adding new Joined room: {:?} ({new_room_id})", new_room.display_name); return add_new_room(new_room, room_list_service, true).await; } RoomState::Invited => { - log!( - "update_room(): adding new Invited room: {:?} ({new_room_id})", - new_room.display_name - ); + log!("update_room(): adding new Invited room: {:?} ({new_room_id})", new_room.display_name); return add_new_room(new_room, room_list_service, true).await; } RoomState::Knocked => { @@ -3546,12 +3164,7 @@ async fn update_room( spawn_fetch_room_avatar(new_room); } if old_room.display_name != new_room.display_name { - log!( - "Updating room {} name: {:?} --> {:?}", - new_room_id, - old_room.display_name, - new_room.display_name - ); + log!("Updating room {} name: {:?} --> {:?}", new_room_id, old_room.display_name, new_room.display_name); enqueue_rooms_list_update(RoomsListUpdate::UpdateRoomName { new_room_name: (new_room.display_name.clone(), new_room_id.clone()).into(), @@ -3561,15 +3174,12 @@ async fn update_room( // Then, we check for changes to room data that is only relevant to joined rooms: // including the latest event, tags, unread counts, is_direct, tombstoned state, power levels, etc. // Invited or left rooms don't care about these details. - if matches!(new_room.state, RoomState::Joined) { + if matches!(new_room.state, RoomState::Joined) { // For some reason, the latest event API does not reliably catch *all* changes // to the latest event in a given room, such as redactions. // Thus, we have to re-obtain the latest event on *every* update, regardless of timestamp. // - let update_latest = match ( - old_room.latest_event_timestamp, - new_room.room.latest_event_timestamp(), - ) { + let update_latest = match (old_room.latest_event_timestamp, new_room.room.latest_event_timestamp()) { (Some(old_ts), Some(new_ts)) => new_ts >= old_ts, (None, Some(_)) => true, _ => false, @@ -3578,13 +3188,9 @@ async fn update_room( update_latest_event(&new_room.room).await; } + if old_room.tags != new_room.tags { - log!( - "Updating room {} tags from {:?} to {:?}", - new_room_id, - old_room.tags, - new_room.tags - ); + log!("Updating room {} tags from {:?} to {:?}", new_room_id, old_room.tags, new_room.tags); enqueue_rooms_list_update(RoomsListUpdate::Tags { room_id: new_room_id.clone(), new_tags: new_room.tags.clone().unwrap_or_default(), @@ -3595,15 +3201,11 @@ async fn update_room( || old_room.num_unread_messages != new_room.num_unread_messages || old_room.num_unread_mentions != new_room.num_unread_mentions { - log!( - "Updating room {}, marked unread {} --> {}, unread messages {} --> {}, unread mentions {} --> {}", + log!("Updating room {}, marked unread {} --> {}, unread messages {} --> {}, unread mentions {} --> {}", new_room_id, - old_room.is_marked_unread, - new_room.is_marked_unread, - old_room.num_unread_messages, - new_room.num_unread_messages, - old_room.num_unread_mentions, - new_room.num_unread_mentions, + old_room.is_marked_unread, new_room.is_marked_unread, + old_room.num_unread_messages, new_room.num_unread_messages, + old_room.num_unread_mentions, new_room.num_unread_mentions, ); enqueue_rooms_list_update(RoomsListUpdate::UpdateNumUnreadMessages { room_id: new_room_id.clone(), @@ -3614,8 +3216,7 @@ async fn update_room( } if old_room.is_direct != new_room.is_direct { - log!( - "Updating room {} is_direct from {} to {}", + log!("Updating room {} is_direct from {} to {}", new_room_id, old_room.is_direct, new_room.is_direct, @@ -3630,8 +3231,7 @@ async fn update_room( let mut get_timeline_update_sender = |room_id| { if __timeline_update_sender_opt.is_none() { if let Some(jrd) = ALL_JOINED_ROOMS.lock().unwrap().get(room_id) { - __timeline_update_sender_opt = - Some(jrd.main_timeline.timeline_update_sender.clone()); + __timeline_update_sender_opt = Some(jrd.main_timeline.timeline_update_sender.clone()); } } __timeline_update_sender_opt.clone() @@ -3640,9 +3240,7 @@ async fn update_room( if !old_room.is_tombstoned && new_room.is_tombstoned { let successor_room = new_room.room.successor_room(); log!("Updating room {new_room_id} to be tombstoned, {successor_room:?}"); - enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { - room_id: new_room_id.clone(), - }); + enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { room_id: new_room_id.clone() }); if let Some(timeline_update_sender) = get_timeline_update_sender(&new_room_id) { spawn_fetch_successor_room_preview( room_list_service.client().clone(), @@ -3651,9 +3249,7 @@ async fn update_room( timeline_update_sender, ); } else { - error!( - "BUG: could not find JoinedRoomDetails for newly-tombstoned room {new_room_id}" - ); + error!("BUG: could not find JoinedRoomDetails for newly-tombstoned room {new_room_id}"); } } @@ -3664,38 +3260,37 @@ async fn update_room( log!("Updating room {new_room_id} user power levels."); match timeline_update_sender.send(TimelineUpdate::UserPowerLevels(nupl)) { Ok(_) => SignalToUI::set_ui_signal(), - Err(_) => error!( - "Failed to send the UserPowerLevels update to room {new_room_id}" - ), + Err(_) => error!("Failed to send the UserPowerLevels update to room {new_room_id}"), } } else { - error!( - "BUG: could not find JoinedRoomDetails for room {new_room_id} where power levels changed." - ); + error!("BUG: could not find JoinedRoomDetails for room {new_room_id} where power levels changed."); } } } Ok(()) - } else { - warning!( - "UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", - old_room.room_id, - new_room_id, + } + else { + warning!("UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", + old_room.room_id, new_room_id, ); remove_room(old_room); add_new_room(new_room, room_list_service, true).await } } + /// Invoked when the room list service has received an update to remove an existing room. fn remove_room(room: &RoomListServiceRoomInfo) { ALL_JOINED_ROOMS.lock().unwrap().remove(&room.room_id); - enqueue_rooms_list_update(RoomsListUpdate::RemoveRoom { - room_id: room.room_id.clone(), - new_state: room.state, - }); + enqueue_rooms_list_update( + RoomsListUpdate::RemoveRoom { + room_id: room.room_id.clone(), + new_state: room.state, + } + ); } + /// Invoked when the room list service has received an update with a brand new room. async fn add_new_room( new_room: &RoomListServiceRoomInfo, @@ -3704,39 +3299,26 @@ async fn add_new_room( ) -> Result<()> { match new_room.state { RoomState::Knocked => { - log!( - "Got new Knocked room: {:?} ({})", - new_room.display_name, - new_room.room_id - ); + log!("Got new Knocked room: {:?} ({})", new_room.display_name, new_room.room_id); // Note: here we could optionally display Knocked rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Banned => { - log!( - "Got new Banned room: {:?} ({})", - new_room.display_name, - new_room.room_id - ); + log!("Got new Banned room: {:?} ({})", new_room.display_name, new_room.room_id); // Note: here we could optionally display Banned rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Left => { - log!( - "Got new Left room: {:?} ({:?})", - new_room.display_name, - new_room.room_id - ); + log!("Got new Left room: {:?} ({:?})", new_room.display_name, new_room.room_id); // Note: here we could optionally display Left rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Invited => { let invite_details = new_room.room.invite_details().await.ok(); - let room_name_id = - RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); + let room_name_id = RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); // Start with a basic text avatar; the avatar image will be fetched asynchronously below. let room_avatar = avatar_from_room_name(room_name_id.name_for_avatar()); let inviter_info = if let Some(inviter) = invite_details.and_then(|d| d.inviter) { @@ -3753,20 +3335,18 @@ async fn add_new_room( } else { None }; - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom( - InvitedRoomInfo { - room_name_id: room_name_id.clone(), - inviter_info, - room_avatar, - canonical_alias: new_room.room.canonical_alias(), - alt_aliases: new_room.room.alt_aliases(), - // we don't actually display the latest event for Invited rooms, so don't bother. - latest: None, - invite_state: Default::default(), - is_selected: false, - is_direct: new_room.is_direct, - }, - )); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom(InvitedRoomInfo { + room_name_id: room_name_id.clone(), + inviter_info, + room_avatar, + canonical_alias: new_room.room.canonical_alias(), + alt_aliases: new_room.room.alt_aliases(), + // we don't actually display the latest event for Invited rooms, so don't bother. + latest: None, + invite_state: Default::default(), + is_selected: false, + is_direct: new_room.is_direct, + })); Cx::post_action(AppStateAction::RoomLoadedSuccessfully { room_name_id, is_invite: true, @@ -3774,21 +3354,17 @@ async fn add_new_room( spawn_fetch_room_avatar(new_room); return Ok(()); } - RoomState::Joined => {} // Fall through to adding the joined room below. + RoomState::Joined => { } // Fall through to adding the joined room below. } // If we didn't already subscribe to this room, do so now. // This ensures we will properly receive all of its states and latest event. if subscribe { - room_list_service - .subscribe_to_rooms(&[&new_room.room_id]) - .await; + room_list_service.subscribe_to_rooms(&[&new_room.room_id]).await; } let timeline = Arc::new( - new_room - .room - .timeline_builder() + new_room.room.timeline_builder() .with_focus(TimelineFocus::Live { // we show threads as separate timelines in their own RoomScreen hide_threaded_events: true, @@ -3796,12 +3372,7 @@ async fn add_new_room( .track_read_marker_and_receipts(TimelineReadReceiptTracking::AllEvents) .build() .await - .map_err(|e| { - anyhow::anyhow!( - "BUG: Failed to build timeline for room {}: {e}", - new_room.room_id - ) - })?, + .map_err(|e| anyhow::anyhow!("BUG: Failed to build timeline for room {}: {e}", new_room.room_id))?, ); let (timeline_update_sender, timeline_update_receiver) = crossbeam_channel::unbounded(); @@ -3817,11 +3388,7 @@ async fn add_new_room( // We need to add the room to the `ALL_JOINED_ROOMS` list before we can send // an `AddJoinedRoom` update to the RoomsList widget, because that widget might // immediately issue a `MatrixRequest` that relies on that room being in `ALL_JOINED_ROOMS`. - log!( - "Adding new joined room {}, name: {:?}", - new_room.room_id, - new_room.display_name - ); + log!("Adding new joined room {}, name: {:?}", new_room.room_id, new_room.display_name); ALL_JOINED_ROOMS.lock().unwrap().insert( new_room.room_id.clone(), JoinedRoomDetails { @@ -3842,8 +3409,7 @@ async fn add_new_room( let latest = get_latest_event_details( &new_room.room.latest_event().await, room_list_service.client(), - ) - .await; + ).await; let room_name_id = RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); // Start with a basic text avatar; the avatar image will be fetched asynchronously below. let room_avatar = avatar_from_room_name(room_name_id.name_for_avatar()); @@ -3874,8 +3440,7 @@ async fn add_new_room( #[allow(unused)] async fn current_ignore_user_list(client: &Client) -> Option> { use matrix_sdk::ruma::events::ignored_user_list::IgnoredUserListEventContent; - let ignored_users = client - .account() + let ignored_users = client.account() .account_data::() .await .ok()?? @@ -3939,9 +3504,7 @@ fn handle_load_app_state(user_id: OwnedUserId) { && !app_state.saved_dock_state_home.dock_items.is_empty() { log!("Loaded room panel state from app data directory. Restoring now..."); - Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState( - app_state, - )); + Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState(app_state)); } } Err(_e) => { @@ -3960,12 +3523,12 @@ fn handle_load_app_state(user_id: OwnedUserId) { fn is_invalid_token_error(e: &sync_service::Error) -> bool { use matrix_sdk::ruma::api::client::error::ErrorKind; let sdk_error = match e { - sync_service::Error::RoomList(matrix_sdk_ui::room_list_service::Error::SlidingSync( - err, - )) => err, - sync_service::Error::EncryptionSync(encryption_sync_service::Error::SlidingSync(err)) => { - err - } + sync_service::Error::RoomList( + matrix_sdk_ui::room_list_service::Error::SlidingSync(err) + ) => err, + sync_service::Error::EncryptionSync( + encryption_sync_service::Error::SlidingSync(err) + ) => err, _ => return false, }; matches!( @@ -4047,12 +3610,14 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { const SYNC_INDICATOR_DELAY: Duration = Duration::from_millis(100); /// Duration for sync indicator delay before hiding const SYNC_INDICATOR_HIDE_DELAY: Duration = Duration::from_millis(200); - let sync_indicator_stream = sync_service - .room_list_service() - .sync_indicator(SYNC_INDICATOR_DELAY, SYNC_INDICATOR_HIDE_DELAY); - + let sync_indicator_stream = sync_service.room_list_service() + .sync_indicator( + SYNC_INDICATOR_DELAY, + SYNC_INDICATOR_HIDE_DELAY + ); + Handle::current().spawn(async move { - let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); + let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); while let Some(indicator) = sync_indicator_stream.next().await { let is_syncing = match indicator { @@ -4065,10 +3630,7 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { } fn handle_room_list_service_loading_state(mut loading_state: Subscriber) { - log!( - "Initial room list loading state is {:?}", - loading_state.get() - ); + log!("Initial room list loading state is {:?}", loading_state.get()); Handle::current().spawn(async move { while let Some(state) = loading_state.next().await { log!("Received a room list loading state update: {state:?}"); @@ -4076,12 +3638,8 @@ fn handle_room_list_service_loading_state(mut loading_state: Subscriber { enqueue_rooms_list_update(RoomsListUpdate::NotLoaded); } - RoomListLoadingState::Loaded { - maximum_number_of_rooms, - } => { - enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { - max_rooms: maximum_number_of_rooms, - }); + RoomListLoadingState::Loaded { maximum_number_of_rooms } => { + enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { max_rooms: maximum_number_of_rooms }); // The SDK docs state that we cannot move from the `Loaded` state // back to the `NotLoaded` state, so we can safely exit this task here. return; @@ -4104,12 +3662,12 @@ fn spawn_fetch_successor_room_preview( Handle::current().spawn(async move { log!("Updating room {tombstoned_room_id} to be tombstoned, {successor_room:?}"); let srd = if let Some(SuccessorRoom { room_id, reason }) = successor_room { - match fetch_room_preview_with_avatar(&client, room_id.deref().into(), Vec::new()).await - { - Ok(room_preview) => SuccessorRoomDetails::Full { - room_preview, - reason, - }, + match fetch_room_preview_with_avatar( + &client, + room_id.deref().into(), + Vec::new(), + ).await { + Ok(room_preview) => SuccessorRoomDetails::Full { room_preview, reason }, Err(e) => { log!("Failed to fetch preview of successor room {room_id}, error: {e:?}"); SuccessorRoomDetails::Basic(SuccessorRoom { room_id, reason }) @@ -4143,18 +3701,12 @@ async fn fetch_room_preview_with_avatar( }; match client.media().get_media_content(&media_request, true).await { Ok(avatar_content) => { - log!( - "Fetched avatar for room preview {:?} ({})", - room_preview.name, - room_preview.room_id - ); + log!("Fetched avatar for room preview {:?} ({})", room_preview.name, room_preview.room_id); FetchedRoomAvatar::Image(avatar_content.into()) } Err(e) => { - log!( - "Failed to fetch avatar for room preview {:?} ({}), error: {e:?}", - room_preview.name, - room_preview.room_id + log!("Failed to fetch avatar for room preview {:?} ({}), error: {e:?}", + room_preview.name, room_preview.room_id ); avatar_from_room_name(room_preview.name.as_deref()) } @@ -4174,10 +3726,7 @@ async fn fetch_room_preview_with_avatar( async fn fetch_thread_summary_details( room: &Room, thread_root_event_id: &EventId, -) -> ( - u32, - Option, -) { +) -> (u32, Option) { let mut num_replies = 0; let mut latest_reply_event = None; @@ -4235,7 +3784,10 @@ async fn fetch_latest_thread_reply_event( } /// Counts all replies in the given thread by paginating `/relations` in batches. -async fn count_thread_replies(room: &Room, thread_root_event_id: &EventId) -> Option { +async fn count_thread_replies( + room: &Room, + thread_root_event_id: &EventId, +) -> Option { let mut total_replies: u32 = 0; let mut next_batch_token = None; @@ -4248,10 +3800,7 @@ async fn count_thread_replies(room: &Room, thread_root_event_id: &EventId) -> Op ..Default::default() }; - let relations = room - .relations(thread_root_event_id.to_owned(), options) - .await - .ok()?; + let relations = room.relations(thread_root_event_id.to_owned(), options).await.ok()?; if relations.chunk.is_empty() { break; } @@ -4277,8 +3826,7 @@ async fn text_preview_of_latest_thread_reply( Ok(Some(rm)) => Some(rm), _ => room.get_member(&sender_id).await.ok().flatten(), }; - let sender_name = sender_room_member - .as_ref() + let sender_name = sender_room_member.as_ref() .and_then(|rm| rm.display_name()) .unwrap_or(sender_id.as_str()); let text_preview = text_preview_of_raw_timeline_event(raw, sender_name).unwrap_or_else(|| { @@ -4295,6 +3843,7 @@ async fn text_preview_of_latest_thread_reply( } } + /// Returns the timestamp and an HTML-formatted text preview of the given `latest_event`. /// /// If the sender profile of the event is not yet available, this function will @@ -4318,37 +3867,29 @@ async fn get_latest_event_details( match latest_event_value { LatestEventValue::None => None, - LatestEventValue::Remote { - timestamp, - sender, - is_own, - profile, - content, - } => { + LatestEventValue::Remote { timestamp, sender, is_own, profile, content } => { let sender_username = get_sender_username!(profile, sender, *is_own); - let latest_message_text = - text_preview_of_timeline_item(content, sender, &sender_username) - .format_with(&sender_username, true); + let latest_message_text = text_preview_of_timeline_item( + content, + sender, + &sender_username, + ).format_with(&sender_username, true); Some((*timestamp, latest_message_text)) } - LatestEventValue::Local { - timestamp, - sender, - profile, - content, - state: _, - } => { + LatestEventValue::Local { timestamp, sender, profile, content, state: _ } => { // TODO: use the `state` enum to augment the preview text with more details. // Example: "Sending... {msg}" or // "Failed to send {msg}" let is_own = current_user_id().is_some_and(|id| &id == sender); let sender_username = get_sender_username!(profile, sender, is_own); - let latest_message_text = - text_preview_of_timeline_item(content, sender, &sender_username) - .format_with(&sender_username, true); + let latest_message_text = text_preview_of_timeline_item( + content, + sender, + &sender_username, + ).format_with(&sender_username, true); Some((*timestamp, latest_message_text)) } - } + } } /// Handles the given updated latest event for the given room. @@ -4356,9 +3897,10 @@ async fn get_latest_event_details( /// This function sends a `RoomsListUpdate::UpdateLatestEvent` /// to update the latest event in the RoomsListEntry for the given room. async fn update_latest_event(room: &Room) { - if let Some((timestamp, latest_message_text)) = - get_latest_event_details(&room.latest_event().await, &room.client()).await - { + if let Some((timestamp, latest_message_text)) = get_latest_event_details( + &room.latest_event().await, + &room.client(), + ).await { enqueue_rooms_list_update(RoomsListUpdate::UpdateLatestEvent { room_id: room.room_id().to_owned(), timestamp, @@ -4395,6 +3937,7 @@ async fn timeline_subscriber_handler( mut request_receiver: watch::Receiver>, thread_root_event_id: Option, ) { + /// An inner function that searches the given new timeline items for a target event. /// /// If the target event is found, it is removed from the `target_event_id_opt` and returned, @@ -4403,13 +3946,14 @@ async fn timeline_subscriber_handler( target_event_id_opt: &mut Option, mut new_items_iter: impl Iterator>, ) -> Option<(usize, OwnedEventId)> { - let found_index = target_event_id_opt.as_ref().and_then(|target_event_id| { - new_items_iter.position(|new_item| { - new_item + let found_index = target_event_id_opt + .as_ref() + .and_then(|target_event_id| new_items_iter + .position(|new_item| new_item .as_event() .is_some_and(|new_ev| new_ev.event_id() == Some(target_event_id)) - }) - }); + ) + ); if let Some(index) = found_index { target_event_id_opt.take().map(|ev| (index, ev)) @@ -4418,13 +3962,11 @@ async fn timeline_subscriber_handler( } } + let room_id = room.room_id().to_owned(); log!("Starting timeline subscriber for room {room_id}, thread {thread_root_event_id:?}..."); let (mut timeline_items, mut subscriber) = timeline.subscribe().await; - log!( - "Received initial timeline update of {} items for room {room_id}, thread {thread_root_event_id:?}.", - timeline_items.len() - ); + log!("Received initial timeline update of {} items for room {room_id}, thread {thread_root_event_id:?}.", timeline_items.len()); timeline_update_sender.send(TimelineUpdate::FirstUpdate { initial_items: timeline_items.clone(), @@ -4437,266 +3979,262 @@ async fn timeline_subscriber_handler( // the timeline index and event ID of the target event, if it has been found. let mut found_target_event_id: Option<(usize, OwnedEventId)> = None; - loop { - tokio::select! { - // we should check for new requests before handling new timeline updates, - // because the request might influence how we handle a timeline update. - biased; - - // Handle updates to the current backwards pagination requests. - Ok(()) = request_receiver.changed() => { - let prev_target_event_id = target_event_id.clone(); - let new_request_details = request_receiver - .borrow_and_update() - .iter() - .find_map(|req| req.room_id - .eq(&room_id) - .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) - ); + loop { tokio::select! { + // we should check for new requests before handling new timeline updates, + // because the request might influence how we handle a timeline update. + biased; + + // Handle updates to the current backwards pagination requests. + Ok(()) = request_receiver.changed() => { + let prev_target_event_id = target_event_id.clone(); + let new_request_details = request_receiver + .borrow_and_update() + .iter() + .find_map(|req| req.room_id + .eq(&room_id) + .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) + ); - target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); + target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); - // If we received a new request, start searching backwards for the target event. - if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { - if prev_target_event_id.as_ref() != Some(&new_target_event_id) { - let starting_index = if current_tl_len == timeline_items.len() { - starting_index - } else { - // The timeline has changed since the request was made, so we can't rely on the `starting_index`. - // Instead, we have no choice but to start from the end of the timeline. - timeline_items.len() - }; - // log!("Received new request to search for event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} starting from index {starting_index} (tl len {}).", timeline_items.len()); - // Search backwards for the target event in the timeline, starting from the given index. - if let Some(target_event_tl_index) = timeline_items - .focus() - .narrow(..starting_index) - .into_iter() - .rev() - .position(|i| i.as_event() - .and_then(|e| e.event_id()) - .is_some_and(|ev_id| ev_id == new_target_event_id) - ) - .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) - { - // log!("Found existing target event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} at index {target_event_tl_index}."); - - // Nice! We found the target event in the current timeline items, - // so there's no need to actually proceed with backwards pagination; - // thus, we can clear the locally-tracked target event ID. - target_event_id = None; - found_target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: new_target_event_id.clone(), - index: target_event_tl_index, + // If we received a new request, start searching backwards for the target event. + if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { + if prev_target_event_id.as_ref() != Some(&new_target_event_id) { + let starting_index = if current_tl_len == timeline_items.len() { + starting_index + } else { + // The timeline has changed since the request was made, so we can't rely on the `starting_index`. + // Instead, we have no choice but to start from the end of the timeline. + timeline_items.len() + }; + // log!("Received new request to search for event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} starting from index {starting_index} (tl len {}).", timeline_items.len()); + // Search backwards for the target event in the timeline, starting from the given index. + if let Some(target_event_tl_index) = timeline_items + .focus() + .narrow(..starting_index) + .into_iter() + .rev() + .position(|i| i.as_event() + .and_then(|e| e.event_id()) + .is_some_and(|ev_id| ev_id == new_target_event_id) + ) + .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) + { + // log!("Found existing target event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} at index {target_event_tl_index}."); + + // Nice! We found the target event in the current timeline items, + // so there's no need to actually proceed with backwards pagination; + // thus, we can clear the locally-tracked target event ID. + target_event_id = None; + found_target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: new_target_event_id.clone(), + index: target_event_tl_index, + } + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}, thread {thread_root_event_id:?}!") + ); + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); + } + else { + log!("Target event not in timeline. Starting backwards pagination \ + in room {room_id}, thread {thread_root_event_id:?} to find target event \ + {new_target_event_id} starting from index {starting_index}.", + ); + // If we didn't find the target event in the current timeline items, + // we need to start loading previous items into the timeline. + submit_async_request(MatrixRequest::PaginateTimeline { + timeline_kind: if let Some(thread_root_event_id) = thread_root_event_id.clone() { + TimelineKind::Thread { + room_id: room_id.clone(), + thread_root_event_id, } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}, thread {thread_root_event_id:?}!") - ); - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); - } - else { - log!("Target event not in timeline. Starting backwards pagination \ - in room {room_id}, thread {thread_root_event_id:?} to find target event \ - {new_target_event_id} starting from index {starting_index}.", - ); - // If we didn't find the target event in the current timeline items, - // we need to start loading previous items into the timeline. - submit_async_request(MatrixRequest::PaginateTimeline { - timeline_kind: if let Some(thread_root_event_id) = thread_root_event_id.clone() { - TimelineKind::Thread { - room_id: room_id.clone(), - thread_root_event_id, - } - } else { - TimelineKind::MainRoom { - room_id: room_id.clone(), - } - }, - num_events: 50, - direction: PaginationDirection::Backwards, - }); - } + } else { + TimelineKind::MainRoom { + room_id: room_id.clone(), + } + }, + num_events: 50, + direction: PaginationDirection::Backwards, + }); } } } + } - // Handle updates to the actual timeline content. - batch_opt = subscriber.next() => { - let Some(batch) = batch_opt else { break }; - let mut num_updates = 0; - let mut index_of_first_change = usize::MAX; - let mut index_of_last_change = usize::MIN; - // whether to clear the entire cache of drawn items - let mut clear_cache = false; - // whether the changes include items being appended to the end of the timeline - let mut is_append = false; - for diff in batch { - num_updates += 1; - match diff { - VectorDiff::Append { values } => { - let _values_len = values.len(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.extend(values); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } - is_append = true; - } - VectorDiff::Clear => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Clear"); } - clear_cache = true; - timeline_items.clear(); + // Handle updates to the actual timeline content. + batch_opt = subscriber.next() => { + let Some(batch) = batch_opt else { break }; + let mut num_updates = 0; + let mut index_of_first_change = usize::MAX; + let mut index_of_last_change = usize::MIN; + // whether to clear the entire cache of drawn items + let mut clear_cache = false; + // whether the changes include items being appended to the end of the timeline + let mut is_append = false; + for diff in batch { + num_updates += 1; + match diff { + VectorDiff::Append { values } => { + let _values_len = values.len(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.extend(values); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } + is_append = true; + } + VectorDiff::Clear => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Clear"); } + clear_cache = true; + timeline_items.clear(); + } + VectorDiff::PushFront { value } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushFront"); } + if let Some((index, _ev)) = found_target_event_id.as_mut() { + *index += 1; // account for this new `value` being prepended. + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); } - VectorDiff::PushFront { value } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushFront"); } - if let Some((index, _ev)) = found_target_event_id.as_mut() { - *index += 1; // account for this new `value` being prepended. - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); - } - clear_cache = true; - timeline_items.push_front(value); - } - VectorDiff::PushBack { value } => { - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.push_back(value); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } - is_append = true; + clear_cache = true; + timeline_items.push_front(value); + } + VectorDiff::PushBack { value } => { + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.push_back(value); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } + is_append = true; + } + VectorDiff::PopFront => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopFront"); } + clear_cache = true; + timeline_items.pop_front(); + if let Some((i, _ev)) = found_target_event_id.as_mut() { + *i = i.saturating_sub(1); // account for the first item being removed. } - VectorDiff::PopFront => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopFront"); } + // This doesn't affect whether we should reobtain the latest event. + } + VectorDiff::PopBack => { + timeline_items.pop_back(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); + index_of_last_change = usize::MAX; + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Insert { index, value } => { + if index == 0 { clear_cache = true; - timeline_items.pop_front(); - if let Some((i, _ev)) = found_target_event_id.as_mut() { - *i = i.saturating_sub(1); // account for the first item being removed. - } - // This doesn't affect whether we should reobtain the latest event. - } - VectorDiff::PopBack => { - timeline_items.pop_back(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); + } else { + index_of_first_change = min(index_of_first_change, index); index_of_last_change = usize::MAX; - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } } - VectorDiff::Insert { index, value } => { - if index == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = usize::MAX; - } - if index >= timeline_items.len() { - is_append = true; - } + if index >= timeline_items.len() { + is_append = true; + } - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for this new `value` being inserted before the previously-found target event's index. - if index <= *i { - *i += 1; - } - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) - .map(|(i, ev)| (i + index, ev)); + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for this new `value` being inserted before the previously-found target event's index. + if index <= *i { + *i += 1; } - - timeline_items.insert(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Set { index, value } => { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = max(index_of_last_change, index.saturating_add(1)); - timeline_items.set(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) + .map(|(i, ev)| (i + index, ev)); } - VectorDiff::Remove { index } => { - if index == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); - index_of_last_change = usize::MAX; - } - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for an item being removed before the previously-found target event's index. - if index <= *i { - *i = i.saturating_sub(1); - } - } - timeline_items.remove(index); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + + timeline_items.insert(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Set { index, value } => { + index_of_first_change = min(index_of_first_change, index); + index_of_last_change = max(index_of_last_change, index.saturating_add(1)); + timeline_items.set(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Remove { index } => { + if index == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); + index_of_last_change = usize::MAX; } - VectorDiff::Truncate { length } => { - if length == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); - index_of_last_change = usize::MAX; + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for an item being removed before the previously-found target event's index. + if index <= *i { + *i = i.saturating_sub(1); } - timeline_items.truncate(length); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } } - VectorDiff::Reset { values } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Reset, new length {}", values.len()); } - clear_cache = true; // we must assume all items have changed. - timeline_items = values; + timeline_items.remove(index); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Truncate { length } => { + if length == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); + index_of_last_change = usize::MAX; } + timeline_items.truncate(length); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Reset { values } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Reset, new length {}", values.len()); } + clear_cache = true; // we must assume all items have changed. + timeline_items = values; } } + } - if num_updates > 0 { - // Handle the case where back pagination inserts items at the beginning of the timeline - // (meaning the entire timeline needs to be re-drawn), - // but there is a virtual event at index 0 (e.g., a day divider). - // When that happens, we want the RoomScreen to treat this as if *all* events changed. - if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { - index_of_first_change = 0; - clear_cache = true; - } - - let changed_indices = index_of_first_change..index_of_last_change; + if num_updates > 0 { + // Handle the case where back pagination inserts items at the beginning of the timeline + // (meaning the entire timeline needs to be re-drawn), + // but there is a virtual event at index 0 (e.g., a day divider). + // When that happens, we want the RoomScreen to treat this as if *all* events changed. + if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { + index_of_first_change = 0; + clear_cache = true; + } - if LOG_TIMELINE_DIFFS { - log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, thread {thread_root_event_id:?}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); - } - timeline_update_sender.send(TimelineUpdate::NewItems { - new_items: timeline_items.clone(), - changed_indices, - clear_cache, - is_append, - }).expect("Error: timeline update sender couldn't send update with new items!"); - - // We must send this update *after* the actual NewItems update, - // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. - if let Some((index, found_event_id)) = found_target_event_id.take() { - target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: found_event_id.clone(), - index, - } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}, thread {thread_root_event_id:?}!") - ); - } + let changed_indices = index_of_first_change..index_of_last_change; - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); + if LOG_TIMELINE_DIFFS { + log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, thread {thread_root_event_id:?}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); + } + timeline_update_sender.send(TimelineUpdate::NewItems { + new_items: timeline_items.clone(), + changed_indices, + clear_cache, + is_append, + }).expect("Error: timeline update sender couldn't send update with new items!"); + + // We must send this update *after* the actual NewItems update, + // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. + if let Some((index, found_event_id)) = found_target_event_id.take() { + target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: found_event_id.clone(), + index, + } + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}, thread {thread_root_event_id:?}!") + ); } - } - else => { - break; + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); } } - } - error!( - "Error: unexpectedly ended timeline subscriber for room {room_id}, thread {thread_root_event_id:?}." - ); + else => { + break; + } + } } + + error!("Error: unexpectedly ended timeline subscriber for room {room_id}, thread {thread_root_event_id:?}."); } /// Spawn a new async task to fetch the room's new avatar. @@ -4721,13 +4259,8 @@ async fn room_avatar(room: &Room, room_name_id: &RoomNameId) -> FetchedRoomAvata _ => { if let Ok(room_members) = room.members(RoomMemberships::ACTIVE).await { if room_members.len() == 2 { - if let Some(non_account_member) = - room_members.iter().find(|m| !m.is_account_user()) - { - if let Ok(Some(avatar)) = non_account_member - .avatar(AVATAR_THUMBNAIL_FORMAT.into()) - .await - { + if let Some(non_account_member) = room_members.iter().find(|m| !m.is_account_user()) { + if let Ok(Some(avatar)) = non_account_member.avatar(AVATAR_THUMBNAIL_FORMAT.into()).await { return FetchedRoomAvatar::Image(avatar.into()); } } @@ -4756,8 +4289,7 @@ async fn spawn_sso_server( // Post a status update to inform the user that we're waiting for the client to be built. Cx::post_action(LoginAction::Status { title: "Initializing client...".into(), - status: "Please wait while Matrix builds and configures the client object for login." - .into(), + status: "Please wait while Matrix builds and configures the client object for login.".into(), }); // Wait for the notification that the client has been built @@ -4778,21 +4310,19 @@ async fn spawn_sso_server( // or if the homeserver_url is *not* empty and isn't the default, // we cannot use the DEFAULT_SSO_CLIENT, so we must build a new one. let mut build_client_error = None; - if client_and_session.is_none() - || (!homeserver_url.is_empty() + if client_and_session.is_none() || ( + !homeserver_url.is_empty() && homeserver_url != "matrix.org" && Url::parse(&homeserver_url) != Url::parse("https://matrix-client.matrix.org/") - && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/")) - { + && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/") + ) { match build_client( &Cli { homeserver: homeserver_url.is_empty().not().then_some(homeserver_url), ..Default::default() }, app_data_dir(), - ) - .await - { + ).await { Ok(success) => client_and_session = Some(success), Err(e) => build_client_error = Some(e), } @@ -4801,12 +4331,10 @@ async fn spawn_sso_server( let Some((client, client_session)) = client_and_session else { Cx::post_action(LoginAction::LoginFailure( if let Some(err) = build_client_error { - format!( - "Could not create client object. Please try to login again.\n\nError: {err}" - ) + format!("Could not create client object. Please try to login again.\n\nError: {err}") } else { String::from("Could not create client object. Please try to login again.") - }, + } )); // This ensures that the called to `DEFAULT_SSO_CLIENT_NOTIFIER.notified()` // at the top of this function will not block upon the next login attempt. @@ -4818,8 +4346,7 @@ async fn spawn_sso_server( let mut is_logged_in = false; Cx::post_action(LoginAction::Status { title: "Opening your browser...".into(), - status: "Please finish logging in using your browser, and then come back to Robrix." - .into(), + status: "Please finish logging in using your browser, and then come back to Robrix.".into(), }); match client .matrix_auth() @@ -4829,15 +4356,12 @@ async fn spawn_sso_server( if key == "redirectUrl" { let redirect_url = Url::parse(&value)?; Cx::post_action(LoginAction::SsoSetRedirectUrl(redirect_url)); - break; + break } } - Uri::new(&sso_url).open().map_err(|err| { - Error::Io(io::Error::other(format!( - "Unable to open SSO login url. Error: {:?}", - err - ))) - }) + Uri::new(&sso_url).open().map_err(|err| + Error::Io(io::Error::other(format!("Unable to open SSO login url. Error: {:?}", err))) + ) }) .identity_provider_id(&identity_provider_id) .initial_device_display_name(&format!("robrix-sso-{brand}")) @@ -4852,13 +4376,10 @@ async fn spawn_sso_server( }) { Ok(identity_provider_res) => { if !is_logged_in { - if let Err(e) = login_sender - .send(LoginRequest::LoginBySSOSuccess(client, client_session)) - .await - { + if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session)).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to matrix worker thread.", + "BUG: failed to send login request to matrix worker thread." ))); } enqueue_rooms_list_update(RoomsListUpdate::Status { @@ -4884,6 +4405,7 @@ async fn spawn_sso_server( }); } + bitflags! { /// The powers that a user has in a given room. #[derive(Copy, Clone, PartialEq, Eq)] @@ -4961,38 +4483,14 @@ impl UserPowerLevels { retval.set(UserPowerLevels::Invite, user_power >= power_levels.invite); retval.set(UserPowerLevels::Kick, user_power >= power_levels.kick); retval.set(UserPowerLevels::Redact, user_power >= power_levels.redact); - retval.set( - UserPowerLevels::NotifyRoom, - user_power >= power_levels.notifications.room, - ); - retval.set( - UserPowerLevels::Location, - user_power >= power_levels.for_message(MessageLikeEventType::Location), - ); - retval.set( - UserPowerLevels::Message, - user_power >= power_levels.for_message(MessageLikeEventType::Message), - ); - retval.set( - UserPowerLevels::Reaction, - user_power >= power_levels.for_message(MessageLikeEventType::Reaction), - ); - retval.set( - UserPowerLevels::RoomMessage, - user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage), - ); - retval.set( - UserPowerLevels::RoomRedaction, - user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction), - ); - retval.set( - UserPowerLevels::Sticker, - user_power >= power_levels.for_message(MessageLikeEventType::Sticker), - ); - retval.set( - UserPowerLevels::RoomPinnedEvents, - user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents), - ); + retval.set(UserPowerLevels::NotifyRoom, user_power >= power_levels.notifications.room); + retval.set(UserPowerLevels::Location, user_power >= power_levels.for_message(MessageLikeEventType::Location)); + retval.set(UserPowerLevels::Message, user_power >= power_levels.for_message(MessageLikeEventType::Message)); + retval.set(UserPowerLevels::Reaction, user_power >= power_levels.for_message(MessageLikeEventType::Reaction)); + retval.set(UserPowerLevels::RoomMessage, user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage)); + retval.set(UserPowerLevels::RoomRedaction, user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction)); + retval.set(UserPowerLevels::Sticker, user_power >= power_levels.for_message(MessageLikeEventType::Sticker)); + retval.set(UserPowerLevels::RoomPinnedEvents, user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents)); retval } @@ -5038,7 +4536,8 @@ impl UserPowerLevels { } pub fn can_send_message(self) -> bool { - self.contains(UserPowerLevels::RoomMessage) || self.contains(UserPowerLevels::Message) + self.contains(UserPowerLevels::RoomMessage) + || self.contains(UserPowerLevels::Message) } pub fn can_send_reaction(self) -> bool { @@ -5055,6 +4554,7 @@ impl UserPowerLevels { } } + /// Shuts down the current Tokio runtime completely and takes ownership to ensure proper cleanup. pub fn shutdown_background_tasks() { if let Some(runtime) = TOKIO_RUNTIME.lock().unwrap().take() { @@ -5072,16 +4572,9 @@ pub async fn clear_app_state(config: &LogoutConfig) -> Result<()> { ALL_JOINED_ROOMS.lock().unwrap().clear(); let on_clear_appstate = Arc::new(Notify::new()); - Cx::post_action(LogoutAction::ClearAppState { - on_clear_appstate: on_clear_appstate.clone(), - }); - - match tokio::time::timeout( - config.app_state_cleanup_timeout, - on_clear_appstate.notified(), - ) - .await - { + Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); + + match tokio::time::timeout(config.app_state_cleanup_timeout, on_clear_appstate.notified()).await { Ok(_) => { log!("Received signal that UI-side app state was cleaned successfully"); Ok(()) From b9b0d780633dcc15ad32465ef57e0c9fafa4b219 Mon Sep 17 00:00:00 2001 From: Alvin Date: Wed, 25 Mar 2026 15:22:03 +0800 Subject: [PATCH 23/66] refactor: migrate streaming detection from heuristic to MSC4357 Replace prefix-match + recency + not-self heuristic with deterministic MSC4357 `org.matrix.msc4357.live` field detection. This simplifies the detection path and makes streaming animation reliable for any compliant server. Key changes: - StreamingAnimState: replace sender_user_id/detection/sender_stopped_typing with is_live bool; add restore() for timeline reset preservation - room_screen: add is_msc4357_live() helper, streaming_scan_range() for bounded detection, remove heuristic detection and typing-latch logic - sliding_sync: simplify TypingUsers back to Vec - Split timeouts: 30s for finished streams, 5min for live streams --- src/home/room_screen.rs | 219 ++++++++++++++++---------------- src/home/streaming_animation.rs | 159 +++++++++++++---------- src/sliding_sync.rs | 2 +- 3 files changed, 198 insertions(+), 182 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 8c0827e66..45cf67566 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -54,8 +54,6 @@ const MAX_ITEMS_TO_SEARCH_THROUGH: usize = 100; /// The max size (width or height) of a blurhash image to decode. const BLURHASH_IMAGE_MAX_SIZE: u32 = 500; -const STREAMING_IDLE_TIMEOUT: Duration = Duration::from_secs(30); - static UNNAMED_ROOM: &str = "Unnamed Room"; /// #FFF4E5 @@ -63,14 +61,24 @@ const COLOR_THREAD_SUMMARY_BG: Vec4 = vec4(1.0, 0.957, 0.898, 1.0); /// #FFEACC const COLOR_THREAD_SUMMARY_BG_HOVER: Vec4 = vec4(1.0, 0.918, 0.8, 1.0); -fn timeline_item_event_id(item: &Arc) -> Option<&EventId> { +fn item_event_id(item: &Arc) -> Option<&EventId> { let TimelineItemKind::Event(event) = item.kind() else { return None; }; event.event_id() } -fn bounded_changed_indices( +/// Check if an event carries the MSC4357 `org.matrix.msc4357.live` field, +/// indicating that the message content is still being streamed. +fn is_msc4357_live(event_tl_item: &EventTimelineItem) -> bool { + event_tl_item.latest_json() + .and_then(|raw| raw.get_field::("content").ok()) + .flatten() + .and_then(|content| content.get("org.matrix.msc4357.live").cloned()) + .is_some() +} + +fn clamp_indices( changed_indices: &Range, old_len: usize, new_len: usize, @@ -80,7 +88,20 @@ fn bounded_changed_indices( start..end } -fn refresh_streaming_message_indices<'a, I>( +fn streaming_scan_range( + clear_cache: bool, + changed_indices: &Range, + old_len: usize, + new_len: usize, +) -> Range { + if clear_cache { + 0..new_len + } else { + clamp_indices(changed_indices, old_len, new_len) + } +} + +fn refresh_stream_indices<'a, I>( event_ids: I, streaming_messages: &mut HashMap, ) @@ -101,13 +122,12 @@ where } } -fn next_streaming_timeout_duration<'a>( +fn next_stream_timeout<'a>( states: impl IntoIterator, - idle_timeout: Duration, ) -> Option { states .into_iter() - .map(|state| idle_timeout.saturating_sub(state.last_update_time.elapsed())) + .map(|state| state.timeout_after().saturating_sub(state.last_update_time.elapsed())) .min() } @@ -773,7 +793,7 @@ impl Widget for RoomScreen { } } - self.schedule_streaming_timeout_if_needed(cx); + self.schedule_stream_timeout(cx); } if self.streaming_timeout_timer.is_event(event).is_some() { @@ -799,7 +819,7 @@ impl Widget for RoomScreen { } } - self.schedule_streaming_timeout_if_needed(cx); + self.schedule_stream_timeout(cx); } // Handle actions here before processing timeline updates. @@ -1383,24 +1403,19 @@ impl RoomScreen { } /// Extract the text body from a timeline item, if it's a text message. - fn extract_message_text_from_item(item: &Arc) -> Option { + fn extract_message_text(item: &Arc) -> Option { let TimelineItemKind::Event(event) = item.kind() else { return None }; - let TimelineItemContent::MsgLike(msg_like) = event.content() else { return None }; - let MsgLikeKind::Message(msg) = &msg_like.kind else { return None }; - match msg.msgtype() { - MessageType::Text(text) => Some(text.body.clone()), - _ => None, - } + let TimelineItemContent::MsgLike(_) = event.content() else { return None }; + Some(plaintext_body_of_timeline_item(event)) } - fn schedule_streaming_timeout_if_needed(&mut self, cx: &mut Cx) { + fn schedule_stream_timeout(&mut self, cx: &mut Cx) { cx.stop_timer(self.streaming_timeout_timer); - self.streaming_timeout_timer = next_streaming_timeout_duration( + self.streaming_timeout_timer = next_stream_timeout( self.tl_state .as_ref() .into_iter() .flat_map(|tl| tl.streaming_messages.values()), - STREAMING_IDLE_TIMEOUT, ) .map(|duration| cx.start_timeout(duration.as_secs_f64())) .unwrap_or_else(Timer::empty); @@ -1433,8 +1448,8 @@ impl RoomScreen { jump_to_bottom_button.update_visibility(cx, true); tl.items = initial_items; - refresh_streaming_message_indices( - tl.items.iter().map(timeline_item_event_id), + refresh_stream_indices( + tl.items.iter().map(item_event_id), &mut tl.streaming_messages, ); done_loading = true; @@ -1555,77 +1570,64 @@ impl RoomScreen { // log!("process_timeline_updates(): changed_indices: {changed_indices:?}, items len: {}\ncontent drawn: {:#?}\nprofile drawn: {:#?}", items.len(), tl.content_drawn_since_last_update, tl.profile_drawn_since_last_update); } - // --- Streaming detection --- - // Clear streaming state on timeline clear - if clear_cache { - tl.streaming_messages.clear(); - } + // --- MSC4357 streaming detection --- + let previous_streaming_messages = + clear_cache.then(|| std::mem::take(&mut tl.streaming_messages)); - // Compare old and new text for changed items to detect streaming - if !new_items.is_empty() && !changed_indices.is_empty() { - let current_uid = crate::sliding_sync::current_user_id(); - let changed_indices = - bounded_changed_indices(&changed_indices, tl.items.len(), new_items.len()); - - for idx in changed_indices { - let Some(old_item) = tl.items.get(idx) else { continue }; - let Some(new_item) = new_items.get(idx) else { continue }; - if timeline_item_event_id(old_item) != timeline_item_event_id(new_item) { - continue; - } + if !new_items.is_empty() { + use crate::home::streaming_animation::StreamingAnimState; - let Some(old_text) = Self::extract_message_text_from_item(old_item) else { continue }; - let Some(new_text) = Self::extract_message_text_from_item(new_item) else { continue }; - if old_text == new_text { continue; } + let mut should_schedule_frame = false; + let scan_range = streaming_scan_range( + clear_cache, + &changed_indices, + tl.items.len(), + new_items.len(), + ); - // Get event_id and sender from new item + for idx in scan_range { + let Some(new_item) = new_items.get(idx) else { continue }; let TimelineItemKind::Event(new_evt) = new_item.kind() else { continue }; let Some(event_id) = new_evt.event_id().map(|id| id.to_owned()) else { continue }; - let sender = new_evt.sender().to_owned(); + let live = is_msc4357_live(new_evt); + let Some(new_text) = Self::extract_message_text(new_item) else { continue }; - // If already tracking: just update target text if let Some(state) = tl.streaming_messages.get_mut(&event_id) { - state.update_target(&new_text); - self.streaming_next_frame = cx.new_next_frame(); + state.update_target(&new_text, live); + should_schedule_frame |= state.needs_frame(); continue; } - // Heuristic detection: prefix extension + recency + not self - let is_prefix_extension = new_text.len() > old_text.len() - && new_text.starts_with(&old_text); - - let is_recent = { - let ts = new_evt.timestamp(); - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - now_ms.saturating_sub(ts.0.into()) < 60_000 - }; - - let is_not_self = current_uid.as_ref() - .is_some_and(|uid| *uid != sender); - - if is_prefix_extension && is_recent && is_not_self { - use crate::home::streaming_animation::*; - tl.streaming_messages.insert( - event_id, - StreamingAnimState::new_from_visible_prefix( - &old_text, - &new_text, - sender, - StreamDetection::Heuristic, - ), - ); - self.streaming_next_frame = cx.new_next_frame(); + if let Some(previous_state) = previous_streaming_messages + .as_ref() + .and_then(|states| states.get(&event_id)) + { + let restored = + StreamingAnimState::restore(previous_state, &new_text, live); + let should_track = live || restored.needs_frame(); + should_schedule_frame |= restored.needs_frame(); + if should_track { + tl.streaming_messages.insert(event_id, restored); + } + continue; } + + if live { + let state = StreamingAnimState::new(&new_text, true); + should_schedule_frame |= state.needs_frame(); + tl.streaming_messages.insert(event_id, state); + } + } + + if should_schedule_frame { + self.streaming_next_frame = cx.new_next_frame(); } } // --- End streaming detection --- tl.items = new_items; - refresh_streaming_message_indices( - tl.items.iter().map(timeline_item_event_id), + refresh_stream_indices( + tl.items.iter().map(item_event_id), &mut tl.streaming_messages, ); done_loading = true; @@ -1801,20 +1803,7 @@ impl RoomScreen { // update loop has completed, which avoids unnecessary expensive work // if the list of typing users gets updated many times in a row. - // Update streaming sender_stopped_typing latch - { - let typing_user_ids: HashSet<&OwnedUserId> = - users.iter().map(|(uid, _)| uid).collect(); - for state in tl.streaming_messages.values_mut() { - state.sender_stopped_typing = - !typing_user_ids.contains(&state.sender_user_id); - } - if !tl.streaming_messages.is_empty() { - self.streaming_next_frame = cx.new_next_frame(); - } - } - // Extract display names for the typing notice widget - typing_users = Some(users.iter().map(|(_, name)| name.clone()).collect::>()); + typing_users = Some(users); } TimelineUpdate::PinnedEvents(pinned_events) => { self.pinned_events = pinned_events; @@ -1870,7 +1859,7 @@ impl RoomScreen { } if num_updates > 0 { - self.schedule_streaming_timeout_if_needed(cx); + self.schedule_stream_timeout(cx); // log!("Applied {} timeline updates for room {}, redrawing with {} items...", num_updates, tl.kind.room_id(), tl.items.len()); self.redraw(cx); } @@ -2608,7 +2597,7 @@ impl RoomScreen { // Store the tl_state for this room into this RoomScreen widget, // such that it can be accessed in future functions like event/draw handlers. self.tl_state = Some(tl_state); - self.schedule_streaming_timeout_if_needed(cx); + self.schedule_stream_timeout(cx); // Now that we have restored the TimelineUiState into this RoomScreen widget, // we can proceed to processing pending background updates. @@ -2708,8 +2697,8 @@ impl RoomScreen { tl_state.tombstone_info.as_ref(), ); - refresh_streaming_message_indices( - tl_state.items.iter().map(timeline_item_event_id), + refresh_stream_indices( + tl_state.items.iter().map(item_event_id), &mut tl_state.streaming_messages, ); @@ -2995,8 +2984,8 @@ pub enum TimelineUpdate { MediaFetched(MediaRequestParameters), /// A notice that one or more members of a this room are currently typing. TypingUsers { - /// The list of users (user_id, display_name) who are currently typing in this room. - users: Vec<(OwnedUserId, String)>, + /// The list of display names of users who are currently typing in this room. + users: Vec, }, /// The result of a pin/unpin request ([`MatrixRequest::PinEvent`]). PinResult { @@ -5095,20 +5084,29 @@ pub fn clear_timeline_states(_cx: &mut Cx) { #[cfg(test)] mod tests { use super::*; - use crate::home::streaming_animation::{StreamDetection, StreamingAnimState}; + use crate::home::streaming_animation::StreamingAnimState; use std::time::{Duration, Instant}; fn make_state(text: &str) -> StreamingAnimState { - let user_id: OwnedUserId = "@bot:example.com".try_into().unwrap(); - StreamingAnimState::new(text, user_id, StreamDetection::Heuristic) + StreamingAnimState::new(text, true) } #[test] fn test_bounded_indices_clamps_max() { - let bounded = bounded_changed_indices(&(1..usize::MAX), 3, 4); + let bounded = clamp_indices(&(1..usize::MAX), 3, 4); assert_eq!(bounded, 1..3); } + #[test] + fn test_scan_range_incremental() { + assert_eq!(streaming_scan_range(false, &(5..usize::MAX), 8, 9), 5..8); + } + + #[test] + fn test_scan_range_clear_cache() { + assert_eq!(streaming_scan_range(true, &(5..usize::MAX), 8, 9), 0..9); + } + #[test] fn test_refresh_stream_indices() { let event_id_a: OwnedEventId = "$event-a:example.com".try_into().unwrap(); @@ -5120,7 +5118,7 @@ mod tests { streaming_messages.insert(missing_event_id.clone(), make_state("missing")); let event_ids = vec![None, Some(event_id_a.as_ref()), Some(event_id_b.as_ref())]; - refresh_streaming_message_indices(event_ids.into_iter(), &mut streaming_messages); + refresh_stream_indices(event_ids.into_iter(), &mut streaming_messages); assert_eq!(streaming_messages[&event_id_a].timeline_index, Some(1)); assert_eq!(streaming_messages[&missing_event_id].timeline_index, None); @@ -5128,16 +5126,13 @@ mod tests { #[test] fn test_timeout_picks_earliest() { - let mut first = make_state("alpha"); - first.last_update_time = Instant::now() - Duration::from_secs(10); - let mut second = make_state("beta"); - second.last_update_time = Instant::now() - Duration::from_secs(29); - - let timeout = next_streaming_timeout_duration( - [&first, &second].into_iter(), - Duration::from_secs(30), - ) - .unwrap(); + let mut live = make_state("alpha"); + live.last_update_time = Instant::now() - Duration::from_secs(40); + let mut finished = make_state("beta"); + finished.is_live = false; + finished.last_update_time = Instant::now() - Duration::from_secs(29); + + let timeout = next_stream_timeout([&live, &finished].into_iter()).unwrap(); assert!(timeout <= Duration::from_secs(1)); } diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs index 4ac2fb0c1..21e992818 100644 --- a/src/home/streaming_animation.rs +++ b/src/home/streaming_animation.rs @@ -1,14 +1,10 @@ use std::time::{Duration, Instant}; -use matrix_sdk::ruma::OwnedUserId; -/// How a streaming session was detected. -#[derive(Debug, Clone, PartialEq)] -pub enum StreamDetection { - /// Detected by heuristic: prefix match + recency + not self. - Heuristic, -} +const FINISHED_STREAM_TIMEOUT: Duration = Duration::from_secs(30); +const LIVE_STREAM_STALL_TIMEOUT: Duration = Duration::from_secs(5 * 60); /// Animation state for a single streaming message. +/// Tracks an MSC4357 live message and drives character-by-character reveal. pub struct StreamingAnimState { pub target_text: String, pub target_char_count: usize, @@ -21,14 +17,13 @@ pub struct StreamingAnimState { pub animation_start_time: Instant, pub chars_at_last_update: usize, pub display_buffer: String, - pub sender_stopped_typing: bool, - pub sender_user_id: OwnedUserId, - pub detection: StreamDetection, + /// Whether the message currently carries the MSC4357 `live` field. + pub is_live: bool, pub timeline_index: Option, } impl StreamingAnimState { - pub fn new(initial_text: &str, sender_user_id: OwnedUserId, detection: StreamDetection) -> Self { + pub fn new(initial_text: &str, is_live: bool) -> Self { let char_count = initial_text.chars().count(); let now = Instant::now(); Self { @@ -43,39 +38,34 @@ impl StreamingAnimState { animation_start_time: now, chars_at_last_update: 0, display_buffer: String::with_capacity(initial_text.len() + 4), - sender_stopped_typing: false, - sender_user_id, - detection, + is_live, timeline_index: None, } } - pub fn new_from_visible_prefix( - visible_prefix: &str, - target_text: &str, - sender_user_id: OwnedUserId, - detection: StreamDetection, - ) -> Self { - let mut state = Self::new(target_text, sender_user_id, detection); - if target_text.starts_with(visible_prefix) { - state.displayed_char_count = visible_prefix.chars().count(); - state.displayed_byte_offset = visible_prefix.len(); - state.chars_at_last_update = state.displayed_char_count; - } - state.update_speed(); - state + pub fn restore(previous: &Self, new_text: &str, is_live: bool) -> Self { + let mut restored = Self::new(new_text, is_live); + let visible_prefix = &previous.target_text[..previous.displayed_byte_offset]; + let (common_chars, common_bytes) = common_prefix_len(visible_prefix, new_text); + + restored.displayed_char_count = common_chars; + restored.displayed_byte_offset = common_bytes; + restored.chars_at_last_update = common_chars; + restored.animation_start_time = previous.animation_start_time; + restored.timeline_index = previous.timeline_index; + restored.update_speed(); + restored } - pub fn update_target(&mut self, new_text: &str) { + pub fn update_target(&mut self, new_text: &str, is_live: bool) { self.target_text.clear(); self.target_text.push_str(new_text); self.target_char_count = new_text.chars().count(); - self.sender_stopped_typing = false; + self.is_live = is_live; // Clamp display pointers if the new text is shorter than what was already displayed. if self.displayed_char_count > self.target_char_count { self.displayed_char_count = self.target_char_count; - // Re-derive byte offset to stay on char boundary. self.displayed_byte_offset = self.target_text .char_indices() .nth(self.target_char_count) @@ -163,17 +153,41 @@ impl StreamingAnimState { self.displayed_char_count < self.target_char_count } - /// Check if streaming is complete. - /// Completes when the sender stops typing and all text has been revealed. + /// Streaming is complete when the live field is absent and all text has been revealed. pub fn is_complete(&self) -> bool { - if self.needs_frame() { return false; } - self.sender_stopped_typing + !self.needs_frame() && !self.is_live + } + + pub fn timeout_after(&self) -> Duration { + if self.is_live { + LIVE_STREAM_STALL_TIMEOUT + } else { + FINISHED_STREAM_TIMEOUT + } } pub fn is_timed_out(&self) -> bool { - self.last_update_time.elapsed().as_secs() > 30 + self.last_update_time.elapsed() > self.timeout_after() + } +} + +fn common_prefix_len(lhs: &str, rhs: &str) -> (usize, usize) { + let mut chars = 0; + let mut bytes = 0; + let mut lhs_chars = lhs.chars(); + + for (byte_idx, rhs_char) in rhs.char_indices() { + let Some(lhs_char) = lhs_chars.next() else { + break; + }; + if lhs_char != rhs_char { + break; + } + chars += 1; + bytes = byte_idx + rhs_char.len_utf8(); } + (chars, bytes) } #[cfg(test)] @@ -181,8 +195,7 @@ mod tests { use super::*; fn make_state(text: &str) -> StreamingAnimState { - let user_id: OwnedUserId = "@bot:example.com".try_into().unwrap(); - StreamingAnimState::new(text, user_id, StreamDetection::Heuristic) + StreamingAnimState::new(text, true) } #[test] @@ -213,8 +226,7 @@ mod tests { fn test_update_target_extends() { let mut s = make_state("Hello"); s.advance_displayed(5); - assert_eq!(s.displayed_char_count, 5); - s.update_target("Hello, world!"); + s.update_target("Hello, world!", true); assert_eq!(s.target_char_count, 13); assert_eq!(s.displayed_char_count, 5); assert!(s.chars_per_second > 0.0); @@ -224,10 +236,9 @@ mod tests { fn test_update_target_shrinks_safely() { let mut s = make_state("Hello, world!"); s.advance_displayed(10); - s.update_target("Hi"); + s.update_target("Hi", true); assert_eq!(s.displayed_char_count, 2); assert_eq!(s.displayed_byte_offset, 2); - // Should not panic s.fill_display_buffer(); assert!(s.display_buffer.starts_with("Hi")); } @@ -241,13 +252,6 @@ mod tests { assert_eq!(s.displayed_char_count, 2); } - #[test] - fn test_tick_complete_noop() { - let mut s = make_state("Hi"); - s.advance_displayed(2); - assert!(!s.tick_with_elapsed(Duration::from_secs(1))); - } - #[test] fn test_tick_large_gap() { let mut s = make_state(&"a".repeat(1000)); @@ -266,30 +270,54 @@ mod tests { } #[test] - fn test_is_complete_heuristic() { + fn test_is_complete_msc4357() { let mut s = make_state("Hi"); s.advance_displayed(2); + // is_live=true → not complete even though all text revealed assert!(!s.is_complete()); - s.sender_stopped_typing = true; + // Simulate final edit without live field + s.is_live = false; assert!(s.is_complete()); } #[test] - fn test_visible_prefix_preserved() { - let user_id: OwnedUserId = "@bot:example.com".try_into().unwrap(); - let s = StreamingAnimState::new_from_visible_prefix( - "Hello", "Hello, world!", user_id, StreamDetection::Heuristic, - ); - assert_eq!(s.displayed_char_count, 5); - assert_eq!(&s.target_text[..s.displayed_byte_offset], "Hello"); + fn test_update_target_sets_live() { + let mut s = make_state("Hello"); + assert!(s.is_live); + s.update_target("Hello, world!", false); + assert!(!s.is_live); + } + + #[test] + fn test_restore_keeps_prefix() { + let mut prev = make_state("Hello, world!"); + prev.advance_displayed(5); + let restored = StreamingAnimState::restore(&prev, "Hello, world!!!", true); + assert_eq!(restored.displayed_char_count, 5); + assert_eq!(&restored.target_text[..restored.displayed_byte_offset], "Hello"); + } + + #[test] + fn test_restore_clamps_prefix() { + let mut prev = make_state("Hello, world!"); + prev.advance_displayed(12); + let restored = StreamingAnimState::restore(&prev, "Hello there", true); + assert_eq!(&restored.target_text[..restored.displayed_byte_offset], "Hello"); } #[test] - fn test_update_target_resets_typing() { + fn test_live_stream_survives_30s() { let mut s = make_state("Hello"); - s.sender_stopped_typing = true; - s.update_target("Hello, world!"); - assert!(!s.sender_stopped_typing); + s.last_update_time = Instant::now() - Duration::from_secs(31); + assert!(!s.is_timed_out()); + } + + #[test] + fn test_finished_stream_times_out() { + let mut s = make_state("Hello"); + s.is_live = false; + s.last_update_time = Instant::now() - Duration::from_secs(31); + assert!(s.is_timed_out()); } #[test] @@ -307,11 +335,4 @@ mod tests { assert_eq!(s.displayed_char_count, 0); } - #[test] - fn test_advance_zero_is_noop() { - let mut s = make_state("Hello"); - s.advance_displayed(0); - assert_eq!(s.displayed_char_count, 0); - assert_eq!(s.displayed_byte_offset, 0); - } } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index e2c1bb34b..84997adec 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1461,7 +1461,7 @@ async fn matrix_worker_task( .flatten() .and_then(|m| m.display_name().map(|d| d.to_owned())) .unwrap_or_else(|| user_id.to_string()); - users.push((user_id, display_name)); + users.push(display_name); } if let Err(e) = timeline_update_sender.send(TimelineUpdate::TypingUsers { users }) { error!("Error: timeline update sender couldn't send the list of typing users: {e:?}"); From c7465b8d428ccdf0dd0dbc8f8ebc39cf17b7a2d3 Mon Sep 17 00:00:00 2001 From: Alvin Date: Wed, 25 Mar 2026 17:04:28 +0800 Subject: [PATCH 24/66] fix: scan appended items for MSC4357 and clean up completed streams promptly Bug 1: streaming_scan_range reused clamp_indices which clamped end to min(old_len, new_len), making PushBack/Append (changed_indices=old_len..new_len) produce an empty range. New live messages were never detected. Fix: clamp directly to new_len so appended items are scanned. Bug 2: when the final live=false update arrived with text already fully revealed, needs_frame()=false meant no NextFrame was scheduled, so the completed state lingered with cursor until timeout. Fix: also schedule a frame when is_complete() becomes true. --- src/home/room_screen.rs | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 45cf67566..4ca57a097 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -78,26 +78,18 @@ fn is_msc4357_live(event_tl_item: &EventTimelineItem) -> bool { .is_some() } -fn clamp_indices( - changed_indices: &Range, - old_len: usize, - new_len: usize, -) -> Range { - let end = changed_indices.end.min(old_len.min(new_len)); - let start = changed_indices.start.min(end); - start..end -} - fn streaming_scan_range( clear_cache: bool, changed_indices: &Range, - old_len: usize, + _old_len: usize, new_len: usize, ) -> Range { if clear_cache { 0..new_len } else { - clamp_indices(changed_indices, old_len, new_len) + let start = changed_indices.start.min(new_len); + let end = changed_indices.end.min(new_len); + start..end } } @@ -1594,7 +1586,8 @@ impl RoomScreen { if let Some(state) = tl.streaming_messages.get_mut(&event_id) { state.update_target(&new_text, live); - should_schedule_frame |= state.needs_frame(); + // Schedule frame for animation OR for cleanup of just-completed state + should_schedule_frame |= state.needs_frame() || state.is_complete(); continue; } @@ -5092,14 +5085,20 @@ mod tests { } #[test] - fn test_bounded_indices_clamps_max() { - let bounded = clamp_indices(&(1..usize::MAX), 3, 4); - assert_eq!(bounded, 1..3); + fn test_scan_range_incremental() { + // changed_indices 5..MAX clamped to new_len=9 → 5..9 (covers appended items) + assert_eq!(streaming_scan_range(false, &(5..usize::MAX), 8, 9), 5..9); } #[test] - fn test_scan_range_incremental() { - assert_eq!(streaming_scan_range(false, &(5..usize::MAX), 8, 9), 5..8); + fn test_scan_range_append() { + // PushBack: old_len=8, new_len=9, changed_indices=8..9 → 8..9 (new item scanned) + assert_eq!(streaming_scan_range(false, &(8..9), 8, 9), 8..9); + } + + #[test] + fn test_scan_range_empty_when_no_changes() { + assert_eq!(streaming_scan_range(false, &(8..8), 8, 8), 8..8); } #[test] From a8d7578b217b266829d3240648373d394c8ebf00 Mon Sep 17 00:00:00 2001 From: Alvin Date: Wed, 25 Mar 2026 18:55:13 +0800 Subject: [PATCH 25/66] test: consolidate streaming animation tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge related test cases to reduce duplication: - 2 restore tests → 1 (test_restore_preserves_common_prefix) - 2 timeout tests → 1 (test_timeout_split_by_live_state) - 4 scan_range tests → 1 (test_streaming_scan_range) - Remove test_needs_frame_when_caught_up (covered by test_is_complete_msc4357) 85 → 79 tests, same coverage. --- src/home/room_screen.rs | 20 ++++----------- src/home/streaming_animation.rs | 45 +++++++++++++-------------------- 2 files changed, 23 insertions(+), 42 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 4ca57a097..0233b32b4 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -5085,24 +5085,14 @@ mod tests { } #[test] - fn test_scan_range_incremental() { - // changed_indices 5..MAX clamped to new_len=9 → 5..9 (covers appended items) + fn test_streaming_scan_range() { + // Incremental: clamp sentinel to new_len assert_eq!(streaming_scan_range(false, &(5..usize::MAX), 8, 9), 5..9); - } - - #[test] - fn test_scan_range_append() { - // PushBack: old_len=8, new_len=9, changed_indices=8..9 → 8..9 (new item scanned) + // Append: new item at end is scanned assert_eq!(streaming_scan_range(false, &(8..9), 8, 9), 8..9); - } - - #[test] - fn test_scan_range_empty_when_no_changes() { + // No changes: empty range assert_eq!(streaming_scan_range(false, &(8..8), 8, 8), 8..8); - } - - #[test] - fn test_scan_range_clear_cache() { + // Clear cache: full scan assert_eq!(streaming_scan_range(true, &(5..usize::MAX), 8, 9), 0..9); } diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs index 21e992818..886f0e1ed 100644 --- a/src/home/streaming_animation.rs +++ b/src/home/streaming_animation.rs @@ -289,42 +289,33 @@ mod tests { } #[test] - fn test_restore_keeps_prefix() { + fn test_restore_preserves_common_prefix() { + // Extension: keep what was already displayed let mut prev = make_state("Hello, world!"); prev.advance_displayed(5); let restored = StreamingAnimState::restore(&prev, "Hello, world!!!", true); assert_eq!(restored.displayed_char_count, 5); assert_eq!(&restored.target_text[..restored.displayed_byte_offset], "Hello"); - } - - #[test] - fn test_restore_clamps_prefix() { - let mut prev = make_state("Hello, world!"); - prev.advance_displayed(12); - let restored = StreamingAnimState::restore(&prev, "Hello there", true); - assert_eq!(&restored.target_text[..restored.displayed_byte_offset], "Hello"); - } - #[test] - fn test_live_stream_survives_30s() { - let mut s = make_state("Hello"); - s.last_update_time = Instant::now() - Duration::from_secs(31); - assert!(!s.is_timed_out()); + // Divergence: clamp to the common prefix + let mut prev2 = make_state("Hello, world!"); + prev2.advance_displayed(12); + let restored2 = StreamingAnimState::restore(&prev2, "Hello there", true); + assert_eq!(&restored2.target_text[..restored2.displayed_byte_offset], "Hello"); } #[test] - fn test_finished_stream_times_out() { - let mut s = make_state("Hello"); - s.is_live = false; - s.last_update_time = Instant::now() - Duration::from_secs(31); - assert!(s.is_timed_out()); - } - - #[test] - fn test_needs_frame_when_caught_up() { - let mut s = make_state("Hello"); - s.advance_displayed(5); - assert!(!s.needs_frame()); + fn test_timeout_split_by_live_state() { + // Live stream survives 31s idle (5min stall timeout) + let mut live = make_state("Hello"); + live.last_update_time = Instant::now() - Duration::from_secs(31); + assert!(!live.is_timed_out()); + + // Finished stream times out after 31s (30s cleanup timeout) + let mut finished = make_state("Hello"); + finished.is_live = false; + finished.last_update_time = Instant::now() - Duration::from_secs(31); + assert!(finished.is_timed_out()); } #[test] From 7cae0cceb9f90812a5efcb25a04959ab276b6b8c 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: Thu, 26 Mar 2026 00:14:16 +0800 Subject: [PATCH 26/66] Finish create room space and users --- src/app.rs | 20 + src/home/add_room.rs | 860 +++++++++++++++++++++++++++++++++++++++- src/home/rooms_list.rs | 97 ++++- src/home/space_lobby.rs | 70 ++++ src/sliding_sync.rs | 118 +++++- 5 files changed, 1156 insertions(+), 9 deletions(-) diff --git a/src/app.rs b/src/app.rs index f04e177d5..433392239 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,6 +8,7 @@ use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, RoomId}}; use serde::{Deserialize, Serialize}; use crate::{ avatar_cache::clear_avatar_cache, home::{ + add_room::{CreateRoomModalAction, CreateRoomModalWidgetRefExt}, event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, invite_screen::InviteScreenWidgetRefExt, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, space_lobby::SpaceLobbyScreenWidgetRefExt }, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt @@ -105,6 +106,12 @@ script_mod! { } } + create_room_modal := Modal { + content +: { + create_room_modal_inner := CreateRoomModal {} + } + } + // Show the logout confirmation modal. logout_confirm_modal := Modal { content +: { @@ -557,6 +564,19 @@ impl MatchEvent for App { _ => {} } + match action.downcast_ref() { + Some(CreateRoomModalAction::Open { parent_space_id }) => { + self.ui.create_room_modal(cx, ids!(create_room_modal_inner)).show(cx, parent_space_id.clone()); + self.ui.modal(cx, ids!(create_room_modal)).open(cx); + continue; + } + Some(CreateRoomModalAction::Close) => { + self.ui.modal(cx, ids!(create_room_modal)).close(cx); + continue; + } + _ => {} + } + // Handle EventSourceModalAction to open/close the event source modal. match action.downcast_ref() { Some(EventSourceModalAction::Open { room_id, event_id, original_json }) => { diff --git a/src/home/add_room.rs b/src/home/add_room.rs index 981369897..a6a32edc7 100644 --- a/src/home/add_room.rs +++ b/src/home/add_room.rs @@ -3,15 +3,138 @@ use makepad_widgets::*; use matrix_sdk::RoomState; -use ruma::{IdParseError, MatrixToUri, MatrixUri, OwnedRoomOrAliasId, OwnedServerName, matrix_uri::MatrixId, room::{JoinRuleSummary, RoomType}}; - -use crate::{app::AppStateAction, home::invite_screen::JoinRoomResultAction, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{avatar::AvatarWidgetRefExt, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{MatrixRequest, submit_async_request}, utils}; +use ruma::{IdParseError, MatrixToUri, MatrixUri, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, matrix_uri::MatrixId, room::{JoinRuleSummary, RoomType}}; + +use crate::{ + app::AppStateAction, + home::{invite_screen::JoinRoomResultAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, rooms_list::RoomsListRef}, + profile::user_profile::UserProfile, + room::{BasicRoomDetails, FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, + shared::{ + avatar::{AvatarState, AvatarWidgetRefExt}, + popup_list::{PopupKind, enqueue_popup_notification}, + styles::COLOR_FG_DANGER_RED, + }, + sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, + space_service_sync::SpaceRequest, + utils::{self, RoomNameId}, +}; script_mod! { use mod.prelude.widgets.* use mod.widgets.* + mod.widgets.CreateRoomForm = set_type_default() do #(CreateRoomForm::register_widget(vm)) { + ..mod.widgets.View + + width: Fill + height: Fit + flow: Down + + create_room_help := Label { + width: Fill, height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + text: "Create a standalone room, or attach it under a space where you can create child rooms." + } + + create_room_view := View { + width: Fill + height: Fit + margin: Inset{ top: 6, bottom: 10 } + spacing: 8 + flow: Down + + create_room_space_row := View { + width: Fill + height: Fit + margin: Inset{left: 5, right: 5} + spacing: 10 + align: Align{y: 0.5} + flow: Right + + create_room_space_dropdown := DropDownFlat { + width: Fill { max: 400 } + height: 40 + labels: ["Create without a space"] + } + + create_room_space_hint := Label { + width: Fill, height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + text: "Choose a space where you have permission to create child rooms." + } + } + + create_room_name_input := RobrixTextInput { + margin: Inset{left: 5, right: 5} + padding: Inset{left: 12, right: 12, top: 11, bottom: 0} + width: Fill { max: 400 } + height: 40 + empty_text: "Enter the new room name..." + } + + create_room_feedback := View { + visible: false + width: Fill + height: Fit + margin: Inset{left: 5, right: 5, top: 6} + spacing: 8 + align: Align{y: 0.5} + flow: Right + + create_room_feedback_spinner_wrap := View { + width: Fit + height: Fit + + create_room_feedback_spinner := LoadingSpinner { + width: 16 + height: 16 + draw_bg +: { + color: (COLOR_ACTIVE_PRIMARY) + border_size: 2.0 + } + } + } + + create_room_feedback_label := Label { + width: Fill + height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + } + } + + create_room_button_row := View { + width: Fill + height: Fit + margin: Inset{top: 4} + padding: Inset{left: 5} + flow: Right + + create_room_button := RobrixPositiveIconButton { + width: Fit + padding: Inset{top: 10, bottom: 10, left: 12, right: 14} + height: 40 + draw_icon.svg: (ICON_ADD) + icon_walk: Walk{width: 16, height: 16} + text: "Create room" + } + } + } + } + // The main view that allows the user to add (join) or explore new rooms/spaces. mod.widgets.AddRoomScreen = #(AddRoomScreen::register_widget(vm)) { @@ -35,6 +158,58 @@ script_mod! { LineH { padding: 10, margin: Inset{top: 10, right: 2} } + SubsectionLabel { + margin: Inset{top: 8} + text: "Create a new room:" + } + + create_room_form := mod.widgets.CreateRoomForm {} + + LineH { padding: 10, margin: Inset{right: 2} } + + SubsectionLabel { + margin: Inset{top: 4} + text: "Add a friend:" + } + + add_friend_help := Label { + width: Fill, height: Fit + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + text: "Enter a Matrix user ID to open or create a direct message room." + } + + add_friend_view := View { + width: Fill + height: Fit + margin: Inset{ top: 6, bottom: 10 } + align: Align{y: 0.5} + spacing: 5 + flow: Right + + friend_user_id_input := RobrixTextInput { + align: Align{y: 0.5} + margin: Inset{top: 0, left: 5, right: 5, bottom: 0} + padding: Inset{left: 12, right: 12, top: 11, bottom: 0} + width: Fill { max: 400 } + height: 40 + empty_text: "Enter a Matrix user ID, like @alice:matrix.org..." + } + + add_friend_button := RobrixIconButton { + padding: Inset{top: 10, bottom: 10, left: 12, right: 14} + height: 40 + draw_icon.svg: (ICON_ADD_USER) + icon_walk: Walk{width: 16, height: 16} + text: "Add friend" + } + } + + LineH { padding: 10, margin: Inset{right: 2} } + SubsectionLabel { text: "Join an existing room or space:" } @@ -250,6 +425,85 @@ script_mod! { } } + + mod.widgets.CreateRoomModal = #(CreateRoomModal::register_widget(vm)) { + width: Fit + height: Fit + + RoundedView { + width: 500 + height: Fit + align: Align{x: 0.5} + flow: Down + padding: Inset{top: 24, right: 24, bottom: 20, left: 24} + + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 4.0 + } + + title_view := View { + width: Fill + height: Fit + padding: Inset{top: 0, bottom: 20} + align: Align{x: 0.5, y: 0.0} + + title := Label { + width: Fill + height: Fit + align: Align{x: 0.5} + flow: Flow.Right{wrap: true} + draw_text +: { + text_style: TITLE_TEXT {font_size: 13} + color: #000 + } + text: "Create New Room" + } + } + + subtitle := Label { + width: Fill + height: Fit + margin: Inset{bottom: 10} + flow: Flow.Right{wrap: true} + draw_text +: { + color: (MESSAGE_TEXT_COLOR) + text_style: MESSAGE_TEXT_STYLE { font_size: 11 } + } + text: "Create a new room directly inside the selected space." + } + + create_room_form := mod.widgets.CreateRoomForm {} + + buttons_view := View { + width: Fill + height: Fit + flow: Right + padding: Inset{top: 16, bottom: 5} + align: Align{x: 1.0, y: 0.5} + spacing: 12 + + create_button := RobrixPositiveIconButton { + width: 140 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_ADD) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } + text: "Create room" + } + + cancel_button := RobrixNeutralIconButton { + width: 120 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_FORBIDDEN) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } + text: "Cancel" + } + } + } + } } #[derive(Script, ScriptHook, Widget)] @@ -258,6 +512,13 @@ pub struct AddRoomScreen { #[rust] state: AddRoomState, /// The function to perform when the user clicks the `join_room_button`. #[rust(JoinButtonFunction::None)] join_function: JoinButtonFunction, + #[rust(false)] adding_friend: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CreateRoomContext { + AddRoomPage, + SpaceLobbyModal, } #[derive(Default)] @@ -345,6 +606,402 @@ impl AddRoomState { } } +#[derive(Script, ScriptHook, Widget)] +pub struct CreateRoomForm { + #[deref] view: View, + #[rust(CreateRoomContext::AddRoomPage)] context: CreateRoomContext, + #[rust(false)] creating_room: bool, + #[rust(None)] pending_created_room: Option, + #[rust(Vec::new())] creatable_spaces: Vec, + #[rust(None)] preferred_parent_space_id: Option, + #[rust(None)] fixed_parent_space_id: Option, +} + +impl Widget for CreateRoomForm { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + self.widget_match_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let create_room_text_is_empty = self.view + .text_input(cx, ids!(create_room_name_input)) + .text() + .trim() + .is_empty(); + self.view.button(cx, ids!(create_room_button)) + .set_enabled(cx, !self.is_busy() && !create_room_text_is_empty); + + let selected_space_id = self.selected_parent_space_id( + self.view.drop_down(cx, ids!(create_room_space_dropdown)).selected_item(), + ); + let create_room_space_hint = self.view.label(cx, ids!(create_room_space_hint)); + update_space_hint( + cx, + &create_room_space_hint, + &self.creatable_spaces, + selected_space_id.as_ref(), + ); + + self.sync_mode_views(cx); + + self.view.draw_walk(cx, scope, walk) + } +} + +impl WidgetMatchEvent for CreateRoomForm { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let create_room_name_input = self.view.text_input(cx, ids!(create_room_name_input)); + let create_room_button = self.view.button(cx, ids!(create_room_button)); + let create_room_space_dropdown = self.view.drop_down(cx, ids!(create_room_space_dropdown)); + let create_room_space_hint = self.view.label(cx, ids!(create_room_space_hint)); + + if let Some(text) = create_room_name_input.changed(actions) { + if !self.is_busy() { + self.clear_feedback(cx); + } + create_room_button.set_enabled(cx, !self.is_busy() && !text.trim().is_empty()); + } + + if create_room_space_dropdown.changed(actions).is_some() { + self.preferred_parent_space_id = + selected_creatable_space(&self.creatable_spaces, create_room_space_dropdown.selected_item()); + update_space_hint( + cx, + &create_room_space_hint, + &self.creatable_spaces, + self.preferred_parent_space_id.as_ref(), + ); + self.view.redraw(cx); + } + + let create_room_request = create_room_button.clicked(actions) + || create_room_name_input.returned(actions).is_some(); + if create_room_request { + let _ = self.submit(cx); + } + + for action in actions { + if let Some(create_room_action) = action.downcast_ref() { + match create_room_action { + CreateRoomAction::Created { room_name_id, parent_space_id, space_link_error, context } + if context == &self.context => + { + self.creating_room = false; + create_room_name_input.set_text(cx, ""); + create_room_button.set_enabled(cx, false); + + if let Some(space_id) = parent_space_id { + refresh_space_children(cx, space_id); + } + + let mut popup_message = format!("Successfully created room \"{}\".", room_name_id); + let popup_kind = if let Some(link_error) = space_link_error { + popup_message.push_str(&format!( + "\n\nThe room was created, but it could not be linked into the selected space.\nError: {link_error}" + )); + PopupKind::Warning + } else { + PopupKind::Success + }; + enqueue_popup_notification(popup_message, popup_kind, Some(5.0)); + + if cx.has_global::() + && cx.get_global::().is_room_loaded(room_name_id.room_id()) + { + self.clear_feedback(cx); + if self.context == CreateRoomContext::SpaceLobbyModal { + cx.action(CreateRoomModalAction::Close); + } + cx.action(AppStateAction::NavigateToRoom { + room_to_close: None, + destination_room: BasicRoomDetails::Name(room_name_id.clone()), + }); + } else { + self.pending_created_room = Some(room_name_id.clone()); + let feedback_text = match (parent_space_id.as_ref(), space_link_error.as_ref()) { + (Some(_), None) => "Room created. Syncing it into the space...", + (Some(_), Some(_)) => "Room created, but linking it into the space failed. Opening the room...", + (None, _) => "Room created. Opening the room...", + }; + self.set_feedback(cx, feedback_text, true, false); + } + + self.view.redraw(cx); + } + CreateRoomAction::Failed { room_name, error, context } + if context == &self.context => + { + self.creating_room = false; + create_room_button.set_enabled(cx, !create_room_name_input.text().trim().is_empty()); + self.set_feedback( + cx, + &format!("Failed to create room: {error}"), + false, + true, + ); + enqueue_popup_notification( + format!("Failed to create room \"{room_name}\".\n\nError: {error}"), + PopupKind::Error, + None, + ); + self.view.redraw(cx); + } + _ => {} + } + } + + if let Some(CreatableSpacesAction::Loaded { spaces }) = action.downcast_ref() { + self.creatable_spaces = spaces.clone(); + sync_space_dropdown( + cx, + &create_room_space_dropdown, + &create_room_space_hint, + &self.creatable_spaces, + self.preferred_parent_space_id.as_ref(), + ); + self.sync_mode_views(cx); + self.view.redraw(cx); + } + + if let Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) = action.downcast_ref() + && self.pending_created_room.as_ref().is_some_and(|pending| pending.room_id() == room_name_id.room_id()) + { + self.pending_created_room = None; + self.clear_feedback(cx); + if self.context == CreateRoomContext::SpaceLobbyModal { + cx.action(CreateRoomModalAction::Close); + } + cx.action(AppStateAction::NavigateToRoom { + room_to_close: None, + destination_room: BasicRoomDetails::Name(room_name_id.clone()), + }); + } + } + } +} + +impl CreateRoomForm { + fn can_submit(&self, cx: &mut Cx) -> bool { + !self.is_busy() + && !self.view + .text_input(cx, ids!(create_room_name_input)) + .text() + .trim() + .is_empty() + } + + fn is_busy(&self) -> bool { + self.creating_room || self.pending_created_room.is_some() + } + + fn set_feedback(&mut self, cx: &mut Cx, text: &str, show_spinner: bool, is_error: bool) { + self.view.view(cx, ids!(create_room_feedback)).set_visible(cx, true); + self.view.view(cx, ids!(create_room_feedback_spinner_wrap)) + .set_visible(cx, show_spinner); + let mut feedback_label = self.view.label(cx, ids!(create_room_feedback_label)); + feedback_label.set_text(cx, text); + script_apply_eval!(cx, feedback_label, { + draw_text +: { + color: #( + if is_error { + COLOR_FG_DANGER_RED + } else { + vec4(0.2, 0.2, 0.2, 1.0) + } + ) + } + }); + } + + fn clear_feedback(&mut self, cx: &mut Cx) { + self.view.view(cx, ids!(create_room_feedback)).set_visible(cx, false); + self.view.label(cx, ids!(create_room_feedback_label)).set_text(cx, ""); + } + + fn submit(&mut self, cx: &mut Cx) -> bool { + if !self.can_submit(cx) { + return false; + } + + let room_name = self.view.text_input(cx, ids!(create_room_name_input)).text(); + let room_name = room_name.trim(); + let parent_space_id = self.selected_parent_space_id( + self.view.drop_down(cx, ids!(create_room_space_dropdown)).selected_item(), + ); + + self.creating_room = true; + self.set_feedback(cx, "Creating room...", true, false); + submit_async_request(MatrixRequest::CreateRoom { + room_name: room_name.to_owned(), + parent_space_id, + context: self.context.clone(), + }); + self.view.redraw(cx); + true + } + + pub fn prepare( + &mut self, + cx: &mut Cx, + preferred_parent_space_id: Option, + context: CreateRoomContext, + clear_room_name: bool, + ) { + self.context = context; + self.creating_room = false; + self.pending_created_room = None; + self.preferred_parent_space_id = preferred_parent_space_id; + self.fixed_parent_space_id = (self.context == CreateRoomContext::SpaceLobbyModal) + .then_some(self.preferred_parent_space_id.clone()) + .flatten(); + + let create_room_name_input = self.view.text_input(cx, ids!(create_room_name_input)); + let create_room_button = self.view.button(cx, ids!(create_room_button)); + let create_room_space_dropdown = self.view.drop_down(cx, ids!(create_room_space_dropdown)); + let create_room_space_hint = self.view.label(cx, ids!(create_room_space_hint)); + + if clear_room_name { + create_room_name_input.set_text(cx, ""); + } + self.clear_feedback(cx); + create_room_button.set_enabled(cx, !create_room_name_input.text().trim().is_empty()); + create_room_button.set_text(cx, "Create room"); + create_room_button.reset_hover(cx); + + sync_space_dropdown( + cx, + &create_room_space_dropdown, + &create_room_space_hint, + &self.creatable_spaces, + self.preferred_parent_space_id.as_ref(), + ); + self.sync_mode_views(cx); + + if self.fixed_parent_space_id.is_none() { + submit_async_request(MatrixRequest::GetCreatableSpaces); + } + create_room_name_input.set_key_focus(cx); + self.view.redraw(cx); + } + + pub fn refresh_creatable_spaces(&mut self, _cx: &mut Cx) { + submit_async_request(MatrixRequest::GetCreatableSpaces); + } + + fn selected_parent_space_id(&self, dropdown_index: usize) -> Option { + self.fixed_parent_space_id.clone() + .or_else(|| selected_creatable_space(&self.creatable_spaces, dropdown_index)) + } + + fn sync_mode_views(&mut self, cx: &mut Cx) { + let show_fixed_parent = self.fixed_parent_space_id.is_some(); + self.view.view(cx, ids!(create_room_space_row)).set_visible(cx, !show_fixed_parent); + self.view.view(cx, ids!(create_room_button_row)).set_visible(cx, !show_fixed_parent); + + let help_text = if show_fixed_parent { + "Enter a room name. It will be created directly in this space." + } else { + "Create a standalone room, or attach it under a space where you can create child rooms." + }; + self.view.label(cx, ids!(create_room_help)).set_text(cx, help_text); + } +} + +impl CreateRoomFormRef { + pub fn can_submit(&self, cx: &mut Cx) -> bool { + self.borrow().is_some_and(|inner| inner.can_submit(cx)) + } + + pub fn is_busy(&self) -> bool { + self.borrow().is_some_and(|inner| inner.is_busy()) + } + + pub fn submit(&self, cx: &mut Cx) -> bool { + self.borrow_mut().is_some_and(|mut inner| inner.submit(cx)) + } + + pub fn prepare( + &self, + cx: &mut Cx, + preferred_parent_space_id: Option, + context: CreateRoomContext, + clear_room_name: bool, + ) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.prepare(cx, preferred_parent_space_id, context, clear_room_name); + } + + pub fn refresh_creatable_spaces(&self, cx: &mut Cx) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.refresh_creatable_spaces(cx); + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct CreateRoomModal { + #[deref] view: View, +} + +impl Widget for CreateRoomModal { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + self.widget_match_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let create_room_form = self.view.create_room_form(cx, ids!(create_room_form)); + let is_busy = create_room_form.is_busy(); + let create_button = self.view.button(cx, ids!(create_button)); + let can_submit = create_room_form.can_submit(cx); + create_button.set_enabled(cx, can_submit); + create_button.set_text(cx, if is_busy { "Syncing..." } else { "Create room" }); + self.view.button(cx, ids!(cancel_button)).set_enabled(cx, !is_busy); + self.view.draw_walk(cx, scope, walk) + } +} + +impl WidgetMatchEvent for CreateRoomModal { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let create_room_form = self.view.create_room_form(cx, ids!(create_room_form)); + let create_button = self.view.button(cx, ids!(create_button)); + let cancel_button = self.view.button(cx, ids!(cancel_button)); + if create_button.clicked(actions) { + let _ = create_room_form.submit(cx); + } + let cancel_clicked = cancel_button.clicked(actions); + if !create_room_form.is_busy() + && (cancel_clicked || actions.iter().any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed)))) + { + if cancel_clicked { + cx.action(CreateRoomModalAction::Close); + } + } + } +} + +impl CreateRoomModal { + pub fn show(&mut self, cx: &mut Cx, preferred_parent_space_id: Option) { + self.view.create_room_form(cx, ids!(create_room_form)).prepare( + cx, + preferred_parent_space_id, + CreateRoomContext::SpaceLobbyModal, + true, + ); + self.view.button(cx, ids!(create_button)).set_text(cx, "Create room"); + self.view.button(cx, ids!(create_button)).reset_hover(cx); + self.view.button(cx, ids!(cancel_button)).reset_hover(cx); + self.view.redraw(cx); + } +} + +impl CreateRoomModalRef { + pub fn show(&self, cx: &mut Cx, preferred_parent_space_id: Option) { + let Some(mut inner) = self.borrow_mut() else { return }; + inner.show(cx, preferred_parent_space_id); + } +} + impl Widget for AddRoomScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.view.handle_event(cx, event, scope); @@ -352,6 +1009,8 @@ impl Widget for AddRoomScreen { if let Event::Actions(actions) = event { let room_alias_id_input = self.view.text_input(cx, ids!(room_alias_id_input)); let search_for_room_button = self.view.button(cx, ids!(search_for_room_button)); + let friend_user_id_input = self.view.text_input(cx, ids!(friend_user_id_input)); + let add_friend_button = self.view.button(cx, ids!(add_friend_button)); let cancel_button = self.view.button(cx, ids!(fetched_room_summary.buttons_view.cancel_button)); let join_room_button = self.view.button(cx, ids!(fetched_room_summary.buttons_view.join_room_button)); @@ -359,6 +1018,48 @@ impl Widget for AddRoomScreen { if let Some(text) = room_alias_id_input.changed(actions) { search_for_room_button.set_enabled(cx, !text.trim().is_empty()); } + if let Some(text) = friend_user_id_input.changed(actions) { + add_friend_button.set_enabled(cx, !self.adding_friend && !text.trim().is_empty()); + } + + let add_friend_request = add_friend_button.clicked(actions) + .then(|| friend_user_id_input.text()) + .or_else(|| friend_user_id_input.returned(actions).map(|(t, _)| t)); + if let Some(user_id_str) = add_friend_request { + let user_id_str = user_id_str.trim(); + if !user_id_str.is_empty() { + match user_id_str.parse::() { + Ok(user_id) => { + if current_user_id().as_ref().is_some_and(|current| current == &user_id) { + enqueue_popup_notification( + "You cannot add yourself as a friend.".to_string(), + PopupKind::Warning, + Some(4.0), + ); + } else { + self.adding_friend = true; + add_friend_button.set_enabled(cx, false); + submit_async_request(MatrixRequest::OpenOrCreateDirectMessage { + user_profile: UserProfile { + user_id, + username: None, + avatar_state: AvatarState::Unknown, + }, + allow_create: false, + }); + } + } + Err(e) => { + enqueue_popup_notification( + format!("Invalid Matrix user ID.\n\nError: {e}"), + PopupKind::Error, + None, + ); + friend_user_id_input.set_key_focus(cx); + } + } + } + } // If the cancel button was clicked, hide the room preview and return to default state. if cancel_button.clicked(actions) { @@ -527,6 +1228,24 @@ impl Widget for AddRoomScreen { } for action in actions { + if matches!( + action.downcast_ref(), + Some( + DirectMessageRoomAction::FoundExisting { .. } + | DirectMessageRoomAction::DidNotExist { .. } + | DirectMessageRoomAction::NewlyCreated { .. } + | DirectMessageRoomAction::FailedToCreate { .. } + ) + ) { + self.adding_friend = false; + add_friend_button.set_enabled(cx, !friend_user_id_input.text().trim().is_empty()); + } + + if let Some(NavigationBarAction::TabSelected(SelectedTab::AddRoom)) = action.downcast_ref() { + self.view.create_room_form(cx, ids!(create_room_form)) + .prepare(cx, None, CreateRoomContext::AddRoomPage, false); + } + // If the room/space the user is searching for has been loaded from the homeserver // (e.g., by getting invited to it, or joining it in another client), // then update the state of @@ -542,6 +1261,14 @@ impl Widget for AddRoomScreen { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + let add_friend_text_is_empty = self.view + .text_input(cx, ids!(friend_user_id_input)) + .text() + .trim() + .is_empty(); + self.view.button(cx, ids!(add_friend_button)) + .set_enabled(cx, !self.adding_friend && !add_friend_text_is_empty); + let loading_room_view = self.view.view(cx, ids!(loading_room_view)); let fetched_room_summary = self.view.view(cx, ids!(fetched_room_summary)); let error_view = self.view.view(cx, ids!(error_view)); @@ -752,6 +1479,96 @@ impl Widget for AddRoomScreen { } } +fn refresh_space_children(cx: &mut Cx, space_id: &OwnedRoomId) { + let Some(rooms_list_ref) = cx.has_global::().then(|| cx.get_global::()) else { + return; + }; + let Some(space_request_sender) = rooms_list_ref.get_space_request_sender() else { + return; + }; + let parent_chain = rooms_list_ref.get_space_parent_chain(space_id).unwrap_or_default(); + if let Err(e) = space_request_sender.send(SpaceRequest::SubscribeToSpaceRoomList { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + }) { + error!("Failed to subscribe to space room list for {space_id}: {e}"); + return; + } + if let Err(e) = space_request_sender.send(SpaceRequest::PaginateSpaceRoomList { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + }) { + error!("Failed to paginate children for space {space_id}: {e}"); + } + if let Err(e) = space_request_sender.send(SpaceRequest::GetChildren { + space_id: space_id.clone(), + parent_chain, + }) { + error!("Failed to refresh children for space {space_id}: {e}"); + } +} + +fn creatable_space_labels(creatable_spaces: &[RoomNameId]) -> Vec { + let mut labels = Vec::with_capacity(creatable_spaces.len() + 1); + labels.push("Create without a space".to_string()); + labels.extend(creatable_spaces.iter().map(ToString::to_string)); + labels +} + +fn selected_creatable_space(creatable_spaces: &[RoomNameId], dropdown_index: usize) -> Option { + dropdown_index.checked_sub(1) + .and_then(|index| creatable_spaces.get(index)) + .map(|space| space.room_id().clone()) +} + +fn apply_space_dropdown_selection( + cx: &mut Cx, + dropdown: &DropDownRef, + creatable_spaces: &[RoomNameId], + preferred_parent_space_id: Option<&OwnedRoomId>, +) { + let selected_index = preferred_parent_space_id + .and_then(|space_id| + creatable_spaces.iter().position(|space| space.room_id() == space_id) + ) + .map(|index| index + 1) + .unwrap_or_else(|| dropdown.selected_item().min(creatable_spaces.len())); + dropdown.set_selected_item(cx, selected_index); +} + +fn update_space_hint( + cx: &mut Cx, + hint_label: &LabelRef, + creatable_spaces: &[RoomNameId], + selected_space_id: Option<&OwnedRoomId>, +) { + if creatable_spaces.is_empty() { + hint_label.set_text(cx, "No joined space currently allows you to create child rooms."); + } else if let Some(space_id) = selected_space_id { + let selected_name = creatable_spaces + .iter() + .find(|space| space.room_id() == space_id) + .map(ToString::to_string) + .unwrap_or_else(|| space_id.to_string()); + hint_label.set_text(cx, &format!("New room will be added under: {selected_name}")); + } else { + hint_label.set_text(cx, "Create a standalone room, or choose a space from the dropdown."); + } +} + +fn sync_space_dropdown( + cx: &mut Cx, + dropdown: &DropDownRef, + hint_label: &LabelRef, + creatable_spaces: &[RoomNameId], + preferred_parent_space_id: Option<&OwnedRoomId>, +) { + dropdown.set_labels(cx, creatable_space_labels(creatable_spaces)); + apply_space_dropdown_selection(cx, dropdown, creatable_spaces, preferred_parent_space_id); + let selected_space_id = selected_creatable_space(creatable_spaces, dropdown.selected_item()); + update_space_hint(cx, hint_label, creatable_spaces, selected_space_id.as_ref()); +} + /// The function to perform when the user clicks the join button in the fetched room preview. enum JoinButtonFunction { @@ -781,6 +1598,43 @@ pub enum KnockResultAction { } } +/// Actions sent from the backend task as a result of a [`MatrixRequest::CreateRoom`]. +#[derive(Debug)] +pub enum CreateRoomAction { + /// A new room was created. + Created { + room_name_id: RoomNameId, + parent_space_id: Option, + /// If set, the room was created but couldn't be linked into the requested space. + space_link_error: Option, + context: CreateRoomContext, + }, + /// There was an error creating the room. + Failed { + room_name: String, + error: matrix_sdk::Error, + context: CreateRoomContext, + }, +} + +/// Actions emitted by other widgets to show or hide the create-room modal. +#[derive(Debug)] +pub enum CreateRoomModalAction { + Open { + parent_space_id: Option, + }, + Close, +} + +/// Actions sent from the backend task containing the spaces where the current user +/// can create child rooms. +#[derive(Debug)] +pub enum CreatableSpacesAction { + Loaded { + spaces: Vec, + }, +} + /// Tries to extract a room address (Alias or ID) from the given text. /// diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 7cf7d5106..8c8baf93d 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -26,7 +26,11 @@ use matrix_sdk::{RoomState, ruma::{events::tag::Tags, MilliSecondsSinceUnixEpoch use crate::{ app::{AppState, SelectedRoom}, home::{ - navigation_tab_bar::{NavigationBarAction, SelectedTab}, room_context_menu::RoomContextMenuDetails, rooms_list_entry::RoomsListEntryAction, space_lobby::{SpaceLobbyAction, SpaceLobbyEntryWidgetExt} + add_room::CreateRoomAction, + navigation_tab_bar::{NavigationBarAction, SelectedTab}, + room_context_menu::RoomContextMenuDetails, + rooms_list_entry::RoomsListEntryAction, + space_lobby::{SpaceLobbyAction, SpaceLobbyEntryWidgetExt}, }, room::{ FetchedRoomAvatar, @@ -512,6 +516,66 @@ impl RoomsList { None } + fn upsert_created_room_placeholder( + &mut self, + cx: &mut Cx, + room_name_id: &RoomNameId, + parent_space_id: Option<&OwnedRoomId>, + should_link_into_space: bool, + ) { + let room_id = room_name_id.room_id().clone(); + let room_avatar = FetchedRoomAvatar::Text( + room_name_id.name_for_avatar().unwrap_or("?").to_owned(), + ); + + match self.all_joined_rooms.entry(room_id.clone()) { + Entry::Occupied(mut occ) => { + occ.get_mut().room_name_id = room_name_id.clone(); + occ.get_mut().room_avatar = room_avatar; + } + Entry::Vacant(vac) => { + vac.insert(JoinedRoomInfo { + room_name_id: room_name_id.clone(), + num_unread_messages: 0, + num_unread_mentions: 0, + is_marked_unread: false, + canonical_alias: None, + alt_aliases: Vec::new(), + tags: Tags::default(), + latest: None, + room_avatar, + has_been_paginated: false, + is_selected: false, + is_direct: false, + is_tombstoned: false, + }); + } + } + + if should_link_into_space { + if let Some(parent_space_id) = parent_space_id { + match self.space_map.entry(parent_space_id.clone()) { + Entry::Occupied(mut occ) => { + let value = occ.get_mut(); + let mut direct_child_rooms = (*value.direct_child_rooms).clone(); + direct_child_rooms.insert(room_id.clone()); + value.direct_child_rooms = Arc::new(direct_child_rooms); + } + Entry::Vacant(vac) => { + let mut direct_child_rooms = HashSet::new(); + direct_child_rooms.insert(room_id.clone()); + vac.insert(SpaceMapValue { + direct_child_rooms: Arc::new(direct_child_rooms), + ..Default::default() + }); + } + } + } + } + + self.update_displayed_rooms(cx, false); + } + /// Handle all pending updates to the list of all rooms. fn handle_rooms_list_updates(&mut self, cx: &mut Cx, _event: &Event, _scope: &mut Scope) { let mut num_updates: usize = 0; @@ -536,8 +600,10 @@ impl RoomsList { let _replaced = self.all_joined_rooms.insert(room_id.clone(), joined_room); if should_display { if is_direct { - self.displayed_direct_rooms.push(room_id.clone()); - } else { + if !self.displayed_direct_rooms.contains(&room_id) { + self.displayed_direct_rooms.push(room_id.clone()); + } + } else if !self.displayed_regular_rooms.contains(&room_id) { self.displayed_regular_rooms.push(room_id.clone()); } } @@ -970,17 +1036,32 @@ impl RoomsList { } // Otherwise, if no sort function was provided (default), use the `all_known_rooms_order`. else { + let mut seen_joined = HashSet::new(); + let mut seen_invited = HashSet::new(); for room_id in &self.all_known_rooms_order { if let Some(jr) = self.all_joined_rooms.get(room_id) { if should_display_room!(self, room_id, jr) { + seen_joined.insert(room_id.clone()); push_joined_room(room_id, jr); } } else if let Some(ir) = invited_rooms_ref.get(room_id) { if should_display_room!(self, room_id, ir) { + seen_invited.insert(room_id.clone()); new_displayed_invited_rooms.push(room_id.clone()); } } } + + for (room_id, jr) in &self.all_joined_rooms { + if !seen_joined.contains(room_id) && should_display_room!(self, room_id, jr) { + push_joined_room(room_id, jr); + } + } + for (room_id, ir) in invited_rooms_ref.iter() { + if !seen_invited.contains(room_id) && should_display_room!(self, room_id, ir) { + new_displayed_invited_rooms.push(room_id.clone()); + } + } } (new_displayed_invited_rooms, new_displayed_regular_rooms, new_displayed_direct_rooms) @@ -1350,6 +1431,16 @@ impl Widget for RoomsList { _ => {} } + if let Some(CreateRoomAction::Created { room_name_id, parent_space_id, space_link_error, .. }) = action.downcast_ref() { + self.upsert_created_room_placeholder( + cx, + room_name_id, + parent_space_id.as_ref(), + space_link_error.is_none(), + ); + continue; + } + if let Some(space_room_list_action) = action.downcast_ref() { self.handle_space_room_list_action(cx, space_room_list_action); continue; diff --git a/src/home/space_lobby.rs b/src/home/space_lobby.rs index 42bca8635..eb9b8277c 100644 --- a/src/home/space_lobby.rs +++ b/src/home/space_lobby.rs @@ -22,6 +22,7 @@ use crate::{ app::AppStateAction, avatar_cache::{self, AvatarCacheEntry}, home::{ + add_room::{CreateRoomAction, CreateRoomModalAction}, invite_modal::InviteModalAction, rooms_list::RoomsListRef, }, @@ -482,6 +483,16 @@ script_mod! { text: "" } + create_room_button := RobrixPositiveIconButton { + width: Fit + align: Align{x: 0.5, y: 0.5} + margin: Inset{left: 6} + padding: 12, + draw_icon.svg: (ICON_ADD) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1} } + text: "New Room" + } + invite_button := RobrixPositiveIconButton { width: Fit align: Align{x: 0.5, y: 0.5} @@ -921,6 +932,14 @@ impl Widget for SpaceLobbyScreen { _ => { } } + if let Some(CreateRoomAction::Created { room_name_id, parent_space_id, space_link_error, .. }) = action.downcast_ref() { + if space_link_error.is_none() + && parent_space_id.as_ref() == self.space_name_id.as_ref().map(RoomNameId::room_id) + { + self.insert_created_room_placeholder(cx, room_name_id); + } + } + // Handle SubspaceEntry clicks match action.as_widget_action().cast_ref() { SubspaceEntryAction::SpaceClicked { space_id } => { @@ -969,6 +988,14 @@ impl Widget for SpaceLobbyScreen { } } + if self.view.button(cx, ids!(header.parent_space_row.create_room_button)).clicked(actions) { + if let Some(space_name_id) = self.space_name_id.as_ref() { + cx.action(CreateRoomModalAction::Open { + parent_space_id: Some(space_name_id.room_id().clone()), + }); + } + } + // Handle the invite button being clicked in the header. if self.view.button(cx, ids!(header.parent_space_row.invite_button)).clicked(actions) { if let Some(space_name_id) = self.space_name_id.as_ref() { @@ -1202,6 +1229,49 @@ impl SpaceLobbyScreen { BasicRoomDetails::Name(room_name_id) } + fn insert_created_room_placeholder(&mut self, cx: &mut Cx, room_name_id: &RoomNameId) { + let Some(space_id) = self.space_name_id.as_ref().map(|space| space.room_id().clone()) else { + return; + }; + let room_id = room_name_id.room_id().clone(); + let display_name = room_name_id.to_string(); + let mut children = self.children_cache.get(&space_id).cloned().unwrap_or_default(); + + if let Some(existing_index) = children.iter().position(|child| child.room_id == room_id) { + if let Some(existing_child) = children.get_mut(existing_index) { + existing_child.name = Some(display_name.clone()); + existing_child.display_name = display_name; + existing_child.state = Some(RoomState::Joined); + existing_child.num_joined_members = existing_child.num_joined_members.max(1); + } + } else { + children.push_back(SpaceRoom { + room_id, + canonical_alias: None, + name: Some(display_name.clone()), + display_name, + topic: None, + avatar_url: None, + room_type: None, + num_joined_members: 1, + join_rule: None, + world_readable: None, + guest_can_join: false, + is_direct: Some(false), + children_count: 0, + state: Some(RoomState::Joined), + heroes: None, + via: Vec::new(), + }); + } + + self.children_cache.insert(space_id.clone(), children); + self.is_loading = false; + self.expanded_spaces.insert(space_id); + self.rebuild_tree_entries(); + self.redraw(cx); + } + /// Handle receiving detailed children for a space. fn update_children_in_space(&mut self, cx: &mut Cx, space_id: &OwnedRoomId, children: &Vector) { self.children_cache.insert(space_id.clone(), children.clone()); diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index d50dd7842..b428d37a7 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -11,6 +11,7 @@ use matrix_sdk::{ config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ api::{Direction, client::{ account::register::v3::Request as RegistrationRequest, + room::create_room::v3::{Request as CreateRoomRequest, RoomPreset}, error::ErrorKind, profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType, @@ -18,8 +19,10 @@ use matrix_sdk::{ }}, events::{ relation::RelationType, room::{ - message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource - }, MessageLikeEventType, StateEventType + encryption::RoomEncryptionEventContent, message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource + }, + space::{child::SpaceChildEventContent, parent::SpaceParentEventContent}, + InitialStateEvent, MessageLikeEventType, StateEventType }, matrix_uri::MatrixId, EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, uint }, sliding_sync::VersionBuilder, Client, ClientBuildError, Error, OwnedServerName, Room, RoomDisplayName, RoomMemberships, RoomState, SessionChange, SuccessorRoom }; @@ -38,7 +41,7 @@ use std::io; use hashbrown::{HashMap, HashSet}; use crate::{ app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ - add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails + add_room::{CreatableSpacesAction, CreateRoomAction, CreateRoomContext, KnockResultAction}, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, @@ -717,6 +720,14 @@ pub enum MatrixRequest { user_profile: UserProfile, allow_create: bool, }, + /// Request to create a new room, optionally underneath a selected parent space. + CreateRoom { + room_name: String, + parent_space_id: Option, + context: CreateRoomContext, + }, + /// Request the list of joined spaces where the current user may create child rooms. + GetCreatableSpaces, /// Request to fetch profile information for the given user ID. GetUserProfile { user_id: OwnedUserId, @@ -1398,6 +1409,74 @@ async fn matrix_worker_task( }); } + MatrixRequest::CreateRoom { room_name, parent_space_id, context } => { + let Some(client) = get_client() else { continue }; + let _create_room_task = Handle::current().spawn(async move { + let mut request = CreateRoomRequest::new(); + request.name = Some(room_name.clone()); + request.preset = Some(RoomPreset::PrivateChat); + request.initial_state.push( + InitialStateEvent::with_empty_state_key( + RoomEncryptionEventContent::with_recommended_defaults(), + ).to_raw_any() + ); + + log!("Creating new room \"{room_name}\"..."); + match client.create_room(request).await { + Ok(room) => { + let mut space_link_error = None; + if let Some(space_id) = parent_space_id.as_ref() + && let Err(error) = attach_room_to_space(&client, &room, space_id).await + { + error!("Created room {} but failed to add it to space {space_id}: {error}", room.room_id()); + space_link_error = Some(error.to_string()); + } + + let room_name_id = RoomNameId::from_room(&room).await; + Cx::post_action(CreateRoomAction::Created { + room_name_id, + parent_space_id, + space_link_error, + context, + }); + } + Err(error) => { + error!("Failed to create room \"{room_name}\": {error}"); + Cx::post_action(CreateRoomAction::Failed { room_name, error, context }); + } + } + }); + } + + MatrixRequest::GetCreatableSpaces => { + let Some(client) = get_client() else { continue }; + let _creatable_spaces_task = Handle::current().spawn(async move { + let Some(user_id) = client.user_id().map(ToOwned::to_owned) else { + Cx::post_action(CreatableSpacesAction::Loaded { spaces: Vec::new() }); + return; + }; + + let mut spaces = Vec::new(); + for room in client.joined_rooms() { + if room.room_type() != Some(ruma::room::RoomType::Space) { + continue; + } + + let Ok(power_levels) = room.power_levels().await else { + continue; + }; + if !power_levels.user_can_send_state(&user_id, StateEventType::SpaceChild) { + continue; + } + + spaces.push(RoomNameId::from_room(&room).await); + } + + spaces.sort_by_cached_key(|space| space.to_string().to_lowercase()); + Cx::post_action(CreatableSpacesAction::Loaded { spaces }); + }); + } + MatrixRequest::GetUserProfile { user_id, room_id, local_only } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { @@ -2187,6 +2266,39 @@ async fn matrix_worker_task( bail!("matrix_worker_task task ended unexpectedly") } +async fn attach_room_to_space(client: &Client, child_room: &Room, space_id: &OwnedRoomId) -> Result<()> { + let user_id = client.user_id().ok_or_else(|| anyhow!("Current user ID not found"))?; + let space_room = client.get_room(space_id) + .ok_or_else(|| anyhow!("Selected space {space_id} was not found"))?; + let child_power_levels = child_room.power_levels().await?; + + let child_route = room_route_with_fallback(child_room).await; + space_room + .send_state_event_for_key(child_room.room_id(), SpaceChildEventContent::new(child_route)) + .await?; + + if child_power_levels.user_can_send_state(user_id, StateEventType::SpaceParent) { + let mut parent_content = SpaceParentEventContent::new(room_route_with_fallback(&space_room).await); + parent_content.canonical = true; + child_room + .send_state_event_for_key(space_room.room_id(), parent_content) + .await?; + } + + Ok(()) +} + +async fn room_route_with_fallback(room: &Room) -> Vec { + match room.route().await { + Ok(route) if !route.is_empty() => route, + Ok(_) | Err(_) => room.room_id() + .server_name() + .map(ToOwned::to_owned) + .into_iter() + .collect(), + } +} + /// The single global Tokio runtime that is used by all async tasks. static TOKIO_RUNTIME: Mutex> = Mutex::new(None); From 271ad5fafb87fd4dff829c3f10a965cd0f7e1bc4 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: Thu, 26 Mar 2026 00:56:36 +0800 Subject: [PATCH 27/66] Migrate app service and BotFather management to robrix2 --- src/app.rs | 558 +++++-- src/home/create_bot_modal.rs | 309 ++++ src/home/delete_bot_modal.rs | 249 +++ src/home/home_screen.rs | 636 ++++---- src/home/mod.rs | 4 + src/home/room_context_menu.rs | 160 +- src/home/room_screen.rs | 2639 +++++++++++++++++++++++-------- src/home/rooms_list.rs | 685 +++++--- src/room/room_input_bar.rs | 351 ++-- src/settings/bot_settings.rs | 187 +++ src/settings/mod.rs | 2 + src/settings/settings_screen.rs | 72 +- src/sliding_sync.rs | 2047 +++++++++++++++--------- 13 files changed, 5628 insertions(+), 2271 deletions(-) create mode 100644 src/home/create_bot_modal.rs create mode 100644 src/home/delete_bot_modal.rs create mode 100644 src/settings/bot_settings.rs diff --git a/src/app.rs b/src/app.rs index f04e177d5..0ed4de033 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,17 +4,47 @@ use std::{cell::RefCell, collections::HashMap}; use makepad_widgets::*; -use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, RoomId}}; +use matrix_sdk::{ + RoomState, + ruma::{OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId}, +}; use serde::{Deserialize, Serialize}; use crate::{ - avatar_cache::clear_avatar_cache, home::{ - event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, invite_screen::InviteScreenWidgetRefExt, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, space_lobby::SpaceLobbyScreenWidgetRefExt - }, join_leave_room_modal::{ - JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt - }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ - VerificationModalAction, - VerificationModalWidgetRefExt, - } + avatar_cache::clear_avatar_cache, + home::{ + event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, + invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, + invite_screen::InviteScreenWidgetRefExt, + main_desktop_ui::MainDesktopUiAction, + navigation_tab_bar::{NavigationBarAction, SelectedTab}, + new_message_context_menu::NewMessageContextMenuWidgetRefExt, + room_context_menu::RoomContextMenuWidgetRefExt, + room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, + rooms_list::{ + RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, + enqueue_rooms_list_update, + }, + space_lobby::SpaceLobbyScreenWidgetRefExt, + }, + join_leave_room_modal::{ + JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt, + }, + login::login_screen::LoginAction, + logout::logout_confirm_modal::{ + LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt, + }, + persistence, + profile::user_profile_cache::clear_user_profile_cache, + room::BasicRoomDetails, + shared::{ + confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, + image_viewer::{ImageViewerAction, LoadState}, + popup_list::{PopupKind, enqueue_popup_notification}, + }, + sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, + utils::RoomNameId, + verification::VerificationAction, + verification_modal::{VerificationModalAction, VerificationModalWidgetRefExt}, }; script_mod! { @@ -51,7 +81,7 @@ script_mod! { close +: { draw_bg +: {color: #0, color_hover: #E81123, color_down: #FF0015} } } } - + body +: { padding: 0, @@ -80,7 +110,7 @@ script_mod! { image_viewer_modal_inner := ImageViewer {} } } - + // Context menus should be shown in front of other UI elements, // but behind verification modals. new_message_context_menu := NewMessageContextMenu { } @@ -164,16 +194,20 @@ app_main!(App); #[derive(Script)] pub struct App { - #[live] ui: WidgetRef, + #[live] + ui: WidgetRef, /// The top-level app state, shared across various parts of the app. - #[rust] app_state: AppState, + #[rust] + app_state: AppState, /// The details of a room we're waiting on to be loaded so that we can navigate to it. /// This can be either a room we're waiting to join, or one we're waiting to be invited to. /// Also includes an optional room ID to be closed once the awaited room has been loaded. - #[rust] waiting_to_navigate_to_room: Option<(BasicRoomDetails, Option)>, + #[rust] + waiting_to_navigate_to_room: Option<(BasicRoomDetails, Option)>, /// A stack of previously-selected rooms for mobile navigation. /// When a view is popped off the stack, the previous `selected_room` is restored from here. - #[rust] mobile_room_nav_stack: Vec, + #[rust] + mobile_room_nav_stack: Vec, } impl ScriptHook for App { @@ -198,15 +232,27 @@ impl MatchEvent for App { let _ = tracing_subscriber::fmt::try_init(); // Override Makepad's new default-JSON logger. We just want regular formatting. - fn regular_log(file_name: &str, line_start: u32, column_start: u32, _line_end: u32, _column_end: u32, message: String, level: LogLevel) { + fn regular_log( + file_name: &str, + line_start: u32, + column_start: u32, + _line_end: u32, + _column_end: u32, + message: String, + level: LogLevel, + ) { let l = match level { - LogLevel::Panic => "[!]", - LogLevel::Error => "[E]", + LogLevel::Panic => "[!]", + LogLevel::Error => "[E]", LogLevel::Warning => "[W]", - LogLevel::Log => "[I]", - LogLevel::Wait => "[.]", + LogLevel::Log => "[I]", + LogLevel::Wait => "[.]", }; - println!("{l} {file_name}:{}:{}: {message}", line_start + 1, column_start + 1); + println!( + "{l} {file_name}:{}:{}: {message}", + line_start + 1, + column_start + 1 + ); } *LOG_WITH_LEVEL.write().unwrap() = regular_log; @@ -221,7 +267,10 @@ impl MatchEvent for App { // Hide the caption bar on macOS and Linux, which use native window chrome. // On Windows (with custom chrome), the caption bar is needed. - if matches!(cx.os_type(), OsType::Macos | OsType::LinuxWindow(_) | OsType::LinuxDirect) { + if matches!( + cx.os_type(), + OsType::Macos | OsType::LinuxWindow(_) | OsType::LinuxDirect + ) { let mut window = self.ui.window(cx, ids!(main_window)); script_apply_eval!(cx, window, { show_caption_bar: false @@ -233,41 +282,52 @@ impl MatchEvent for App { log!("App::Startup: starting matrix sdk loop"); let _tokio_rt_handle = crate::sliding_sync::start_matrix_tokio().unwrap(); - #[cfg(feature = "tsp")] { + #[cfg(feature = "tsp")] + { log!("App::Startup: initializing TSP (Trust Spanning Protocol) module."); crate::tsp::tsp_init(_tokio_rt_handle).unwrap(); } } fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { - let invite_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(invite_confirmation_modal_inner)); + let invite_confirmation_modal_inner = self + .ui + .confirmation_modal(cx, ids!(invite_confirmation_modal_inner)); if let Some(_accepted) = invite_confirmation_modal_inner.closed(actions) { self.ui.modal(cx, ids!(invite_confirmation_modal)).close(cx); } - let delete_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(delete_confirmation_modal_inner)); + let delete_confirmation_modal_inner = self + .ui + .confirmation_modal(cx, ids!(delete_confirmation_modal_inner)); if let Some(_accepted) = delete_confirmation_modal_inner.closed(actions) { self.ui.modal(cx, ids!(delete_confirmation_modal)).close(cx); } - let positive_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(positive_confirmation_modal_inner)); + let positive_confirmation_modal_inner = self + .ui + .confirmation_modal(cx, ids!(positive_confirmation_modal_inner)); if let Some(_accepted) = positive_confirmation_modal_inner.closed(actions) { - self.ui.modal(cx, ids!(positive_confirmation_modal)).close(cx); + self.ui + .modal(cx, ids!(positive_confirmation_modal)) + .close(cx); } for action in actions { match action.downcast_ref() { Some(LogoutConfirmModalAction::Open) => { - self.ui.logout_confirm_modal(cx, ids!(logout_confirm_modal_inner)).reset_state(cx); + self.ui + .logout_confirm_modal(cx, ids!(logout_confirm_modal_inner)) + .reset_state(cx); self.ui.modal(cx, ids!(logout_confirm_modal)).open(cx); continue; - }, + } Some(LogoutConfirmModalAction::Close { was_internal, .. }) => { if *was_internal { self.ui.modal(cx, ids!(logout_confirm_modal)).close(cx); } continue; - }, + } _ => {} } @@ -279,8 +339,8 @@ impl MatchEvent for App { self.ui.redraw(cx); continue; } - Some(LogoutAction::ClearAppState { on_clear_appstate }) => { - // Clear user profile cache, invited_rooms timeline states + Some(LogoutAction::ClearAppState { on_clear_appstate }) => { + // Clear user profile cache, invited_rooms timeline states clear_all_app_state(cx); // Reset all app state to its default. self.app_state = Default::default(); @@ -303,7 +363,9 @@ impl MatchEvent for App { // When not yet logged in, the login_screen widget handles displaying the failure modal. if let Some(LoginAction::LoginFailure(_)) = action.downcast_ref() { if self.app_state.logged_in { - log!("Received LoginAction::LoginFailure while logged in; showing login screen."); + log!( + "Received LoginAction::LoginFailure while logged in; showing login screen." + ); self.app_state.logged_in = false; self.update_login_visibility(cx); self.ui.redraw(cx); @@ -312,9 +374,13 @@ impl MatchEvent for App { } // Handle an action requesting to open the new message context menu. - if let MessageAction::OpenMessageContextMenu { details, abs_pos } = action.as_widget_action().cast() { + if let MessageAction::OpenMessageContextMenu { details, abs_pos } = + action.as_widget_action().cast() + { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); - let new_message_context_menu = self.ui.new_message_context_menu(cx, ids!(new_message_context_menu)); + let new_message_context_menu = self + .ui + .new_message_context_menu(cx, ids!(new_message_context_menu)); let expected_dimensions = new_message_context_menu.show(cx, details); // Ensure the context menu does not spill over the window's bounds. let rect = self.ui.window(cx, ids!(main_window)).area().rect(cx); @@ -335,7 +401,9 @@ impl MatchEvent for App { } // Handle an action requesting to open the room context menu. - if let RoomsListAction::OpenRoomContextMenu { details, pos } = action.as_widget_action().cast() { + if let RoomsListAction::OpenRoomContextMenu { details, pos } = + action.as_widget_action().cast() + { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); let room_context_menu = self.ui.room_context_menu(cx, ids!(room_context_menu)); let expected_dimensions = room_context_menu.show(cx, details); @@ -369,7 +437,9 @@ impl MatchEvent for App { // An invite was accepted; upgrade the selected room from invite to joined. // In Desktop mode, MainDesktopUI also handles this (harmless duplicate). RoomsListAction::InviteAccepted { room_name_id } => { - cx.action(AppStateAction::UpgradedInviteToJoinedRoom(room_name_id.room_id().clone())); + cx.action(AppStateAction::UpgradedInviteToJoinedRoom( + room_name_id.room_id().clone(), + )); continue; } _ => {} @@ -413,18 +483,77 @@ impl MatchEvent for App { cx.action(MainDesktopUiAction::LoadDockFromAppState); continue; } - Some(AppStateAction::NavigateToRoom { room_to_close, destination_room }) => { + Some(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id, + warning, + }) => { + self.app_state + .bot_settings + .set_room_bound(room_id.clone(), *bound); + let kind = if warning.is_some() { + PopupKind::Warning + } else { + PopupKind::Success + }; + let message = match (*bound, bot_user_id.as_ref(), warning.as_deref()) { + (true, Some(bot_user_id), Some(warning)) => { + format!( + "BotFather {bot_user_id} is available for room {room_id}, but inviting it reported a warning: {warning}" + ) + } + (true, Some(bot_user_id), None) => { + format!("Bound room {room_id} to BotFather {bot_user_id}.") + } + (false, Some(bot_user_id), Some(warning)) => { + format!( + "Unbound BotFather {bot_user_id} from room {room_id}, with warning: {warning}" + ) + } + (false, Some(bot_user_id), None) => { + format!("Unbound BotFather {bot_user_id} from room {room_id}.") + } + (false, None, Some(warning)) => { + format!( + "Unbound room {room_id} from BotFather, with warning: {warning}" + ) + } + (false, None, None) => { + format!("Unbound room {room_id} from BotFather.") + } + (true, None, Some(warning)) => { + format!( + "BotFather is available for room {room_id}, with warning: {warning}" + ) + } + (true, None, None) => { + format!("Bound room {room_id} to BotFather.") + } + }; + enqueue_popup_notification(message, kind, Some(5.0)); + self.ui.redraw(cx); + continue; + } + Some(AppStateAction::NavigateToRoom { + room_to_close, + destination_room, + }) => { self.navigate_to_room(cx, room_to_close.as_ref(), destination_room); continue; } // If we successfully loaded a room that we were waiting on, // we can now navigate to it and optionally close a previous room. - Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) if - self.waiting_to_navigate_to_room.as_ref() + Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) + if self + .waiting_to_navigate_to_room + .as_ref() .is_some_and(|(dr, _)| dr.room_id() == room_name_id.room_id()) => { log!("Loaded awaited room {room_name_id:?}, navigating to it now..."); - if let Some((dest_room, room_to_close)) = self.waiting_to_navigate_to_room.take() { + if let Some((dest_room, room_to_close)) = + self.waiting_to_navigate_to_room.take() + { self.navigate_to_room(cx, room_to_close.as_ref(), &dest_room); } continue; @@ -434,18 +563,22 @@ impl MatchEvent for App { // Handle actions for showing or hiding the tooltip. match action.as_widget_action().cast() { - TooltipAction::HoverIn { text, widget_rect, options } => { + TooltipAction::HoverIn { + text, + widget_rect, + options, + } => { // Don't show any tooltips if the message context menu is currently shown. - if self.ui.new_message_context_menu(cx, ids!(new_message_context_menu)).is_currently_shown(cx) { + if self + .ui + .new_message_context_menu(cx, ids!(new_message_context_menu)) + .is_currently_shown(cx) + { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); - } - else { - self.ui.callout_tooltip(cx, ids!(app_tooltip)).show_with_options( - cx, - &text, - widget_rect, - options, - ); + } else { + self.ui + .callout_tooltip(cx, ids!(app_tooltip)) + .show_with_options(cx, &text, widget_rect, options); } continue; } @@ -479,7 +612,8 @@ impl MatchEvent for App { // // Note: other verification actions are handled by the verification modal itself. if let Some(VerificationAction::RequestReceived(state)) = action.downcast_ref() { - self.ui.verification_modal(cx, ids!(verification_modal_inner)) + self.ui + .verification_modal(cx, ids!(verification_modal_inner)) .initialize_with_data(cx, state.clone()); self.ui.modal(cx, ids!(verification_modal)).open(cx); continue; @@ -500,12 +634,23 @@ impl MatchEvent for App { _ => {} } // Handle actions to open/close the TSP verification modal. - #[cfg(feature = "tsp")] { + #[cfg(feature = "tsp")] + { use std::ops::Deref; - use crate::tsp::{tsp_verification_modal::{TspVerificationModalAction, TspVerificationModalWidgetRefExt}, TspIdentityAction}; + use crate::tsp::{ + tsp_verification_modal::{ + TspVerificationModalAction, TspVerificationModalWidgetRefExt, + }, + TspIdentityAction, + }; - if let Some(TspIdentityAction::ReceivedDidAssociationRequest { details, wallet_db }) = action.downcast_ref() { - self.ui.tsp_verification_modal(cx, ids!(tsp_verification_modal_inner)) + if let Some(TspIdentityAction::ReceivedDidAssociationRequest { + details, + wallet_db, + }) = action.downcast_ref() + { + self.ui + .tsp_verification_modal(cx, ids!(tsp_verification_modal_inner)) .initialize_with_details(cx, details.clone(), wallet_db.deref().clone()); self.ui.modal(cx, ids!(tsp_verification_modal)).open(cx); continue; @@ -517,7 +662,9 @@ impl MatchEvent for App { } // Handle a request to show the invite confirmation modal. - if let Some(InviteAction::ShowInviteConfirmationModal(content_opt)) = action.downcast_ref() { + if let Some(InviteAction::ShowInviteConfirmationModal(content_opt)) = + action.downcast_ref() + { if let Some(content) = content_opt.borrow_mut().take() { invite_confirmation_modal_inner.show(cx, content); self.ui.modal(cx, ids!(invite_confirmation_modal)).open(cx); @@ -526,10 +673,13 @@ impl MatchEvent for App { } // Handle a request to show the generic positive confirmation modal. - if let Some(PositiveConfirmationModalAction::Show(content_opt)) = action.downcast_ref() { + if let Some(PositiveConfirmationModalAction::Show(content_opt)) = action.downcast_ref() + { if let Some(content) = content_opt.borrow_mut().take() { positive_confirmation_modal_inner.show(cx, content); - self.ui.modal(cx, ids!(positive_confirmation_modal)).open(cx); + self.ui + .modal(cx, ids!(positive_confirmation_modal)) + .open(cx); } continue; } @@ -537,7 +687,9 @@ impl MatchEvent for App { // Handle a request to show the delete confirmation modal. if let Some(ConfirmDeleteAction::Show(content_opt)) = action.downcast_ref() { if let Some(content) = content_opt.borrow_mut().take() { - self.ui.confirmation_modal(cx, ids!(delete_confirmation_modal_inner)).show(cx, content); + self.ui + .confirmation_modal(cx, ids!(delete_confirmation_modal_inner)) + .show(cx, content); self.ui.modal(cx, ids!(delete_confirmation_modal)).open(cx); } continue; @@ -546,8 +698,10 @@ impl MatchEvent for App { // Handle InviteModalAction to open/close the invite modal. match action.downcast_ref() { Some(InviteModalAction::Open(room_name_id)) => { - self.ui.invite_modal(cx, ids!(invite_modal_inner)).show(cx, room_name_id.clone()); - self.ui.modal(cx, ids!(invite_modal)).open(cx); + self.ui + .invite_modal(cx, ids!(invite_modal_inner)) + .show(cx, room_name_id.clone()); + self.ui.modal(cx, ids!(invite_modal)).open(cx); continue; } Some(InviteModalAction::Close) => { @@ -559,8 +713,13 @@ impl MatchEvent for App { // Handle EventSourceModalAction to open/close the event source modal. match action.downcast_ref() { - Some(EventSourceModalAction::Open { room_id, event_id, original_json }) => { - self.ui.event_source_modal(cx, ids!(event_source_modal_inner)) + Some(EventSourceModalAction::Open { + room_id, + event_id, + original_json, + }) => { + self.ui + .event_source_modal(cx, ids!(event_source_modal_inner)) .show(cx, room_id.clone(), event_id.clone(), original_json.clone()); self.ui.modal(cx, ids!(event_source_modal)).open(cx); continue; @@ -575,7 +734,11 @@ impl MatchEvent for App { // Handle DirectMessageRoomActions match action.downcast_ref() { Some(DirectMessageRoomAction::FoundExisting { room_name_id, .. }) => { - self.navigate_to_room(cx, None, &BasicRoomDetails::RoomId(room_name_id.clone())); + self.navigate_to_room( + cx, + None, + &BasicRoomDetails::RoomId(room_name_id.clone()), + ); } Some(DirectMessageRoomAction::DidNotExist { user_profile }) => { let user_profile = user_profile.clone(); @@ -583,8 +746,7 @@ impl MatchEvent for App { Some(un) if !un.is_empty() => format!( "You don't have an existing direct message room with {} ({}).\n\n\ Would you like to create one now?", - un, - user_profile.user_id, + un, user_profile.user_id, ), _ => format!( "You don't have an existing direct message room with {}.\n\n\ @@ -612,17 +774,29 @@ impl MatchEvent for App { ..Default::default() }, ); - self.ui.modal(cx, ids!(positive_confirmation_modal)).open(cx); + self.ui + .modal(cx, ids!(positive_confirmation_modal)) + .open(cx); } - Some(DirectMessageRoomAction::FailedToCreate { user_profile, error }) => { + Some(DirectMessageRoomAction::FailedToCreate { + user_profile, + error, + }) => { enqueue_popup_notification( - format!("Failed to create a new DM room with {}.\n\nError: {error}", user_profile.displayable_name()), + format!( + "Failed to create a new DM room with {}.\n\nError: {error}", + user_profile.displayable_name() + ), PopupKind::Error, None, ); } Some(DirectMessageRoomAction::NewlyCreated { room_name_id, .. }) => { - self.navigate_to_room(cx, None, &BasicRoomDetails::RoomId(room_name_id.clone())); + self.navigate_to_room( + cx, + None, + &BasicRoomDetails::RoomId(room_name_id.clone()), + ); } _ => {} } @@ -631,7 +805,7 @@ impl MatchEvent for App { } /// Clears all thread-local UI caches (user profiles, invited rooms, and timeline states). -/// The `cx` parameter ensures that these thread-local caches are cleared on the main UI thread, +/// The `cx` parameter ensures that these thread-local caches are cleared on the main UI thread, fn clear_all_app_state(cx: &mut Cx) { clear_user_profile_cache(cx); clear_all_invited_rooms(cx); @@ -683,27 +857,34 @@ impl AppMain for App { error!("Failed to save app state. Error: {e}"); } } - #[cfg(feature = "tsp")] { + #[cfg(feature = "tsp")] + { // Save the TSP wallet state, if it exists, with a 3-second timeout. let tsp_state = std::mem::take(&mut *crate::tsp::tsp_state_ref().lock().unwrap()); let res = crate::sliding_sync::block_on_async_with_timeout( Some(std::time::Duration::from_secs(3)), async move { match tsp_state.close_and_serialize().await { - Ok(saved_state) => match persistence::save_tsp_state_async(saved_state).await { - Ok(_) => { } - Err(e) => error!("Failed to save TSP wallet state. Error: {e}"), + Ok(saved_state) => { + match persistence::save_tsp_state_async(saved_state).await { + Ok(_) => {} + Err(e) => error!("Failed to save TSP wallet state. Error: {e}"), + } + } + Err(e) => { + error!("Failed to close and serialize TSP wallet state. Error: {e}") } - Err(e) => error!("Failed to close and serialize TSP wallet state. Error: {e}"), } }, ); if let Err(_e) = res { - error!("Failed to save TSP wallet state before app shutdown. Error: Timed Out."); + error!( + "Failed to save TSP wallet state before app shutdown. Error: Timed Out." + ); } } } - + // Forward events to the MatchEvent trait implementation. self.match_event(cx, event); let scope = &mut Scope::with_data(&mut self.app_state); @@ -751,8 +932,12 @@ impl App { .modal(cx, ids!(login_screen_view.login_screen.login_status_modal)) .close(cx); } - self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, show_login); - self.ui.view(cx, ids!(home_screen_view)).set_visible(cx, !show_login); + self.ui + .view(cx, ids!(login_screen_view)) + .set_visible(cx, show_login); + self.ui + .view(cx, ids!(home_screen_view)) + .set_visible(cx, !show_login); } /// Navigates to the given `destination_room`, optionally closing the `room_to_close`. @@ -767,16 +952,17 @@ impl App { let tab_id = LiveId::from_str(to_close.as_str()); let widget_uid = self.ui.widget_uid(); move |cx: &mut Cx| { - cx.widget_action( - widget_uid, - DockAction::TabCloseWasPressed(tab_id), - ); - enqueue_rooms_list_update(RoomsListUpdate::HideRoom { room_id: to_close.clone() }); + cx.widget_action(widget_uid, DockAction::TabCloseWasPressed(tab_id)); + enqueue_rooms_list_update(RoomsListUpdate::HideRoom { + room_id: to_close.clone(), + }); } }); let destination_room_id = destination_room.room_id(); - let room_state = cx.get_global::().get_room_state(destination_room_id); + let room_state = cx + .get_global::() + .get_room_state(destination_room_id); let new_selected_room = match room_state { Some(RoomState::Joined) => SelectedRoom::JoinedRoom { room_name_id: destination_room.room_name_id().clone(), @@ -786,11 +972,12 @@ impl App { }, // If the destination room is not yet loaded, show a join modal. _ => { - log!("Destination room {:?} not loaded, showing join modal...", destination_room.room_name_id()); - self.waiting_to_navigate_to_room = Some(( - destination_room.clone(), - room_to_close.cloned(), - )); + log!( + "Destination room {:?} not loaded, showing join modal...", + destination_room.room_name_id() + ); + self.waiting_to_navigate_to_room = + Some((destination_room.clone(), room_to_close.cloned())); cx.action(JoinLeaveRoomModalAction::Open { kind: JoinLeaveModalKind::JoinRoom { details: destination_room.clone(), @@ -802,8 +989,8 @@ impl App { } }; - - log!("Navigating to destination room {:?}, closing room {:?}", + log!( + "Navigating to destination room {:?}, closing room {:?}", destination_room.room_name_id(), room_to_close, ); @@ -814,7 +1001,7 @@ impl App { cx.action(NavigationBarAction::GoToHome); } cx.widget_action( - self.ui.widget_uid(), + self.ui.widget_uid(), RoomsListAction::Selected(new_selected_room), ); // Select and scroll to the destination room in the rooms list. @@ -830,27 +1017,43 @@ impl App { /// Each depth gets its own dedicated view widget to avoid /// complex state save/restore when views would otherwise be reused. const ROOM_VIEW_IDS: [LiveId; 16] = [ - live_id!(room_view_0), live_id!(room_view_1), - live_id!(room_view_2), live_id!(room_view_3), - live_id!(room_view_4), live_id!(room_view_5), - live_id!(room_view_6), live_id!(room_view_7), - live_id!(room_view_8), live_id!(room_view_9), - live_id!(room_view_10), live_id!(room_view_11), - live_id!(room_view_12), live_id!(room_view_13), - live_id!(room_view_14), live_id!(room_view_15), + live_id!(room_view_0), + live_id!(room_view_1), + live_id!(room_view_2), + live_id!(room_view_3), + live_id!(room_view_4), + live_id!(room_view_5), + live_id!(room_view_6), + live_id!(room_view_7), + live_id!(room_view_8), + live_id!(room_view_9), + live_id!(room_view_10), + live_id!(room_view_11), + live_id!(room_view_12), + live_id!(room_view_13), + live_id!(room_view_14), + live_id!(room_view_15), ]; /// The RoomScreen widget IDs inside each room view, /// corresponding 1:1 with [`Self::ROOM_VIEW_IDS`]. const ROOM_SCREEN_IDS: [LiveId; 16] = [ - live_id!(room_screen_0), live_id!(room_screen_1), - live_id!(room_screen_2), live_id!(room_screen_3), - live_id!(room_screen_4), live_id!(room_screen_5), - live_id!(room_screen_6), live_id!(room_screen_7), - live_id!(room_screen_8), live_id!(room_screen_9), - live_id!(room_screen_10), live_id!(room_screen_11), - live_id!(room_screen_12), live_id!(room_screen_13), - live_id!(room_screen_14), live_id!(room_screen_15), + live_id!(room_screen_0), + live_id!(room_screen_1), + live_id!(room_screen_2), + live_id!(room_screen_3), + live_id!(room_screen_4), + live_id!(room_screen_5), + live_id!(room_screen_6), + live_id!(room_screen_7), + live_id!(room_screen_8), + live_id!(room_screen_9), + live_id!(room_screen_10), + live_id!(room_screen_11), + live_id!(room_screen_12), + live_id!(room_screen_13), + live_id!(room_screen_14), + live_id!(room_screen_15), ]; /// Returns the room view and room screen LiveIds for the given stack depth. @@ -884,7 +1087,11 @@ impl App { | SelectedRoom::Thread { room_name_id, .. } => { let (view_id, room_screen_id) = Self::room_ids_for_depth(new_depth); - let thread_root = if let SelectedRoom::Thread { thread_root_event_id, .. } = &selected_room { + let thread_root = if let SelectedRoom::Thread { + thread_root_event_id, + .. + } = &selected_room + { Some(thread_root_event_id.clone()) } else { None @@ -910,8 +1117,16 @@ impl App { }; // Set the header title for the view being pushed. - let title_path = &[view_id, live_id!(header), live_id!(content), live_id!(title_container), live_id!(title)]; - self.ui.label(cx, title_path).set_text(cx, &selected_room.display_name()); + let title_path = &[ + view_id, + live_id!(header), + live_id!(content), + live_id!(title_container), + live_id!(title), + ]; + self.ui + .label(cx, title_path) + .set_text(cx, &selected_room.display_name()); // Save the current selected_room onto the navigation stack before replacing it. if let Some(prev) = self.app_state.selected_room.take() { @@ -921,10 +1136,11 @@ impl App { self.app_state.selected_room = Some(selected_room); // Push the view onto the mobile navigation stack. - self.ui.stack_navigation(cx, ids!(view_stack)).push(cx, view_id); + self.ui + .stack_navigation(cx, ids!(view_stack)) + .push(cx, view_id); self.ui.redraw(cx); } - } /// App-wide state that is stored persistently across multiple app runs @@ -950,6 +1166,91 @@ pub struct AppState { pub saved_dock_state_per_space: HashMap, /// Whether a user is currently logged in to Robrix or not. pub logged_in: bool, + /// Local configuration and UI state for bot-assisted room binding. + #[serde(default)] + pub bot_settings: BotSettingsState, +} + +/// Local bot integration settings persisted per Matrix account. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct BotSettingsState { + /// Whether bot-assisted room binding is enabled in the UI. + pub enabled: bool, + /// The configured botfather user, either as a full MXID or localpart. + pub botfather_user_id: String, + /// Rooms that Robrix currently considers bound to BotFather. + pub bound_rooms: Vec, +} + +impl Default for BotSettingsState { + fn default() -> Self { + Self { + enabled: false, + botfather_user_id: Self::DEFAULT_BOTFATHER_LOCALPART.to_string(), + bound_rooms: Vec::new(), + } + } +} + +impl BotSettingsState { + pub const DEFAULT_BOTFATHER_LOCALPART: &'static str = "bot"; + + /// Returns `true` if the given room is currently marked as bound locally. + pub fn is_room_bound(&self, room_id: &RoomId) -> bool { + self.bound_rooms + .iter() + .any(|bound_room_id| bound_room_id == room_id) + } + + /// Updates the local bound/unbound state for the given room. + pub fn set_room_bound(&mut self, room_id: OwnedRoomId, bound: bool) { + if bound { + if !self.is_room_bound(&room_id) { + self.bound_rooms.push(room_id); + self.bound_rooms + .sort_by(|lhs, rhs| lhs.as_str().cmp(rhs.as_str())); + } + } else { + self.bound_rooms + .retain(|existing_room_id| existing_room_id != &room_id); + } + } + + /// Returns the configured botfather user ID, resolving a localpart against + /// the current user's homeserver when needed. + pub fn resolved_bot_user_id( + &self, + current_user_id: Option<&UserId>, + ) -> Result { + let raw = self.botfather_user_id.trim(); + if raw.starts_with('@') || raw.contains(':') { + let full_user_id = if raw.starts_with('@') { + raw.to_string() + } else { + format!("@{raw}") + }; + return UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| format!("Invalid bot user ID: {full_user_id}")); + } + + let Some(current_user_id) = current_user_id else { + return Err( + "Current user ID is unavailable, so the bot homeserver cannot be resolved.".into(), + ); + }; + + let localpart = if raw.is_empty() { + Self::DEFAULT_BOTFATHER_LOCALPART + } else { + raw + }; + let full_user_id = format!("@{localpart}:{}", current_user_id.server_name()); + UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| format!("Invalid bot user ID: {full_user_id}")) + } } /// A snapshot of the main dock: all state needed to restore the dock tabs/layout. @@ -966,7 +1267,6 @@ pub struct SavedDockState { pub selected_room: Option, } - /// Represents a room currently or previously selected by the user. /// /// ## PartialEq/Eq equality comparison behavior @@ -1023,9 +1323,7 @@ impl SelectedRoom { match self { SelectedRoom::InvitedRoom { room_name_id } if room_name_id.room_id() == room_id => { let name = room_name_id.clone(); - *self = SelectedRoom::JoinedRoom { - room_name_id: name, - }; + *self = SelectedRoom::JoinedRoom { room_name_id: name }; true } _ => false, @@ -1035,11 +1333,14 @@ impl SelectedRoom { /// Returns the `LiveId` of the room tab corresponding to this `SelectedRoom`. pub fn tab_id(&self) -> LiveId { match self { - SelectedRoom::Thread { room_name_id, thread_root_event_id } => { - LiveId::from_str( - &format!("{}##{}", room_name_id.room_id(), thread_root_event_id) - ) - } + SelectedRoom::Thread { + room_name_id, + thread_root_event_id, + } => LiveId::from_str(&format!( + "{}##{}", + room_name_id.room_id(), + thread_root_event_id + )), other => LiveId::from_str(other.room_id().as_str()), } } @@ -1093,6 +1394,13 @@ pub enum AppStateAction { /// The given app state was loaded from persistent storage /// and is ready to be restored. RestoreAppStateFromPersistentState(AppState), + /// A room-level BotFather bind or unbind action completed. + BotRoomBindingUpdated { + room_id: OwnedRoomId, + bound: bool, + bot_user_id: Option, + warning: Option, + }, /// The given room was successfully loaded from the homeserver /// and is now known to our client. /// diff --git a/src/home/create_bot_modal.rs b/src/home/create_bot_modal.rs new file mode 100644 index 000000000..bafb822e6 --- /dev/null +++ b/src/home/create_bot_modal.rs @@ -0,0 +1,309 @@ +//! A modal dialog for creating a Matrix child bot through BotFather slash commands. + +use makepad_widgets::*; + +use crate::utils::RoomNameId; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + mod.widgets.CreateBotModalLabel = Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 10.5 } + color: #333 + wrap: Word + } + text: "" + } + + mod.widgets.CreateBotModal = #(CreateBotModal::register_widget(vm)) { + width: Fit + height: Fit + + RoundedView { + width: 448 + height: Fit + align: Align{x: 0.5} + flow: Down + padding: Inset{top: 28, right: 24, bottom: 20, left: 24} + spacing: 16 + + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 6.0 + } + + title := Label { + width: Fill + height: Fit + draw_text +: { + text_style: TITLE_TEXT { font_size: 13 } + color: #000 + wrap: Word + } + text: "Create Bot" + } + + body := mod.widgets.CreateBotModalLabel { + text: "" + } + + form := RoundedView { + width: Fill + height: Fit + flow: Down + spacing: 12 + padding: 14 + + show_bg: true + draw_bg +: { + color: (COLOR_SECONDARY) + border_radius: 4.0 + } + + username_label := mod.widgets.CreateBotModalLabel { + text: "Username" + } + + username_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11.5 } + color: #000 + } + empty_text: "weather" + } + + username_hint := mod.widgets.CreateBotModalLabel { + draw_text +: { + text_style: REGULAR_TEXT { font_size: 9.5 } + color: #666 + } + text: "Lowercase letters, digits, and underscores only. BotFather will create @bot_:server." + } + + display_name_label := mod.widgets.CreateBotModalLabel { + text: "Display Name" + } + + display_name_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11.5 } + color: #000 + } + empty_text: "Weather Bot" + } + + prompt_label := mod.widgets.CreateBotModalLabel { + text: "System Prompt (Optional)" + } + + prompt_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11.5 } + color: #000 + } + empty_text: "You are a weather assistant." + } + } + + status_label := Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 10.5 } + color: #000 + wrap: Word + } + text: "" + } + + buttons := View { + width: Fill + height: Fit + flow: Right + align: Align{x: 1.0, y: 0.5} + spacing: 16 + + cancel_button := RobrixNeutralIconButton { + width: 110 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_FORBIDDEN) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Cancel" + } + + create_button := RobrixPositiveIconButton { + width: 130 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_CHECKMARK) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Create Bot" + } + } + } + } +} + +fn is_valid_bot_username(username: &str) -> bool { + !username.is_empty() + && username + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateBotRequest { + pub username: String, + pub display_name: String, + pub system_prompt: Option, +} + +#[derive(Clone, Debug)] +pub enum CreateBotModalAction { + Close, + Submit(CreateBotRequest), +} + +#[derive(Script, ScriptHook, Widget)] +pub struct CreateBotModal { + #[deref] + view: View, + #[rust] + room_name_id: Option, + #[rust] + is_showing_error: bool, +} + +impl Widget for CreateBotModal { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + self.widget_match_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl WidgetMatchEvent for CreateBotModal { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let cancel_button = self.view.button(cx, ids!(buttons.cancel_button)); + let create_button = self.view.button(cx, ids!(buttons.create_button)); + let username_input = self.view.text_input(cx, ids!(form.username_input)); + let display_name_input = self.view.text_input(cx, ids!(form.display_name_input)); + let prompt_input = self.view.text_input(cx, ids!(form.prompt_input)); + let mut status_label = self.view.label(cx, ids!(status_label)); + + if cancel_button.clicked(actions) + || actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) + { + cx.action(CreateBotModalAction::Close); + return; + } + + if self.is_showing_error + && (username_input.changed(actions).is_some() + || display_name_input.changed(actions).is_some() + || prompt_input.changed(actions).is_some()) + { + self.is_showing_error = false; + status_label.set_text(cx, ""); + self.view.redraw(cx); + } + + if create_button.clicked(actions) || prompt_input.returned(actions).is_some() { + let username = username_input.text().trim().to_string(); + if !is_valid_bot_username(&username) { + self.is_showing_error = true; + script_apply_eval!(cx, status_label, { + text: "Username must use lowercase letters, digits, or underscores." + draw_text +: { + color: mod.widgets.COLOR_FG_DANGER_RED + } + }); + self.view.redraw(cx); + return; + } + + let display_name = display_name_input.text().trim().to_string(); + let system_prompt = prompt_input.text().trim().to_string(); + + cx.action(CreateBotModalAction::Submit(CreateBotRequest { + username: username.clone(), + display_name: if display_name.is_empty() { + username + } else { + display_name + }, + system_prompt: (!system_prompt.is_empty()).then_some(system_prompt), + })); + } + } +} + +impl CreateBotModal { + pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId) { + self.room_name_id = Some(room_name_id.clone()); + self.is_showing_error = false; + + self.view + .label(cx, ids!(title)) + .set_text(cx, "Create Room Bot"); + self.view.label(cx, ids!(body)).set_text( + cx, + &format!( + "Robrix will send `/createbot` to BotFather in {}. The bot becomes available immediately after octos creates it.", + room_name_id + ), + ); + self.view + .text_input(cx, ids!(form.username_input)) + .set_text(cx, ""); + self.view + .text_input(cx, ids!(form.display_name_input)) + .set_text(cx, ""); + self.view + .text_input(cx, ids!(form.prompt_input)) + .set_text(cx, ""); + self.view.label(cx, ids!(status_label)).set_text(cx, ""); + self.view + .button(cx, ids!(buttons.create_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(buttons.cancel_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(buttons.create_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(buttons.cancel_button)) + .reset_hover(cx); + self.view.redraw(cx); + } +} + +impl CreateBotModalRef { + pub fn show(&self, cx: &mut Cx, room_name_id: RoomNameId) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.show(cx, room_name_id); + } +} diff --git a/src/home/delete_bot_modal.rs b/src/home/delete_bot_modal.rs new file mode 100644 index 000000000..caab2bd49 --- /dev/null +++ b/src/home/delete_bot_modal.rs @@ -0,0 +1,249 @@ +//! A modal dialog for deleting a Matrix bot through BotFather slash commands. + +use makepad_widgets::*; + +use crate::utils::RoomNameId; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + mod.widgets.DeleteBotModalLabel = Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 10.5 } + color: #333 + wrap: Word + } + text: "" + } + + mod.widgets.DeleteBotModal = #(DeleteBotModal::register_widget(vm)) { + width: Fit + height: Fit + + RoundedView { + width: 448 + height: Fit + align: Align{x: 0.5} + flow: Down + padding: Inset{top: 28, right: 24, bottom: 20, left: 24} + spacing: 16 + + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 6.0 + } + + title := Label { + width: Fill + height: Fit + draw_text +: { + text_style: TITLE_TEXT { font_size: 13 } + color: #000 + wrap: Word + } + text: "Delete Bot" + } + + body := mod.widgets.DeleteBotModalLabel { + text: "" + } + + form := RoundedView { + width: Fill + height: Fit + flow: Down + spacing: 12 + padding: 14 + + show_bg: true + draw_bg +: { + color: (COLOR_SECONDARY) + border_radius: 4.0 + } + + user_id_label := mod.widgets.DeleteBotModalLabel { + text: "Bot Matrix User ID" + } + + user_id_input := RobrixTextInput { + width: Fill + height: Fit + padding: 10 + draw_text +: { + text_style: REGULAR_TEXT { font_size: 11.5 } + color: #000 + } + empty_text: "@bot_weather:server or bot_weather" + } + + user_id_hint := mod.widgets.DeleteBotModalLabel { + draw_text +: { + text_style: REGULAR_TEXT { font_size: 9.5 } + color: #666 + } + text: "Use the full Matrix user ID when possible. A plain localpart like `bot_weather` will be resolved on your current homeserver." + } + } + + status_label := Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 10.5 } + color: #000 + wrap: Word + } + text: "" + } + + buttons := View { + width: Fill + height: Fit + flow: Right + align: Align{x: 1.0, y: 0.5} + spacing: 16 + + cancel_button := RobrixNeutralIconButton { + width: 110 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_FORBIDDEN) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Cancel" + } + + delete_button := RobrixNegativeIconButton { + width: 130 + align: Align{x: 0.5, y: 0.5} + padding: 12 + draw_icon.svg: (ICON_CLOSE) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Delete Bot" + } + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeleteBotRequest { + pub user_id_or_localpart: String, +} + +#[derive(Clone, Debug)] +pub enum DeleteBotModalAction { + Close, + Submit(DeleteBotRequest), +} + +#[derive(Script, ScriptHook, Widget)] +pub struct DeleteBotModal { + #[deref] + view: View, + #[rust] + room_name_id: Option, + #[rust] + is_showing_error: bool, +} + +impl Widget for DeleteBotModal { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + self.widget_match_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl WidgetMatchEvent for DeleteBotModal { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let cancel_button = self.view.button(cx, ids!(buttons.cancel_button)); + let delete_button = self.view.button(cx, ids!(buttons.delete_button)); + let user_id_input = self.view.text_input(cx, ids!(form.user_id_input)); + let mut status_label = self.view.label(cx, ids!(status_label)); + + if cancel_button.clicked(actions) + || actions + .iter() + .any(|a| matches!(a.downcast_ref(), Some(ModalAction::Dismissed))) + { + cx.action(DeleteBotModalAction::Close); + return; + } + + if self.is_showing_error && user_id_input.changed(actions).is_some() { + self.is_showing_error = false; + status_label.set_text(cx, ""); + self.view.redraw(cx); + } + + if delete_button.clicked(actions) || user_id_input.returned(actions).is_some() { + let user_id_or_localpart = user_id_input.text().trim().to_string(); + if user_id_or_localpart.is_empty() { + self.is_showing_error = true; + script_apply_eval!(cx, status_label, { + text: "Enter the bot Matrix user ID or localpart to delete." + draw_text +: { + color: mod.widgets.COLOR_FG_DANGER_RED + } + }); + self.view.redraw(cx); + return; + } + + cx.action(DeleteBotModalAction::Submit(DeleteBotRequest { + user_id_or_localpart, + })); + } + } +} + +impl DeleteBotModal { + pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId) { + self.room_name_id = Some(room_name_id.clone()); + self.is_showing_error = false; + + self.view + .label(cx, ids!(title)) + .set_text(cx, "Delete Room Bot"); + self.view.label(cx, ids!(body)).set_text( + cx, + &format!( + "Robrix will send `/deletebot` to BotFather in {}. This only removes bots already managed by octos.", + room_name_id + ), + ); + self.view + .text_input(cx, ids!(form.user_id_input)) + .set_text(cx, ""); + self.view.label(cx, ids!(status_label)).set_text(cx, ""); + self.view + .button(cx, ids!(buttons.delete_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(buttons.cancel_button)) + .set_enabled(cx, true); + self.view + .button(cx, ids!(buttons.delete_button)) + .reset_hover(cx); + self.view + .button(cx, ids!(buttons.cancel_button)) + .reset_hover(cx); + self.view.redraw(cx); + } +} + +impl DeleteBotModalRef { + pub fn show(&self, cx: &mut Cx, room_name_id: RoomNameId) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.show(cx, room_name_id); + } +} diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index 910f817e1..c45c7309f 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -1,365 +1,371 @@ use makepad_widgets::*; -use crate::{app::AppState, home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, settings::settings_screen::SettingsScreenWidgetRefExt}; +use crate::{ + app::AppState, + home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, + settings::settings_screen::SettingsScreenWidgetRefExt, +}; script_mod! { - use mod.prelude.widgets.* - use mod.widgets.* - - - // Defines the total height of the StackNavigationView's header. - // This has to be set in multiple places because of how StackNavigation - // uses an Overlay view internally. - mod.widgets.STACK_VIEW_HEADER_HEIGHT = 75 - - // A reusable base for StackNavigationView children in the mobile layout. - // Each specific content view (room, invite, space lobby) extends this - // and places its own screen widget inside the body. - mod.widgets.RobrixContentView = StackNavigationView { - width: Fill, height: Fill - draw_bg.color: (COLOR_PRIMARY) - header +: { - clip_x: false, - clip_y: false, - show_bg: true, - draw_bg +: { - color: instance((COLOR_PRIMARY_DARKER)) - color_dither: uniform(1.0) - gradient_border_horizontal: uniform(0.0) - gradient_fill_horizontal: uniform(0.0) - color_2: instance(vec4(-1)) - - border_radius: uniform(4.0) - border_size: uniform(0.0) - border_color: instance(#0000) - border_color_2: instance(vec4(-1)) - - shadow_color: instance(#0005) - shadow_radius: uniform(9.0) - shadow_offset: uniform(vec2(1.0, 0.0)) - - rect_size2: varying(vec2(0)) - rect_size3: varying(vec2(0)) - rect_pos2: varying(vec2(0)) - rect_shift: varying(vec2(0)) - sdf_rect_pos: varying(vec2(0)) - sdf_rect_size: varying(vec2(0)) - - vertex: fn() { - let min_offset = min(self.shadow_offset vec2(0)) - self.rect_size2 = self.rect_size + 2.0*vec2(self.shadow_radius) - self.rect_size3 = self.rect_size2 + abs(self.shadow_offset) - self.rect_pos2 = self.rect_pos - vec2(self.shadow_radius) + min_offset - self.sdf_rect_size = self.rect_size2 - vec2(self.shadow_radius * 2.0 + self.border_size * 2.0) - self.sdf_rect_pos = -min_offset + vec2(self.border_size + self.shadow_radius) - self.rect_shift = -min_offset - - return self.clip_and_transform_vertex(self.rect_pos2 self.rect_size3) - } +use mod.prelude.widgets.* +use mod.widgets.* + + +// Defines the total height of the StackNavigationView's header. +// This has to be set in multiple places because of how StackNavigation +// uses an Overlay view internally. +mod.widgets.STACK_VIEW_HEADER_HEIGHT = 75 + +// A reusable base for StackNavigationView children in the mobile layout. +// Each specific content view (room, invite, space lobby) extends this +// and places its own screen widget inside the body. +mod.widgets.RobrixContentView = StackNavigationView { + width: Fill, height: Fill + draw_bg.color: (COLOR_PRIMARY) + header +: { + clip_x: false, + clip_y: false, + show_bg: true, + draw_bg +: { + color: instance((COLOR_PRIMARY_DARKER)) + color_dither: uniform(1.0) + gradient_border_horizontal: uniform(0.0) + gradient_fill_horizontal: uniform(0.0) + color_2: instance(vec4(-1)) + + border_radius: uniform(4.0) + border_size: uniform(0.0) + border_color: instance(#0000) + border_color_2: instance(vec4(-1)) + + shadow_color: instance(#0005) + shadow_radius: uniform(9.0) + shadow_offset: uniform(vec2(1.0, 0.0)) + + rect_size2: varying(vec2(0)) + rect_size3: varying(vec2(0)) + rect_pos2: varying(vec2(0)) + rect_shift: varying(vec2(0)) + sdf_rect_pos: varying(vec2(0)) + sdf_rect_size: varying(vec2(0)) + + vertex: fn() { + let min_offset = min(self.shadow_offset vec2(0)) + self.rect_size2 = self.rect_size + 2.0*vec2(self.shadow_radius) + self.rect_size3 = self.rect_size2 + abs(self.shadow_offset) + self.rect_pos2 = self.rect_pos - vec2(self.shadow_radius) + min_offset + self.sdf_rect_size = self.rect_size2 - vec2(self.shadow_radius * 2.0 + self.border_size * 2.0) + self.sdf_rect_pos = -min_offset + vec2(self.border_size + self.shadow_radius) + self.rect_shift = -min_offset + + return self.clip_and_transform_vertex(self.rect_pos2 self.rect_size3) + } - pixel: fn() { - let sdf = Sdf2d.viewport(self.pos * self.rect_size3) + pixel: fn() { + let sdf = Sdf2d.viewport(self.pos * self.rect_size3) - let mut fill_color = self.color - if self.color_2.x > -0.5 { - let dither = Math.random_2d(self.pos.xy) * 0.04 * self.color_dither - let dir = if self.gradient_fill_horizontal > 0.5 self.pos.x else self.pos.y - fill_color = mix(self.color self.color_2 dir + dither) - } + let mut fill_color = self.color + if self.color_2.x > -0.5 { + let dither = Math.random_2d(self.pos.xy) * 0.04 * self.color_dither + let dir = if self.gradient_fill_horizontal > 0.5 self.pos.x else self.pos.y + fill_color = mix(self.color self.color_2 dir + dither) + } - let mut stroke_color = self.border_color - if self.border_color_2.x > -0.5 { - let dither = Math.random_2d(self.pos.xy) * 0.04 * self.color_dither - let dir = if self.gradient_border_horizontal > 0.5 self.pos.x else self.pos.y - stroke_color = mix(self.border_color self.border_color_2 dir + dither) - } + let mut stroke_color = self.border_color + if self.border_color_2.x > -0.5 { + let dither = Math.random_2d(self.pos.xy) * 0.04 * self.color_dither + let dir = if self.gradient_border_horizontal > 0.5 self.pos.x else self.pos.y + stroke_color = mix(self.border_color self.border_color_2 dir + dither) + } - sdf.box( - self.sdf_rect_pos.x - self.sdf_rect_pos.y - self.sdf_rect_size.x - self.sdf_rect_size.y - max(1.0 self.border_radius) - ) - if sdf.shape > -1.0 { - let m = self.shadow_radius - let o = self.shadow_offset + self.rect_shift - let v = GaussShadow.rounded_box_shadow(vec2(m) + o self.rect_size2+o self.pos * (self.rect_size3+vec2(m)) self.shadow_radius*0.5 self.border_radius*2.0) - sdf.clear(self.shadow_color*v) - } + sdf.box( + self.sdf_rect_pos.x + self.sdf_rect_pos.y + self.sdf_rect_size.x + self.sdf_rect_size.y + max(1.0 self.border_radius) + ) + if sdf.shape > -1.0 { + let m = self.shadow_radius + let o = self.shadow_offset + self.rect_shift + let v = GaussShadow.rounded_box_shadow(vec2(m) + o self.rect_size2+o self.pos * (self.rect_size3+vec2(m)) self.shadow_radius*0.5 self.border_radius*2.0) + sdf.clear(self.shadow_color*v) + } - sdf.fill_keep(fill_color) + sdf.fill_keep(fill_color) - if self.border_size > 0.0 { - sdf.stroke(stroke_color self.border_size) - } - return sdf.result + if self.border_size > 0.0 { + sdf.stroke(stroke_color self.border_size) } + return sdf.result } + } - padding: Inset{top: 30, bottom: 0} - height: (mod.widgets.STACK_VIEW_HEADER_HEIGHT), - - content +: { - height: (mod.widgets.STACK_VIEW_HEADER_HEIGHT) - button_container +: { - padding: 0, - margin: 0 - left_button +: { - width: Fit, height: Fit, - padding: Inset{left: 20, right: 23, top: 10, bottom: 10} - margin: Inset{left: 8, right: 0, top: 0, bottom: 0} - draw_icon +: { color: (ROOM_NAME_TEXT_COLOR) } - icon_walk: Walk{width: 13, height: Fit} - spacing: 0 - text: "" - } + padding: Inset{top: 30, bottom: 0} + height: (mod.widgets.STACK_VIEW_HEADER_HEIGHT), + + content +: { + height: (mod.widgets.STACK_VIEW_HEADER_HEIGHT) + button_container +: { + padding: 0, + margin: 0 + left_button +: { + width: Fit, height: Fit, + padding: Inset{left: 20, right: 23, top: 10, bottom: 10} + margin: Inset{left: 8, right: 0, top: 0, bottom: 0} + draw_icon +: { color: (ROOM_NAME_TEXT_COLOR) } + icon_walk: Walk{width: 13, height: Fit} + spacing: 0 + text: "" } - title_container +: { - padding: Inset{top: 8} - title +: { - draw_text +: { - color: (ROOM_NAME_TEXT_COLOR) - } + } + title_container +: { + padding: Inset{top: 8} + title +: { + draw_text +: { + color: (ROOM_NAME_TEXT_COLOR) } } } } - body +: { - margin: Inset{top: (mod.widgets.STACK_VIEW_HEADER_HEIGHT)} - } } + body +: { + margin: Inset{top: (mod.widgets.STACK_VIEW_HEADER_HEIGHT)} + } +} - // A wrapper view around the SpacesBar that lets us show/hide it via animation. - mod.widgets.SpacesBarWrapper = set_type_default() do #(SpacesBarWrapper::register_widget(vm)) { - ..mod.widgets.RoundedShadowView - - width: Fill, - height: (NAVIGATION_TAB_BAR_SIZE) - margin: Inset{left: 4, right: 4} - show_bg: true - draw_bg +: { - color: (COLOR_PRIMARY_DARKER) - border_radius: 4.0 - border_size: 0.0 - shadow_color: #0005 - shadow_radius: 15.0 - shadow_offset: vec2(1.0, 0.0) - } +// A wrapper view around the SpacesBar that lets us show/hide it via animation. +mod.widgets.SpacesBarWrapper = set_type_default() do #(SpacesBarWrapper::register_widget(vm)) { + ..mod.widgets.RoundedShadowView + + width: Fill, + height: (NAVIGATION_TAB_BAR_SIZE) + margin: Inset{left: 4, right: 4} + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY_DARKER) + border_radius: 4.0 + border_size: 0.0 + shadow_color: #0005 + shadow_radius: 15.0 + shadow_offset: vec2(1.0, 0.0) + } - CachedWidget { - root_spaces_bar := mod.widgets.SpacesBar {} - } + CachedWidget { + root_spaces_bar := mod.widgets.SpacesBar {} + } - animator: Animator{ - spaces_bar_animator: { - default: @hide - show: AnimatorState{ - redraw: true - from: { all: Forward { duration: (mod.widgets.SPACES_BAR_ANIMATION_DURATION_SECS) } } - apply: { height: (NAVIGATION_TAB_BAR_SIZE), draw_bg: { shadow_color: #x00000055 } } - } - hide: AnimatorState{ - redraw: true - from: { all: Forward { duration: (mod.widgets.SPACES_BAR_ANIMATION_DURATION_SECS) } } - apply: { height: 0, draw_bg: { shadow_color: (COLOR_TRANSPARENT) } } - } + animator: Animator{ + spaces_bar_animator: { + default: @hide + show: AnimatorState{ + redraw: true + from: { all: Forward { duration: (mod.widgets.SPACES_BAR_ANIMATION_DURATION_SECS) } } + apply: { height: (NAVIGATION_TAB_BAR_SIZE), draw_bg: { shadow_color: #x00000055 } } + } + hide: AnimatorState{ + redraw: true + from: { all: Forward { duration: (mod.widgets.SPACES_BAR_ANIMATION_DURATION_SECS) } } + apply: { height: 0, draw_bg: { shadow_color: (COLOR_TRANSPARENT) } } } } } +} - // The home screen widget contains the main content: - // rooms list, room screens, and the settings screen as an overlay. - // It adapts to both desktop and mobile layouts. - mod.widgets.HomeScreen = #(HomeScreen::register_widget(vm)) { - AdaptiveView { - // NOTE: within each of these sub views, we used `CachedWidget` wrappers - // to ensure that there is only a single global instance of each - // of those widgets, which means they maintain their state - // across transitions between the Desktop and Mobile variant. - Desktop := SolidView { - width: Fill, height: Fill - flow: Right - align: Align{x: 0.0, y: 0.0} - padding: 0, - margin: 0, +// The home screen widget contains the main content: +// rooms list, room screens, and the settings screen as an overlay. +// It adapts to both desktop and mobile layouts. +mod.widgets.HomeScreen = #(HomeScreen::register_widget(vm)) { + AdaptiveView { + // NOTE: within each of these sub views, we used `CachedWidget` wrappers + // to ensure that there is only a single global instance of each + // of those widgets, which means they maintain their state + // across transitions between the Desktop and Mobile variant. + Desktop := SolidView { + width: Fill, height: Fill + flow: Right + align: Align{x: 0.0, y: 0.0} + padding: 0, + margin: 0, + + show_bg: true + draw_bg +: { + color: (COLOR_SECONDARY) + } - show_bg: true - draw_bg +: { - color: (COLOR_SECONDARY) - } + // On the left, show the navigation tab bar vertically. + CachedWidget { + navigation_tab_bar := mod.widgets.NavigationTabBar {} + } - // On the left, show the navigation tab bar vertically. - CachedWidget { - navigation_tab_bar := mod.widgets.NavigationTabBar {} - } + // To the right of that, we use the PageFlip widget to show either + // the main desktop UI or the settings screen. + home_screen_page_flip := PageFlip { + width: Fill, height: Fill - // To the right of that, we use the PageFlip widget to show either - // the main desktop UI or the settings screen. - home_screen_page_flip := PageFlip { + lazy_init: true, + active_page: @home_page + + home_page := View { width: Fill, height: Fill + flow: Down - lazy_init: true, - active_page: @home_page + View { + width: Fill, + height: 39, + flow: Right + padding: Inset{top: 2, bottom: 2} + margin: Inset{right: 2} + spacing: 2 + align: Align{y: 0.5} - home_page := View { - width: Fill, height: Fill - flow: Down + CachedWidget { + room_filter_input_bar := RoomFilterInputBar {} + } - View { - width: Fill, - height: 39, - flow: Right - padding: Inset{top: 2, bottom: 2} + search_messages_button := SearchMessagesButton { + // make this button match/align with the RoomFilterInputBar + height: 32.5, margin: Inset{right: 2} - spacing: 2 - align: Align{y: 0.5} - - CachedWidget { - room_filter_input_bar := RoomFilterInputBar {} - } - - search_messages_button := SearchMessagesButton { - // make this button match/align with the RoomFilterInputBar - height: 32.5, - margin: Inset{right: 2} - } } - - mod.widgets.MainDesktopUI {} } - settings_page := SolidView { - width: Fill, height: Fill - show_bg: true, - draw_bg.color: (COLOR_PRIMARY) + mod.widgets.MainDesktopUI {} + } - CachedWidget { - settings_screen := mod.widgets.SettingsScreen {} - } + settings_page := SolidView { + width: Fill, height: Fill + show_bg: true, + draw_bg.color: (COLOR_PRIMARY) + + CachedWidget { + settings_screen := mod.widgets.SettingsScreen {} } + } - add_room_page := SolidView { - width: Fill, height: Fill - show_bg: true, - draw_bg.color: (COLOR_PRIMARY) + add_room_page := SolidView { + width: Fill, height: Fill + show_bg: true, + draw_bg.color: (COLOR_PRIMARY) - CachedWidget { - add_room_screen := mod.widgets.AddRoomScreen {} - } + CachedWidget { + add_room_screen := mod.widgets.AddRoomScreen {} } } } + } - Mobile := SolidView { - width: Fill, height: Fill - flow: Down + Mobile := SolidView { + width: Fill, height: Fill + flow: Down - show_bg: true - draw_bg.color: (COLOR_PRIMARY) + show_bg: true + draw_bg.color: (COLOR_PRIMARY) - view_stack := StackNavigation { - root_view +: { - flow: Down - width: Fill, height: Fill + view_stack := StackNavigation { + root_view +: { + flow: Down + width: Fill, height: Fill - // At the top of the root view, we use the PageFlip widget to show either - // the main list of rooms or the settings screen. - home_screen_page_flip := PageFlip { - width: Fill, height: Fill + // At the top of the root view, we use the PageFlip widget to show either + // the main list of rooms or the settings screen. + home_screen_page_flip := PageFlip { + width: Fill, height: Fill - lazy_init: true, - active_page: @home_page + lazy_init: true, + active_page: @home_page - home_page := View { - width: Fill, height: Fill - // Note: while the other page views have top padding, we do NOT add that here - // because it is added in the `RoomsSideBar`'s `RoundedShadowView` itself. - flow: Down + home_page := View { + width: Fill, height: Fill + // Note: while the other page views have top padding, we do NOT add that here + // because it is added in the `RoomsSideBar`'s `RoundedShadowView` itself. + flow: Down - mod.widgets.RoomsSideBar {} - } + mod.widgets.RoomsSideBar {} + } - settings_page := View { - width: Fill, height: Fill - padding: Inset{top: 20} + settings_page := View { + width: Fill, height: Fill + padding: Inset{top: 20} - CachedWidget { - settings_screen := mod.widgets.SettingsScreen {} - } + CachedWidget { + settings_screen := mod.widgets.SettingsScreen {} } + } - add_room_page := View { - width: Fill, height: Fill - padding: Inset{top: 20} + add_room_page := View { + width: Fill, height: Fill + padding: Inset{top: 20} - CachedWidget { - add_room_screen := mod.widgets.AddRoomScreen {} - } + CachedWidget { + add_room_screen := mod.widgets.AddRoomScreen {} } } + } - // Show the SpacesBar right above the navigation tab bar. - // We wrap it in the SpacesBarWrapper in order to animate it in or out, - // and wrap *that* in a CachedWidget in order to maintain its shown/hidden state - // across AdaptiveView transitions between Mobile view mode and Desktop view mode. - // - // ... Then we wrap *that* in a ... - CachedWidget { - spaces_bar_wrapper := mod.widgets.SpacesBarWrapper {} - } + // Show the SpacesBar right above the navigation tab bar. + // We wrap it in the SpacesBarWrapper in order to animate it in or out, + // and wrap *that* in a CachedWidget in order to maintain its shown/hidden state + // across AdaptiveView transitions between Mobile view mode and Desktop view mode. + // + // ... Then we wrap *that* in a ... + CachedWidget { + spaces_bar_wrapper := mod.widgets.SpacesBarWrapper {} + } - // At the bottom of the root view, show the navigation tab bar horizontally. - CachedWidget { - navigation_tab_bar := mod.widgets.NavigationTabBar {} - } + // At the bottom of the root view, show the navigation tab bar horizontally. + CachedWidget { + navigation_tab_bar := mod.widgets.NavigationTabBar {} } + } - // Room views: multiple instances to support deep stacking - // (e.g., room -> thread -> room -> thread -> ...). - // Each stack depth gets its own dedicated view widget, - // avoiding complex state save/restore when views are reused. - room_view_0 := mod.widgets.RobrixContentView { body +: { room_screen_0 := mod.widgets.RoomScreen {} } } - room_view_1 := mod.widgets.RobrixContentView { body +: { room_screen_1 := mod.widgets.RoomScreen {} } } - room_view_2 := mod.widgets.RobrixContentView { body +: { room_screen_2 := mod.widgets.RoomScreen {} } } - room_view_3 := mod.widgets.RobrixContentView { body +: { room_screen_3 := mod.widgets.RoomScreen {} } } - room_view_4 := mod.widgets.RobrixContentView { body +: { room_screen_4 := mod.widgets.RoomScreen {} } } - room_view_5 := mod.widgets.RobrixContentView { body +: { room_screen_5 := mod.widgets.RoomScreen {} } } - room_view_6 := mod.widgets.RobrixContentView { body +: { room_screen_6 := mod.widgets.RoomScreen {} } } - room_view_7 := mod.widgets.RobrixContentView { body +: { room_screen_7 := mod.widgets.RoomScreen {} } } - room_view_8 := mod.widgets.RobrixContentView { body +: { room_screen_8 := mod.widgets.RoomScreen {} } } - room_view_9 := mod.widgets.RobrixContentView { body +: { room_screen_9 := mod.widgets.RoomScreen {} } } - room_view_10 := mod.widgets.RobrixContentView { body +: { room_screen_10 := mod.widgets.RoomScreen {} } } - room_view_11 := mod.widgets.RobrixContentView { body +: { room_screen_11 := mod.widgets.RoomScreen {} } } - room_view_12 := mod.widgets.RobrixContentView { body +: { room_screen_12 := mod.widgets.RoomScreen {} } } - room_view_13 := mod.widgets.RobrixContentView { body +: { room_screen_13 := mod.widgets.RoomScreen {} } } - room_view_14 := mod.widgets.RobrixContentView { body +: { room_screen_14 := mod.widgets.RoomScreen {} } } - room_view_15 := mod.widgets.RobrixContentView { body +: { room_screen_15 := mod.widgets.RoomScreen {} } } - - invite_view := mod.widgets.RobrixContentView { - body +: { - invite_screen := mod.widgets.InviteScreen {} - } + // Room views: multiple instances to support deep stacking + // (e.g., room -> thread -> room -> thread -> ...). + // Each stack depth gets its own dedicated view widget, + // avoiding complex state save/restore when views are reused. + room_view_0 := mod.widgets.RobrixContentView { body +: { room_screen_0 := mod.widgets.RoomScreen {} } } + room_view_1 := mod.widgets.RobrixContentView { body +: { room_screen_1 := mod.widgets.RoomScreen {} } } + room_view_2 := mod.widgets.RobrixContentView { body +: { room_screen_2 := mod.widgets.RoomScreen {} } } + room_view_3 := mod.widgets.RobrixContentView { body +: { room_screen_3 := mod.widgets.RoomScreen {} } } + room_view_4 := mod.widgets.RobrixContentView { body +: { room_screen_4 := mod.widgets.RoomScreen {} } } + room_view_5 := mod.widgets.RobrixContentView { body +: { room_screen_5 := mod.widgets.RoomScreen {} } } + room_view_6 := mod.widgets.RobrixContentView { body +: { room_screen_6 := mod.widgets.RoomScreen {} } } + room_view_7 := mod.widgets.RobrixContentView { body +: { room_screen_7 := mod.widgets.RoomScreen {} } } + room_view_8 := mod.widgets.RobrixContentView { body +: { room_screen_8 := mod.widgets.RoomScreen {} } } + room_view_9 := mod.widgets.RobrixContentView { body +: { room_screen_9 := mod.widgets.RoomScreen {} } } + room_view_10 := mod.widgets.RobrixContentView { body +: { room_screen_10 := mod.widgets.RoomScreen {} } } + room_view_11 := mod.widgets.RobrixContentView { body +: { room_screen_11 := mod.widgets.RoomScreen {} } } + room_view_12 := mod.widgets.RobrixContentView { body +: { room_screen_12 := mod.widgets.RoomScreen {} } } + room_view_13 := mod.widgets.RobrixContentView { body +: { room_screen_13 := mod.widgets.RoomScreen {} } } + room_view_14 := mod.widgets.RobrixContentView { body +: { room_screen_14 := mod.widgets.RoomScreen {} } } + room_view_15 := mod.widgets.RobrixContentView { body +: { room_screen_15 := mod.widgets.RoomScreen {} } } + + invite_view := mod.widgets.RobrixContentView { + body +: { + invite_screen := mod.widgets.InviteScreen {} } + } - space_lobby_view := mod.widgets.RobrixContentView { - body +: { - space_lobby_screen := mod.widgets.SpaceLobbyScreen {} - } + space_lobby_view := mod.widgets.RobrixContentView { + body +: { + space_lobby_screen := mod.widgets.SpaceLobbyScreen {} } } } } } } - +} /// A simple wrapper around the SpacesBar that allows us to animate showing or hiding it. #[derive(Script, ScriptHook, Widget, Animator)] pub struct SpacesBarWrapper { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, } impl Widget for SpacesBarWrapper { @@ -384,7 +390,9 @@ impl Widget for SpacesBarWrapper { impl SpacesBarWrapperRef { /// Shows or hides the spaces bar by animating it in or out. fn show_or_hide(&self, cx: &mut Cx, show: bool) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; if show { inner.animator_play(cx, ids!(spaces_bar_animator.show)); } else { @@ -394,18 +402,20 @@ impl SpacesBarWrapperRef { } } - #[derive(Script, ScriptHook, Widget)] pub struct HomeScreen { - #[deref] view: View, + #[deref] + view: View, /// The previously-selected navigation tab, used to determine which tab /// and top-level view we return to after closing the settings screen. /// /// Note that the current selected tap is stored in `AppState` so that /// other widgets can easily access it. - #[rust] previous_selection: SelectedTab, - #[rust] is_spaces_bar_shown: bool, + #[rust] + previous_selection: SelectedTab, + #[rust] + is_spaces_bar_shown: bool, } impl Widget for HomeScreen { @@ -418,7 +428,9 @@ impl Widget for HomeScreen { if !matches!(app_state.selected_tab, SelectedTab::Home) { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = SelectedTab::Home; - cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); + cx.action(NavigationBarAction::TabSelected( + app_state.selected_tab.clone(), + )); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } @@ -427,17 +439,23 @@ impl Widget for HomeScreen { if !matches!(app_state.selected_tab, SelectedTab::AddRoom) { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = SelectedTab::AddRoom; - cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); + cx.action(NavigationBarAction::TabSelected( + app_state.selected_tab.clone(), + )); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } } Some(NavigationBarAction::GoToSpace { space_name_id }) => { - let new_space_selection = SelectedTab::Space { space_name_id: space_name_id.clone() }; + let new_space_selection = SelectedTab::Space { + space_name_id: space_name_id.clone(), + }; if app_state.selected_tab != new_space_selection { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = new_space_selection; - cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); + cx.action(NavigationBarAction::TabSelected( + app_state.selected_tab.clone(), + )); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } @@ -447,11 +465,15 @@ impl Widget for HomeScreen { if !matches!(app_state.selected_tab, SelectedTab::Settings) { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = SelectedTab::Settings; - cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); - if let Some(settings_page) = self.update_active_page_from_selection(cx, app_state) { + cx.action(NavigationBarAction::TabSelected( + app_state.selected_tab.clone(), + )); + if let Some(settings_page) = + self.update_active_page_from_selection(cx, app_state) + { settings_page .settings_screen(cx, ids!(settings_screen)) - .populate(cx, None); + .populate(cx, None, &app_state.bot_settings); self.view.redraw(cx); } else { error!("BUG: failed to set active page to show settings screen."); @@ -461,19 +483,21 @@ impl Widget for HomeScreen { Some(NavigationBarAction::CloseSettings) => { if matches!(app_state.selected_tab, SelectedTab::Settings) { app_state.selected_tab = self.previous_selection.clone(); - cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); + cx.action(NavigationBarAction::TabSelected( + app_state.selected_tab.clone(), + )); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } } Some(NavigationBarAction::ToggleSpacesBar) => { self.is_spaces_bar_shown = !self.is_spaces_bar_shown; - self.view.spaces_bar_wrapper(cx, ids!(spaces_bar_wrapper)) + self.view + .spaces_bar_wrapper(cx, ids!(spaces_bar_wrapper)) .show_or_hide(cx, self.is_spaces_bar_shown); } // We're the ones who emitted this action, so we don't need to handle it again. - Some(NavigationBarAction::TabSelected(_)) - | None => { } + Some(NavigationBarAction::TabSelected(_)) | None => {} } } } @@ -504,12 +528,10 @@ impl HomeScreen { .set_active_page( cx, match app_state.selected_tab { - SelectedTab::Space { .. } - | SelectedTab::Home => id!(home_page), + SelectedTab::Space { .. } | SelectedTab::Home => id!(home_page), SelectedTab::Settings => id!(settings_page), SelectedTab::AddRoom => id!(add_room_page), }, ) } } - diff --git a/src/home/mod.rs b/src/home/mod.rs index 23a1de96d..8dae34f82 100644 --- a/src/home/mod.rs +++ b/src/home/mod.rs @@ -1,6 +1,8 @@ use makepad_widgets::ScriptVm; pub mod add_room; +pub mod create_bot_modal; +pub mod delete_bot_modal; pub mod edited_indicator; pub mod editing_pane; pub mod event_source_modal; @@ -35,6 +37,8 @@ pub fn script_mod(vm: &mut ScriptVm) { loading_pane::script_mod(vm); location_preview::script_mod(vm); add_room::script_mod(vm); + create_bot_modal::script_mod(vm); + delete_bot_modal::script_mod(vm); space_lobby::script_mod(vm); link_preview::script_mod(vm); event_reaction_list::script_mod(vm); diff --git a/src/home/room_context_menu.rs b/src/home/room_context_menu.rs index c55b7fa54..9a048b91f 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -3,7 +3,13 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; -use crate::{home::invite_modal::InviteModalAction, shared::popup_list::{PopupKind, enqueue_popup_notification}, sliding_sync::{MatrixRequest, submit_async_request}, utils::RoomNameId}; +use crate::{ + app::AppState, + home::invite_modal::InviteModalAction, + shared::popup_list::{PopupKind, enqueue_popup_notification}, + sliding_sync::{MatrixRequest, current_user_id, submit_async_request}, + utils::RoomNameId, +}; const BUTTON_HEIGHT: f64 = 35.0; const MENU_WIDTH: f64 = 215.0; @@ -69,7 +75,7 @@ script_mod! { } priority_button := mod.widgets.RoomContextMenuButton { - draw_icon +: { svg: (ICON_TOMBSTONE) } + draw_icon +: { svg: (ICON_TOMBSTONE) } text: "Set Low Priority" } @@ -77,7 +83,7 @@ script_mod! { draw_icon +: { svg: (ICON_LINK) } text: "Copy Link to Room" } - + divider1 := LineH { margin: Inset{top: 3, bottom: 3} width: Fill, @@ -99,6 +105,11 @@ script_mod! { text: "Invite" } + bot_binding_button := mod.widgets.RoomContextMenuButton { + draw_icon +: { svg: (ICON_HIERARCHY) } + text: "Bind BotFather" + } + divider2 := LineH { margin: Inset{top: 3, bottom: 3} width: Fill, @@ -123,6 +134,8 @@ pub struct RoomContextMenuDetails { pub is_favorite: bool, pub is_low_priority: bool, pub is_marked_unread: bool, + pub app_service_enabled: bool, + pub is_bot_bound: bool, } /// Actions emitted from the RoomContextMenu widget, as they must be handled @@ -137,9 +150,12 @@ pub enum RoomContextMenuAction { #[derive(Script, ScriptHook, Widget)] pub struct RoomContextMenu { - #[deref] view: View, - #[source] source: ScriptObjectRef, - #[rust] details: Option, + #[deref] + view: View, + #[source] + source: ScriptObjectRef, + #[rust] + details: Option, } impl Widget for RoomContextMenu { @@ -151,21 +167,25 @@ impl Widget for RoomContextMenu { } fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - if !self.visible { return; } + if !self.visible { + return; + } self.view.handle_event(cx, event, scope); // Close logic similar to NewMessageContextMenu let area = self.view.area(); let close_menu = { event.back_pressed() - || match event.hits_with_capture_overload(cx, area, true) { - Hit::KeyUp(key) => key.key_code == KeyCode::Escape, - Hit::FingerUp(fue) if fue.is_over => { - !self.view(cx, ids!(main_content)).area().rect(cx).contains(fue.abs) + || match event.hits_with_capture_overload(cx, area, true) { + Hit::KeyUp(key) => key.key_code == KeyCode::Escape, + Hit::FingerUp(fue) if fue.is_over => !self + .view(cx, ids!(main_content)) + .area() + .rect(cx) + .contains(fue.abs), + Hit::FingerScroll(_) => true, + _ => false, } - Hit::FingerScroll(_) => true, - _ => false, - } }; if close_menu { @@ -178,32 +198,31 @@ impl Widget for RoomContextMenu { } impl WidgetMatchEvent for RoomContextMenu { - fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { - let Some(details) = self.details.as_ref() else { return }; + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, scope: &mut Scope) { + let Some(details) = self.details.as_ref() else { + return; + }; let mut close_menu = false; - + if self.button(cx, ids!(mark_unread_button)).clicked(actions) { submit_async_request(MatrixRequest::SetUnreadFlag { room_id: details.room_name_id.room_id().clone(), mark_as_unread: !details.is_marked_unread, }); close_menu = true; - } - else if self.button(cx, ids!(favorite_button)).clicked(actions) { + } else if self.button(cx, ids!(favorite_button)).clicked(actions) { submit_async_request(MatrixRequest::SetIsFavorite { room_id: details.room_name_id.room_id().clone(), is_favorite: !details.is_favorite, }); close_menu = true; - } - else if self.button(cx, ids!(priority_button)).clicked(actions) { + } else if self.button(cx, ids!(priority_button)).clicked(actions) { submit_async_request(MatrixRequest::SetIsLowPriority { room_id: details.room_name_id.room_id().clone(), is_low_priority: !details.is_low_priority, }); close_menu = true; - } - else if self.button(cx, ids!(copy_link_button)).clicked(actions) { + } else if self.button(cx, ids!(copy_link_button)).clicked(actions) { submit_async_request(MatrixRequest::GenerateMatrixLink { room_id: details.room_name_id.room_id().clone(), event_id: None, @@ -211,8 +230,7 @@ impl WidgetMatchEvent for RoomContextMenu { join_on_click: false, }); close_menu = true; - } - else if self.button(cx, ids!(room_settings_button)).clicked(actions) { + } else if self.button(cx, ids!(room_settings_button)).clicked(actions) { // TODO: handle/implement this enqueue_popup_notification( "The room settings page is not yet implemented.", @@ -220,8 +238,7 @@ impl WidgetMatchEvent for RoomContextMenu { Some(5.0), ); close_menu = true; - } - else if self.button(cx, ids!(notifications_button)).clicked(actions) { + } else if self.button(cx, ids!(notifications_button)).clicked(actions) { // TODO: handle/implement this enqueue_popup_notification( "The room notifications page is not yet implemented.", @@ -229,12 +246,54 @@ impl WidgetMatchEvent for RoomContextMenu { Some(5.0), ); close_menu = true; - } - else if self.button(cx, ids!(invite_button)).clicked(actions) { + } else if self.button(cx, ids!(invite_button)).clicked(actions) { cx.action(InviteModalAction::Open(details.room_name_id.clone())); close_menu = true; - } - else if self.button(cx, ids!(leave_button)).clicked(actions) { + } else if self.button(cx, ids!(bot_binding_button)).clicked(actions) { + if let Some(app_state) = scope.data.get::() { + let room_id = details.room_name_id.room_id().clone(); + match app_state + .bot_settings + .resolved_bot_user_id(current_user_id().as_deref()) + { + Ok(bot_user_id) => { + if details.is_bot_bound { + submit_async_request(MatrixRequest::SetRoomBotBinding { + room_id, + bound: false, + bot_user_id: bot_user_id.clone(), + }); + enqueue_popup_notification( + format!("Removing BotFather {bot_user_id} from this room..."), + PopupKind::Info, + Some(4.0), + ); + } else { + submit_async_request(MatrixRequest::SetRoomBotBinding { + room_id, + bound: true, + bot_user_id: bot_user_id.clone(), + }); + enqueue_popup_notification( + format!("Inviting BotFather {bot_user_id} into this room..."), + PopupKind::Info, + Some(5.0), + ); + } + } + Err(error) => { + enqueue_popup_notification(error, PopupKind::Error, Some(5.0)); + } + } + } else { + enqueue_popup_notification( + "Bot settings are unavailable right now.", + PopupKind::Error, + Some(5.0), + ); + } + close_menu = true; + } else if self.button(cx, ids!(leave_button)).clicked(actions) { use crate::join_leave_room_modal::{JoinLeaveRoomModalAction, JoinLeaveModalKind}; use crate::room::BasicRoomDetails; let room_details = BasicRoomDetails::Name(details.room_name_id.clone()); @@ -263,7 +322,7 @@ impl RoomContextMenu { cx.set_key_focus(self.view.area()); dvec2(MENU_WIDTH, height) } - + fn update_buttons(&mut self, cx: &mut Cx, details: &RoomContextMenuDetails) -> f64 { let mark_unread_button = self.button(cx, ids!(mark_unread_button)); if details.is_marked_unread { @@ -271,12 +330,12 @@ impl RoomContextMenu { } else { mark_unread_button.set_text(cx, "Mark as Unread"); } - + let favorite_button = self.button(cx, ids!(favorite_button)); if details.is_favorite { favorite_button.set_text(cx, "Un-favorite"); } else { - favorite_button.set_text(cx, "Favorite"); + favorite_button.set_text(cx, "Favorite"); } let priority_button = self.button(cx, ids!(priority_button)); @@ -285,7 +344,15 @@ impl RoomContextMenu { } else { priority_button.set_text(cx, "Set Low Priority"); } - + + let bot_binding_button = self.button(cx, ids!(bot_binding_button)); + bot_binding_button.set_visible(cx, details.app_service_enabled); + if details.is_bot_bound { + bot_binding_button.set_text(cx, "Unbind BotFather"); + } else { + bot_binding_button.set_text(cx, "Bind BotFather"); + } + // Reset hover states mark_unread_button.reset_hover(cx); favorite_button.reset_hover(cx); @@ -294,13 +361,18 @@ impl RoomContextMenu { self.button(cx, ids!(room_settings_button)).reset_hover(cx); self.button(cx, ids!(notifications_button)).reset_hover(cx); self.button(cx, ids!(invite_button)).reset_hover(cx); + bot_binding_button.reset_hover(cx); self.button(cx, ids!(leave_button)).reset_hover(cx); - + self.redraw(cx); - - // Calculate height (rudimentary) - sum of visible buttons + padding - // 8 buttons * 35.0 + 2 dividers * ~10.0 + padding - (8.0 * BUTTON_HEIGHT) + 20.0 + 10.0 // approx + + // Calculate height (rudimentary) - sum of visible buttons + padding. + let button_count = if details.app_service_enabled { + 9.0 + } else { + 8.0 + }; + (button_count * BUTTON_HEIGHT) + 20.0 + 10.0 // approx } fn close(&mut self, cx: &mut Cx) { @@ -313,12 +385,16 @@ impl RoomContextMenu { impl RoomContextMenuRef { pub fn is_currently_shown(&self, cx: &mut Cx) -> bool { - let Some(inner) = self.borrow() else { return false }; + let Some(inner) = self.borrow() else { + return false; + }; inner.is_currently_shown(cx) } pub fn show(&self, cx: &mut Cx, details: RoomContextMenuDetails) -> DVec2 { - let Some(mut inner) = self.borrow_mut() else { return DVec2::default()}; + let Some(mut inner) = self.borrow_mut() else { + return DVec2::default(); + }; inner.show(cx, details) } } diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 61f20ced9..1b4ddc171 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1,40 +1,106 @@ //! The `RoomScreen` widget is the UI view that displays a single room or thread's timeline //! of events (messages,state changes, etc.), along with an input bar at the bottom. -use std::{borrow::Cow, cell::RefCell, ops::{DerefMut, Range}, sync::Arc}; +use std::{ + borrow::Cow, + cell::RefCell, + ops::{DerefMut, Range}, + sync::Arc, +}; use bytesize::ByteSize; use hashbrown::{HashMap, HashSet}; use imbl::Vector; use makepad_widgets::{image_cache::ImageBuffer, *}; use matrix_sdk::{ - OwnedServerName, RoomDisplayName, media::{MediaFormat, MediaRequestParameters}, room::RoomMember, ruma::{ - EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, events::{ + OwnedServerName, RoomDisplayName, + media::{MediaFormat, MediaRequestParameters}, + room::RoomMember, + ruma::{ + EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, + events::{ receipt::Receipt, room::{ - ImageInfo, MediaSource, message::{ - AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent - } + ImageInfo, MediaSource, + message::{ + AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, + FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, + LocationMessageEventContent, MessageFormat, MessageType, + NoticeMessageEventContent, RoomMessageEventContent, TextMessageEventContent, + VideoMessageEventContent, + }, }, sticker::{StickerEventContent, StickerMediaSource}, - }, matrix_uri::MatrixId, uint - } + }, + matrix_uri::MatrixId, + uint, + }, }; use matrix_sdk_ui::timeline::{ - self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, MemberProfileChange, MembershipChange, MsgLikeContent, MsgLikeKind, OtherMessageLike, PollState, RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem + self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, + MemberProfileChange, MembershipChange, MsgLikeContent, MsgLikeKind, OtherMessageLike, + PollState, RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, + TimelineItemContent, TimelineItemKind, VirtualTimelineItem, +}; +use ruma::{ + OwnedUserId, + api::client::receipt::create_receipt::v3::ReceiptType, + events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent}, + owned_room_id, }; -use ruma::{OwnedUserId, api::client::receipt::create_receipt::v3::ReceiptType, events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent}, owned_room_id}; use crate::{ - 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}, + app::{AppState, 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::{ + create_bot_modal::{CreateBotModalAction, CreateBotModalWidgetExt}, + delete_bot_modal::{DeleteBotModalAction, DeleteBotModalWidgetExt}, + 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, }, - room::{BasicRoomDetails, room_input_bar::{RoomInputBarState, RoomInputBarWidgetRefExt}, typing_notice::TypingNoticeWidgetExt}, + room::{ + BasicRoomDetails, + room_input_bar::{RoomInputBarState, RoomInputBarWidgetRefExt}, + typing_notice::TypingNoticeWidgetExt, + }, shared::{ - avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::ConfirmationModalContent, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt + avatar::{AvatarState, AvatarWidgetRefExt}, + confirmation_modal::ConfirmationModalContent, + html_or_plaintext::{ + HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction, + }, + image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, + jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, + popup_list::{PopupKind, enqueue_popup_notification}, + restore_status_view::RestoreStatusViewWidgetExt, + styles::*, + text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, + timestamp::TimestampWidgetRefExt, + }, + sliding_sync::{ + BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, + TimelineKind, TimelineRequestSender, UserPowerLevels, current_user_id, get_client, + submit_async_request, take_timeline_endpoints, }, - sliding_sync::{BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, get_client, submit_async_request, take_timeline_endpoints}, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime} + utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime}, }; use crate::home::event_reaction_list::ReactionListWidgetRefExt; use crate::home::room_read_receipt::AvatarRowWidgetRefExt; @@ -43,7 +109,12 @@ use crate::shared::mentionable_text_input::MentionableTextInputAction; use rangemap::RangeSet; -use super::{event_reaction_list::ReactionData, loading_pane::LoadingPaneRef, new_message_context_menu::{MessageAbilities, MessageDetails}, room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}}; +use super::{ + event_reaction_list::ReactionData, + loading_pane::LoadingPaneRef, + new_message_context_menu::{MessageAbilities, MessageDetails}, + room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}, +}; /// The maximum number of timeline items to search through /// when looking for a particular event. @@ -62,6 +133,62 @@ const COLOR_THREAD_SUMMARY_BG: Vec4 = vec4(1.0, 0.957, 0.898, 1.0); /// #FFEACC const COLOR_THREAD_SUMMARY_BG_HOVER: Vec4 = vec4(1.0, 0.918, 0.8, 1.0); +fn escape_slash_command_arg(value: &str) -> String { + value.trim().replace('\\', "\\\\").replace('"', "\\\"") +} + +fn format_create_bot_command( + username: &str, + display_name: &str, + system_prompt: Option<&str>, +) -> String { + let mut command = format!("/createbot {} {}", username.trim(), display_name.trim()); + if let Some(system_prompt) = system_prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + { + command.push_str(" --prompt \""); + command.push_str(&escape_slash_command_arg(system_prompt)); + command.push('"'); + } + command +} + +fn format_delete_bot_command(matrix_user_id: &UserId) -> String { + format!("/deletebot {matrix_user_id}") +} + +fn resolve_delete_bot_user_id( + user_id_or_localpart: &str, + current_user_id: Option<&UserId>, +) -> Result { + let raw = user_id_or_localpart.trim(); + if raw.is_empty() { + return Err("Please enter the bot Matrix user ID to delete.".into()); + } + + if raw.starts_with('@') || raw.contains(':') { + let full_user_id = if raw.starts_with('@') { + raw.to_string() + } else { + format!("@{raw}") + }; + return UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| format!("Invalid Matrix user ID: {full_user_id}")); + } + + let Some(current_user_id) = current_user_id else { + return Err( + "Current user ID is unavailable, so the bot homeserver cannot be resolved.".into(), + ); + }; + + let full_user_id = format!("@{raw}:{}", current_user_id.server_name()); + UserId::parse(&full_user_id) + .map(|user_id| user_id.to_owned()) + .map_err(|_| format!("Invalid Matrix user ID: {full_user_id}")) +} script_mod! { use mod.prelude.widgets.* @@ -504,6 +631,192 @@ script_mod! { } } + mod.widgets.AppServicePanel = #(AppServicePanel::register_widget(vm)) { + width: Fill + height: Fit + margin: Inset{left: 14, right: 54, top: 10, bottom: 16} + flow: Down + align: Align{x: 0.0, y: 0.0} + spacing: 8 + + sender_row := View { + width: Fit + height: Fit + flow: Right + spacing: 6 + + sender_name := Label { + width: Fit + height: Fit + draw_text +: { + text_style: USERNAME_TEXT_STYLE { font_size: 10.8 } + color: (COLOR_ACTIVE_PRIMARY) + } + text: "BotFather" + } + + sender_tag := Label { + width: Fit + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 9.5 } + color: #8A8A8A + } + text: "bot" + } + } + + bubble := RoundedView { + width: 408 + height: Fit + flow: Down + spacing: 8 + padding: Inset{top: 14, right: 14, bottom: 12, left: 14} + + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY) + border_radius: 0.0 + border_size: 1.0 + border_color: (COLOR_SECONDARY_DARKER) + } + + header := View { + width: Fill + height: Fit + flow: Right + align: Align{y: 0.5} + + title := Label { + width: Fit + height: Fit + draw_text +: { + text_style: USERNAME_TEXT_STYLE { font_size: 11.2 } + color: #1F1F1F + } + text: "App Service Actions" + } + + spacer := View { + width: Fill + height: Fit + } + + dismiss_button := RobrixNeutralIconButton { + width: 28 + height: 24 + align: Align{x: 0.5, y: 0.5} + spacing: 0 + padding: 0 + draw_icon.svg: (ICON_CLOSE) + icon_walk: Walk{width: 12, height: 12} + text: "" + } + } + + subtitle := Label { + width: Fill + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 10.5 } + color: (COLOR_TEXT) + wrap: Word + } + text: "Create a bot through BotFather. Robrix only sends the matching slash command." + } + + footer := View { + width: Fill + height: Fit + flow: Right + align: Align{x: 1.0, y: 0.5} + + timestamp := Label { + width: Fit + height: Fit + draw_text +: { + text_style: REGULAR_TEXT { font_size: 8.8 } + color: #9A9A9A + } + text: "now" + } + } + } + + keyboard := View { + width: Fit + height: Fit + flow: Down + spacing: 8 + + first_row := View { + width: Fit + height: Fit + flow: Right + spacing: 8 + + create_button := RobrixPositiveIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_CHECKMARK) + icon_walk: Walk{width: 16, height: 16, margin: Inset{left: -2, right: -1}} + text: "Create Bot" + } + + list_button := RobrixNeutralIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_SEARCH) + icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} + text: "List Bots" + } + } + + second_row := View { + width: Fit + height: Fit + flow: Right + spacing: 8 + + delete_button := RobrixNegativeIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_CLOSE) + icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} + text: "Delete Bot" + } + + help_button := RobrixNeutralIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_INFO) + icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} + text: "Bot Help" + } + } + + third_row := View { + width: Fit + height: Fit + flow: Right + spacing: 8 + + unbind_button := RobrixNeutralIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_CLOSE) + icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} + text: "Unbind" + } + } + } + } + mod.widgets.Timeline = View { width: Fill, height: Fill, @@ -527,6 +840,7 @@ script_mod! { Empty := mod.widgets.Empty {} DateDivider := mod.widgets.DateDivider {} ReadMarker := mod.widgets.ReadMarker {} + AppServicePanel := mod.widgets.AppServicePanel {} } // A jump to bottom button (with an unread message badge) that is shown @@ -582,6 +896,18 @@ script_mod! { // to finish loading, e.g., when loading an older replied-to message. loading_pane := LoadingPane { } + create_bot_modal := Modal { + content +: { + create_bot_modal_inner := mod.widgets.CreateBotModal {} + } + } + + delete_bot_modal := Modal { + content +: { + delete_bot_modal_inner := mod.widgets.DeleteBotModal {} + } + } + /* * TODO: add the action bar back in as a series of floating buttons. @@ -608,20 +934,30 @@ script_mod! { /// The main widget that displays a single Matrix room. #[derive(Script, Widget)] pub struct RoomScreen { - #[deref] view: View, + #[deref] + view: View, /// The name and ID of the currently-shown room, if any. - #[rust] room_name_id: Option, + #[rust] + room_name_id: Option, /// The timeline currently displayed by this RoomScreen, if any. - #[rust] timeline_kind: Option, + #[rust] + timeline_kind: Option, /// The persistent UI-relevant states for the room that this widget is currently displaying. - #[rust] tl_state: Option, + #[rust] + tl_state: Option, /// The set of pinned events in this room. - #[rust] pinned_events: Vec, + #[rust] + pinned_events: Vec, /// Whether this room has been successfully loaded (received from the homeserver). - #[rust] is_loaded: bool, + #[rust] + is_loaded: bool, /// Whether or not all rooms have been loaded (received from the homeserver). - #[rust] all_rooms_loaded: bool, + #[rust] + all_rooms_loaded: bool, + /// Whether the in-room app service quick actions card is currently visible. + #[rust] + show_app_service_actions: bool, } impl Drop for RoomScreen { @@ -653,7 +989,8 @@ impl Widget for RoomScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { let room_screen_widget_uid = self.widget_uid(); let portal_list = self.portal_list(cx, ids!(timeline.list)); - let user_profile_sliding_pane = self.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)); + let user_profile_sliding_pane = + self.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)); let loading_pane = self.loading_pane(cx, ids!(loading_pane)); // Handle actions here before processing timeline updates. @@ -668,9 +1005,13 @@ impl Widget for RoomScreen { if let RoomScreenTooltipActions::HoverInReactionButton { widget_rect, reaction_data, - } = reaction_list.hovered_in(actions) { - let Some(_tl_state) = self.tl_state.as_ref() else { continue }; - let tooltip_text_arr: Vec = reaction_data.reaction_senders + } = reaction_list.hovered_in(actions) + { + let Some(_tl_state) = self.tl_state.as_ref() else { + continue; + }; + let tooltip_text_arr: Vec = reaction_data + .reaction_senders .iter() .map(|(sender, _react_info)| { user_profile_cache::get_user_display_name_for_room( @@ -684,10 +1025,13 @@ impl Widget for RoomScreen { }) .collect(); - let mut tooltip_text = utils::human_readable_list(&tooltip_text_arr, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT); + let mut tooltip_text = utils::human_readable_list( + &tooltip_text_arr, + MAX_VISIBLE_AVATARS_IN_READ_RECEIPT, + ); tooltip_text.push_str(&format!(" reacted with: {}", reaction_data.reaction)); cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, TooltipAction::HoverIn { text: tooltip_text, widget_rect, @@ -701,24 +1045,23 @@ impl Widget for RoomScreen { // Handle a hover-out action on the reaction list or avatar row. let avatar_row_ref = wr.avatar_row(cx, ids!(avatar_row)); - if reaction_list.hovered_out(actions) - || avatar_row_ref.hover_out(actions) - { - cx.widget_action( - room_screen_widget_uid, - TooltipAction::HoverOut, - ); + if reaction_list.hovered_out(actions) || avatar_row_ref.hover_out(actions) { + cx.widget_action(room_screen_widget_uid, TooltipAction::HoverOut); } // Handle a hover-in action on the avatar row: show a read receipts summary. if let RoomScreenTooltipActions::HoverInReadReceipt { widget_rect, - read_receipts - } = avatar_row_ref.hover_in(actions) { - let Some(room_id) = self.room_id() else { return; }; - let tooltip_text= room_read_receipt::populate_tooltip(cx, read_receipts, room_id); + read_receipts, + } = avatar_row_ref.hover_in(actions) + { + let Some(room_id) = self.room_id() else { + return; + }; + let tooltip_text = + room_read_receipt::populate_tooltip(cx, read_receipts, room_id); cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, TooltipAction::HoverIn { text: tooltip_text, widget_rect, @@ -732,23 +1075,27 @@ impl Widget for RoomScreen { // Handle an image within the message being clicked. let content_message = wr.text_or_image(cx, ids!(content.message)); - if let TextOrImageAction::Clicked(mxc_uri) = actions.find_widget_action(content_message.widget_uid()).cast() { + if let TextOrImageAction::Clicked(mxc_uri) = actions + .find_widget_action(content_message.widget_uid()) + .cast() + { let texture = content_message.get_texture(cx); - self.handle_image_click( - cx, - mxc_uri, - texture, - index, - ); + self.handle_image_click(cx, mxc_uri, texture, index); continue; } // Handle the invite_user_button (in a SmallStateEvent) being clicked. if wr.button(cx, ids!(invite_user_button)).clicked(actions) { - let Some(tl) = self.tl_state.as_ref() else { continue }; - if let Some(event_tl_item) = tl.items.get(index).and_then(|item| item.as_event()) { + let Some(tl) = self.tl_state.as_ref() else { + continue; + }; + if let Some(event_tl_item) = + tl.items.get(index).and_then(|item| item.as_event()) + { let user_id = event_tl_item.sender().to_owned(); - let username = if let TimelineDetails::Ready(profile) = event_tl_item.sender_profile() { + let username = if let TimelineDetails::Ready(profile) = + event_tl_item.sender_profile() + { profile.display_name.as_deref().unwrap_or(user_id.as_str()) } else { user_id.as_str() @@ -756,14 +1103,22 @@ impl Widget for RoomScreen { let room_id = tl.kind.room_id().clone(); let content = ConfirmationModalContent { title_text: "Send Invitation".into(), - body_text: format!("Are you sure you want to invite {username} to this room?").into(), + body_text: format!( + "Are you sure you want to invite {username} to this room?" + ) + .into(), accept_button_text: Some("Invite".into()), on_accept_clicked: Some(Box::new(move |_cx| { - submit_async_request(MatrixRequest::InviteUser { room_id, user_id }); + submit_async_request(MatrixRequest::InviteUser { + room_id, + user_id, + }); })), ..Default::default() }; - cx.action(InviteAction::ShowInviteConfirmationModal(RefCell::new(Some(content)))); + cx.action(InviteAction::ShowInviteConfirmationModal(RefCell::new( + Some(content), + ))); } } } @@ -772,11 +1127,19 @@ impl Widget for RoomScreen { for action in actions { // Handle actions related to restoring the previously-saved state of rooms. - if let Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, ..}) = action.downcast_ref() { - if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_name_id.room_id()) { + if let Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) = + action.downcast_ref() + { + if self + .room_name_id + .as_ref() + .is_some_and(|rn| rn.room_id() == room_name_id.room_id()) + { // `set_displayed_room()` does nothing if the room_name_id is unchanged, so we clear it first. self.room_name_id = None; - let thread_root_event_id = self.timeline_kind.as_ref() + let thread_root_event_id = self + .timeline_kind + .as_ref() .and_then(|k| k.thread_root_event_id().cloned()); self.set_displayed_room(cx, room_name_id, thread_root_event_id); return; @@ -786,7 +1149,11 @@ impl Widget for RoomScreen { // Handle InviteResultAction to show popup notifications. if let Some(InviteResultAction::Sent { room_id, .. }) = action.downcast_ref() { // Only handle if this is for the current room. - if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_id) { + if self + .room_name_id + .as_ref() + .is_some_and(|rn| rn.room_id() == room_id) + { enqueue_popup_notification( "Sent invite successfully.", PopupKind::Success, @@ -794,9 +1161,15 @@ impl Widget for RoomScreen { ); } } - if let Some(InviteResultAction::Failed { room_id, error, .. }) = action.downcast_ref() { + if let Some(InviteResultAction::Failed { room_id, error, .. }) = + action.downcast_ref() + { // Only handle if this is for the current room. - if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_id) { + if self + .room_name_id + .as_ref() + .is_some_and(|rn| rn.room_id() == room_id) + { enqueue_popup_notification( format!("Failed to send invite.\n\nError: {error}"), PopupKind::Error, @@ -806,11 +1179,15 @@ impl Widget for RoomScreen { } // Handle the highlight animation for a message. - let Some(tl) = self.tl_state.as_mut() else { continue }; - if let MessageHighlightAnimationState::Pending { item_id } = tl.message_highlight_animation_state { + let Some(tl) = self.tl_state.as_mut() else { + continue; + }; + if let MessageHighlightAnimationState::Pending { item_id } = + tl.message_highlight_animation_state + { if portal_list.smooth_scroll_reached(actions) { cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, MessageAction::HighlightMessage(item_id), ); tl.message_highlight_animation_state = MessageHighlightAnimationState::Off; @@ -834,22 +1211,25 @@ impl Widget for RoomScreen { self.send_user_read_receipts_based_on_scroll_pos(cx, actions, &portal_list); // Handle the jump to bottom button: update its visibility, and handle clicks. - self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)).update_from_actions( - cx, - &portal_list, - actions, - ); + self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)) + .update_from_actions(cx, &portal_list, actions); } // Currently, a Signal event is only used to tell this widget: // 1. to check if the room has been loaded from the homeserver yet, or // 2. that its timeline events have been updated in the background. if let Event::Signal = event { - if let (false, Some(room_name_id), true) = (self.is_loaded, self.room_name_id.as_ref(), cx.has_global::()) { + if let (false, Some(room_name_id), true) = ( + self.is_loaded, + self.room_name_id.as_ref(), + cx.has_global::(), + ) { let rooms_list_ref = cx.get_global::(); if rooms_list_ref.is_room_loaded(room_name_id.room_id()) { let room_name_clone = room_name_id.clone(); - let thread_root_event_id = self.timeline_kind.as_ref() + let thread_root_event_id = self + .timeline_kind + .as_ref() .and_then(|k| k.thread_root_event_id().cloned()); // This room has been loaded now, so we call `set_displayed_room()`. // We first clear the `room_name_id`, otherwise that function will do nothing. @@ -891,14 +1271,12 @@ impl Widget for RoomScreen { if is_interactive_hit { loading_pane.handle_event(cx, event, scope); } - } - else if user_profile_sliding_pane.is_currently_shown(cx) { + } else if user_profile_sliding_pane.is_currently_shown(cx) { is_pane_shown = true; if is_interactive_hit { user_profile_sliding_pane.handle_event(cx, event, scope); } - } - else { + } else { is_pane_shown = false; } @@ -913,14 +1291,26 @@ impl Widget for RoomScreen { let room_props = if let Some(tl) = self.tl_state.as_ref() { let room_id = tl.kind.room_id().clone(); let room_members = tl.room_members.clone(); + let (app_service_enabled, app_service_room_bound) = scope + .data + .get::() + .map(|app_state| { + ( + app_state.bot_settings.enabled, + app_state.bot_settings.is_room_bound(&room_id), + ) + }) + .unwrap_or((false, false)); // Fetch room data once to avoid duplicate expensive lookups let (room_display_name, room_avatar_url) = get_client() .and_then(|client| client.get_room(&room_id)) - .map(|room| ( - room.cached_display_name().unwrap_or(RoomDisplayName::Empty), - room.avatar_url() - )) + .map(|room| { + ( + room.cached_display_name().unwrap_or(RoomDisplayName::Empty), + room.avatar_url(), + ) + }) .unwrap_or((RoomDisplayName::Empty, None)); RoomScreenProps { @@ -929,23 +1319,31 @@ impl Widget for RoomScreen { timeline_kind: tl.kind.clone(), room_members, room_avatar_url, + app_service_enabled, + app_service_room_bound, } } else if let Some(room_name) = &self.room_name_id { // Fallback case: we have a room_name but no tl_state yet RoomScreenProps { room_screen_widget_uid, room_name_id: room_name.clone(), - timeline_kind: self.timeline_kind.clone() + timeline_kind: self + .timeline_kind + .clone() .expect("BUG: room_name_id was set but timeline_kind was missing"), room_members: None, room_avatar_url: None, + app_service_enabled: false, + app_service_room_bound: false, } } else { // No room selected yet, skip event handling that requires room context if !is_pane_shown || !is_interactive_hit { return; } - log!("RoomScreen handling event with no room_name_id and no tl_state, skipping room-dependent event handling"); + log!( + "RoomScreen handling event with no room_name_id and no tl_state, skipping room-dependent event handling" + ); // Use a dummy room props for non-room-specific events let room_id = owned_room_id!("!dummy:matrix.org"); RoomScreenProps { @@ -954,17 +1352,17 @@ impl Widget for RoomScreen { timeline_kind: TimelineKind::MainRoom { room_id }, room_members: None, room_avatar_url: None, + app_service_enabled: false, + app_service_room_bound: false, } }; let mut room_scope = Scope::with_props(&room_props); - // Forward the event to the inner timeline view, but capture any actions it produces // such that we can handle the ones relevant to only THIS RoomScreen widget right here and now, // ensuring they are not mistakenly handled by other RoomScreen widget instances. - let mut actions_generated_within_this_room_screen = cx.capture_actions(|cx| - self.view.handle_event(cx, event, &mut room_scope) - ); + let mut actions_generated_within_this_room_screen = + cx.capture_actions(|cx| self.view.handle_event(cx, event, &mut room_scope)); // Here, we handle and remove any general actions that are relevant to only this RoomScreen. // Removing the handled actions ensures they are not mistakenly handled by other RoomScreen widget instances. actions_generated_within_this_room_screen.retain(|action| { @@ -972,6 +1370,224 @@ impl Widget for RoomScreen { return false; } + match action + .as_widget_action() + .widget_uid_eq(room_screen_widget_uid) + .cast() + { + AppServicePanelAction::Dismiss => { + self.set_app_service_actions_visible(cx, false); + return false; + } + AppServicePanelAction::OpenCreateBotModal => { + if let Some(app_state) = scope.data.get::() { + if !app_state.bot_settings.enabled { + enqueue_popup_notification( + "Enable App Service before creating bots in a room.", + PopupKind::Warning, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } else if !room_props.app_service_room_bound { + enqueue_popup_notification( + "Bind BotFather to this room before creating a bot.", + PopupKind::Warning, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } else { + self.open_create_bot_modal(cx); + } + } else { + enqueue_popup_notification( + "App state is unavailable, so bot creation is temporarily unavailable.", + PopupKind::Error, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } + return false; + } + AppServicePanelAction::OpenDeleteBotModal => { + if let Some(app_state) = scope.data.get::() { + if !app_state.bot_settings.enabled { + enqueue_popup_notification( + "Enable App Service before deleting bots in a room.", + PopupKind::Warning, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } else if !room_props.app_service_room_bound { + enqueue_popup_notification( + "Bind BotFather to this room before deleting a bot.", + PopupKind::Warning, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } else { + self.open_delete_bot_modal(cx); + } + } else { + enqueue_popup_notification( + "App state is unavailable, so bot deletion is temporarily unavailable.", + PopupKind::Error, + Some(4.0), + ); + self.set_app_service_actions_visible(cx, false); + } + return false; + } + AppServicePanelAction::SendListBots => { + if let Some(app_state) = scope.data.get::() { + self.send_botfather_command( + cx, + app_state, + "/listbots", + "Sent `/listbots` to BotFather.", + ); + } + return false; + } + AppServicePanelAction::SendBotHelp => { + if let Some(app_state) = scope.data.get::() { + self.send_botfather_command( + cx, + app_state, + "/bothelp", + "Sent `/bothelp` to BotFather.", + ); + } + return false; + } + AppServicePanelAction::Unbind => { + if let Some(app_state) = scope.data.get::() { + if !room_props.app_service_room_bound { + enqueue_popup_notification( + "This room is not currently bound to BotFather.", + PopupKind::Warning, + Some(4.0), + ); + } else { + match app_state + .bot_settings + .resolved_bot_user_id(current_user_id().as_deref()) + { + Ok(bot_user_id) => { + submit_async_request(MatrixRequest::SetRoomBotBinding { + room_id: room_props.room_name_id.room_id().clone(), + bound: false, + bot_user_id: bot_user_id.clone(), + }); + enqueue_popup_notification( + format!( + "Removing BotFather {bot_user_id} from this room..." + ), + PopupKind::Info, + Some(4.0), + ); + } + Err(error) => { + enqueue_popup_notification( + error, + PopupKind::Error, + Some(4.0), + ); + } + } + } + } else { + enqueue_popup_notification( + "App state is unavailable, so BotFather could not be removed from this room.", + PopupKind::Error, + Some(4.0), + ); + } + self.set_app_service_actions_visible(cx, false); + return false; + } + _ => {} + } + + match action.downcast_ref::() { + Some(CreateBotModalAction::Close) => { + self.close_create_bot_modal(cx); + return false; + } + Some(CreateBotModalAction::Submit(request)) => { + let Some(app_state) = scope.data.get::() else { + enqueue_popup_notification( + "App state is unavailable, so the create-bot command was not sent.", + PopupKind::Error, + Some(4.0), + ); + self.close_create_bot_modal(cx); + return false; + }; + self.send_create_bot_command( + cx, + app_state, + &request.username, + &request.display_name, + request.system_prompt.as_deref(), + ); + return false; + } + None => {} + } + + match action.downcast_ref::() { + Some(DeleteBotModalAction::Close) => { + self.close_delete_bot_modal(cx); + return false; + } + Some(DeleteBotModalAction::Submit(request)) => { + let Some(app_state) = scope.data.get::() else { + enqueue_popup_notification( + "App state is unavailable, so the delete-bot command was not sent.", + PopupKind::Error, + Some(4.0), + ); + self.close_delete_bot_modal(cx); + return false; + }; + self.send_delete_bot_command(cx, app_state, &request.user_id_or_localpart); + return false; + } + None => {} + } + + match action + .as_widget_action() + .widget_uid_eq(room_screen_widget_uid) + .cast() + { + MessageAction::ToggleAppServiceActions => { + if room_props.timeline_kind.thread_root_event_id().is_some() { + enqueue_popup_notification( + "Bot commands are only supported in the main room timeline.", + PopupKind::Warning, + Some(4.0), + ); + } else if !room_props.app_service_enabled { + enqueue_popup_notification( + "Enable App Service in Settings before using /bot.", + PopupKind::Warning, + Some(4.0), + ); + } else if !room_props.app_service_room_bound { + enqueue_popup_notification( + "Bind BotFather to this room before using /bot.", + PopupKind::Warning, + Some(4.0), + ); + } else { + self.toggle_app_service_actions(cx); + } + return false; + } + _ => {} + } + // Handle the action that requests to show the user profile sliding pane. if let ShowUserProfileAction::ShowUserProfile(profile_and_room_id) = action.as_widget_action().cast() { self.show_user_profile( @@ -1033,7 +1649,6 @@ impl Widget for RoomScreen { } } - fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // If the room isn't loaded yet, we show the restore status label only. if !self.is_loaded { @@ -1041,7 +1656,8 @@ impl Widget for RoomScreen { // No room selected yet, nothing to show. return DrawStep::done(); }; - let mut restore_status_view = self.view.restore_status_view(cx, ids!(restore_status_view)); + let mut restore_status_view = + self.view.restore_status_view(cx, ids!(restore_status_view)); restore_status_view.set_content(cx, self.all_rooms_loaded, room_name); return restore_status_view.draw(cx, scope); } @@ -1051,13 +1667,14 @@ impl Widget for RoomScreen { return DrawStep::done(); } - let room_screen_widget_uid = self.widget_uid(); while let Some(subview) = self.view.draw_walk(cx, scope, walk).step() { // Here, we only need to handle drawing the portal list. let portal_list_ref = subview.as_portal_list(); let Some(mut list_ref) = portal_list_ref.borrow_mut() else { - error!("!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else"); + error!( + "!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else" + ); continue; }; let Some(tl_state) = self.tl_state.as_mut() else { @@ -1066,7 +1683,7 @@ impl Widget for RoomScreen { // Set the portal list's range based on the number of timeline items. let tl_items = &tl_state.items; - let last_item_id = tl_items.len(); + let last_item_id = tl_items.len() + usize::from(self.show_app_service_actions); let list = list_ref.deref_mut(); list.set_item_range(cx, 0, last_item_id); @@ -1074,143 +1691,174 @@ impl Widget for RoomScreen { while let Some(item_id) = list.next_visible_item(cx) { let item = { let tl_idx = item_id; - let Some(timeline_item) = tl_items.get(tl_idx) else { - // This shouldn't happen (unless the timeline gets corrupted or some other weird error), - // but we can always safely fill the item with an empty widget that takes up no space. - list.item(cx, item_id, id!(Empty)); - continue; - }; + if self.show_app_service_actions && tl_idx == tl_items.len() { + list.item(cx, item_id, id!(AppServicePanel)) + } else { + let Some(timeline_item) = tl_items.get(tl_idx) else { + // This shouldn't happen (unless the timeline gets corrupted or some other weird error), + // but we can always safely fill the item with an empty widget that takes up no space. + list.item(cx, item_id, id!(Empty)); + continue; + }; - // Determine whether this item's content and profile have been drawn since the last update. - // Pass this state to each of the `populate_*` functions so they can attempt to re-use - // an item in the timeline's portallist that was previously populated, if one exists. - let item_drawn_status = ItemDrawnStatus { - content_drawn: tl_state.content_drawn_since_last_update.contains(&tl_idx), - profile_drawn: tl_state.profile_drawn_since_last_update.contains(&tl_idx), - }; - let (item, item_new_draw_status) = match timeline_item.kind() { - TimelineItemKind::Event(event_tl_item) => match event_tl_item.content() { - TimelineItemContent::MsgLike(msg_like_content) => { - if tl_state.kind.thread_root_event_id().is_none() - && msg_like_content.thread_root.is_some() - { - // Hide threaded replies from the main room timeline UI. - (list.item(cx, item_id, id!(Empty)), ItemDrawnStatus::both_drawn()) - } else { - match &msg_like_content.kind { - MsgLikeKind::Message(_) - | MsgLikeKind::Sticker(_) - | MsgLikeKind::Redacted => { - let prev_event = tl_idx.checked_sub(1).and_then(|i| tl_items.get(i)); - populate_message_view( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - msg_like_content, - prev_event, - &mut tl_state.media_cache, - &mut tl_state.link_preview_cache, - &tl_state.fetched_thread_summaries, - &mut tl_state.pending_thread_summary_fetches, - &tl_state.user_power, - &self.pinned_events, - item_drawn_status, - room_screen_widget_uid, - ) - }, - // TODO: properly implement `Poll` as a regular Message-like timeline item. - MsgLikeKind::Poll(poll_state) => populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - poll_state, - item_drawn_status, - ), - MsgLikeKind::UnableToDecrypt(utd) => populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - utd, - item_drawn_status, - ), - MsgLikeKind::Other(other) => populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - other, - item_drawn_status, - ), + // Determine whether this item's content and profile have been drawn since the last update. + // Pass this state to each of the `populate_*` functions so they can attempt to re-use + // an item in the timeline's portallist that was previously populated, if one exists. + let item_drawn_status = ItemDrawnStatus { + content_drawn: tl_state + .content_drawn_since_last_update + .contains(&tl_idx), + profile_drawn: tl_state + .profile_drawn_since_last_update + .contains(&tl_idx), + }; + let (item, item_new_draw_status) = match timeline_item.kind() { + TimelineItemKind::Event(event_tl_item) => match event_tl_item.content() + { + TimelineItemContent::MsgLike(msg_like_content) => { + if tl_state.kind.thread_root_event_id().is_none() + && msg_like_content.thread_root.is_some() + { + // Hide threaded replies from the main room timeline UI. + ( + list.item(cx, item_id, id!(Empty)), + ItemDrawnStatus::both_drawn(), + ) + } else { + match &msg_like_content.kind { + MsgLikeKind::Message(_) + | MsgLikeKind::Sticker(_) + | MsgLikeKind::Redacted => { + let prev_event = tl_idx + .checked_sub(1) + .and_then(|i| tl_items.get(i)); + populate_message_view( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + msg_like_content, + prev_event, + &mut tl_state.media_cache, + &mut tl_state.link_preview_cache, + &tl_state.fetched_thread_summaries, + &mut tl_state.pending_thread_summary_fetches, + &tl_state.user_power, + &self.pinned_events, + item_drawn_status, + room_screen_widget_uid, + ) + } + // TODO: properly implement `Poll` as a regular Message-like timeline item. + MsgLikeKind::Poll(poll_state) => { + populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + poll_state, + item_drawn_status, + ) + } + MsgLikeKind::UnableToDecrypt(utd) => { + populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + utd, + item_drawn_status, + ) + } + MsgLikeKind::Other(other) => { + populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + other, + item_drawn_status, + ) + } + } } } + TimelineItemContent::MembershipChange(membership_change) => { + populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + membership_change, + item_drawn_status, + ) + } + TimelineItemContent::ProfileChange(profile_change) => { + populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + profile_change, + item_drawn_status, + ) + } + TimelineItemContent::OtherState(other) => { + populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + other, + item_drawn_status, + ) + } + unhandled => { + let item = list.item(cx, item_id, id!(SmallStateEvent)); + item.label(cx, ids!(content)) + .set_text(cx, &format!("[Unsupported] {:?}", unhandled)); + (item, ItemDrawnStatus::both_drawn()) + } }, - TimelineItemContent::MembershipChange(membership_change) => populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - membership_change, - item_drawn_status, - ), - TimelineItemContent::ProfileChange(profile_change) => populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - profile_change, - item_drawn_status, - ), - TimelineItemContent::OtherState(other) => populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - other, - item_drawn_status, - ), - unhandled => { - let item = list.item(cx, item_id, id!(SmallStateEvent)); - item.label(cx, ids!(content)).set_text(cx, &format!("[Unsupported] {:?}", unhandled)); + TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(millis)) => { + let item = list.item(cx, item_id, id!(DateDivider)); + let text = unix_time_millis_to_datetime(*millis) + // format the time as a shortened date (Sat, Sept 5, 2021) + .map(|dt| format!("{}", dt.date_naive().format("%a %b %-d, %Y"))) + .unwrap_or_else(|| format!("{:?}", millis)); + item.label(cx, ids!(date)).set_text(cx, &text); (item, ItemDrawnStatus::both_drawn()) } + TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => { + let item = list.item(cx, item_id, id!(ReadMarker)); + (item, ItemDrawnStatus::both_drawn()) + } + TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => { + let item = list.item(cx, item_id, id!(Empty)); + (item, ItemDrawnStatus::both_drawn()) + } + }; + + // Now that we've drawn the item, add its index to the set of drawn items. + if item_new_draw_status.content_drawn { + tl_state + .content_drawn_since_last_update + .insert(tl_idx..tl_idx + 1); } - TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(millis)) => { - let item = list.item(cx, item_id, id!(DateDivider)); - let text = unix_time_millis_to_datetime(*millis) - // format the time as a shortened date (Sat, Sept 5, 2021) - .map(|dt| format!("{}", dt.date_naive().format("%a %b %-d, %Y"))) - .unwrap_or_else(|| format!("{:?}", millis)); - item.label(cx, ids!(date)).set_text(cx, &text); - (item, ItemDrawnStatus::both_drawn()) - } - TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => { - let item = list.item(cx, item_id, id!(ReadMarker)); - (item, ItemDrawnStatus::both_drawn()) - } - TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => { - let item = list.item(cx, item_id, id!(Empty)); - (item, ItemDrawnStatus::both_drawn()) + if item_new_draw_status.profile_drawn { + tl_state + .profile_drawn_since_last_update + .insert(tl_idx..tl_idx + 1); } - }; - - // Now that we've drawn the item, add its index to the set of drawn items. - if item_new_draw_status.content_drawn { - tl_state.content_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); - } - if item_new_draw_status.profile_drawn { - tl_state.profile_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); + item } - item }; item.draw_all(cx, scope); } @@ -1218,7 +1866,10 @@ impl Widget for RoomScreen { // If the list is not filling the viewport, we need to back paginate the timeline // until we have enough events items to fill the viewport. if !tl_state.fully_paginated && !list.is_filling_viewport() { - log!("Automatically paginating timeline to fill viewport for room {:?}", self.room_name_id); + log!( + "Automatically paginating timeline to fill viewport for room {:?}", + self.room_name_id + ); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl_state.kind.clone(), num_events: 50, @@ -1235,6 +1886,184 @@ impl RoomScreen { self.room_name_id.as_ref().map(|r| r.room_id()) } + fn set_app_service_actions_visible(&mut self, cx: &mut Cx, visible: bool) { + self.show_app_service_actions = visible; + self.redraw(cx); + } + + fn toggle_app_service_actions(&mut self, cx: &mut Cx) { + self.set_app_service_actions_visible(cx, !self.show_app_service_actions); + } + + fn close_create_bot_modal(&self, cx: &mut Cx) { + self.view.modal(cx, ids!(create_bot_modal)).close(cx); + } + + fn close_delete_bot_modal(&self, cx: &mut Cx) { + self.view.modal(cx, ids!(delete_bot_modal)).close(cx); + } + + fn open_create_bot_modal(&mut self, cx: &mut Cx) { + let Some(room_name_id) = self.room_name_id.clone() else { + return; + }; + self.set_app_service_actions_visible(cx, false); + self.view + .create_bot_modal(cx, ids!(create_bot_modal_inner)) + .show(cx, room_name_id); + self.view.modal(cx, ids!(create_bot_modal)).open(cx); + } + + fn open_delete_bot_modal(&mut self, cx: &mut Cx) { + let Some(room_name_id) = self.room_name_id.clone() else { + return; + }; + self.set_app_service_actions_visible(cx, false); + self.view + .delete_bot_modal(cx, ids!(delete_bot_modal_inner)) + .show(cx, room_name_id); + self.view.modal(cx, ids!(delete_bot_modal)).open(cx); + } + + fn reset_app_service_ui(&mut self, cx: &mut Cx) { + self.set_app_service_actions_visible(cx, false); + self.close_create_bot_modal(cx); + self.close_delete_bot_modal(cx); + } + + fn send_botfather_command( + &mut self, + cx: &mut Cx, + app_state: &AppState, + command: &str, + success_message: &str, + ) { + let Some(timeline_kind) = self.timeline_kind.clone() else { + return; + }; + if timeline_kind.thread_root_event_id().is_some() { + enqueue_popup_notification( + "Bot commands are only supported in the main room timeline.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + + let Some(room_id) = self.room_id().cloned() else { + return; + }; + if !app_state.bot_settings.enabled { + enqueue_popup_notification( + "Enable App Service before using BotFather commands in a room.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + if !app_state.bot_settings.is_room_bound(&room_id) { + enqueue_popup_notification( + "Bind BotFather to this room before using BotFather commands.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + + submit_async_request(MatrixRequest::SendMessage { + timeline_kind, + message: RoomMessageEventContent::text_plain(command), + replied_to: None, + #[cfg(feature = "tsp")] + sign_with_tsp: false, + }); + + enqueue_popup_notification(success_message.to_string(), PopupKind::Info, Some(4.0)); + self.set_app_service_actions_visible(cx, false); + } + + fn send_create_bot_command( + &mut self, + cx: &mut Cx, + app_state: &AppState, + username: &str, + display_name: &str, + system_prompt: Option<&str>, + ) { + let Some(timeline_kind) = self.timeline_kind.clone() else { + return; + }; + if timeline_kind.thread_root_event_id().is_some() { + enqueue_popup_notification( + "Bot creation commands are only supported in the main room timeline.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + + let Some(room_id) = self.room_id().cloned() else { + return; + }; + if !app_state.bot_settings.enabled { + enqueue_popup_notification( + "Enable App Service before creating bots in a room.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + if !app_state.bot_settings.is_room_bound(&room_id) { + enqueue_popup_notification( + "Bind BotFather to this room before creating a bot.", + PopupKind::Warning, + Some(4.0), + ); + return; + } + + let command = format_create_bot_command(username, display_name, system_prompt); + submit_async_request(MatrixRequest::SendMessage { + timeline_kind, + message: RoomMessageEventContent::text_plain(command), + replied_to: None, + #[cfg(feature = "tsp")] + sign_with_tsp: false, + }); + + enqueue_popup_notification( + format!("Sent `/createbot` for `{username}` to BotFather."), + PopupKind::Info, + Some(4.0), + ); + self.close_create_bot_modal(cx); + } + + fn send_delete_bot_command( + &mut self, + cx: &mut Cx, + app_state: &AppState, + user_id_or_localpart: &str, + ) { + let matrix_user_id = + match resolve_delete_bot_user_id(user_id_or_localpart, current_user_id().as_deref()) { + Ok(user_id) => user_id, + Err(error) => { + enqueue_popup_notification(error, PopupKind::Error, Some(4.0)); + return; + } + }; + + let command = format_delete_bot_command(matrix_user_id.as_ref()); + self.send_botfather_command( + cx, + app_state, + &command, + &format!("Sent `/deletebot` for {matrix_user_id} to BotFather."), + ); + self.close_delete_bot_modal(cx); + } + /// Processes all pending background updates to the currently-shown timeline. /// /// Redraws this RoomScreen view if any updates were applied. @@ -1243,7 +2072,9 @@ impl RoomScreen { let jump_to_bottom_button = self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)); let curr_first_id = portal_list.first_id(); let ui = self.widget_uid(); - let Some(tl) = self.tl_state.as_mut() else { return }; + let Some(tl) = self.tl_state.as_mut() else { + return; + }; let mut done_loading = false; let mut should_continue_backwards_pagination = false; @@ -1264,10 +2095,19 @@ impl RoomScreen { tl.items = initial_items; done_loading = true; } - TimelineUpdate::NewItems { new_items, changed_indices, is_append, clear_cache } => { + TimelineUpdate::NewItems { + new_items, + changed_indices, + is_append, + clear_cache, + } => { if new_items.is_empty() { if !tl.items.is_empty() { - log!("process_timeline_updates(): timeline (had {} items) was cleared for room {}", tl.items.len(), tl.kind.room_id()); + log!( + "process_timeline_updates(): timeline (had {} items) was cleared for room {}", + tl.items.len(), + tl.kind.room_id() + ); // For now, we paginate a cleared timeline in order to be able to show something at least. // A proper solution would be what's described below, which would be to save a few event IDs // and then either focus on them (if we're not close to the end of the timeline) @@ -1301,9 +2141,12 @@ impl RoomScreen { if new_items.len() == tl.items.len() { // log!("process_timeline_updates(): no jump necessary for updated timeline of same length: {}", items.len()); - } - else if curr_first_id > new_items.len() { - log!("process_timeline_updates(): jumping to bottom: curr_first_id {} is out of bounds for {} new items", curr_first_id, new_items.len()); + } else if curr_first_id > new_items.len() { + log!( + "process_timeline_updates(): jumping to bottom: curr_first_id {} is out of bounds for {} new items", + curr_first_id, + new_items.len() + ); portal_list.set_first_id_and_scroll(new_items.len().saturating_sub(1), 0.0); portal_list.set_tail_range(true); jump_to_bottom_button.update_visibility(cx, true); @@ -1312,19 +2155,28 @@ impl RoomScreen { // in the timeline viewport so that we can maintain the scroll position of that item, // which ensures that the timeline doesn't jump around unexpectedly and ruin the user's experience. else if let Some((curr_item_idx, new_item_idx, new_item_scroll, _event_id)) = - prior_items_changed.then(|| - find_new_item_matching_current_item(cx, portal_list, curr_first_id, &tl.items, &new_items) - ) - .flatten() + prior_items_changed + .then(|| { + find_new_item_matching_current_item( + cx, + portal_list, + curr_first_id, + &tl.items, + &new_items, + ) + }) + .flatten() { if curr_item_idx != new_item_idx { - log!("process_timeline_updates(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}"); + log!( + "process_timeline_updates(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}" + ); portal_list.set_first_id_and_scroll(new_item_idx, new_item_scroll); tl.prev_first_index = Some(new_item_idx); // Set scrolled_past_read_marker false when we jump to a new event tl.scrolled_past_read_marker = false; // Hide the tooltip when the timeline jumps, as a hover-out event won't occur. - cx.widget_action(ui, RoomScreenTooltipActions::HoverOut); + cx.widget_action(ui, RoomScreenTooltipActions::HoverOut); } } // @@ -1340,8 +2192,9 @@ impl RoomScreen { // because the matrix SDK doesn't currently support querying unread message counts for threads. if matches!(tl.kind, TimelineKind::MainRoom { .. }) { // Immediately show the unread badge with no count while we fetch the actual count in the background. - jump_to_bottom_button.show_unread_message_badge(cx, UnreadMessageCount::Unknown); - submit_async_request(MatrixRequest::GetNumberUnreadMessages{ + jump_to_bottom_button + .show_unread_message_badge(cx, UnreadMessageCount::Unknown); + submit_async_request(MatrixRequest::GetNumberUnreadMessages { timeline_kind: tl.kind.clone(), }); } @@ -1355,10 +2208,15 @@ impl RoomScreen { let loading_pane = self.view.loading_pane(cx, ids!(loading_pane)); let mut loading_pane_state = loading_pane.take_state(); if let LoadingPaneState::BackwardsPaginateUntilEvent { - events_paginated, target_event_id, .. - } = &mut loading_pane_state { + events_paginated, + target_event_id, + .. + } = &mut loading_pane_state + { *events_paginated += new_items.len().saturating_sub(tl.items.len()); - log!("While finding target event {target_event_id}, we have now loaded {events_paginated} messages..."); + log!( + "While finding target event {target_event_id}, we have now loaded {events_paginated} messages..." + ); // Here, we assume that we have not yet found the target event, // so we need to continue paginating backwards. // If the target event has already been found, it will be handled @@ -1375,8 +2233,10 @@ impl RoomScreen { tl.profile_drawn_since_last_update.clear(); tl.fully_paginated = false; } else { - tl.content_drawn_since_last_update.remove(changed_indices.clone()); - tl.profile_drawn_since_last_update.remove(changed_indices.clone()); + tl.content_drawn_since_last_update + .remove(changed_indices.clone()); + tl.profile_drawn_since_last_update + .remove(changed_indices.clone()); // log!("process_timeline_updates(): changed_indices: {changed_indices:?}, items len: {}\ncontent drawn: {:#?}\nprofile drawn: {:#?}", items.len(), tl.content_drawn_since_last_update, tl.profile_drawn_since_last_update); } tl.items = new_items; @@ -1389,7 +2249,10 @@ impl RoomScreen { jump_to_bottom_button.show_unread_message_badge(cx, unread_messages_count); } } - TimelineUpdate::TargetEventFound { target_event_id, index } => { + TimelineUpdate::TargetEventFound { + target_event_id, + index, + } => { // log!("Target event found in room {}: {target_event_id}, index: {index}", tl.kind.room_id()); tl.request_sender.send_if_modified(|requests| { requests.retain(|r| &r.room_id != tl.kind.room_id()); @@ -1399,10 +2262,10 @@ impl RoomScreen { // sanity check: ensure the target event is in the timeline at the given `index`. let item = tl.items.get(index); - let is_valid = item.is_some_and(|item| + let is_valid = item.is_some_and(|item| { item.as_event() .is_some_and(|ev| ev.event_id() == Some(&target_event_id)) - ); + }); let loading_pane = self.view.loading_pane(cx, ids!(loading_pane)); // log!("TargetEventFound: is_valid? {is_valid}. room {}, event {target_event_id}, index {index} of {}\n --> item: {item:?}", tl.kind.room_id(), tl.items.len()); @@ -1421,19 +2284,24 @@ impl RoomScreen { // appear beneath the top of the viewport. portal_list.smooth_scroll_to(cx, index.saturating_sub(1), speed, None); // start highlight animation. - tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { - item_id: index - }; - } - else { + tl.message_highlight_animation_state = + MessageHighlightAnimationState::Pending { item_id: index }; + } else { // Here, the target event was not found in the current timeline, // or we found it previously but it is no longer in the timeline (or has moved), // which means we encountered an error and are unable to jump to the target event. - error!("Target event index {index} of {} is out of bounds for room {}", tl.items.len(), tl.kind.room_id()); + error!( + "Target event index {index} of {} is out of bounds for room {}", + tl.items.len(), + tl.kind.room_id() + ); // Show this error in the loading pane, which should already be open. - loading_pane.set_state(cx, LoadingPaneState::Error( - String::from("Unable to find related message; it may have been deleted.") - )); + loading_pane.set_state( + cx, + LoadingPaneState::Error(String::from( + "Unable to find related message; it may have been deleted.", + )), + ); } should_continue_backwards_pagination = false; @@ -1450,16 +2318,25 @@ impl RoomScreen { } } TimelineUpdate::PaginationError { error, direction } => { - error!("Pagination error ({direction}) in {:?}: {error:?}", self.room_name_id); + error!( + "Pagination error ({direction}) in {:?}: {error:?}", + self.room_name_id + ); let room_name = self.room_name_id.as_ref().map(|r| r.to_string()); enqueue_popup_notification( - utils::stringify_pagination_error(&error, room_name.as_deref().unwrap_or(UNNAMED_ROOM)), + utils::stringify_pagination_error( + &error, + room_name.as_deref().unwrap_or(UNNAMED_ROOM), + ), PopupKind::Error, Some(10.0), ); done_loading = true; } - TimelineUpdate::PaginationIdle { fully_paginated, direction } => { + TimelineUpdate::PaginationIdle { + fully_paginated, + direction, + } => { if direction == PaginationDirection::Backwards { // Don't set `done_loading` to `true` here, because we want to keep the top space visible // (with the "loading" message) until the corresponding `NewItems` update is received. @@ -1471,9 +2348,12 @@ impl RoomScreen { error!("Unexpected PaginationIdle update in the Forwards direction"); } } - TimelineUpdate::EventDetailsFetched {event_id, result } => { + TimelineUpdate::EventDetailsFetched { event_id, result } => { if let Err(_e) = result { - error!("Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}", tl.kind.room_id()); + error!( + "Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}", + tl.kind.room_id() + ); } // Here, to be most efficient, we could redraw only the updated event, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. @@ -1484,7 +2364,8 @@ impl RoomScreen { num_replies, latest_reply_preview_text, } => { - tl.pending_thread_summary_fetches.remove(&thread_root_event_id); + tl.pending_thread_summary_fetches + .remove(&thread_root_event_id); tl.fetched_thread_summaries.insert( thread_root_event_id.clone(), FetchedThreadSummary { @@ -1492,14 +2373,15 @@ impl RoomScreen { latest_reply_preview_text, }, ); - let event_id_matches_at_index = tl.items + let event_id_matches_at_index = tl + .items .get(timeline_item_index) .and_then(|item| item.as_event()) .and_then(|ev| ev.event_id()) .is_some_and(|id| id == thread_root_event_id); if event_id_matches_at_index { tl.content_drawn_since_last_update - .remove(timeline_item_index .. timeline_item_index + 1); + .remove(timeline_item_index..timeline_item_index + 1); } else { tl.content_drawn_since_last_update.clear(); } @@ -1512,9 +2394,12 @@ impl RoomScreen { TimelineUpdate::RoomMembersListFetched { members } => { // Store room members directly in TimelineUiState tl.room_members = Some(Arc::new(members)); - }, + } TimelineUpdate::MediaFetched(request) => { - log!("process_timeline_updates(): media fetched for room {}", tl.kind.room_id()); + log!( + "process_timeline_updates(): media fetched for room {}", + tl.kind.room_id() + ); // Set Image to image viewer modal if the media is not a thumbnail. if let (MediaFormat::File, media_source) = (request.format, request.source) { populate_matrix_image_modal(cx, media_source, &mut tl.media_cache); @@ -1522,26 +2407,39 @@ impl RoomScreen { // Here, to be most efficient, we could redraw only the media items in the timeline, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. } - TimelineUpdate::MessageEdited { timeline_event_item_id: timeline_event_id, result } => { - self.view.room_input_bar(cx, ids!(room_input_bar)) + TimelineUpdate::MessageEdited { + timeline_event_item_id: timeline_event_id, + result, + } => { + self.view + .room_input_bar(cx, ids!(room_input_bar)) .handle_edit_result(cx, timeline_event_id, result); } TimelineUpdate::PinResult { result, pin, .. } => { let (message, auto_dismissal_duration, kind) = match &result { Ok(true) => ( - format!("Successfully {} event.", if pin { "pinned" } else { "unpinned" }), + format!( + "Successfully {} event.", + if pin { "pinned" } else { "unpinned" } + ), Some(4.0), - PopupKind::Success + PopupKind::Success, ), Ok(false) => ( - format!("Message was already {}.", if pin { "pinned" } else { "unpinned" }), + format!( + "Message was already {}.", + if pin { "pinned" } else { "unpinned" } + ), Some(4.0), - PopupKind::Info + PopupKind::Info, ), Err(e) => ( - format!("Failed to {} event. Error: {e}", if pin { "pin" } else { "unpin" }), + format!( + "Failed to {} event. Error: {e}", + if pin { "pin" } else { "unpin" } + ), None, - PopupKind::Error + PopupKind::Error, ), }; enqueue_popup_notification(message, kind, auto_dismissal_duration); @@ -1565,7 +2463,8 @@ impl RoomScreen { } TimelineUpdate::UserPowerLevels(user_power_levels) => { tl.user_power = user_power_levels; - self.view.room_input_bar(cx, ids!(room_input_bar)) + self.view + .room_input_bar(cx, ids!(room_input_bar)) .update_user_power_levels(cx, user_power_levels); // Update the @room mention capability based on the user's power level cx.action(MentionableTextInputAction::PowerLevelsUpdated { @@ -1581,8 +2480,13 @@ impl RoomScreen { tl.latest_own_user_receipt = Some(receipt); } TimelineUpdate::Tombstoned(successor_room_details) => { - self.view.room_input_bar(cx, ids!(room_input_bar)) - .update_tombstone_footer(cx, tl.kind.room_id(), Some(&successor_room_details)); + self.view + .room_input_bar(cx, ids!(room_input_bar)) + .update_tombstone_footer( + cx, + tl.kind.room_id(), + Some(&successor_room_details), + ); tl.tombstone_info = Some(successor_room_details); } TimelineUpdate::LinkPreviewFetched => {} @@ -1613,7 +2517,6 @@ impl RoomScreen { } } - /// Handles a link being clicked in any child widgets of this RoomScreen. /// /// Returns `true` if the given `action` was handled as a link click. @@ -1657,7 +2560,11 @@ impl RoomScreen { true } MatrixId::Room(room_id) => { - if self.room_name_id.as_ref().is_some_and(|r| r.room_id() == room_id) { + if self + .room_name_id + .as_ref() + .is_some_and(|r| r.room_id() == room_id) + { enqueue_popup_notification( "You are already viewing that room.", PopupKind::Info, @@ -1665,7 +2572,9 @@ impl RoomScreen { ); return true; } - if let Some(room_name_id) = cx.get_global::().get_room_name(room_id) { + if let Some(room_name_id) = + cx.get_global::().get_room_name(room_id) + { cx.action(AppStateAction::NavigateToRoom { room_to_close: None, destination_room: BasicRoomDetails::Name(room_name_id), @@ -1699,8 +2608,7 @@ impl RoomScreen { let mut link_was_handled = false; if let Ok(matrix_to_uri) = MatrixToUri::parse(&url) { link_was_handled |= handle_matrix_link(matrix_to_uri.id(), matrix_to_uri.via()); - } - else if let Ok(matrix_uri) = MatrixUri::parse(&url) { + } else if let Ok(matrix_uri) = MatrixUri::parse(&url) { link_was_handled |= handle_matrix_link(matrix_uri.id(), matrix_uri.via()); } @@ -1716,8 +2624,13 @@ impl RoomScreen { } } true - } - else if let RobrixHtmlLinkAction::ClickedMatrixLink { url, matrix_id, via, .. } = action.as_widget_action().cast() { + } else if let RobrixHtmlLinkAction::ClickedMatrixLink { + url, + matrix_id, + via, + .. + } = action.as_widget_action().cast() + { let link_was_handled = handle_matrix_link(&matrix_id, &via); if !link_was_handled { log!("Opening URL \"{}\"", url); @@ -1731,8 +2644,7 @@ impl RoomScreen { } } true - } - else { + } else { false } } @@ -1748,8 +2660,13 @@ impl RoomScreen { let Some(media_source) = mxc_uri else { return; }; - let Some(tl_state) = self.tl_state.as_mut() else { return }; - let Some(event_tl_item) = tl_state.items.get(item_id).and_then(|item| item.as_event()) else { return }; + let Some(tl_state) = self.tl_state.as_mut() else { + return; + }; + let Some(event_tl_item) = tl_state.items.get(item_id).and_then(|item| item.as_event()) + else { + return; + }; let timestamp_millis = event_tl_item.timestamp(); let (image_name, image_file_size) = get_image_name_and_filesize(event_tl_item); @@ -1759,10 +2676,7 @@ impl RoomScreen { image_name, image_file_size, timestamp: unix_time_millis_to_datetime(timestamp_millis), - avatar_parameter: Some(( - tl_state.kind.clone(), - event_tl_item.clone(), - )), + avatar_parameter: Some((tl_state.kind.clone(), event_tl_item.clone())), }), ))); @@ -1783,13 +2697,15 @@ impl RoomScreen { details: &MessageDetails, ) -> Option<&'a EventTimelineItem> { let target_event_id = details.event_id()?; - if let Some(event) = items.get(details.item_id) + if let Some(event) = items + .get(details.item_id) .and_then(|item| item.as_event()) .filter(|ev| ev.event_id().is_some_and(|id| id == target_event_id)) { return Some(event); } - items.iter() + items + .iter() .rev() .take(MAX_ITEMS_TO_SEARCH_THROUGH) .filter_map(|item| item.as_event()) @@ -1806,9 +2722,15 @@ impl RoomScreen { ) { let room_screen_widget_uid = self.widget_uid(); for action in actions { - match action.as_widget_action().widget_uid_eq(room_screen_widget_uid).cast_ref() { + match action + .as_widget_action() + .widget_uid_eq(room_screen_widget_uid) + .cast_ref() + { MessageAction::React { details, reaction } => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; submit_async_request(MatrixRequest::ToggleReaction { timeline_kind: tl.kind.clone(), timeline_event_id: details.timeline_event_id.clone(), @@ -1816,19 +2738,24 @@ impl RoomScreen { }); } MessageAction::Reply(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details).cloned() { + let Some(tl) = self.tl_state.as_ref() else { + return; + }; + if let Some(event_tl_item) = + Self::find_event_in_timeline(&tl.items, details).cloned() + { let replied_to_info = EmbeddedEvent::from_timeline_item(&event_tl_item); - self.view.room_input_bar(cx, ids!(room_input_bar)) + self.view + .room_input_bar(cx, ids!(room_input_bar)) .show_replying_to(cx, (event_tl_item, replied_to_info), &tl.kind); - } - else { + } else { enqueue_popup_notification( "Could not find message in timeline to reply to. Please try again.", PopupKind::Error, Some(5.0), ); - error!("MessageAction::Reply: couldn't find event [{}] {:?} to reply to in room {:?}", + error!( + "MessageAction::Reply: couldn't find event [{}] {:?} to reply to in room {:?}", details.item_id, details.timeline_event_id, self.room_id(), @@ -1836,22 +2763,21 @@ impl RoomScreen { } } MessageAction::Edit(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { - self.view.room_input_bar(cx, ids!(room_input_bar)) - .show_editing_pane( - cx, - event_tl_item.clone(), - tl.kind.clone(), - ); - } - else { + self.view + .room_input_bar(cx, ids!(room_input_bar)) + .show_editing_pane(cx, event_tl_item.clone(), tl.kind.clone()); + } else { enqueue_popup_notification( "Could not find message in timeline to edit. Please try again.", PopupKind::Error, Some(5.0), ); - error!("MessageAction::Edit: couldn't find event [{}] {:?} to edit in room {:?}", + error!( + "MessageAction::Edit: couldn't find event [{}] {:?} to edit in room {:?}", details.item_id, details.timeline_event_id, self.room_id(), @@ -1859,21 +2785,20 @@ impl RoomScreen { } } MessageAction::EditLatest => { - let Some(tl) = self.tl_state.as_ref() else { return }; - if let Some(latest_sent_msg) = tl.items + let Some(tl) = self.tl_state.as_ref() else { + return; + }; + if let Some(latest_sent_msg) = tl + .items .iter() .rev() .take(MAX_ITEMS_TO_SEARCH_THROUGH) .find_map(|item| item.as_event().filter(|ev| ev.is_editable()).cloned()) { - self.view.room_input_bar(cx, ids!(room_input_bar)) - .show_editing_pane( - cx, - latest_sent_msg, - tl.kind.clone(), - ); - } - else { + self.view + .room_input_bar(cx, ids!(room_input_bar)) + .show_editing_pane(cx, latest_sent_msg, tl.kind.clone()); + } else { enqueue_popup_notification( "No recent message available to edit. Please manually select a message to edit.", PopupKind::Warning, @@ -1882,7 +2807,9 @@ impl RoomScreen { } } MessageAction::Pin(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_id) = details.event_id() { submit_async_request(MatrixRequest::PinEvent { timeline_kind: tl.kind.clone(), @@ -1898,7 +2825,9 @@ impl RoomScreen { } } MessageAction::Unpin(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_id) = details.event_id() { submit_async_request(MatrixRequest::PinEvent { timeline_kind: tl.kind.clone(), @@ -1914,17 +2843,19 @@ impl RoomScreen { } } MessageAction::CopyText(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { cx.copy_to_clipboard(&plaintext_body_of_timeline_item(event_tl_item)); - } - else { + } else { enqueue_popup_notification( "Could not find message in timeline to copy text from. Please try again.", PopupKind::Error, Some(5.0), ); - error!("MessageAction::CopyText: couldn't find event [{}] {:?} to copy text from in room {}", + error!( + "MessageAction::CopyText: couldn't find event [{}] {:?} to copy text from in room {}", details.item_id, details.timeline_event_id, tl.kind.room_id(), @@ -1932,22 +2863,49 @@ impl RoomScreen { } } MessageAction::CopyHtml(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; // The logic for getting the formatted body of a message is the same // as the logic used in `populate_message_view()`. let mut success = false; if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { if let Some(message) = event_tl_item.content().as_message() { match message.msgtype() { - MessageType::Text(TextMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Notice(NoticeMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Emote(EmoteMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Image(ImageMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::File(FileMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Audio(AudioMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::Video(VideoMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) - | MessageType::VerificationRequest(KeyVerificationRequestEventContent { formatted: Some(FormattedBody { body, .. }), .. }) => - { + MessageType::Text(TextMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Notice(NoticeMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Emote(EmoteMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Image(ImageMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::File(FileMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Audio(AudioMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::Video(VideoMessageEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }) + | MessageType::VerificationRequest( + KeyVerificationRequestEventContent { + formatted: Some(FormattedBody { body, .. }), + .. + }, + ) => { cx.copy_to_clipboard(body); success = true; } @@ -1961,7 +2919,8 @@ impl RoomScreen { PopupKind::Error, Some(5.0), ); - error!("MessageAction::CopyHtml: couldn't find event [{}] {:?} to copy HTML from in room {}", + error!( + "MessageAction::CopyHtml: couldn't find event [{}] {:?} to copy HTML from in room {}", details.item_id, details.timeline_event_id, tl.kind.room_id(), @@ -1969,7 +2928,9 @@ impl RoomScreen { } } MessageAction::CopyLink(details) => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; if let Some(event_id) = details.event_id() { let matrix_to_uri = tl.kind.room_id().matrix_to_event_uri(event_id.clone()); cx.copy_to_clipboard(&matrix_to_uri.to_string()); @@ -1979,7 +2940,8 @@ impl RoomScreen { PopupKind::Error, Some(5.0), ); - error!("MessageAction::CopyLink: no `event_id`: [{}] {:?} in room {}", + error!( + "MessageAction::CopyLink: no `event_id`: [{}] {:?} in room {}", details.item_id, details.timeline_event_id, tl.kind.room_id(), @@ -1987,8 +2949,11 @@ impl RoomScreen { } } MessageAction::ViewSource(details) => { - let Some(tl) = self.tl_state.as_ref() else { continue }; - let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) else { + let Some(tl) = self.tl_state.as_ref() else { + continue; + }; + let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) + else { enqueue_popup_notification( "Could not find message in timeline to view source.", PopupKind::Error, @@ -2012,7 +2977,9 @@ impl RoomScreen { } MessageAction::JumpToRelated(details) => { let Some(related_event_id) = details.related_event_id.as_ref() else { - error!("BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}"); + error!( + "BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}" + ); enqueue_popup_notification( "Could not find related message or event in timeline.", PopupKind::Error, @@ -2025,25 +2992,21 @@ impl RoomScreen { related_event_id, Some(details.item_id), portal_list, - loading_pane + loading_pane, ); } MessageAction::JumpToEvent(event_id) => { - self.jump_to_event( - cx, - event_id, - None, - portal_list, - loading_pane - ); + self.jump_to_event(cx, event_id, None, portal_list, loading_pane); } MessageAction::OpenThread(thread_root_event_id) => { let Some(room_name_id) = self.room_name_id.as_ref().cloned() else { - error!("### ERROR: MessageAction::OpenThread: thread_root_event_id: {thread_root_event_id}, but room_name_id was None!"); - continue + error!( + "### ERROR: MessageAction::OpenThread: thread_root_event_id: {thread_root_event_id}, but room_name_id was None!" + ); + continue; }; cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, RoomsListAction::Selected(SelectedRoom::Thread { room_name_id, thread_root_event_id: thread_root_event_id.clone(), @@ -2051,13 +3014,17 @@ impl RoomScreen { ); } MessageAction::Redact { details, reason } => { - let Some(tl) = self.tl_state.as_ref() else { return }; + let Some(tl) = self.tl_state.as_ref() else { + return; + }; let timeline_event_id = details.timeline_event_id.clone(); let timeline_kind = tl.kind.clone(); let reason = reason.clone(); let content = ConfirmationModalContent { title_text: "Delete Message".into(), - body_text: "Are you sure you want to delete this message? This cannot be undone.".into(), + body_text: + "Are you sure you want to delete this message? This cannot be undone." + .into(), accept_button_text: Some("Delete".into()), on_accept_clicked: Some(Box::new(move |_cx| { submit_async_request(MatrixRequest::RedactMessage { @@ -2075,14 +3042,15 @@ impl RoomScreen { // } // This is handled within the Message widget itself. - MessageAction::HighlightMessage(..) => { } + MessageAction::HighlightMessage(..) => {} // This is handled by the top-level App itself. - MessageAction::OpenMessageContextMenu { .. } => { } + MessageAction::OpenMessageContextMenu { .. } => {} // This isn't yet handled, as we need to completely redesign it. - MessageAction::ActionBarOpen { .. } => { } + MessageAction::ActionBarOpen { .. } => {} // This isn't yet handled, as we need to completely redesign it. - MessageAction::ActionBarClose => { } - MessageAction::None => { } + MessageAction::ActionBarClose => {} + MessageAction::ToggleAppServiceActions => {} + MessageAction::None => {} } } } @@ -2100,14 +3068,17 @@ impl RoomScreen { portal_list: &PortalListRef, loading_pane: &LoadingPaneRef, ) { - let Some(tl) = self.tl_state.as_mut() else { return }; + let Some(tl) = self.tl_state.as_mut() else { + return; + }; let max_tl_idx = max_tl_idx.unwrap_or_else(|| tl.items.len()); // Attempt to find the index of replied-to message in the timeline. // Start from the current item's index (`tl_idx`) and search backwards, // since we know the related message must come before the current item. let mut num_items_searched = 0; - let related_msg_tl_index = tl.items + let related_msg_tl_index = tl + .items .focus() .narrow(..max_tl_idx) .into_iter() @@ -2130,11 +3101,13 @@ impl RoomScreen { // appear beneath the top of the viewport. portal_list.smooth_scroll_to(cx, index.saturating_sub(1), speed, None); // start highlight animation. - tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { - item_id: index - }; + tl.message_highlight_animation_state = + MessageHighlightAnimationState::Pending { item_id: index }; } else { - log!("The related event {target_event_id} wasn't immediately available in room {}, searching for it in the background...", tl.kind.room_id()); + log!( + "The related event {target_event_id} wasn't immediately available in room {}, searching for it in the background...", + tl.kind.room_id() + ); // Here, we set the state of the loading pane and display it to the user. // The main logic will be handled in `process_timeline_updates()`, which is the only // place where we can receive updates to the timeline from the background tasks. @@ -2187,7 +3160,9 @@ impl RoomScreen { /// Invoke this when this timeline is being shown, /// e.g., when the user navigates to this timeline. fn show_timeline(&mut self, cx: &mut Cx) { - let kind = self.timeline_kind.clone() + let kind = self + .timeline_kind + .clone() .expect("BUG: Timeline::show_timeline(): no timeline_kind was set."); let room_id = kind.room_id().clone(); @@ -2204,8 +3179,10 @@ impl RoomScreen { return; } if !self.is_loaded && self.all_rooms_loaded { - panic!("BUG: timeline {kind} is not loaded, but its RoomScreen \ - was not waiting for its timeline to be loaded either."); + panic!( + "BUG: timeline {kind} is not loaded, but its RoomScreen \ + was not waiting for its timeline to be loaded either." + ); } return; }; @@ -2278,14 +3255,19 @@ impl RoomScreen { self.is_loaded = is_loaded_now; } - self.view.restore_status_view(cx, ids!(restore_status_view)).set_visible(cx, !self.is_loaded); + self.view + .restore_status_view(cx, ids!(restore_status_view)) + .set_visible(cx, !self.is_loaded); // Kick off a back pagination request if it's the first time loading this room, // because we want to show the user some messages as soon as possible // when they first open the room, and there might not be any messages yet. if is_first_time_being_loaded { if !tl_state.fully_paginated { - log!("Sending a first-time backwards pagination request for {}", tl_state.kind); + log!( + "Sending a first-time backwards pagination request for {}", + tl_state.kind + ); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl_state.kind.clone(), num_events: 50, @@ -2354,7 +3336,9 @@ impl RoomScreen { /// Invoke this when this RoomScreen/timeline is being hidden or no longer being shown. fn hide_timeline(&mut self) { - let Some(timeline_kind) = self.timeline_kind.clone() else { return }; + let Some(timeline_kind) = self.timeline_kind.clone() else { + return; + }; self.save_state(); @@ -2386,13 +3370,23 @@ impl RoomScreen { /// Note: after calling this function, the widget's `tl_state` will be `None`. fn save_state(&mut self) { let Some(mut tl) = self.tl_state.take() else { - error!("Timeline::save_state(): skipping due to missing state, room {:?}, {:?}", self.timeline_kind, self.room_name_id.as_ref().map(|r| r.display_name())); + error!( + "Timeline::save_state(): skipping due to missing state, room {:?}, {:?}", + self.timeline_kind, + self.room_name_id.as_ref().map(|r| r.display_name()) + ); return; }; let portal_list = self.child_by_path(ids!(timeline.list)).as_portal_list(); let room_input_bar = self.child_by_path(ids!(room_input_bar)).as_room_input_bar(); - log!("Saving state for room {:?}\n\t{:?}\n\tfirst_id: {:?}, scroll: {}", self.room_name_id.as_ref().map(|r| r.display_name()), self.timeline_kind, portal_list.first_id(), portal_list.scroll_position()); + log!( + "Saving state for room {:?}\n\t{:?}\n\tfirst_id: {:?}, scroll: {}", + self.room_name_id.as_ref().map(|r| r.display_name()), + self.timeline_kind, + portal_list.first_id(), + portal_list.scroll_position() + ); let state = SavedState { first_index_and_scroll: Some((portal_list.first_id(), portal_list.scroll_position())), room_input_bar_state: room_input_bar.save_state(), @@ -2417,7 +3411,12 @@ impl RoomScreen { // 1. Restore the position of the timeline. let portal_list = self.portal_list(cx, ids!(timeline.list)); if let Some((first_index, scroll_from_first_id)) = first_index_and_scroll { - log!("Restoring state for room {:?}: first_id: {:?}, scroll: {}", self.room_name_id, first_index, scroll_from_first_id); + log!( + "Restoring state for room {:?}: first_id: {:?}, scroll: {}", + self.room_name_id, + first_index, + scroll_from_first_id + ); portal_list.set_first_id_and_scroll(*first_index, *scroll_from_first_id); portal_list.set_tail_range(false); } else { @@ -2426,7 +3425,10 @@ impl RoomScreen { // The explicit reset is necessary when the same RoomScreen widget is reused for a // different room (e.g., via stack navigation view alternation), otherwise the portal list // would retain the previous room's scroll position which may be out of bounds. - log!("Restoring state for room {:?}: first_id: None, scroll: None", self.room_name_id); + log!( + "Restoring state for room {:?}: first_id: None, scroll: None", + self.room_name_id + ); portal_list.set_first_id_and_scroll(0, 0.0); portal_list.set_tail_range(true); } @@ -2463,12 +3465,17 @@ impl RoomScreen { // If this timeline is already displayed, we don't need to do anything major, // but we do need update the `room_name_id` in case it has changed, or it has been cleared. - if self.timeline_kind.as_ref().is_some_and(|kind| kind == &timeline_kind) { + if self + .timeline_kind + .as_ref() + .is_some_and(|kind| kind == &timeline_kind) + { self.room_name_id = Some(room_name_id.clone()); return; } self.hide_timeline(); + self.reset_app_service_ui(cx); // Reset the the state of the inner loading pane. self.loading_pane(cx, ids!(loading_pane)).take_state(); @@ -2498,7 +3505,9 @@ impl RoomScreen { return; } let first_index = portal_list.first_id(); - let Some(tl_state) = self.tl_state.as_mut() else { return }; + let Some(tl_state) = self.tl_state.as_mut() else { + return; + }; if let Some(ref mut index) = tl_state.prev_first_index { // to detect change of scroll when scroll ends @@ -2509,7 +3518,7 @@ impl RoomScreen { .items .get(std::cmp::min( first_index + portal_list.visible_items(), - tl_state.items.len().saturating_sub(1) + tl_state.items.len().saturating_sub(1), )) .and_then(|f| f.as_event()) .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) @@ -2529,17 +3538,20 @@ impl RoomScreen { receipt_type: ReceiptType::FullyRead, }); } else { - if let Some(own_user_receipt_timestamp) = &tl_state.latest_own_user_receipt.clone() - .and_then(|receipt| receipt.ts) { + if let Some(own_user_receipt_timestamp) = &tl_state + .latest_own_user_receipt + .clone() + .and_then(|receipt| receipt.ts) + { let Some((_first_event_id, first_timestamp)) = tl_state .items .get(first_index) .and_then(|f| f.as_event()) .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) - else { - *index = first_index; - return; - }; + else { + *index = first_index; + return; + }; if own_user_receipt_timestamp >= &first_timestamp && own_user_receipt_timestamp <= &last_timestamp { @@ -2550,7 +3562,6 @@ impl RoomScreen { receipt_type: ReceiptType::FullyRead, }); } - } } } @@ -2569,14 +3580,22 @@ impl RoomScreen { actions: &ActionsBuf, portal_list: &PortalListRef, ) { - let Some(tl) = self.tl_state.as_mut() else { return }; - if tl.fully_paginated { return }; - if !portal_list.scrolled(actions) { return }; + let Some(tl) = self.tl_state.as_mut() else { + return; + }; + if tl.fully_paginated { + return; + }; + if !portal_list.scrolled(actions) { + return; + }; let first_index = portal_list.first_id(); if first_index == 0 && tl.last_scrolled_index > 0 { - log!("Scrolled up from item {} --> 0, sending back pagination request for room {}", - tl.last_scrolled_index, tl.kind, + log!( + "Scrolled up from item {} --> 0, sending back pagination request for room {}", + tl.last_scrolled_index, + tl.kind, ); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl.kind.clone(), @@ -2596,7 +3615,9 @@ impl RoomScreenRef { room_name_id: &RoomNameId, thread_root_event_id: Option, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_displayed_room(cx, room_name_id, thread_root_event_id); } } @@ -2609,9 +3630,10 @@ pub struct RoomScreenProps { pub timeline_kind: TimelineKind, pub room_members: Option>>, pub room_avatar_url: Option, + pub app_service_enabled: bool, + pub app_service_room_bound: bool, } - /// Actions for the room screen's tooltip. #[derive(Clone, Debug, Default)] pub enum RoomScreenTooltipActions { @@ -2710,9 +3732,7 @@ pub enum TimelineUpdate { /// includes a complete list of room members that can be shared across components. /// This is different from RoomMembersSynced which only indicates members were fetched /// but doesn't provide the actual data. - RoomMembersListFetched { - members: Vec, - }, + RoomMembersListFetched { members: Vec }, /// A notice with an option of Media Request Parameters that one or more requested media items (images, videos, etc.) /// that should be displayed in this timeline have now been fetched and are available. MediaFetched(MediaRequestParameters), @@ -2744,7 +3764,7 @@ thread_local! { /// The global set of all timeline states, one entry per room. /// /// This is only useful when accessed from the main UI thread. - static TIMELINE_STATES: RefCell> = + static TIMELINE_STATES: RefCell> = RefCell::new(HashMap::new()); } @@ -2860,7 +3880,9 @@ struct TimelineUiState { #[derive(Default, Debug)] enum MessageHighlightAnimationState { - Pending { item_id: usize }, + Pending { + item_id: usize, + }, #[default] Off, } @@ -2897,9 +3919,8 @@ fn find_new_item_matching_current_item( ) -> Option<(usize, usize, f64, OwnedEventId)> { let mut curr_item_focus = curr_items.focus(); let mut idx_curr = starting_at_curr_idx; - let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = Vec::with_capacity( - portal_list.visible_items() - ); + let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = + Vec::with_capacity(portal_list.visible_items()); // Find all items with real event IDs that are currently visible in the portal list. // TODO: if this is slow, we could limit it to 3-5 events at the most. @@ -2928,7 +3949,9 @@ fn find_new_item_matching_current_item( // some may be zeroed-out, so we need to account for that possibility by only // using events that have a real non-zero area if let Some(pos_offset) = portal_list.position_of_item(cx, *idx_curr) { - log!("Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}"); + log!( + "Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}" + ); return Some((*idx_curr, idx_new, pos_offset, event_id.to_owned())); } } @@ -3002,7 +4025,8 @@ fn populate_message_view( TimelineItemContent::MsgLike(_msg_like_content) => { let prev_msg_sender = prev_event_tl_item.sender(); prev_msg_sender == event_tl_item.sender() - && ts_millis.0 + && ts_millis + .0 .checked_sub(prev_event_tl_item.timestamp().0) .is_some_and(|d| d < uint!(600000)) // 10 mins in millis } @@ -3019,8 +4043,12 @@ fn populate_message_view( let (item, used_cached_item) = match &msg_like_content.kind { MsgLikeKind::Message(msg) => { match msg.msgtype() { - MessageType::Text(TextMessageEventContent { body, formatted, .. }) => { - has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + MessageType::Text(TextMessageEventContent { + body, formatted, .. + }) => { + has_html_body = formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3048,9 +4076,13 @@ fn populate_message_view( } // A notice message is just a message sent by an automated bot, // so we treat it just like a message but use a different font color. - MessageType::Notice(NoticeMessageEventContent{body, formatted, ..}) => { + MessageType::Notice(NoticeMessageEventContent { + body, formatted, .. + }) => { is_notice = true; - has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3060,7 +4092,8 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); + let html_or_plaintext_ref = + item.html_or_plaintext(cx, ids!(content.message)); // Apply gray color to all text styles for notice messages. let mut html_widget = html_or_plaintext_ref.html(cx, ids!(html_view.html)); script_apply_eval!(cx, html_widget, { @@ -3090,7 +4123,8 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); + let html_or_plaintext_ref = + item.html_or_plaintext(cx, ids!(content.message)); // Apply red color to all text styles for server notices. let mut html_widget = html_or_plaintext_ref.html(cx, ids!(html_view.html)); script_apply_eval!(cx, html_widget, { @@ -3105,10 +4139,12 @@ fn populate_message_view( "Server notice: {}\n\nNotice type:: {}{}{}", sn.body, sn.server_notice_type.as_str(), - sn.limit_type.as_ref() + sn.limit_type + .as_ref() .map(|l| format!("\nLimit type: {}", l.as_str())) .unwrap_or_default(), - sn.admin_contact.as_ref() + sn.admin_contact + .as_ref() .map(|c| format!("\nAdmin contact: {}", c)) .unwrap_or_default(), ); @@ -3131,8 +4167,12 @@ fn populate_message_view( } // An emote is just like a message but is prepended with the user's name // to indicate that it's an "action" that the user is performing. - MessageType::Emote(EmoteMessageEventContent { body, formatted, .. }) => { - has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + MessageType::Emote(EmoteMessageEventContent { + body, formatted, .. + }) => { + has_html_body = formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3143,14 +4183,16 @@ fn populate_message_view( (item, true) } else { // Draw the profile up front here because we need the username for the emote body. - let (username, profile_drawn) = item.avatar(cx, ids!(profile.avatar)).set_avatar_and_get_username( - cx, - timeline_kind, - event_tl_item.sender(), - Some(event_tl_item.sender_profile()), - event_tl_item.event_id(), - true, - ); + let (username, profile_drawn) = item + .avatar(cx, ids!(profile.avatar)) + .set_avatar_and_get_username( + cx, + timeline_kind, + event_tl_item.sender(), + Some(event_tl_item.sender_profile()), + event_tl_item.event_id(), + true, + ); // Prepend a "* " to the emote body, as suggested by the Matrix spec. let (body, formatted) = if let Some(fb) = formatted.as_ref() { @@ -3159,7 +4201,7 @@ fn populate_message_view( Some(FormattedBody { format: fb.format.clone(), body: format!("* {} {}", &username, &fb.body), - }) + }), ) } else { (Cow::from(format!("* {} {}", &username, body)), None) @@ -3183,7 +4225,9 @@ fn populate_message_view( } } MessageType::Image(image) => { - has_html_body = image.formatted.as_ref() + has_html_body = image + .formatted + .as_ref() .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedImageMessage) @@ -3221,17 +4265,17 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - let is_location_fully_drawn = populate_location_message_content( - cx, - &html_or_plaintext_ref, - location, - ); + let is_location_fully_drawn = + populate_location_message_content(cx, &html_or_plaintext_ref, location); new_drawn_status.content_drawn = is_location_fully_drawn; (item, false) } } MessageType::File(file_content) => { - has_html_body = file_content.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = file_content + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3243,16 +4287,16 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - new_drawn_status.content_drawn = populate_file_message_content( - cx, - &html_or_plaintext_ref, - file_content, - ); + new_drawn_status.content_drawn = + populate_file_message_content(cx, &html_or_plaintext_ref, file_content); (item, false) } } MessageType::Audio(audio) => { - has_html_body = audio.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = audio + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3264,16 +4308,16 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - new_drawn_status.content_drawn = populate_audio_message_content( - cx, - &html_or_plaintext_ref, - audio, - ); + new_drawn_status.content_drawn = + populate_audio_message_content(cx, &html_or_plaintext_ref, audio); (item, false) } } MessageType::Video(video) => { - has_html_body = video.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = video + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -3285,16 +4329,16 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - new_drawn_status.content_drawn = populate_video_message_content( - cx, - &html_or_plaintext_ref, - video, - ); + new_drawn_status.content_drawn = + populate_video_message_content(cx, &html_or_plaintext_ref, video); (item, false) } } MessageType::VerificationRequest(verification) => { - has_html_body = verification.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = verification + .formatted + .as_ref() + .is_some_and(|f| f.format == MessageFormat::Html); let template = id!(Message); let (item, existed) = list.item_with_existed(cx, item_id, template); if existed && item_drawn_status.content_drawn { @@ -3306,7 +4350,8 @@ fn populate_message_view( body: format!( "Sent a verification request to {}.
(Supported methods: {})
", verification.to, - verification.methods + verification + .methods .iter() .map(|m| m.as_str()) .collect::>() @@ -3336,10 +4381,8 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - item.label(cx, ids!(content.message)).set_text( - cx, - &format!("[Unsupported {:?}]", msg_like_content.kind), - ); + item.label(cx, ids!(content.message)) + .set_text(cx, &format!("[Unsupported {:?}]", msg_like_content.kind)); new_drawn_status.content_drawn = true; (item, false) } @@ -3349,7 +4392,9 @@ fn populate_message_view( // Handle sticker messages that are static images. MsgLikeKind::Sticker(sticker) => { has_html_body = false; - let StickerEventContent { body, info, source, .. } = sticker.content(); + let StickerEventContent { + body, info, source, .. + } = sticker.content(); let template = if use_compact_view { id!(CondensedImageMessage) @@ -3378,7 +4423,7 @@ fn populate_message_view( (item, true) } } - } + } // Handle messages that have been redacted (deleted). MsgLikeKind::Redacted => { has_html_body = false; @@ -3417,10 +4462,8 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - item.label(cx, ids!(content.message)).set_text( - cx, - &format!("[Unsupported {:?}] ", other), - ); + item.label(cx, ids!(content.message)) + .set_text(cx, &format!("[Unsupported {:?}] ", other)); new_drawn_status.content_drawn = true; (item, false) } @@ -3432,13 +4475,14 @@ fn populate_message_view( // If we didn't use a cached item, we need to draw all other message content: // the reactions, the read receipts avatar row, the reply preview. if !used_cached_item { - item.reaction_list(cx, ids!(content.reaction_list)).set_list( - cx, - event_tl_item.content().reactions(), - timeline_kind.clone(), - timeline_event_id.clone(), - item_id, - ); + item.reaction_list(cx, ids!(content.reaction_list)) + .set_list( + cx, + event_tl_item.content().reactions(), + timeline_kind.clone(), + timeline_event_id.clone(), + item_id, + ); populate_read_receipts(&item, cx, timeline_kind, event_tl_item); let is_reply_fully_drawn = draw_replied_to_message( cx, @@ -3465,17 +4509,21 @@ fn populate_message_view( new_drawn_status.content_drawn &= is_thread_summary_fully_drawn; } - // We must always re-set the message details, even when re-using a cached portallist item, // because the item type might be the same but for a different message entirely. let message_details = MessageDetails { thread_root_event_id: msg_like_content.thread_root.clone().or_else(|| { - msg_like_content.thread_summary.as_ref() + msg_like_content + .thread_summary + .as_ref() .and_then(|_| event_tl_item.event_id().map(|id| id.to_owned())) }), timeline_event_id, item_id, - related_event_id: msg_like_content.in_reply_to.as_ref().map(|r| r.event_id.clone()), + related_event_id: msg_like_content + .in_reply_to + .as_ref() + .map(|r| r.event_id.clone()), room_screen_widget_uid, abilities: MessageAbilities::from_user_power_and_event( user_power_levels, @@ -3488,7 +4536,6 @@ fn populate_message_view( }; item.as_message().set_data(message_details); - // If `used_cached_item` is false, we should always redraw the profile, even if profile_drawn is true. let skip_draw_profile = use_compact_view || (used_cached_item && item_drawn_status.profile_drawn); @@ -3499,17 +4546,20 @@ fn populate_message_view( // log!("\t --> populate_message_view(): DRAWING profile draw for item_id: {item_id}"); let mut username_label = item.label(cx, ids!(content.username)); - if !is_server_notice { // the normal case - let (username, profile_drawn) = set_username_and_get_avatar_retval.unwrap_or_else(|| - item.avatar(cx, ids!(profile.avatar)).set_avatar_and_get_username( - cx, - timeline_kind, - event_tl_item.sender(), - Some(event_tl_item.sender_profile()), - event_tl_item.event_id(), - true, - ) - ); + if !is_server_notice { + // the normal case + let (username, profile_drawn) = + set_username_and_get_avatar_retval.unwrap_or_else(|| { + item.avatar(cx, ids!(profile.avatar)) + .set_avatar_and_get_username( + cx, + timeline_kind, + event_tl_item.sender(), + Some(event_tl_item.sender_profile()), + event_tl_item.event_id(), + true, + ) + }); if is_notice { script_apply_eval!(cx, username_label, { draw_text +: { @@ -3519,8 +4569,7 @@ fn populate_message_view( } username_label.set_text(cx, &username); new_drawn_status.profile_drawn = profile_drawn; - } - else { + } else { // Server notices are drawn with a red color avatar background and username. let avatar = item.avatar(cx, ids!(profile.avatar)); avatar.show_text(cx, Some(COLOR_FG_DANGER_RED), None, "⚠"); @@ -3541,33 +4590,46 @@ fn populate_message_view( // Set the timestamp. if let Some(dt) = unix_time_millis_to_datetime(ts_millis) { - item.timestamp(cx, ids!(profile.timestamp)).set_date_time(cx, dt); + item.timestamp(cx, ids!(profile.timestamp)) + .set_date_time(cx, dt); } // Set the "edited" indicator if this message was edited. if msg_like_content.as_message().is_some_and(|m| m.is_edited()) { - item.edited_indicator(cx, ids!(profile.edited_indicator)).set_latest_edit( - cx, - event_tl_item, - ); + item.edited_indicator(cx, ids!(profile.edited_indicator)) + .set_latest_edit(cx, event_tl_item); } - #[cfg(feature = "tsp")] { + #[cfg(feature = "tsp")] + { use matrix_sdk::ruma::serde::Base64; - use crate::tsp::{self, tsp_sign_indicator::{TspSignState, TspSignIndicatorWidgetRefExt}}; + use crate::tsp::{ + self, + tsp_sign_indicator::{TspSignState, TspSignIndicatorWidgetRefExt}, + }; - if let Some(mut tsp_sig) = event_tl_item.latest_json() + if let Some(mut tsp_sig) = event_tl_item + .latest_json() .and_then(|raw| raw.get_field::("content").ok()) .flatten() .and_then(|content_obj| content_obj.get("org.robius.tsp_signature").cloned()) .and_then(|tsp_sig_value| serde_json::from_value::(tsp_sig_value).ok()) .map(|b64| b64.into_inner()) { - log!("Found event {:?} with TSP signature.", event_tl_item.event_id()); - let tsp_sign_state = if let Some(sender_vid) = tsp::tsp_state_ref().lock().unwrap() + log!( + "Found event {:?} with TSP signature.", + event_tl_item.event_id() + ); + let tsp_sign_state = if let Some(sender_vid) = tsp::tsp_state_ref() + .lock() + .unwrap() .get_verified_vid_for(event_tl_item.sender()) { - log!("Found verified VID for sender {}: \"{}\"", event_tl_item.sender(), sender_vid.identifier()); + log!( + "Found verified VID for sender {}: \"{}\"", + event_tl_item.sender(), + sender_vid.identifier() + ); tsp_sdk::crypto::verify(&*sender_vid, &mut tsp_sig).map_or( TspSignState::WrongSignature, |(msg, msg_type)| { @@ -3579,7 +4641,11 @@ fn populate_message_view( TspSignState::Unknown }; - log!("TSP signature state for event {:?} is {:?}", event_tl_item.event_id(), tsp_sign_state); + log!( + "TSP signature state for event {:?} is {:?}", + event_tl_item.event_id(), + tsp_sign_state + ); item.tsp_sign_indicator(cx, ids!(profile.tsp_sign_indicator)) .show_with_state(cx, tsp_sign_state); } @@ -3602,7 +4668,8 @@ fn populate_text_message_content( ) -> bool { // The message was HTML-formatted rich text. let mut links = Vec::new(); - if let Some(fb) = formatted_body.as_ref() + if let Some(fb) = formatted_body + .as_ref() .and_then(|fb| (fb.format == MessageFormat::Html).then_some(fb)) { let linkified_html = utils::linkify_get_urls( @@ -3622,7 +4689,7 @@ fn populate_text_message_content( }; // Populate link previews if all required parameters are provided - if let (Some(link_preview_ref), Some(media_cache), Some(link_preview_cache)) = + if let (Some(link_preview_ref), Some(media_cache), Some(link_preview_cache)) = (link_preview_ref, media_cache, link_preview_cache) { link_preview_ref.populate_below_message( @@ -3650,7 +4717,8 @@ fn populate_image_message_content( ) -> bool { // We don't use thumbnails, as their resolution is too low to be visually useful. // We also don't trust the provided mimetype, as it can be incorrect. - let (mimetype, _width, _height) = image_info_source.as_ref() + let (mimetype, _width, _height) = image_info_source + .as_ref() .map(|info| (info.mimetype.as_deref(), info.width, info.height)) .unwrap_or_default(); @@ -3658,10 +4726,7 @@ fn populate_image_message_content( // then show a message about it being unsupported (e.g., for animated gifs). if let Some(mime) = mimetype.as_ref() { if ImageFormat::from_mimetype(mime).is_none() { - text_or_image_ref.show_text( - cx, - format!("{body}\n\nUnsupported type {mime:?}"), - ); + text_or_image_ref.show_text(cx, format!("{body}\n\nUnsupported type {mime:?}")); return true; // consider this as fully drawn } } @@ -3670,102 +4735,132 @@ fn populate_image_message_content( // A closure that fetches and shows the image from the given `mxc_uri`, // marking it as fully drawn if the image was available. - let mut fetch_and_show_image_uri = |cx: &mut Cx, mxc_uri: OwnedMxcUri, image_info: Box| { - match media_cache.try_get_media_or_fetch(&mxc_uri, MEDIA_THUMBNAIL_FORMAT.into()) { - (MediaCacheEntry::Loaded(data), _media_format) => { - let show_image_result = text_or_image_ref.show_image(cx, Some(MediaSource::Plain(mxc_uri)),|cx, img| { - utils::load_png_or_jpg(&img, cx, &data) - .map(|()| img.size_in_pixels(cx).unwrap_or_default()) - }); - if let Err(e) = show_image_result { - let err_str = format!("{body}\n\nFailed to display image: {e:?}"); - error!("{err_str}"); - text_or_image_ref.show_text(cx, &err_str); - } - - // We're done drawing the image, so mark it as fully drawn. - fully_drawn = true; - } - (MediaCacheEntry::Requested, _media_format) => { - // If the image is being fetched, we try to show its blurhash. - if let (Some(ref blurhash), Some(width), Some(height)) = (image_info.blurhash.clone(), image_info.width, image_info.height) { - let show_image_result = text_or_image_ref.show_image(cx, Some(MediaSource::Plain(mxc_uri)), |cx, img| { - let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) else { - return Err(image_cache::ImageError::EmptyData) - }; - let (width, height): (u32, u32) = (width, height); - if width == 0 || height == 0 { - warning!("Image had an invalid aspect ratio (width or height of 0)."); - return Err(image_cache::ImageError::EmptyData); - } - let aspect_ratio: f32 = width as f32 / height as f32; - // Cap the blurhash to a max size of 500 pixels in each dimension - // because the `blurhash::decode()` function can be rather expensive. - let (mut capped_width, mut capped_height) = (width, height); - if capped_height > BLURHASH_IMAGE_MAX_SIZE { - capped_height = BLURHASH_IMAGE_MAX_SIZE; - capped_width = (capped_height as f32 * aspect_ratio).floor() as u32; - } - if capped_width > BLURHASH_IMAGE_MAX_SIZE { - capped_width = BLURHASH_IMAGE_MAX_SIZE; - capped_height = (capped_width as f32 / aspect_ratio).floor() as u32; - } - - match blurhash::decode(blurhash, capped_width, capped_height, 1.0) { - Ok(data) => { - ImageBuffer::new(&data, capped_width as usize, capped_height as usize).map(|img_buff| { - let texture = Some(img_buff.into_new_texture(cx)); - img.set_texture(cx, texture); - img.size_in_pixels(cx).unwrap_or_default() - }) - } - Err(e) => { - error!("Failed to decode blurhash {e:?}"); - Err(image_cache::ImageError::EmptyData) - } - } - }); + let mut fetch_and_show_image_uri = + |cx: &mut Cx, mxc_uri: OwnedMxcUri, image_info: Box| { + match media_cache.try_get_media_or_fetch(&mxc_uri, MEDIA_THUMBNAIL_FORMAT.into()) { + (MediaCacheEntry::Loaded(data), _media_format) => { + let show_image_result = text_or_image_ref.show_image( + cx, + Some(MediaSource::Plain(mxc_uri)), + |cx, img| { + utils::load_png_or_jpg(&img, cx, &data) + .map(|()| img.size_in_pixels(cx).unwrap_or_default()) + }, + ); if let Err(e) = show_image_result { let err_str = format!("{body}\n\nFailed to display image: {e:?}"); error!("{err_str}"); text_or_image_ref.show_text(cx, &err_str); } + + // We're done drawing the image, so mark it as fully drawn. + fully_drawn = true; } - fully_drawn = false; - } - (MediaCacheEntry::Failed(_status_code), _media_format) => { - if text_or_image_ref.view(cx, ids!(default_image_view)).visible() { + (MediaCacheEntry::Requested, _media_format) => { + // If the image is being fetched, we try to show its blurhash. + if let (Some(ref blurhash), Some(width), Some(height)) = ( + image_info.blurhash.clone(), + image_info.width, + image_info.height, + ) { + let show_image_result = text_or_image_ref.show_image( + cx, + Some(MediaSource::Plain(mxc_uri)), + |cx, img| { + let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) + else { + return Err(image_cache::ImageError::EmptyData); + }; + let (width, height): (u32, u32) = (width, height); + if width == 0 || height == 0 { + warning!( + "Image had an invalid aspect ratio (width or height of 0)." + ); + return Err(image_cache::ImageError::EmptyData); + } + let aspect_ratio: f32 = width as f32 / height as f32; + // Cap the blurhash to a max size of 500 pixels in each dimension + // because the `blurhash::decode()` function can be rather expensive. + let (mut capped_width, mut capped_height) = (width, height); + if capped_height > BLURHASH_IMAGE_MAX_SIZE { + capped_height = BLURHASH_IMAGE_MAX_SIZE; + capped_width = + (capped_height as f32 * aspect_ratio).floor() as u32; + } + if capped_width > BLURHASH_IMAGE_MAX_SIZE { + capped_width = BLURHASH_IMAGE_MAX_SIZE; + capped_height = + (capped_width as f32 / aspect_ratio).floor() as u32; + } + + match blurhash::decode(blurhash, capped_width, capped_height, 1.0) { + Ok(data) => ImageBuffer::new( + &data, + capped_width as usize, + capped_height as usize, + ) + .map(|img_buff| { + let texture = Some(img_buff.into_new_texture(cx)); + img.set_texture(cx, texture); + img.size_in_pixels(cx).unwrap_or_default() + }), + Err(e) => { + error!("Failed to decode blurhash {e:?}"); + Err(image_cache::ImageError::EmptyData) + } + } + }, + ); + if let Err(e) = show_image_result { + let err_str = format!("{body}\n\nFailed to display image: {e:?}"); + error!("{err_str}"); + text_or_image_ref.show_text(cx, &err_str); + } + } + fully_drawn = false; + } + (MediaCacheEntry::Failed(_status_code), _media_format) => { + if text_or_image_ref + .view(cx, ids!(default_image_view)) + .visible() + { + fully_drawn = true; + return; + } + text_or_image_ref.show_text( + cx, + format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri), + ); + // For now, we consider this as being "complete". In the future, we could support + // retrying to fetch thumbnail of the image on a user click/tap. fully_drawn = true; - return; } - text_or_image_ref - .show_text(cx, format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri)); - // For now, we consider this as being "complete". In the future, we could support - // retrying to fetch thumbnail of the image on a user click/tap. - fully_drawn = true; } - } - }; + }; - let mut fetch_and_show_media_source = |cx: &mut Cx, media_source: MediaSource, image_info: Box| { - match media_source { - MediaSource::Encrypted(encrypted) => { - // We consider this as "fully drawn" since we don't yet support encryption. - text_or_image_ref.show_text( - cx, - format!("{body}\n\n[TODO] fetch encrypted image at {:?}", encrypted.url) - ); - }, - MediaSource::Plain(mxc_uri) => { - fetch_and_show_image_uri(cx, mxc_uri, image_info) + let mut fetch_and_show_media_source = + |cx: &mut Cx, media_source: MediaSource, image_info: Box| { + match media_source { + MediaSource::Encrypted(encrypted) => { + // We consider this as "fully drawn" since we don't yet support encryption. + text_or_image_ref.show_text( + cx, + format!( + "{body}\n\n[TODO] fetch encrypted image at {:?}", + encrypted.url + ), + ); + } + MediaSource::Plain(mxc_uri) => fetch_and_show_image_uri(cx, mxc_uri, image_info), } - } - }; + }; match image_info_source { Some(image_info) => { // Use the provided thumbnail URI if it exists; otherwise use the original URI. - let media_source = image_info.thumbnail_source.clone() + let media_source = image_info + .thumbnail_source + .clone() .unwrap_or(original_source); fetch_and_show_media_source(cx, media_source, image_info); } @@ -3778,7 +4873,6 @@ fn populate_image_message_content( fully_drawn } - /// Draws a file message's content into the given `message_content_widget`. /// /// Returns whether the file message content was fully drawn. @@ -3795,7 +4889,8 @@ fn populate_file_message_content( .and_then(|info| info.size) .map(|bytes| format!(" ({})", ByteSize::b(bytes.into()))) .unwrap_or_default(); - let caption = file_content.formatted_caption() + let caption = file_content + .formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| file_content.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -3822,20 +4917,23 @@ fn populate_audio_message_content( let (duration, mime, size) = audio .info .as_ref() - .map(|info| ( - info.duration - .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) - .unwrap_or_default(), - info.mimetype - .as_ref() - .map(|m| format!(" {m},")) - .unwrap_or_default(), - info.size - .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) - .unwrap_or_default(), - )) + .map(|info| { + ( + info.duration + .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) + .unwrap_or_default(), + info.mimetype + .as_ref() + .map(|m| format!(" {m},")) + .unwrap_or_default(), + info.size + .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) + .unwrap_or_default(), + ) + }) .unwrap_or_default(); - let caption = audio.formatted_caption() + let caption = audio + .formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| audio.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -3849,7 +4947,6 @@ fn populate_audio_message_content( true } - /// Draws a video message's content into the given `message_content_widget`. /// /// Returns whether the video message content was fully drawn. @@ -3863,23 +4960,26 @@ fn populate_video_message_content( let (duration, mime, size, dimensions) = video .info .as_ref() - .map(|info| ( - info.duration - .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) - .unwrap_or_default(), - info.mimetype - .as_ref() - .map(|m| format!(" {m},")) - .unwrap_or_default(), - info.size - .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) - .unwrap_or_default(), - info.width.and_then(|width| - info.height.map(|height| format!(" {width}x{height},")) - ).unwrap_or_default(), - )) + .map(|info| { + ( + info.duration + .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) + .unwrap_or_default(), + info.mimetype + .as_ref() + .map(|m| format!(" {m},")) + .unwrap_or_default(), + info.size + .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) + .unwrap_or_default(), + info.width + .and_then(|width| info.height.map(|height| format!(" {width}x{height},"))) + .unwrap_or_default(), + ) + }) .unwrap_or_default(); - let caption = video.formatted_caption() + let caption = video + .formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| video.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -3893,8 +4993,6 @@ fn populate_video_message_content( true } - - /// Draws the given location message's content into the `message_content_widget`. /// /// Returns whether the location message content was fully drawn. @@ -3903,8 +5001,9 @@ fn populate_location_message_content( message_content_widget: &HtmlOrPlaintextRef, location: &LocationMessageEventContent, ) -> bool { - let coords = location.geo_uri - .get(utils::GEO_URI_SCHEME.len() ..) + let coords = location + .geo_uri + .get(utils::GEO_URI_SCHEME.len()..) .and_then(|s| { let mut iter = s.split(','); if let (Some(lat), Some(long)) = (iter.next(), iter.next()) { @@ -3914,8 +5013,14 @@ fn populate_location_message_content( } }); if let Some((lat, long)) = coords { - let short_lat = lat.find('.').and_then(|dot| lat.get(..dot + 7)).unwrap_or(lat); - let short_long = long.find('.').and_then(|dot| long.get(..dot + 7)).unwrap_or(long); + let short_lat = lat + .find('.') + .and_then(|dot| lat.get(..dot + 7)) + .unwrap_or(lat); + let short_long = long + .find('.') + .and_then(|dot| long.get(..dot + 7)) + .unwrap_or(long); let safe_lat = htmlize::escape_attribute(lat); let safe_long = htmlize::escape_attribute(long); let safe_geo_uri = htmlize::escape_attribute(&location.geo_uri); @@ -3934,7 +5039,10 @@ fn populate_location_message_content( } else { message_content_widget.show_html( cx, - format!("[Location invalid] {}", htmlize::escape_text(&location.body)) + format!( + "[Location invalid] {}", + htmlize::escape_text(&location.body) + ), ); } @@ -3944,7 +5052,6 @@ fn populate_location_message_content( true } - /// Draws the given redacted message's content into the `message_content_widget`. /// /// Returns whether the redacted message content was fully drawn. @@ -3957,16 +5064,13 @@ fn populate_redacted_message_content( let fully_drawn: bool; let mut redactor_id_and_reason = None; if let Some(redacted_msg) = event_tl_item.latest_json() { - if let Ok(AnySyncTimelineEvent::MessageLike( - AnySyncMessageLikeEvent::RoomMessage( - SyncMessageLikeEvent::Redacted(redaction) - ) - )) = redacted_msg.deserialize() { + if let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage( + SyncMessageLikeEvent::Redacted(redaction), + ))) = redacted_msg.deserialize() + { if let Ok(redacted_because) = redaction.unsigned.redacted_because.deserialize() { - redactor_id_and_reason = Some(( - redacted_because.sender, - redacted_because.content.reason, - )); + redactor_id_and_reason = + Some((redacted_because.sender, redacted_because.content.reason)); } } } @@ -3975,7 +5079,10 @@ fn populate_redacted_message_content( if redactor == event_tl_item.sender() { fully_drawn = true; match reason { - Some(r) => format!("⛔ Deleted their own message. Reason: \"{}\".", htmlize::escape_text(r)), + Some(r) => format!( + "⛔ Deleted their own message. Reason: \"{}\".", + htmlize::escape_text(r) + ), None => String::from("⛔ Deleted their own message."), } } else { @@ -3987,9 +5094,11 @@ fn populate_redacted_message_content( true, ); fully_drawn = redactor_name.was_found(); - let redactor_name_esc = htmlize::escape_text(redactor_name.as_deref().unwrap_or(redactor.as_str())); + let redactor_name_esc = + htmlize::escape_text(redactor_name.as_deref().unwrap_or(redactor.as_str())); match reason { - Some(r) => format!("⛔ {} deleted this message. Reason: \"{}\".", + Some(r) => format!( + "⛔ {} deleted this message. Reason: \"{}\".", redactor_name_esc, htmlize::escape_text(r), ), @@ -4004,7 +5113,6 @@ fn populate_redacted_message_content( fully_drawn } - /// Draws a ReplyPreview above a message if it was in-reply to another message. /// /// ## Arguments @@ -4031,24 +5139,24 @@ fn draw_replied_to_message( show_reply = true; match &in_reply_to_details.event { TimelineDetails::Ready(replied_to_event) => { - let (in_reply_to_username, is_avatar_fully_drawn) = - replied_to_message_view - .avatar(cx, ids!(replied_to_message_content.reply_preview_avatar)) - .set_avatar_and_get_username( - cx, - timeline_kind, - &replied_to_event.sender, - Some(&replied_to_event.sender_profile), - Some(in_reply_to_details.event_id.as_ref()), - true, - ); + let (in_reply_to_username, is_avatar_fully_drawn) = replied_to_message_view + .avatar(cx, ids!(replied_to_message_content.reply_preview_avatar)) + .set_avatar_and_get_username( + cx, + timeline_kind, + &replied_to_event.sender, + Some(&replied_to_event.sender_profile), + Some(in_reply_to_details.event_id.as_ref()), + true, + ); fully_drawn = is_avatar_fully_drawn; replied_to_message_view .label(cx, ids!(replied_to_message_content.reply_preview_username)) .set_text(cx, in_reply_to_username.as_str()); - let msg_body = replied_to_message_view.html_or_plaintext(cx, ids!(reply_preview_body)); + let msg_body = + replied_to_message_view.html_or_plaintext(cx, ids!(reply_preview_body)); populate_preview_of_timeline_item( cx, &msg_body, @@ -4160,7 +5268,8 @@ fn populate_thread_root_summary( &embedded_event.content, &embedded_event.sender, sender_username, - ).format_with(sender_username, true); + ) + .format_with(sender_username, true); match utils::replace_linebreaks_separators(&preview, true) { Cow::Borrowed(_) => Cow::Owned(preview), Cow::Owned(replaced) => Cow::Owned(replaced), @@ -4171,9 +5280,11 @@ fn populate_thread_root_summary( if td.is_unavailable() && let Some(thread_root_event_id) = thread_root_event_id.clone() { - let needs_refresh = fetched_summary - .is_none_or(|fs| fs.latest_reply_preview_text.is_none()); - if needs_refresh && pending_thread_summary_fetches.insert(thread_root_event_id.clone()) { + let needs_refresh = + fetched_summary.is_none_or(|fs| fs.latest_reply_preview_text.is_none()); + if needs_refresh + && pending_thread_summary_fetches.insert(thread_root_event_id.clone()) + { submit_async_request(MatrixRequest::FetchThreadSummaryDetails { timeline_kind: timeline_kind.clone(), thread_root_event_id, @@ -4181,7 +5292,8 @@ fn populate_thread_root_summary( }); } } - fetched_summary.and_then(|fs| fs.latest_reply_preview_text.as_deref()) + fetched_summary + .and_then(|fs| fs.latest_reply_preview_text.as_deref()) .unwrap_or("Loading latest reply...") .into() } @@ -4193,7 +5305,7 @@ fn populate_thread_root_summary( let replies_count_text = match replies_count { 1 => Cow::Borrowed("1 reply"), - n => Cow::Owned(format!("{n} replies")) + n => Cow::Owned(format!("{n} replies")), }; item.label(cx, ids!(thread_summary_count)) .set_text(cx, &replies_count_text); @@ -4213,23 +5325,32 @@ pub fn populate_preview_of_timeline_item( ) { if let Some(m) = timeline_item_content.as_message() { match m.msgtype() { - MessageType::Text(TextMessageEventContent { body, formatted, .. }) - | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. }) => { - let _ = populate_text_message_content(cx, widget_out, body, formatted.as_ref(), None, None, None); + MessageType::Text(TextMessageEventContent { + body, formatted, .. + }) + | MessageType::Notice(NoticeMessageEventContent { + body, formatted, .. + }) => { + let _ = populate_text_message_content( + cx, + widget_out, + body, + formatted.as_ref(), + None, + None, + None, + ); return; } - _ => { } // fall through to the general case for all timeline items below. + _ => {} // fall through to the general case for all timeline items below. } } - let html = text_preview_of_timeline_item( - timeline_item_content, - sender_user_id, - sender_username, - ).format_with(sender_username, true); + let html = + text_preview_of_timeline_item(timeline_item_content, sender_user_id, sender_username) + .format_with(sender_username, true); widget_out.show_html(cx, html); } - /// A trait for abstracting over the different types of timeline events /// that can be displayed in a `SmallStateEvent` widget. trait SmallStateEventContent { @@ -4320,7 +5441,9 @@ impl SmallStateEventContent for PollState { ) -> (WidgetRef, ItemDrawnStatus) { item.label(cx, ids!(content)).set_text( cx, - self.fallback_text().unwrap_or_else(|| self.results().question).as_str(), + self.fallback_text() + .unwrap_or_else(|| self.results().question) + .as_str(), ); new_drawn_status.content_drawn = true; (item, new_drawn_status) @@ -4389,20 +5512,15 @@ impl SmallStateEventContent for RoomMembershipChange { ) -> (WidgetRef, ItemDrawnStatus) { let Some(preview) = text_preview_of_room_membership_change(self, false) else { // Don't actually display anything for nonexistent/unimportant membership changes. - return ( - list.item(cx, item_id, id!(Empty)), - ItemDrawnStatus::new(), - ); + return (list.item(cx, item_id, id!(Empty)), ItemDrawnStatus::new()); }; item.label(cx, ids!(content)) .set_text(cx, &preview.format_with(username, false)); // The invite_user_button is only used for "Knocked" membership change events. - item.button(cx, ids!(invite_user_button)).set_visible( - cx, - matches!(self.change(), Some(MembershipChange::Knocked)), - ); + item.button(cx, ids!(invite_user_button)) + .set_visible(cx, matches!(self.change(), Some(MembershipChange::Knocked))); new_drawn_status.content_drawn = true; (item, new_drawn_status) @@ -4454,7 +5572,8 @@ fn populate_small_state_event( ); // Draw the timestamp as part of the profile. if let Some(dt) = unix_time_millis_to_datetime(event_tl_item.timestamp()) { - item.timestamp(cx, ids!(left_container.timestamp)).set_date_time(cx, dt); + item.timestamp(cx, ids!(left_container.timestamp)) + .set_date_time(cx, dt); } new_drawn_status.profile_drawn = profile_drawn; username @@ -4473,7 +5592,6 @@ fn populate_small_state_event( ) } - /// Returns the display name of the sender of the given `event_tl_item`, if available. fn get_profile_display_name(event_tl_item: &EventTimelineItem) -> Option { if let TimelineDetails::Ready(profile) = event_tl_item.sender_profile() { @@ -4483,7 +5601,6 @@ fn get_profile_display_name(event_tl_item: &EventTimelineItem) -> Option } } - /// Actions related to invites within a room. /// /// These are NOT widget actions, just regular actions. @@ -4518,7 +5635,6 @@ pub enum InviteResultAction { }, } - /// Actions related to a specific message within a room timeline. #[derive(Clone, Default, Debug)] pub enum MessageAction { @@ -4563,7 +5679,6 @@ pub enum MessageAction { // /// The user clicked the "report" button on a message. // Report(MessageDetails), - /// The message at the given item index in the timeline should be highlighted. HighlightMessage(usize), /// The user requested that we show a context menu with actions @@ -4583,6 +5698,8 @@ pub enum MessageAction { }, /// The user requested closing the message action bar ActionBarClose, + /// The user requested toggling the in-room app service quick actions card. + ToggleAppServiceActions, #[default] None, } @@ -4594,14 +5711,126 @@ impl ActionDefaultRef for MessageAction { } } +#[derive(Clone, Default, Debug)] +pub enum AppServicePanelAction { + Dismiss, + OpenCreateBotModal, + OpenDeleteBotModal, + SendListBots, + SendBotHelp, + Unbind, + #[default] + None, +} + +impl ActionDefaultRef for AppServicePanelAction { + fn default_ref() -> &'static Self { + static DEFAULT: AppServicePanelAction = AppServicePanelAction::None; + &DEFAULT + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct AppServicePanel { + #[deref] + view: View, +} + +impl Widget for AppServicePanel { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + + let room_screen_props = scope + .props + .get::() + .expect("BUG: RoomScreenProps should be available in Scope::props for AppServicePanel"); + + if let Event::Actions(actions) = event { + if self + .view + .button(cx, ids!(bubble.header.dismiss_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::Dismiss, + ); + } + + if self + .view + .button(cx, ids!(keyboard.first_row.create_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::OpenCreateBotModal, + ); + } + + if self + .view + .button(cx, ids!(keyboard.first_row.list_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::SendListBots, + ); + } + + if self + .view + .button(cx, ids!(keyboard.second_row.delete_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::OpenDeleteBotModal, + ); + } + + if self + .view + .button(cx, ids!(keyboard.second_row.help_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::SendBotHelp, + ); + } + + if self + .view + .button(cx, ids!(keyboard.third_row.unbind_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::Unbind, + ); + } + } + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + /// A widget representing a single message of any kind within a room timeline. #[derive(Script, ScriptHook, Widget, Animator)] pub struct Message { - #[source] source: ScriptObjectRef, - #[deref] view: View, - #[apply_default] animator: Animator, - - #[rust] details: Option, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, + #[apply_default] + animator: Animator, + + #[rust] + details: Option, } impl Widget for Message { @@ -4616,7 +5845,9 @@ impl Widget for Message { self.animator_play(cx, ids!(highlight.off)); } - let Some(details) = self.details.clone() else { return }; + let Some(details) = self.details.clone() else { + return; + }; // We first handle a click on the replied-to message preview, if present, // because we don't want any widgets within the replied-to message to be @@ -4625,31 +5856,31 @@ impl Widget for Message { Hit::FingerDown(fe) => { if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - } + }, ); } } Hit::FingerLongPress(lp) => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - } + }, ); } // If the hit occurred on the replied-to message preview, jump to it. Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::JumpToRelated(details.clone()), ); } - _ => { } + _ => {} } // Handle clicks on the thread summary shown beneath a thread-root message. @@ -4666,11 +5897,11 @@ impl Widget for Message { apply_hover(cx, COLOR_THREAD_SUMMARY_BG_HOVER); if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - } + }, ); } } @@ -4682,23 +5913,23 @@ impl Widget for Message { } Hit::FingerLongPress(lp) => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - } + }, ); } Hit::FingerUp(fe) => { apply_hover(cx, COLOR_THREAD_SUMMARY_BG); if fe.is_over && fe.is_primary_hit() && fe.was_tap() { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenThread(thread_root_event_id.clone()), ); } } - _ => { } + _ => {} } } @@ -4717,21 +5948,21 @@ impl Widget for Message { // A right click means we should display the context menu. if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - } + }, ); } } Hit::FingerLongPress(lp) => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - } + }, ); } Hit::FingerHoverIn(..) => { @@ -4742,12 +5973,16 @@ impl Widget for Message { self.animator_play(cx, ids!(hover.off)); // TODO: here, hide the "action bar" buttons upon hover-out } - _ => { } + _ => {} } if let Event::Actions(actions) = event { for action in actions { - match action.as_widget_action().widget_uid_eq(details.room_screen_widget_uid).cast_ref() { + match action + .as_widget_action() + .widget_uid_eq(details.room_screen_widget_uid) + .cast_ref() + { MessageAction::HighlightMessage(id) if id == &details.item_id => { self.animator_play(cx, ids!(highlight.on)); self.redraw(cx); @@ -4759,7 +5994,11 @@ impl Widget for Message { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - if self.details.as_ref().is_some_and(|d| d.should_be_highlighted) { + if self + .details + .as_ref() + .is_some_and(|d| d.should_be_highlighted) + { script_apply_eval!(cx, self, { draw_bg +: { color: #ffffd1, @@ -4780,7 +6019,9 @@ impl Message { impl MessageRef { fn set_data(&self, details: MessageDetails) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.set_data(details); } } @@ -4789,7 +6030,7 @@ impl MessageRef { /// /// This function requires passing in a reference to `Cx`, /// which isn't used, but acts as a guarantee that this function -/// must only be called by the main UI thread. +/// must only be called by the main UI thread. pub fn clear_timeline_states(_cx: &mut Cx) { // Clear timeline states cache TIMELINE_STATES.with_borrow_mut(|states| { diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 7cf7d5106..0b1ae8c77 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -16,30 +16,50 @@ //! so you can use it from other widgets or functions on the main UI thread //! that need to query basic info about a particular room or space. -use std::{cell::RefCell, collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, rc::Rc, sync::Arc}; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, + rc::Rc, + sync::Arc, +}; use crossbeam_queue::SegQueue; 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, OwnedRoomAliasId, OwnedRoomId, OwnedUserId}}; +use matrix_sdk::{ + RoomState, + ruma::{ + events::tag::Tags, MilliSecondsSinceUnixEpoch, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, + }, +}; use crate::{ app::{AppState, SelectedRoom}, home::{ - navigation_tab_bar::{NavigationBarAction, SelectedTab}, room_context_menu::RoomContextMenuDetails, rooms_list_entry::RoomsListEntryAction, space_lobby::{SpaceLobbyAction, SpaceLobbyEntryWidgetExt} + navigation_tab_bar::{NavigationBarAction, SelectedTab}, + room_context_menu::RoomContextMenuDetails, + rooms_list_entry::RoomsListEntryAction, + space_lobby::{SpaceLobbyAction, SpaceLobbyEntryWidgetExt}, }, room::{ FetchedRoomAvatar, - room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria, SortFn}, + room_display_filter::{ + RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria, SortFn, + }, }, shared::{ - collapsible_header::{CollapsibleHeaderAction, CollapsibleHeaderWidgetRefExt, HeaderCategory}, + collapsible_header::{ + CollapsibleHeaderAction, CollapsibleHeaderWidgetRefExt, HeaderCategory, + }, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction, }, - sliding_sync::{MatrixLinkAction, MatrixRequest, PaginationDirection, TimelineKind, submit_async_request}, - space_service_sync::{ParentChain, SpaceRequest, SpaceRoomListAction}, utils::{RoomNameId, VecDiff}, + sliding_sync::{ + MatrixLinkAction, MatrixRequest, PaginationDirection, TimelineKind, submit_async_request, + }, + space_service_sync::{ParentChain, SpaceRequest, SpaceRoomListAction}, + utils::{RoomNameId, VecDiff}, }; /// Whether to pre-paginate visible rooms at least once in order to @@ -71,11 +91,10 @@ pub fn get_invited_rooms(_cx: &mut Cx) -> Rc }, + LoadedRooms { max_rooms: Option }, /// Add a new room to the list of rooms the user has been invited to. /// This will be maintained and displayed separately from joined rooms. AddInvitedRoom(InvitedRoomInfo), @@ -171,9 +189,7 @@ pub enum RoomsListUpdate { unread_mentions: u64, }, /// Update the displayable name for the given room. - UpdateRoomName { - new_room_name: RoomNameId, - }, + UpdateRoomName { new_room_name: RoomNameId }, /// Update the avatar (image) for the given room. UpdateRoomAvatar { room_id: OwnedRoomId, @@ -196,21 +212,15 @@ pub enum RoomsListUpdate { new_tags: Tags, }, /// Update the status label at the bottom of the list of all rooms. - Status { - status: String, - }, + Status { status: String }, /// Mark the given room as tombstoned. - TombstonedRoom { - room_id: OwnedRoomId - }, + TombstonedRoom { room_id: OwnedRoomId }, /// Hide the given room from being displayed. /// /// This is useful for temporarily preventing a room from being shown, /// e.g., after a room has been left but before the homeserver has registered /// that we left it and removed it via the RoomListService. - HideRoom { - room_id: OwnedRoomId, - }, + HideRoom { room_id: OwnedRoomId }, /// Scroll to the given room. ScrollToRoom(OwnedRoomId), /// The background space service is now listening for requests, @@ -237,9 +247,7 @@ pub enum RoomsListAction { /// A new room was joined from an accepted invite, /// meaning that the existing `InviteScreen` should be converted /// to a `RoomScreen` to display the now-joined room. - InviteAccepted { - room_name_id: RoomNameId, - }, + InviteAccepted { room_name_id: RoomNameId }, /// Instructs the top-level app to show the context menu for the given room. /// /// Emitted by the RoomsList when the user right-clicks or long-presses @@ -259,7 +267,6 @@ impl ActionDefaultRef for RoomsListAction { } } - /// UI-related info about a joined room. /// /// This includes info needed display a preview of that room in the RoomsList @@ -298,7 +305,6 @@ pub struct JoinedRoomInfo { pub is_direct: bool, /// Whether this room is tombstoned (shut down and replaced with a successor room). pub is_tombstoned: bool, - // TODO: we could store the parent chain(s) of this room, i.e., which spaces // they are children of. One room can be in multiple spaces. } @@ -390,28 +396,34 @@ struct SpaceMapValue { #[derive(Script, Widget)] pub struct RoomsList { - #[deref] view: View, + #[deref] + view: View, /// The list of all rooms that the user has been invited to. /// /// This is a shared reference to the thread-local [`ALL_INVITED_ROOMS`] variable. - #[rust] invited_rooms: Rc>>, + #[rust] + invited_rooms: Rc>>, /// The set of all joined rooms and their cached info. /// This includes both direct rooms and regular rooms, but not invited rooms. - #[rust] all_joined_rooms: HashMap, + #[rust] + all_joined_rooms: HashMap, /// The list of all room IDs in display order, matching the order from the room list service. - #[rust] all_known_rooms_order: VecDeque, + #[rust] + all_known_rooms_order: VecDeque, /// 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. - #[rust] selected_space: Option, + #[rust] + selected_space: Option, /// The sender used to send Space-related requests to the background service. - #[rust] space_request_sender: Option>, + #[rust] + space_request_sender: Option>, /// A flattened map of all spaces known to the client. /// @@ -419,50 +431,66 @@ pub struct RoomsList { /// and nested subspaces *directly* within that space. /// /// This can include both joined and non-joined spaces. - #[rust] space_map: HashMap, + #[rust] + space_map: HashMap, /// Rooms that are explicitly hidden and should never be shown in the rooms list. - #[rust] hidden_rooms: HashSet, + #[rust] + hidden_rooms: HashSet, /// The currently-active filter function for the list of rooms. /// /// ## Important Notes /// 1. Do not use this directly. Instead, use the `should_display_room!()` macro. /// 2. This does *not* get auto-applied when it changes, for performance reasons. - #[rust] display_filter: RoomDisplayFilter, + #[rust] + display_filter: RoomDisplayFilter, /// The currently-active sort function for the list of rooms. - #[rust] sort_fn: Option>, + #[rust] + sort_fn: Option>, /// The list of invited rooms currently displayed in the UI. - #[rust] displayed_invited_rooms: Vec, - #[rust(false)] is_invited_rooms_header_expanded: bool, - #[rust] invited_rooms_indexes: RoomCategoryIndexes, + #[rust] + displayed_invited_rooms: Vec, + #[rust(false)] + is_invited_rooms_header_expanded: bool, + #[rust] + invited_rooms_indexes: RoomCategoryIndexes, /// The list of direct rooms currently displayed in the UI. - #[rust] displayed_direct_rooms: Vec, - #[rust(false)] is_direct_rooms_header_expanded: bool, - #[rust] direct_rooms_indexes: RoomCategoryIndexes, + #[rust] + displayed_direct_rooms: Vec, + #[rust(false)] + is_direct_rooms_header_expanded: bool, + #[rust] + direct_rooms_indexes: RoomCategoryIndexes, /// The list of regular (non-direct) joined rooms currently displayed in the UI. /// /// **Direct rooms are excluded** from this; they are in `displayed_direct_rooms`. - #[rust] displayed_regular_rooms: Vec, - #[rust(true)] is_regular_rooms_header_expanded: bool, - #[rust] regular_rooms_indexes: RoomCategoryIndexes, + #[rust] + displayed_regular_rooms: Vec, + #[rust(true)] + is_regular_rooms_header_expanded: bool, + #[rust] + regular_rooms_indexes: RoomCategoryIndexes, /// The latest status message that should be displayed in the bottom status label. - #[rust] status: String, + #[rust] + status: String, /// The currently-selected room. - #[rust] current_active_room: Option, + #[rust] + current_active_room: Option, /// The maximum number of rooms that will ever be loaded. /// /// This should not be used to determine whether all requested rooms have been loaded, /// because we will likely never receive this many rooms due to the room list service /// excluding rooms that we have filtered out (e.g., left or tombstoned rooms, spaces, etc). - #[rust] max_known_rooms: Option, + #[rust] + max_known_rooms: Option, // /// Whether the room list service has loaded all requested rooms from the homeserver. // #[rust] all_rooms_loaded: bool, } @@ -485,15 +513,16 @@ macro_rules! should_display_room { ($self:expr, $room_id:expr, $room:expr) => { !$self.hidden_rooms.contains($room_id) && ($self.display_filter)($room) - && $self.selected_space.as_ref() + && $self + .selected_space + .as_ref() .is_none_or(|space| $self.is_room_indirectly_in_space(space.room_id(), $room_id)) }; } - impl RoomsList { /// Returns whether the homeserver has finished syncing all of the rooms - /// that should be synced to our client based on the currently-specified room list filter. + /// that should be synced to our client based on the currently-specified room list filter. pub fn all_rooms_loaded(&self) -> bool { // TODO: fix this: figure out a way to determine if // all requested rooms have been received from the homeserver. @@ -522,7 +551,10 @@ impl RoomsList { RoomsListUpdate::AddInvitedRoom(invited_room) => { let room_id = invited_room.room_name_id.room_id().clone(); let should_display = should_display_room!(self, &room_id, &invited_room); - let _replaced = self.invited_rooms.borrow_mut().insert(room_id.clone(), invited_room); + let _replaced = self + .invited_rooms + .borrow_mut() + .insert(room_id.clone(), invited_room); if should_display { self.displayed_invited_rooms.push(room_id); } @@ -548,24 +580,29 @@ impl RoomsList { // 3. Emit an action to inform other widgets that the InviteScreen // displaying the invite to this room should be converted to a // RoomScreen displaying the now-joined room. - if let Some(_accepted_invite) = self.invited_rooms.borrow_mut().remove(&room_id) { + if let Some(_accepted_invite) = self.invited_rooms.borrow_mut().remove(&room_id) + { log!("Removed room {room_id} from the list of invited rooms"); - self.displayed_invited_rooms.iter() + self.displayed_invited_rooms + .iter() .position(|r| r == &room_id) .map(|index| self.displayed_invited_rooms.remove(index)); if let Some(room) = self.all_joined_rooms.get(&room_id) { cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::InviteAccepted { room_name_id: room.room_name_id.clone(), - } + }, ); } } self.update_status(); SignalToUI::set_ui_signal(); // signal the RoomScreen to update itself } - RoomsListUpdate::UpdateRoomAvatar { room_id, room_avatar } => { + RoomsListUpdate::UpdateRoomAvatar { + room_id, + room_avatar, + } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.room_avatar = room_avatar; } else if let Some(room) = self.invited_rooms.borrow_mut().get_mut(&room_id) { @@ -574,14 +611,23 @@ impl RoomsList { error!("Error: couldn't find room {room_id} to update avatar"); } } - RoomsListUpdate::UpdateLatestEvent { room_id, timestamp, latest_message_text } => { + RoomsListUpdate::UpdateLatestEvent { + room_id, + timestamp, + latest_message_text, + } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.latest = Some((timestamp, latest_message_text)); } else { error!("Error: couldn't find room {room_id} to update latest event"); } } - RoomsListUpdate::UpdateNumUnreadMessages { room_id, is_marked_unread, unread_messages, unread_mentions } => { + RoomsListUpdate::UpdateNumUnreadMessages { + room_id, + is_marked_unread, + unread_messages, + unread_mentions, + } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.num_unread_messages = match unread_messages { UnreadMessageCount::Unknown => 0, @@ -590,11 +636,13 @@ impl RoomsList { room.num_unread_mentions = unread_mentions; room.is_marked_unread = is_marked_unread; } else { - warning!("Warning: couldn't find room {} to update unread messages count", room_id); + warning!( + "Warning: couldn't find room {} to update unread messages count", + room_id + ); } } RoomsListUpdate::UpdateRoomName { new_room_name } => { - // TODO: broadcast a new AppState action to ensure that this room's or space's new name // gets updated in all of the `SelectedRoom` instances throughout Robrix, // e.g., the name of the room in the Dock Tab or the StackNav header. @@ -607,12 +655,16 @@ impl RoomsList { let should_display = should_display_room!(self, &room_id, room); let (pos_in_list, displayed_list) = if is_direct { ( - self.displayed_direct_rooms.iter().position(|r| r == &room_id), + self.displayed_direct_rooms + .iter() + .position(|r| r == &room_id), &mut self.displayed_direct_rooms, ) } else { ( - self.displayed_regular_rooms.iter().position(|r| r == &room_id), + self.displayed_regular_rooms + .iter() + .position(|r| r == &room_id), &mut self.displayed_regular_rooms, ) }; @@ -630,7 +682,9 @@ impl RoomsList { if let Some(invited_room) = invited_rooms.get_mut(&room_id) { invited_room.room_name_id = new_room_name; let should_display = should_display_room!(self, &room_id, invited_room); - let pos_in_list = self.displayed_invited_rooms.iter() + let pos_in_list = self + .displayed_invited_rooms + .iter() .position(|r| r == &room_id); if should_display { if pos_in_list.is_none() { @@ -640,7 +694,9 @@ impl RoomsList { pos_in_list.map(|i| self.displayed_invited_rooms.remove(i)); } } else { - warning!("Warning: couldn't find room {new_room_name} to update its name."); + warning!( + "Warning: couldn't find room {new_room_name} to update its name." + ); } } } @@ -651,7 +707,8 @@ impl RoomsList { continue; } enqueue_popup_notification( - format!("{} was changed from {} to {}.", + format!( + "{} was changed from {} to {}.", room.room_name_id, if room.is_direct { "direct" } else { "regular" }, if is_direct { "direct" } else { "regular" } @@ -666,7 +723,8 @@ impl RoomsList { } else { &mut self.displayed_regular_rooms }; - list_to_remove_from.iter() + list_to_remove_from + .iter() .position(|r| r == &room_id) .map(|index| list_to_remove_from.remove(index)); @@ -690,19 +748,23 @@ impl RoomsList { // and then options/buttons for the user to re-join it if desired. if let Some(removed) = self.all_joined_rooms.remove(&room_id) { - log!("Removed room {room_id} from the list of all joined rooms, now has state {new_state:?}"); + log!( + "Removed room {room_id} from the list of all joined rooms, now has state {new_state:?}" + ); let list_to_remove_from = if removed.is_direct { &mut self.displayed_direct_rooms } else { &mut self.displayed_regular_rooms }; - list_to_remove_from.iter() + list_to_remove_from + .iter() .position(|r| r == &room_id) .map(|index| list_to_remove_from.remove(index)); - } - else if let Some(_removed) = self.invited_rooms.borrow_mut().remove(&room_id) { + } else if let Some(_removed) = self.invited_rooms.borrow_mut().remove(&room_id) + { log!("Removed room {room_id} from the list of all invited rooms"); - self.displayed_invited_rooms.iter() + self.displayed_invited_rooms + .iter() .position(|r| r == &room_id) .map(|index| self.displayed_invited_rooms.remove(index)); } @@ -723,7 +785,7 @@ impl RoomsList { } RoomsListUpdate::LoadedRooms { max_rooms } => { self.max_known_rooms = max_rooms; - }, + } RoomsListUpdate::Tags { room_id, new_tags } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.tags = new_tags; @@ -743,12 +805,16 @@ impl RoomsList { let should_display = should_display_room!(self, &room_id, room); let (pos_in_list, displayed_list) = if is_direct { ( - self.displayed_direct_rooms.iter().position(|r| r == &room_id), + self.displayed_direct_rooms + .iter() + .position(|r| r == &room_id), &mut self.displayed_direct_rooms, ) } else { ( - self.displayed_regular_rooms.iter().position(|r| r == &room_id), + self.displayed_regular_rooms + .iter() + .position(|r| r == &room_id), &mut self.displayed_regular_rooms, ) }; @@ -760,20 +826,32 @@ impl RoomsList { pos_in_list.map(|i| displayed_list.remove(i)); } } else { - warning!("Warning: couldn't find room {room_id} to update the tombstone status"); + warning!( + "Warning: couldn't find room {room_id} to update the tombstone status" + ); } } RoomsListUpdate::HideRoom { room_id } => { self.hidden_rooms.insert(room_id.clone()); // Hiding a regular room is the most common case (e.g., after its successor is joined), // so we check that list first. - if let Some(i) = self.displayed_regular_rooms.iter().position(|r| r == &room_id) { + if let Some(i) = self + .displayed_regular_rooms + .iter() + .position(|r| r == &room_id) + { self.displayed_regular_rooms.remove(i); - } - else if let Some(i) = self.displayed_direct_rooms.iter().position(|r| r == &room_id) { + } else if let Some(i) = self + .displayed_direct_rooms + .iter() + .position(|r| r == &room_id) + { self.displayed_direct_rooms.remove(i); - } - else if let Some(i) = self.displayed_invited_rooms.iter().position(|r| r == &room_id) { + } else if let Some(i) = self + .displayed_invited_rooms + .iter() + .position(|r| r == &room_id) + { self.displayed_invited_rooms.remove(i); } } @@ -782,75 +860,89 @@ impl RoomsList { self.recalculate_indexes(); let portal_list = self.view.portal_list(cx, ids!(list)); let speed = 50.0; - let portal_list_index = if let Some(regular_index) = self.displayed_regular_rooms.iter().position(|r| r == &room_id) { + let portal_list_index = if let Some(regular_index) = self + .displayed_regular_rooms + .iter() + .position(|r| r == &room_id) + { self.regular_rooms_indexes.first_room_index + regular_index - } - else if let Some(direct_index) = self.displayed_direct_rooms.iter().position(|r| r == &room_id) { + } else if let Some(direct_index) = self + .displayed_direct_rooms + .iter() + .position(|r| r == &room_id) + { self.direct_rooms_indexes.first_room_index + direct_index - } - else if let Some(invited_index) = self.displayed_invited_rooms.iter().position(|r| r == &room_id) { + } else if let Some(invited_index) = self + .displayed_invited_rooms + .iter() + .position(|r| r == &room_id) + { self.invited_rooms_indexes.first_room_index + invited_index - } - else { continue }; + } else { + continue; + }; // 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)); + 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. + num_updates -= 1; // this does not require a redraw. } - RoomsListUpdate::RoomOrderUpdate(diff) => { - match diff { - VecDiff::Append { values } => { - self.all_known_rooms_order.extend(values); - needs_sort = true; - } - VecDiff::Clear => { - self.all_known_rooms_order.clear(); - needs_sort = true; - } - VecDiff::PushFront { value } => { - self.all_known_rooms_order.push_front(value); - needs_sort = true; - } - VecDiff::PushBack { value } => { - self.all_known_rooms_order.push_back(value); - needs_sort = true; - } - VecDiff::PopFront => { - self.all_known_rooms_order.pop_front(); - needs_sort = true; - } - VecDiff::PopBack => { - self.all_known_rooms_order.pop_back(); + RoomsListUpdate::RoomOrderUpdate(diff) => match diff { + VecDiff::Append { values } => { + self.all_known_rooms_order.extend(values); + needs_sort = true; + } + VecDiff::Clear => { + self.all_known_rooms_order.clear(); + needs_sort = true; + } + VecDiff::PushFront { value } => { + self.all_known_rooms_order.push_front(value); + needs_sort = true; + } + VecDiff::PushBack { value } => { + self.all_known_rooms_order.push_back(value); + needs_sort = true; + } + VecDiff::PopFront => { + self.all_known_rooms_order.pop_front(); + needs_sort = true; + } + VecDiff::PopBack => { + self.all_known_rooms_order.pop_back(); + needs_sort = true; + } + VecDiff::Insert { index, value } => { + if index <= self.all_known_rooms_order.len() { + self.all_known_rooms_order.insert(index, value); needs_sort = true; } - VecDiff::Insert { index, value } => { - if index <= self.all_known_rooms_order.len() { - self.all_known_rooms_order.insert(index, value); - needs_sort = true; - } - } - VecDiff::Set { index, value } => { - if let Some(existing) = self.all_known_rooms_order.get_mut(index) { - if *existing != value { - *existing = value; - needs_sort = true; - } - } - } - VecDiff::Remove { index } => { - if index < self.all_known_rooms_order.len() { - self.all_known_rooms_order.remove(index); + } + VecDiff::Set { index, value } => { + if let Some(existing) = self.all_known_rooms_order.get_mut(index) { + if *existing != value { + *existing = value; needs_sort = true; } } - VecDiff::Truncate { length } => { - self.all_known_rooms_order.truncate(length); + } + VecDiff::Remove { index } => { + if index < self.all_known_rooms_order.len() { + self.all_known_rooms_order.remove(index); needs_sort = true; } } - } + VecDiff::Truncate { length } => { + self.all_known_rooms_order.truncate(length); + needs_sort = true; + } + }, } } if needs_sort { @@ -875,9 +967,9 @@ impl RoomsList { + self.displayed_regular_rooms.len(); let mut text = match (self.display_filter.is_none(), num_rooms) { - (true, 0) => "No joined or invited rooms found".to_string(), - (true, 1) => "Loaded 1 room".to_string(), - (true, n) => format!("Loaded {n} rooms"), + (true, 0) => "No joined or invited rooms found".to_string(), + (true, 1) => "Loaded 1 room".to_string(), + (true, n) => format!("Loaded {n} rooms"), (false, 0) => "No matching rooms found".to_string(), (false, 1) => "Found 1 matching room".to_string(), (false, n) => format!("Found {n} matching rooms"), @@ -926,7 +1018,6 @@ impl RoomsList { self.redraw(cx); } - /// Generates a tuple of three kinds of displayed rooms (accounting for the current `display_filter`): /// 1. displayed_invited_rooms /// 2. displayed_regular_rooms @@ -934,7 +1025,7 @@ impl RoomsList { /// /// If `self.sort_fn` is `Some`, the rooms are ordered based on that function. /// Otherwise, the rooms are ordered based on `self.all_known_rooms_order` (the default). - fn generate_displayed_rooms(&self) -> (Vec,Vec, Vec) { + fn generate_displayed_rooms(&self) -> (Vec, Vec, Vec) { let mut new_displayed_invited_rooms = Vec::new(); let mut new_displayed_regular_rooms = Vec::new(); let mut new_displayed_direct_rooms = Vec::new(); @@ -952,7 +1043,9 @@ impl RoomsList { // If a sort function was provided, use it. if let Some(sort_fn) = self.sort_fn.as_deref() { - let mut filtered_joined_rooms = self.all_joined_rooms.iter() + let mut filtered_joined_rooms = self + .all_joined_rooms + .iter() .filter(|&(room_id, room)| should_display_room!(self, room_id, room)) .collect::>(); filtered_joined_rooms.sort_by(|(_, room_a), (_, room_b)| sort_fn(*room_a, *room_b)); @@ -960,7 +1053,8 @@ impl RoomsList { push_joined_room(room_id, jr) } - let mut filtered_invited_rooms = invited_rooms_ref.iter() + let mut filtered_invited_rooms = invited_rooms_ref + .iter() .filter(|&(room_id, room)| should_display_room!(self, room_id, room)) .collect::>(); filtered_invited_rooms.sort_by(|(_, room_a), (_, room_b)| sort_fn(*room_a, *room_b)); @@ -983,7 +1077,11 @@ impl RoomsList { } } - (new_displayed_invited_rooms, new_displayed_regular_rooms, new_displayed_direct_rooms) + ( + new_displayed_invited_rooms, + new_displayed_regular_rooms, + new_displayed_direct_rooms, + ) } /// Calculates the indexes in the PortalList where the headers and rooms should be drawn. @@ -996,35 +1094,35 @@ impl RoomsList { // Based on the various displayed room lists and is_expanded state of each room header, // calculate the indexes in the PortalList where the headers and rooms should be drawn. let should_show_invited_rooms_header = !self.displayed_invited_rooms.is_empty(); - let should_show_direct_rooms_header = !self.displayed_direct_rooms.is_empty(); + let should_show_direct_rooms_header = !self.displayed_direct_rooms.is_empty(); let should_show_regular_rooms_header = !self.displayed_regular_rooms.is_empty(); let index_of_invited_rooms_header = should_show_invited_rooms_header.then_some(0); let index_of_first_invited_room = should_show_invited_rooms_header as usize; - let index_after_invited_rooms = index_of_first_invited_room + - if self.is_invited_rooms_header_expanded { + let index_after_invited_rooms = index_of_first_invited_room + + if self.is_invited_rooms_header_expanded { self.displayed_invited_rooms.len() } else { 0 }; - let index_of_direct_rooms_header = should_show_direct_rooms_header - .then_some(index_after_invited_rooms); - let index_of_first_direct_room = index_after_invited_rooms + - should_show_direct_rooms_header as usize; - let index_after_direct_rooms = index_of_first_direct_room + - if self.is_direct_rooms_header_expanded { + let index_of_direct_rooms_header = + should_show_direct_rooms_header.then_some(index_after_invited_rooms); + let index_of_first_direct_room = + index_after_invited_rooms + should_show_direct_rooms_header as usize; + let index_after_direct_rooms = index_of_first_direct_room + + if self.is_direct_rooms_header_expanded { self.displayed_direct_rooms.len() } else { 0 }; - let index_of_regular_rooms_header = should_show_regular_rooms_header - .then_some(index_after_direct_rooms); - let index_of_first_regular_room = index_after_direct_rooms + - should_show_regular_rooms_header as usize; - let index_after_regular_rooms = index_of_first_regular_room + - if self.is_regular_rooms_header_expanded { + let index_of_regular_rooms_header = + should_show_regular_rooms_header.then_some(index_after_direct_rooms); + let index_of_first_regular_room = + index_after_direct_rooms + should_show_regular_rooms_header as usize; + let index_after_regular_rooms = index_of_first_regular_room + + if self.is_regular_rooms_header_expanded { self.displayed_regular_rooms.len() } else { 0 @@ -1050,32 +1148,43 @@ impl RoomsList { /// Handle any incoming updates to spaces' room lists and pagination state. fn handle_space_room_list_action(&mut self, cx: &mut Cx, action: &SpaceRoomListAction) { match action { - SpaceRoomListAction::UpdatedChildren { space_id, parent_chain, direct_child_rooms, direct_subspaces } => { + SpaceRoomListAction::UpdatedChildren { + space_id, + parent_chain, + direct_child_rooms, + direct_subspaces, + } => { match self.space_map.entry(space_id.clone()) { Entry::Occupied(mut occ) => { let occ_mut = occ.get_mut(); occ_mut.parent_chain = parent_chain.clone(); occ_mut.direct_child_rooms = Arc::clone(direct_child_rooms); - occ_mut.direct_subspaces = Arc::clone(direct_subspaces); + occ_mut.direct_subspaces = Arc::clone(direct_subspaces); } Entry::Vacant(vac) => { vac.insert_entry(SpaceMapValue { is_fully_paginated: false, parent_chain: parent_chain.clone(), direct_child_rooms: Arc::clone(direct_child_rooms), - direct_subspaces: Arc::clone(direct_subspaces), + direct_subspaces: Arc::clone(direct_subspaces), }); } } - if self.selected_space.as_ref().is_some_and(|sel_space| - sel_space.room_id() == space_id - || parent_chain.contains(sel_space.room_id()) - ) { + if self.selected_space.as_ref().is_some_and(|sel_space| { + sel_space.room_id() == space_id || parent_chain.contains(sel_space.room_id()) + }) { self.update_displayed_rooms(cx, false); } } - SpaceRoomListAction::PaginationState { space_id, parent_chain, state } => { - let is_fully_paginated = matches!(state, SpaceRoomListPaginationState::Idle { end_reached: true }); + SpaceRoomListAction::PaginationState { + space_id, + parent_chain, + state, + } => { + let is_fully_paginated = matches!( + state, + SpaceRoomListPaginationState::Idle { end_reached: true } + ); // Only re-fetch the list of rooms in this space if it was not already fully paginated. let should_fetch_rooms: bool; match self.space_map.entry(space_id.clone()) { @@ -1094,15 +1203,22 @@ impl RoomsList { } } let Some(sender) = self.space_request_sender.as_ref() else { - error!("BUG: RoomsList: no space request sender was available after pagination state update."); + error!( + "BUG: RoomsList: no space request sender was available after pagination state update." + ); return; }; if should_fetch_rooms { - if sender.send(SpaceRequest::GetChildren { - space_id: space_id.clone(), - parent_chain: parent_chain.clone(), - }).is_err() { - error!("BUG: RoomsList: failed to send GetRooms request for space {space_id}."); + if sender + .send(SpaceRequest::GetChildren { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + }) + .is_err() + { + error!( + "BUG: RoomsList: failed to send GetRooms request for space {space_id}." + ); } } @@ -1112,11 +1228,16 @@ impl RoomsList { // all of its children, such that we can see if any of them are subspaces, // and then we'll paginate those as well. if !is_fully_paginated { - if sender.send(SpaceRequest::PaginateSpaceRoomList { - space_id: space_id.clone(), - parent_chain: parent_chain.clone(), - }).is_err() { - error!("BUG: RoomsList: failed to send pagination request for space {space_id}."); + if sender + .send(SpaceRequest::PaginateSpaceRoomList { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + }) + .is_err() + { + error!( + "BUG: RoomsList: failed to send pagination request for space {space_id}." + ); } } } @@ -1128,7 +1249,10 @@ impl RoomsList { None, ); } - SpaceRoomListAction::LeaveSpaceResult { space_name_id, result } => match result { + SpaceRoomListAction::LeaveSpaceResult { + space_name_id, + result, + } => match result { Ok(()) => { enqueue_popup_notification( format!("Successfully left space \"{}\".", space_name_id), @@ -1136,7 +1260,11 @@ impl RoomsList { Some(4.0), ); // If the space we left was the currently-selected one, go back to the main Home view. - if self.selected_space.as_ref().is_some_and(|s| s.room_id() == space_name_id.room_id()) { + if self + .selected_space + .as_ref() + .is_some_and(|s| s.room_id() == space_name_id.room_id()) + { cx.action(NavigationBarAction::GoToHome); } } @@ -1151,14 +1279,18 @@ impl RoomsList { }, // Details-related space actions are handled by SpaceLobbyScreen, not RoomsList. SpaceRoomListAction::DetailedChildren { .. } - | SpaceRoomListAction::TopLevelSpaceDetails(_) => { } + | SpaceRoomListAction::TopLevelSpaceDetails(_) => {} } } /// Returns whether the given target room or space is indirectly within the given parent space. /// /// This will recursively search all nested spaces within the given `parent_space`. - fn is_room_indirectly_in_space(&self, parent_space: &OwnedRoomId, target: &OwnedRoomId) -> bool { + fn is_room_indirectly_in_space( + &self, + parent_space: &OwnedRoomId, + target: &OwnedRoomId, + ) -> bool { if let Some(smv) = self.space_map.get(parent_space) { if smv.direct_child_rooms.contains(target) { return true; @@ -1186,12 +1318,14 @@ impl Widget for RoomsList { let props = RoomsListScopeProps { was_scrolling: self.view.portal_list(cx, ids!(list)).was_scrolling(), }; - let rooms_list_actions = cx.capture_actions( - |cx| self.view.handle_event(cx, event, &mut Scope::with_props(&props)) - ); + let rooms_list_actions = cx.capture_actions(|cx| { + self.view + .handle_event(cx, event, &mut Scope::with_props(&props)) + }); for action in rooms_list_actions { // Handle a regular room (joined or invited) being clicked. - if let RoomsListEntryAction::PrimaryClicked(room_id) = action.as_widget_action().cast() { + if let RoomsListEntryAction::PrimaryClicked(room_id) = action.as_widget_action().cast() + { let new_selected_room = if let Some(jr) = self.all_joined_rooms.get(&room_id) { SelectedRoom::JoinedRoom { room_name_id: jr.room_name_id.clone(), @@ -1207,48 +1341,59 @@ impl Widget for RoomsList { self.current_active_room = Some(new_selected_room.clone()); cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::Selected(new_selected_room), ); self.redraw(cx); } // Handle a room being right-clicked or long-pressed by opening the room context menu. - else if let RoomsListEntryAction::SecondaryClicked(room_id, pos) = action.as_widget_action().cast() { + else if let RoomsListEntryAction::SecondaryClicked(room_id, pos) = + action.as_widget_action().cast() + { // Determine details for the context menu let Some(jr) = self.all_joined_rooms.get(&room_id) else { error!("BUG: couldn't find right-clicked room details for room {room_id}"); continue; }; + let app_state = scope.data.get::().unwrap(); let details = RoomContextMenuDetails { room_name_id: jr.room_name_id.clone(), is_favorite: jr.tags.contains_key(&TagName::Favorite), is_low_priority: jr.tags.contains_key(&TagName::LowPriority), is_marked_unread: jr.is_marked_unread, + app_service_enabled: app_state.bot_settings.enabled, + is_bot_bound: app_state.bot_settings.is_room_bound(&room_id), }; cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::OpenRoomContextMenu { details, pos }, ); } // Handle the space lobby being clicked. else if let Some(SpaceLobbyAction::SpaceLobbyEntryClicked) = action.downcast_ref() { - let Some(space_name_id) = self.selected_space.clone() else { continue }; + let Some(space_name_id) = self.selected_space.clone() else { + continue; + }; let new_selected_space = SelectedRoom::Space { space_name_id }; self.current_active_room = Some(new_selected_space.clone()); cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::Selected(new_selected_space), ); self.redraw(cx); } // Handle a collapsible header being clicked. - else if let CollapsibleHeaderAction::Toggled { category } = 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; + self.is_invited_rooms_header_expanded = + !self.is_invited_rooms_header_expanded; } HeaderCategory::RegularRooms => { - self.is_regular_rooms_header_expanded = !self.is_regular_rooms_header_expanded; + self.is_regular_rooms_header_expanded = + !self.is_regular_rooms_header_expanded; } HeaderCategory::DirectRooms => { self.is_direct_rooms_header_expanded = @@ -1273,47 +1418,73 @@ impl Widget for RoomsList { if let Some(NavigationBarAction::TabSelected(tab)) = action.downcast_ref() { match tab { SelectedTab::Space { space_name_id } => { - if self.selected_space.as_ref().is_some_and(|s| s.room_id() == space_name_id.room_id()) { + if self + .selected_space + .as_ref() + .is_some_and(|s| s.room_id() == space_name_id.room_id()) + { continue; } self.selected_space = Some(space_name_id.clone()); - self.view.space_lobby_entry(cx, ids!(space_lobby_entry)).set_visible(cx, true); + self.view + .space_lobby_entry(cx, ids!(space_lobby_entry)) + .set_visible(cx, true); // If we don't have the full list of children in this newly-selected space, then fetch it. - let (is_fully_paginated, parent_chain) = self.space_map + let (is_fully_paginated, parent_chain) = self + .space_map .get(space_name_id.room_id()) .map(|smv| (smv.is_fully_paginated, smv.parent_chain.clone())) .unwrap_or_default(); if !is_fully_paginated { let Some(sender) = self.space_request_sender.as_ref() else { - error!("BUG: RoomsList: no space request sender was available."); + error!( + "BUG: RoomsList: no space request sender was available." + ); continue; }; - if sender.send(SpaceRequest::SubscribeToSpaceRoomList { - space_id: space_name_id.room_id().clone(), - parent_chain: parent_chain.clone(), - }).is_err() { - error!("BUG: RoomsList: failed to send SubscribeToSpaceRoomList request for space {space_name_id}."); + if sender + .send(SpaceRequest::SubscribeToSpaceRoomList { + space_id: space_name_id.room_id().clone(), + parent_chain: parent_chain.clone(), + }) + .is_err() + { + error!( + "BUG: RoomsList: failed to send SubscribeToSpaceRoomList request for space {space_name_id}." + ); } - if sender.send(SpaceRequest::PaginateSpaceRoomList { - space_id: space_name_id.room_id().clone(), - parent_chain: parent_chain.clone(), - }).is_err() { - error!("BUG: RoomsList: failed to send PaginateSpaceRoomList request for space {space_name_id}."); + if sender + .send(SpaceRequest::PaginateSpaceRoomList { + space_id: space_name_id.room_id().clone(), + parent_chain: parent_chain.clone(), + }) + .is_err() + { + error!( + "BUG: RoomsList: failed to send PaginateSpaceRoomList request for space {space_name_id}." + ); } - if sender.send(SpaceRequest::GetChildren { - space_id: space_name_id.room_id().clone(), - parent_chain, - }).is_err() { - error!("BUG: RoomsList: failed to send GetRooms request for space {space_name_id}."); + if sender + .send(SpaceRequest::GetChildren { + space_id: space_name_id.room_id().clone(), + parent_chain, + }) + .is_err() + { + error!( + "BUG: RoomsList: failed to send GetRooms request for space {space_name_id}." + ); } } } _ => { self.selected_space = None; - self.view.space_lobby_entry(cx, ids!(space_lobby_entry)).set_visible(cx, false); + self.view + .space_lobby_entry(cx, ids!(space_lobby_entry)) + .set_visible(cx, false); } } @@ -1372,25 +1543,31 @@ impl Widget for RoomsList { let total_count = status_label_id + 1; let get_invited_room_id = |portal_list_index: usize| { - portal_list_index.checked_sub(self.invited_rooms_indexes.first_room_index) - .and_then(|index| self.is_invited_rooms_header_expanded - .then(|| self.displayed_invited_rooms.get(index)) - ) + portal_list_index + .checked_sub(self.invited_rooms_indexes.first_room_index) + .and_then(|index| { + self.is_invited_rooms_header_expanded + .then(|| self.displayed_invited_rooms.get(index)) + }) .flatten() }; let get_direct_room_id = |portal_list_index: usize| { - portal_list_index.checked_sub(self.direct_rooms_indexes.first_room_index) - .and_then(|index| self.is_direct_rooms_header_expanded - .then(|| self.displayed_direct_rooms.get(index)) - ) + portal_list_index + .checked_sub(self.direct_rooms_indexes.first_room_index) + .and_then(|index| { + self.is_direct_rooms_header_expanded + .then(|| self.displayed_direct_rooms.get(index)) + }) .flatten() }; let get_regular_room_id = |portal_list_index: usize| { - portal_list_index.checked_sub(self.regular_rooms_indexes.first_room_index) - .and_then(|index| self.is_regular_rooms_header_expanded - .then(|| self.displayed_regular_rooms.get(index)) - ) + portal_list_index + .checked_sub(self.regular_rooms_indexes.first_room_index) + .and_then(|index| { + self.is_regular_rooms_header_expanded + .then(|| self.displayed_regular_rooms.get(index)) + }) .flatten() }; @@ -1402,7 +1579,9 @@ impl Widget for RoomsList { portal_list_ref.set_first_id_and_scroll(status_label_id, 0.0); } // We only care about drawing the portal list. - let Some(mut list) = portal_list_ref.borrow_mut() else { continue }; + let Some(mut list) = portal_list_ref.borrow_mut() else { + continue; + }; list.set_item_range(cx, 0, total_count); @@ -1418,12 +1597,13 @@ impl Widget for RoomsList { self.displayed_invited_rooms.len() as u64, ); item.draw_all(cx, &mut scope); - } - else if let Some(invited_room_id) = get_invited_room_id(portal_list_index) { + } else if let Some(invited_room_id) = get_invited_room_id(portal_list_index) { let mut invited_rooms_mut = self.invited_rooms.borrow_mut(); if let Some(invited_room) = invited_rooms_mut.get_mut(invited_room_id) { let item = list.item(cx, portal_list_index, id!(rooms_list_entry)); - invited_room.is_selected = self.current_active_room.as_ref() + invited_room.is_selected = self + .current_active_room + .as_ref() .is_some_and(|sel_room| sel_room.room_id() == invited_room_id); // Pass the room info down to the RoomsListEntry widget via Scope. scope = Scope::with_props(&*invited_room); @@ -1432,8 +1612,7 @@ impl Widget for RoomsList { list.item(cx, portal_list_index, id!(empty)) .draw_all(cx, &mut scope); } - } - else if self.direct_rooms_indexes.header_index == Some(portal_list_index) { + } else if self.direct_rooms_indexes.header_index == Some(portal_list_index) { let item = list.item(cx, portal_list_index, id!(collapsible_header)); item.as_collapsible_header().set_details( cx, @@ -1444,11 +1623,12 @@ impl Widget for RoomsList { // NOTE: this might be really slow, so we should maintain a running total of mentions in this struct ); item.draw_all(cx, &mut scope); - } - else if let Some(direct_room_id) = get_direct_room_id(portal_list_index) { + } else if let Some(direct_room_id) = get_direct_room_id(portal_list_index) { if let Some(direct_room) = self.all_joined_rooms.get_mut(direct_room_id) { let item = list.item(cx, portal_list_index, id!(rooms_list_entry)); - direct_room.is_selected = self.current_active_room.as_ref() + direct_room.is_selected = self + .current_active_room + .as_ref() .is_some_and(|sel_room| sel_room.room_id() == direct_room_id); // Paginate the room if it hasn't been paginated yet. @@ -1469,8 +1649,7 @@ impl Widget for RoomsList { list.item(cx, portal_list_index, id!(empty)) .draw_all(cx, &mut scope); } - } - else if self.regular_rooms_indexes.header_index == Some(portal_list_index) { + } else if self.regular_rooms_indexes.header_index == Some(portal_list_index) { let item = list.item(cx, portal_list_index, id!(collapsible_header)); item.as_collapsible_header().set_details( cx, @@ -1481,11 +1660,12 @@ impl Widget for RoomsList { // NOTE: this might be really slow, so we should maintain a running total of mentions in this struct ); item.draw_all(cx, &mut scope); - } - else if let Some(regular_room_id) = get_regular_room_id(portal_list_index) { + } else if let Some(regular_room_id) = get_regular_room_id(portal_list_index) { if let Some(regular_room) = self.all_joined_rooms.get_mut(regular_room_id) { let item = list.item(cx, portal_list_index, id!(rooms_list_entry)); - regular_room.is_selected = self.current_active_room.as_ref() + regular_room.is_selected = self + .current_active_room + .as_ref() .is_some_and(|sel_room| sel_room.room_id() == regular_room_id); // Paginate the room if it hasn't been paginated yet. @@ -1503,7 +1683,8 @@ impl Widget for RoomsList { scope = Scope::with_props(&*regular_room); item.draw_all(cx, &mut scope); } else { - list.item(cx, portal_list_index, id!(empty)).draw_all(cx, &mut scope); + list.item(cx, portal_list_index, id!(empty)) + .draw_all(cx, &mut scope); } } // Draw the status label as the bottom entry. @@ -1527,7 +1708,9 @@ impl Widget for RoomsList { impl RoomsListRef { /// See [`RoomsList::all_rooms_loaded()`]. pub fn all_rooms_loaded(&self) -> bool { - let Some(inner) = self.borrow() else { return false; }; + let Some(inner) = self.borrow() else { + return false; + }; inner.all_rooms_loaded() } @@ -1544,14 +1727,17 @@ impl RoomsListRef { /// Returns the name of the given room, if it is known and loaded. pub fn get_room_name(&self, room_id: &OwnedRoomId) -> Option { let inner = self.borrow()?; - inner.all_joined_rooms + inner + .all_joined_rooms .get(room_id) .map(|jr| jr.room_name_id.clone()) - .or_else(|| - inner.invited_rooms.borrow() + .or_else(|| { + inner + .invited_rooms + .borrow() .get(room_id) .map(|ir| ir.room_name_id.clone()) - ) + }) } /// Returns the currently-selected space (the one selected in the SpacesBar). @@ -1561,7 +1747,10 @@ impl RoomsListRef { /// Same as [`Self::get_selected_space()`], but only returns the space ID. pub fn get_selected_space_id(&self) -> Option { - self.borrow()?.selected_space.as_ref().map(|ss| ss.room_id().clone()) + self.borrow()? + .selected_space + .as_ref() + .map(|ss| ss.room_id().clone()) } /// Returns a clone of the space request sender channel, if available. diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index 93b8d4a9d..292ebc23b 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -15,12 +15,33 @@ //! * A "cannot-send-message" notice, which is shown if the user cannot send messages to the room. //! - use makepad_widgets::*; use matrix_sdk::room::reply::{EnforceThread, Reply}; use matrix_sdk_ui::timeline::{EmbeddedEvent, EventTimelineItem, TimelineEventItemId}; -use ruma::{events::room::message::{LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent}, OwnedRoomId}; -use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}}, location::init_location_subscriber, shared::{avatar::AvatarWidgetRefExt, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; +use ruma::{ + events::room::message::{ + LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent, + }, + OwnedRoomId, +}; +use crate::{ + home::{ + editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, + location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, + room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, + tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}, + }, + location::init_location_subscriber, + shared::{ + avatar::AvatarWidgetRefExt, + html_or_plaintext::HtmlOrPlaintextWidgetRefExt, + mentionable_text_input::MentionableTextInputWidgetExt, + popup_list::{PopupKind, enqueue_popup_notification}, + styles::*, + }, + sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, + utils, +}; script_mod! { use mod.prelude.widgets.* @@ -161,14 +182,18 @@ script_mod! { /// Main component for message input with @mention support #[derive(Script, ScriptHook, Widget)] pub struct RoomInputBar { - #[source] source: ScriptObjectRef, - #[deref] view: View, + #[source] + source: ScriptObjectRef, + #[deref] + view: View, /// Whether the `ReplyingPreview` was visible when the `EditingPane` was shown. /// If true, when the `EditingPane` gets hidden, we need to re-show the `ReplyingPreview`. - #[rust] was_replying_preview_visible: bool, + #[rust] + was_replying_preview_visible: bool, /// Info about the message event that the user is currently replying to, if any. - #[rust] replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, + #[rust] + replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, } impl Widget for RoomInputBar { @@ -178,14 +203,21 @@ impl Widget for RoomInputBar { .get::() .expect("BUG: RoomScreenProps should be available in Scope::props for RoomInputBar"); - match event.hits(cx, self.view.view(cx, ids!(replying_preview.reply_preview_content)).area()) { + match event.hits( + cx, + self.view + .view(cx, ids!(replying_preview.reply_preview_content)) + .area(), + ) { // If the hit occurred on the replying message preview, jump to it. Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { - if let Some(event_id) = self.replying_to.as_ref() + if let Some(event_id) = self + .replying_to + .as_ref() .and_then(|(event_tl_item, _)| event_tl_item.event_id().map(ToOwned::to_owned)) { cx.widget_action( - room_screen_props.room_screen_widget_uid, + room_screen_props.room_screen_widget_uid, MessageAction::JumpToEvent(event_id), ); } else { @@ -241,40 +273,56 @@ impl RoomInputBar { None, ); } - self.view.location_preview(cx, ids!(location_preview)).show(); + self.view + .location_preview(cx, ids!(location_preview)) + .show(); self.redraw(cx); } // Handle the send location button being clicked. - if self.button(cx, ids!(location_preview.send_location_button)).clicked(actions) { + if self + .button(cx, ids!(location_preview.send_location_button)) + .clicked(actions) + { let location_preview = self.location_preview(cx, ids!(location_preview)); if let Some((coords, _system_time_opt)) = location_preview.get_current_data() { - let geo_uri = format!("{}{},{}", utils::GEO_URI_SCHEME, coords.latitude, coords.longitude); - let message = RoomMessageEventContent::new( - MessageType::Location( - LocationMessageEventContent::new(geo_uri.clone(), geo_uri) - ) + let geo_uri = format!( + "{}{},{}", + utils::GEO_URI_SCHEME, + coords.latitude, + coords.longitude ); - let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| - event_tl_item.event_id().map(|event_id| { - let enforce_thread = if room_screen_props.timeline_kind.thread_root_event_id().is_some() { - EnforceThread::Threaded(ReplyWithinThread::Yes) - } else { - EnforceThread::MaybeThreaded - }; - Reply { - event_id: event_id.to_owned(), - enforce_thread, - } + let message = RoomMessageEventContent::new(MessageType::Location( + LocationMessageEventContent::new(geo_uri.clone(), geo_uri), + )); + let replied_to = self + .replying_to + .take() + .and_then(|(event_tl_item, _emb)| { + event_tl_item.event_id().map(|event_id| { + let enforce_thread = if room_screen_props + .timeline_kind + .thread_root_event_id() + .is_some() + { + EnforceThread::Threaded(ReplyWithinThread::Yes) + } else { + EnforceThread::MaybeThreaded + }; + Reply { + event_id: event_id.to_owned(), + enforce_thread, + } + }) }) - ).or_else(|| - room_screen_props.timeline_kind.thread_root_event_id().map(|thread_root_event_id| - Reply { - event_id: thread_root_event_id.clone(), - enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), - } - ) - ); + .or_else(|| { + room_screen_props.timeline_kind.thread_root_event_id().map( + |thread_root_event_id| Reply { + event_id: thread_root_event_id.clone(), + enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), + }, + ) + }); submit_async_request(MatrixRequest::SendMessage { timeline_kind: room_screen_props.timeline_kind.clone(), message, @@ -291,31 +339,53 @@ impl RoomInputBar { // Handle the send message button being clicked or Cmd/Ctrl + Return being pressed. if self.button(cx, ids!(send_message_button)).clicked(actions) - || text_input.returned(actions).is_some_and(|(_, m)| m.is_primary()) + || text_input + .returned(actions) + .is_some_and(|(_, m)| m.is_primary()) { let entered_text = mentionable_text_input.text().trim().to_string(); if !entered_text.is_empty() { + if self.try_handle_bot_shortcut(cx, &entered_text, room_screen_props) { + self.clear_replying_to(cx); + mentionable_text_input.set_text(cx, ""); + submit_async_request(MatrixRequest::SendTypingNotice { + room_id: room_screen_props.timeline_kind.room_id().clone(), + typing: false, + }); + self.enable_send_message_button(cx, false); + self.redraw(cx); + return; + } + let message = mentionable_text_input.create_message_with_mentions(&entered_text); - let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| - event_tl_item.event_id().map(|event_id| { - let enforce_thread = if room_screen_props.timeline_kind.thread_root_event_id().is_some() { - EnforceThread::Threaded(ReplyWithinThread::Yes) - } else { - EnforceThread::MaybeThreaded - }; - Reply { - event_id: event_id.to_owned(), - enforce_thread, - } + let replied_to = self + .replying_to + .take() + .and_then(|(event_tl_item, _emb)| { + event_tl_item.event_id().map(|event_id| { + let enforce_thread = if room_screen_props + .timeline_kind + .thread_root_event_id() + .is_some() + { + EnforceThread::Threaded(ReplyWithinThread::Yes) + } else { + EnforceThread::MaybeThreaded + }; + Reply { + event_id: event_id.to_owned(), + enforce_thread, + } + }) }) - ).or_else(|| - room_screen_props.timeline_kind.thread_root_event_id().map(|thread_root_event_id| - Reply { - event_id: thread_root_event_id.clone(), - enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), - } - ) - ); + .or_else(|| { + room_screen_props.timeline_kind.thread_root_event_id().map( + |thread_root_event_id| Reply { + event_id: thread_root_event_id.clone(), + enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), + }, + ) + }); submit_async_request(MatrixRequest::SendMessage { timeline_kind: room_screen_props.timeline_kind.clone(), message, @@ -349,18 +419,29 @@ impl RoomInputBar { if is_text_input_empty { if let Some(KeyEvent { key_code: KeyCode::ArrowUp, - modifiers: KeyModifiers { shift: false, control: false, alt: false, logo: false }, + modifiers: + KeyModifiers { + shift: false, + control: false, + alt: false, + logo: false, + }, .. - }) = text_input.key_down_unhandled(actions) { + }) = text_input.key_down_unhandled(actions) + { cx.widget_action( - room_screen_props.room_screen_widget_uid, + room_screen_props.room_screen_widget_uid, MessageAction::EditLatest, ); } } // If the EditingPane has been hidden, handle that. - if self.view.editing_pane(cx, ids!(editing_pane)).was_hidden(actions) { + if self + .view + .editing_pane(cx, ids!(editing_pane)) + .was_hidden(actions) + { self.on_editing_pane_hidden(cx); } } @@ -408,13 +489,15 @@ impl RoomInputBar { // 2. Hide other views that are irrelevant to a reply, e.g., // the `EditingPane` would improperly cover up the ReplyPreview. - self.editing_pane(cx, ids!(editing_pane)).force_reset_hide(cx); + self.editing_pane(cx, ids!(editing_pane)) + .force_reset_hide(cx); self.on_editing_pane_hidden(cx); // 3. Automatically focus the keyboard on the message input box // so that the user can immediately start typing their reply // without having to manually click on the message input box. if grab_key_focus { - self.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)).set_key_focus(cx); + self.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)) + .set_key_focus(cx); } self.button(cx, ids!(cancel_reply_button)).reset_hover(cx); self.redraw(cx); @@ -444,7 +527,9 @@ impl RoomInputBar { let replying_preview = self.view.view(cx, ids!(replying_preview)); self.was_replying_preview_visible = replying_preview.visible(); replying_preview.set_visible(cx, false); - self.view.location_preview(cx, ids!(location_preview)).clear(); + self.view + .location_preview(cx, ids!(location_preview)) + .clear(); let editing_pane = self.view.editing_pane(cx, ids!(editing_pane)); match behavior { @@ -466,12 +551,14 @@ impl RoomInputBar { // Same goes for the replying_preview, if it was previously shown. self.view.view(cx, ids!(input_bar)).set_visible(cx, true); if self.was_replying_preview_visible && self.replying_to.is_some() { - self.view.view(cx, ids!(replying_preview)).set_visible(cx, true); + self.view + .view(cx, ids!(replying_preview)) + .set_visible(cx, true); } self.redraw(cx); // We don't need to do anything with the editing pane itself here, // because it has already been hidden by the time this function gets called. - } + } /// Updates (populates and shows or hides) this room's tombstone footer /// based on the given successor room details. @@ -489,7 +576,10 @@ impl RoomInputBar { input_bar.set_visible(cx, false); } else { tombstone_footer.hide(cx); - if !self.editing_pane(cx, ids!(editing_pane)).is_currently_shown(cx) { + if !self + .editing_pane(cx, ids!(editing_pane)) + .is_currently_shown(cx) + { input_bar.set_visible(cx, true); } } @@ -512,17 +602,69 @@ impl RoomInputBar { }); } + /// Intercepts `/bot` commands and opens the room-level app service actions UI instead + /// of sending the raw command text into the room. + fn try_handle_bot_shortcut( + &mut self, + cx: &mut Cx, + entered_text: &str, + room_screen_props: &RoomScreenProps, + ) -> bool { + if !(entered_text == "/bot" || entered_text.starts_with("/bot ")) { + return false; + } + + let popup_message = if room_screen_props + .timeline_kind + .thread_root_event_id() + .is_some() + { + Some(( + "Bot commands are only supported in the main room timeline.", + PopupKind::Warning, + )) + } else if entered_text != "/bot" { + Some(( + "Only `/bot` is supported right now. Use `/bot` and choose an action from the room panel.", + PopupKind::Info, + )) + } else if !room_screen_props.app_service_enabled { + Some(( + "Enable App Service in Settings before using /bot.", + PopupKind::Warning, + )) + } else if !room_screen_props.app_service_room_bound { + Some(( + "Bind BotFather to this room before using /bot.", + PopupKind::Warning, + )) + } else { + None + }; + + if let Some((message, kind)) = popup_message { + enqueue_popup_notification(message, kind, Some(4.0)); + } else { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + MessageAction::ToggleAppServiceActions, + ); + } + + true + } + /// Updates the visibility of select views based on the user's new power levels. /// /// This will show/hide the `input_bar` and the `can_not_send_message_notice` views. - fn update_user_power_levels( - &mut self, - cx: &mut Cx, - user_power_levels: UserPowerLevels, - ) { + fn update_user_power_levels(&mut self, cx: &mut Cx, user_power_levels: UserPowerLevels) { let can_send = user_power_levels.can_send_message(); - self.view.view(cx, ids!(input_bar)).set_visible(cx, can_send); - self.view.view(cx, ids!(can_not_send_message_notice)).set_visible(cx, !can_send); + self.view + .view(cx, ids!(input_bar)) + .set_visible(cx, can_send); + self.view + .view(cx, ids!(can_not_send_message_notice)) + .set_visible(cx, !can_send); } /// Returns true if the TSP signing checkbox is checked, false otherwise. @@ -543,7 +685,9 @@ impl RoomInputBarRef { replying_to: (EventTimelineItem, EmbeddedEvent), timeline_kind: &TimelineKind, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show_replying_to(cx, replying_to, timeline_kind, true); } @@ -554,7 +698,9 @@ impl RoomInputBarRef { event_tl_item: EventTimelineItem, timeline_kind: TimelineKind, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.show_editing_pane( cx, ShowEditingPaneBehavior::ShowNew { event_tl_item }, @@ -565,12 +711,10 @@ impl RoomInputBarRef { /// Updates the visibility of select views based on the user's new power levels. /// /// This will show/hide the `input_bar` and the `can_not_send_message_notice` views. - pub fn update_user_power_levels( - &self, - cx: &mut Cx, - user_power_levels: UserPowerLevels, - ) { - let Some(mut inner) = self.borrow_mut() else { return }; + pub fn update_user_power_levels(&self, cx: &mut Cx, user_power_levels: UserPowerLevels) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.update_user_power_levels(cx, user_power_levels); } @@ -581,7 +725,9 @@ impl RoomInputBarRef { tombstoned_room_id: &OwnedRoomId, successor_room_details: Option<&SuccessorRoomDetails>, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; inner.update_tombstone_footer(cx, tombstoned_room_id, successor_room_details); } @@ -593,22 +739,36 @@ impl RoomInputBarRef { timeline_event_item_id: TimelineEventItemId, edit_result: Result<(), matrix_sdk_ui::timeline::Error>, ) { - let Some(inner) = self.borrow_mut() else { return }; - inner.editing_pane(cx, ids!(editing_pane)) + let Some(inner) = self.borrow_mut() else { + return; + }; + inner + .editing_pane(cx, ids!(editing_pane)) .handle_edit_result(cx, timeline_event_item_id, edit_result); } /// Save a snapshot of the UI state of this `RoomInputBar`. pub fn save_state(&self) -> RoomInputBarState { - let Some(inner) = self.borrow() else { return Default::default() }; + let Some(inner) = self.borrow() else { + return Default::default(); + }; // Clear the location preview. We don't save this state because the // current location might change by the next time the user opens this same room. - inner.child_by_path(ids!(location_preview)).as_location_preview().clear(); + inner + .child_by_path(ids!(location_preview)) + .as_location_preview() + .clear(); RoomInputBarState { was_replying_preview_visible: inner.was_replying_preview_visible, replying_to: inner.replying_to.clone(), - editing_pane_state: inner.child_by_path(ids!(editing_pane)).as_editing_pane().save_state(), - text_input_state: inner.child_by_path(ids!(input_bar.mentionable_text_input.text_input)).as_text_input().save_state(), + editing_pane_state: inner + .child_by_path(ids!(editing_pane)) + .as_editing_pane() + .save_state(), + text_input_state: inner + .child_by_path(ids!(input_bar.mentionable_text_input.text_input)) + .as_text_input() + .save_state(), } } @@ -621,7 +781,9 @@ impl RoomInputBarRef { user_power_levels: UserPowerLevels, tombstone_info: Option<&SuccessorRoomDetails>, ) { - let Some(mut inner) = self.borrow_mut() else { return }; + let Some(mut inner) = self.borrow_mut() else { + return; + }; let RoomInputBarState { was_replying_preview_visible, text_input_state, @@ -637,7 +799,8 @@ impl RoomInputBarRef { inner.update_user_power_levels(cx, user_power_levels); // 1. Restore the state of the TextInput within the MentionableTextInput. - inner.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)) + inner + .text_input(cx, ids!(input_bar.mentionable_text_input.text_input)) .restore_state(cx, text_input_state); // 2. Restore the state of the replying-to preview. @@ -656,7 +819,9 @@ impl RoomInputBarRef { timeline_kind.clone(), ); } else { - inner.editing_pane(cx, ids!(editing_pane)).force_reset_hide(cx); + inner + .editing_pane(cx, ids!(editing_pane)) + .force_reset_hide(cx); inner.on_editing_pane_hidden(cx); } @@ -682,9 +847,7 @@ pub struct RoomInputBarState { /// Defines what to do when showing the `EditingPane` from the `RoomInputBar`. enum ShowEditingPaneBehavior { /// Show a new edit session, e.g., when first clicking "edit" on a message. - ShowNew { - event_tl_item: EventTimelineItem, - }, + ShowNew { event_tl_item: EventTimelineItem }, /// Restore the state of an `EditingPane` that already existed, e.g., when /// reopening a room that had an `EditingPane` open when it was closed. RestoreExisting { diff --git a/src/settings/bot_settings.rs b/src/settings/bot_settings.rs new file mode 100644 index 000000000..c1fc6a837 --- /dev/null +++ b/src/settings/bot_settings.rs @@ -0,0 +1,187 @@ +use makepad_widgets::*; + +use crate::{ + app::{AppState, BotSettingsState}, + shared::popup_list::{PopupKind, enqueue_popup_notification}, +}; + +script_mod! { + use mod.prelude.widgets.* + use mod.widgets.* + + mod.widgets.BotSettingsInfoLabel = Label { + width: Fill + height: Fit + margin: Inset{left: 5, top: 2, bottom: 2} + draw_text +: { + wrap: Word + color: (MESSAGE_TEXT_COLOR) + text_style: REGULAR_TEXT { font_size: 10.5 } + } + text: "" + } + + mod.widgets.BotSettings = #(BotSettings::register_widget(vm)) { + width: Fill + height: Fit + flow: Down + spacing: 10 + + TitleLabel { + text: "App Service" + } + + description := mod.widgets.BotSettingsInfoLabel { + margin: Inset{left: 5, right: 8, bottom: 4} + text: "Enable Matrix app service support here. Robrix stays a normal Matrix client: it binds BotFather to a room and sends the matching slash commands." + } + + toggle_row := View { + width: Fill + height: Fit + flow: Right + align: Align{y: 0.5} + spacing: 12 + margin: Inset{left: 5, bottom: 2} + + enable_label := SubsectionLabel { + width: Fit + height: Fit + margin: 0 + text: "Enable App Service" + } + + toggle_button := RobrixNeutralIconButton { + width: Fit + height: Fit + padding: Inset{top: 10, bottom: 10, left: 12, right: 15} + draw_icon.svg: (ICON_HIERARCHY) + icon_walk: Walk{width: 16, height: 16} + text: "Enable App Service" + } + } + + bot_details := View { + visible: false + width: Fill + height: Fit + flow: Down + + SubsectionLabel { + text: "BotFather User ID:" + } + + bot_user_id_input := RobrixTextInput { + margin: Inset{top: 2, left: 5, right: 5, bottom: 8} + width: 280 + height: Fit + empty_text: "bot or @bot:server" + } + + buttons := View { + width: Fill + height: Fit + flow: Right + spacing: 10 + + save_button := RobrixPositiveIconButton { + width: Fit + height: Fit + padding: Inset{top: 10, bottom: 10, left: 12, right: 15} + margin: Inset{left: 5} + draw_icon.svg: (ICON_CHECKMARK) + icon_walk: Walk{width: 16, height: 16} + text: "Save" + } + } + } + } +} + +#[derive(Script, ScriptHook, Widget)] +pub struct BotSettings { + #[deref] + view: View, +} + +impl Widget for BotSettings { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + self.view.handle_event(cx, event, scope); + self.widget_match_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.view.draw_walk(cx, scope, walk) + } +} + +impl WidgetMatchEvent for BotSettings { + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, _scope: &mut Scope) { + let toggle_button = self.view.button(cx, ids!(toggle_button)); + let bot_details = self.view.view(cx, ids!(bot_details)); + let bot_user_id_input = self.view.text_input(cx, ids!(bot_user_id_input)); + let save_button = self.view.button(cx, ids!(buttons.save_button)); + + let Some(app_state) = _scope.data.get_mut::() else { + return; + }; + + if toggle_button.clicked(actions) { + let enabled = !app_state.bot_settings.enabled; + app_state.bot_settings.enabled = enabled; + self.sync_ui(cx, &app_state.bot_settings); + bot_details.set_visible(cx, enabled); + self.view.redraw(cx); + } + + if save_button.clicked(actions) || bot_user_id_input.returned(actions).is_some() { + app_state.bot_settings.botfather_user_id = bot_user_id_input.text().trim().to_string(); + enqueue_popup_notification( + "Saved Matrix app service settings.", + PopupKind::Success, + Some(3.0), + ); + self.sync_ui(cx, &app_state.bot_settings); + } + } +} + +impl BotSettings { + fn sync_ui(&mut self, cx: &mut Cx, bot_settings: &BotSettingsState) { + self.view + .view(cx, ids!(bot_details)) + .set_visible(cx, bot_settings.enabled); + self.view + .text_input(cx, ids!(bot_user_id_input)) + .set_text(cx, &bot_settings.botfather_user_id); + + let toggle_text = if bot_settings.enabled { + "Disable App Service" + } else { + "Enable App Service" + }; + self.view + .button(cx, ids!(toggle_button)) + .set_text(cx, toggle_text); + self.view.button(cx, ids!(toggle_button)).reset_hover(cx); + self.view + .button(cx, ids!(buttons.save_button)) + .reset_hover(cx); + self.view.redraw(cx); + } + + /// Populates the bot settings UI from the current persisted app state. + pub fn populate(&mut self, cx: &mut Cx, bot_settings: &BotSettingsState) { + self.sync_ui(cx, bot_settings); + } +} + +impl BotSettingsRef { + /// See [`BotSettings::populate()`]. + pub fn populate(&self, cx: &mut Cx, bot_settings: &BotSettingsState) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.populate(cx, bot_settings); + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 579bf0849..3155e1186 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -2,8 +2,10 @@ use makepad_widgets::ScriptVm; pub mod settings_screen; pub mod account_settings; +pub mod bot_settings; pub fn script_mod(vm: &mut ScriptVm) { account_settings::script_mod(vm); + bot_settings::script_mod(vm); settings_screen::script_mod(vm); } diff --git a/src/settings/settings_screen.rs b/src/settings/settings_screen.rs index 24baf849d..38246560c 100644 --- a/src/settings/settings_screen.rs +++ b/src/settings/settings_screen.rs @@ -1,7 +1,11 @@ - use makepad_widgets::*; -use crate::{home::navigation_tab_bar::{NavigationBarAction, get_own_profile}, profile::user_profile::UserProfile, settings::account_settings::AccountSettingsWidgetExt}; +use crate::{ + app::BotSettingsState, + home::navigation_tab_bar::{NavigationBarAction, get_own_profile}, + profile::user_profile::UserProfile, + settings::{account_settings::AccountSettingsWidgetExt, bot_settings::BotSettingsWidgetExt}, +}; script_mod! { use mod.prelude.widgets.* @@ -58,6 +62,10 @@ script_mod! { LineH { width: 400, padding: 10, margin: Inset{top: 20, bottom: 5} } + bot_settings := BotSettings {} + + LineH { width: 400, padding: 10, margin: Inset{top: 20, bottom: 5} } + // The TSP wallet settings section. tsp_settings_screen := TspSettingsScreen {} @@ -84,11 +92,11 @@ script_mod! { } } - /// The top-level widget showing all app and user settings/preferences. #[derive(Script, ScriptHook, Widget)] pub struct SettingsScreen { - #[deref] view: View, + #[deref] + view: View, } impl Widget for SettingsScreen { @@ -105,16 +113,15 @@ impl Widget for SettingsScreen { matches!( event, Event::Actions(actions) if self.button(cx, ids!(close_button)).clicked(actions) - ) - || event.back_pressed() - || match event.hits(cx, area) { - Hit::KeyUp(key) => key.key_code == KeyCode::Escape, - Hit::FingerDown(_fde) => { - cx.set_key_focus(area); - false + ) || event.back_pressed() + || match event.hits(cx, area) { + Hit::KeyUp(key) => key.key_code == KeyCode::Escape, + Hit::FingerDown(_fde) => { + cx.set_key_focus(area); + false + } + _ => false, } - _ => false, - } }; if close_pane { cx.action(NavigationBarAction::CloseSettings); @@ -132,26 +139,30 @@ impl Widget for SettingsScreen { match action.downcast_ref() { Some(CreateWalletModalAction::Open) => { use crate::tsp::create_wallet_modal::CreateWalletModalWidgetExt; - self.view.create_wallet_modal(cx, ids!(create_wallet_modal_inner)).show(cx); + self.view + .create_wallet_modal(cx, ids!(create_wallet_modal_inner)) + .show(cx); self.view.modal(cx, ids!(create_wallet_modal)).open(cx); } Some(CreateWalletModalAction::Close) => { self.view.modal(cx, ids!(create_wallet_modal)).close(cx); } - None => { } + None => {} } // Handle the create DID modal being opened or closed. match action.downcast_ref() { Some(CreateDidModalAction::Open) => { use crate::tsp::create_did_modal::CreateDidModalWidgetExt; - self.view.create_did_modal(cx, ids!(create_did_modal_inner)).show(cx); + self.view + .create_did_modal(cx, ids!(create_did_modal_inner)) + .show(cx); self.view.modal(cx, ids!(create_did_modal)).open(cx); } Some(CreateDidModalAction::Close) => { self.view.modal(cx, ids!(create_did_modal)).close(cx); } - None => { } + None => {} } } } @@ -164,12 +175,22 @@ impl Widget for SettingsScreen { impl SettingsScreen { /// Fetches the current user's profile and uses it to populate the settings screen. - pub fn populate(&mut self, cx: &mut Cx, own_profile: Option) { + pub fn populate( + &mut self, + cx: &mut Cx, + own_profile: Option, + bot_settings: &BotSettingsState, + ) { let Some(profile) = own_profile.or_else(|| get_own_profile(cx)) else { error!("Failed to get own profile for settings screen."); return; }; - self.view.account_settings(cx, ids!(account_settings)).populate(cx, profile); + self.view + .account_settings(cx, ids!(account_settings)) + .populate(cx, profile); + self.view + .bot_settings(cx, ids!(bot_settings)) + .populate(cx, bot_settings); self.view.button(cx, ids!(close_button)).reset_hover(cx); cx.set_key_focus(self.view.area()); self.redraw(cx); @@ -178,8 +199,15 @@ impl SettingsScreen { impl SettingsScreenRef { /// See [`SettingsScreen::populate()`]. - pub fn populate(&self, cx: &mut Cx, own_profile: Option) { - let Some(mut inner) = self.borrow_mut() else { return; }; - inner.populate(cx, own_profile); + pub fn populate( + &self, + cx: &mut Cx, + own_profile: Option, + bot_settings: &BotSettingsState, + ) { + let Some(mut inner) = self.borrow_mut() else { + return; + }; + inner.populate(cx, own_profile, bot_settings); } } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index d50dd7842..1ffdf7de6 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -8,43 +8,110 @@ use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ - config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ - api::{Direction, client::{ - account::register::v3::Request as RegistrationRequest, - error::ErrorKind, - profile::{AvatarUrl, DisplayName}, - receipt::create_receipt::v3::ReceiptType, - uiaa::{AuthData, AuthType, Dummy}, - }}, events::{ + config::RequestConfig, + encryption::EncryptionSettings, + event_handler::EventHandlerDropGuard, + media::MediaRequestParameters, + room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, + ruma::{ + api::{ + Direction, + client::{ + account::register::v3::Request as RegistrationRequest, + error::ErrorKind, + profile::{AvatarUrl, DisplayName}, + receipt::create_receipt::v3::ReceiptType, + uiaa::{AuthData, AuthType, Dummy}, + }, + }, + events::{ relation::RelationType, - room::{ - message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource - }, MessageLikeEventType, StateEventType - }, matrix_uri::MatrixId, EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, uint - }, sliding_sync::VersionBuilder, Client, ClientBuildError, Error, OwnedServerName, Room, RoomDisplayName, RoomMemberships, RoomState, SessionChange, SuccessorRoom + room::{message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource}, + MessageLikeEventType, StateEventType, + }, + matrix_uri::MatrixId, + EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, + OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, uint, + }, + sliding_sync::VersionBuilder, + Client, ClientBuildError, Error, OwnedServerName, Room, RoomDisplayName, RoomMemberships, + RoomState, SessionChange, SuccessorRoom, }; use matrix_sdk_ui::{ - RoomListService, Timeline, encryption_sync_service, room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator, filters}, sync_service::{self, SyncService}, timeline::{LatestEventValue, RoomExt, TimelineEventItemId, TimelineFocus, TimelineItem, TimelineReadReceiptTracking, TimelineDetails} + RoomListService, Timeline, encryption_sync_service, + room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator, filters}, + sync_service::{self, SyncService}, + timeline::{ + LatestEventValue, RoomExt, TimelineEventItemId, TimelineFocus, TimelineItem, + TimelineReadReceiptTracking, TimelineDetails, + }, }; use robius_open::Uri; use ruma::{OwnedRoomOrAliasId, RoomId, events::tag::Tags}; use tokio::{ runtime::Handle, - sync::{broadcast, mpsc::{Sender, UnboundedReceiver, UnboundedSender}, watch, Notify}, task::JoinHandle, time::error::Elapsed, + sync::{ + broadcast, + mpsc::{Sender, UnboundedReceiver, UnboundedSender}, + watch, Notify, + }, + task::JoinHandle, + time::error::Elapsed, }; use url::Url; -use std::{borrow::Cow, cmp::{max, min}, future::Future, hash::{BuildHasherDefault, DefaultHasher}, iter::Peekable, ops::{Deref, DerefMut, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; +use std::{ + borrow::Cow, + cmp::{max, min}, + future::Future, + hash::{BuildHasherDefault, DefaultHasher}, + iter::Peekable, + ops::{Deref, DerefMut, Not}, + path::Path, + sync::{Arc, LazyLock, Mutex}, + time::Duration, +}; use std::io; use hashbrown::{HashMap, HashSet}; use crate::{ - app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ - add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails - }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ + app::AppStateAction, + app_data_dir, + avatar_cache::AvatarUpdate, + event_preview::{ + BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item, + }, + home::{ + add_room::KnockResultAction, + invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, + link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, + room_screen::{InviteResultAction, TimelineUpdate}, + rooms_list::{ + self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, + enqueue_rooms_list_update, + }, + rooms_list_header::RoomsListHeaderAction, + tombstone_footer::SuccessorRoomDetails, + }, + login::login_screen::LoginAction, + logout::{ + logout_confirm_modal::LogoutAction, + logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}, + }, + media_cache::{MediaCacheEntry, MediaCacheEntryRef}, + persistence::{self, ClientSessionPersisted, load_app_state}, + profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, - }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ - avatar::AvatarState, html_or_plaintext::MatrixLinkPillState, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification} - }, space_service_sync::space_service_loop, utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, verification::add_verification_event_handlers_and_sync_client + }, + room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, + shared::{ + avatar::AvatarState, + html_or_plaintext::MatrixLinkPillState, + jump_to_bottom_button::UnreadMessageCount, + popup_list::{PopupKind, enqueue_popup_notification}, + }, + space_service_sync::space_service_loop, + utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, + verification::add_verification_event_handlers_and_sync_client, }; #[derive(Parser, Default)] @@ -92,7 +159,8 @@ impl From for Cli { Self { user_id: login.user_id.trim().to_owned(), password: login.password, - homeserver: login.homeserver + homeserver: login + .homeserver .map(|homeserver| homeserver.trim().to_owned()) .filter(|homeserver| !homeserver.is_empty()), proxy: None, @@ -107,7 +175,8 @@ impl From for Cli { Self { user_id: registration.user_id.trim().to_owned(), password: registration.password, - homeserver: registration.homeserver + homeserver: registration + .homeserver .map(|homeserver| homeserver.trim().to_owned()) .filter(|homeserver| !homeserver.is_empty()), proxy: None, @@ -128,7 +197,8 @@ async fn finalize_authenticated_client( fallback_user_id: &str, ) -> Result<(Client, Option)> { if client.matrix_auth().logged_in() { - let logged_in_user_id = client.user_id() + let logged_in_user_id = client + .user_id() .map(ToString::to_string) .unwrap_or_else(|| fallback_user_id.to_owned()); log!("Logged in successfully."); @@ -145,7 +215,9 @@ async fn finalize_authenticated_client( "Authentication succeeded for {fallback_user_id}, but the homeserver did not return a login session." ); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.clone(), + }); bail!(err_msg); } } @@ -161,7 +233,8 @@ fn registration_localpart(user_id: &str) -> Result { } let localpart = trimmed.trim_start_matches('@'); - if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) { + if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) + { bail!("Please enter a valid username or full Matrix user ID."); } @@ -268,9 +341,14 @@ async fn reset_runtime_state_for_relogin() { ALL_JOINED_ROOMS.lock().unwrap().clear(); let on_clear_appstate = Arc::new(Notify::new()); - Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); + Cx::post_action(LogoutAction::ClearAppState { + on_clear_appstate: on_clear_appstate.clone(), + }); - if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()).await.is_err() { + if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()) + .await + .is_err() + { warning!("Timed out waiting for UI-side app state cleanup during re-login reset"); } } @@ -282,7 +360,6 @@ fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { ) } - /// Build a new client. async fn build_client( cli: &Cli, @@ -305,11 +382,13 @@ async fn build_client( }; let inferred_homeserver = infer_homeserver_from_user_id(&cli.user_id); - let homeserver_url = cli.homeserver.as_deref() + let homeserver_url = cli + .homeserver + .as_deref() .filter(|homeserver| !homeserver.trim().is_empty()) .or(inferred_homeserver.as_deref()) .unwrap_or("https://matrix-client.matrix.org/"); - // .unwrap_or("https://matrix.org/"); + // .unwrap_or("https://matrix.org/"); let mut builder = Client::builder() .server_name_or_homeserver_url(homeserver_url) @@ -337,13 +416,11 @@ async fn build_client( // Use a 60 second timeout for all requests to the homeserver. // Yes, this is a long timeout, but the standard matrix homeserver is often very slow. - builder = builder.request_config( - RequestConfig::new() - .timeout(std::time::Duration::from_secs(60)) - ); + builder = + builder.request_config(RequestConfig::new().timeout(std::time::Duration::from_secs(60))); let client = builder.build().await?; - let homeserver_url = client.homeserver().to_string(); + let homeserver_url = client.homeserver().to_string(); Ok(( client, ClientSessionPersisted { @@ -359,10 +436,7 @@ async fn build_client( /// This function is used by the login screen to log in to the Matrix server. /// /// Upon success, this function returns the logged-in client and an optional sync token. -async fn login( - cli: &Cli, - login_request: LoginRequest, -) -> Result<(Client, Option)> { +async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option)> { match login_request { LoginRequest::LoginByCli | LoginRequest::LoginByPassword(_) => { let cli = if let LoginRequest::LoginByPassword(login_by_password) = login_request { @@ -385,7 +459,9 @@ async fn login( if !client.matrix_auth().logged_in() { let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.clone(), + }); bail!(err_msg); } finalize_authenticated_client(client, client_session, &cli.user_id).await @@ -441,7 +517,9 @@ async fn login( register_result.user_id, ); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.clone(), + }); bail!(err_msg); } @@ -461,7 +539,6 @@ async fn login( } } - /// Which direction to paginate in. /// /// * `Forwards` will retrieve later events (towards the end of the timeline), @@ -530,7 +607,6 @@ pub type OnLinkPreviewFetchedFn = fn( Option>, ); - /// Actions emitted in response to a [`MatrixRequest::GenerateMatrixLink`]. #[derive(Clone, Debug)] pub enum MatrixLinkAction { @@ -561,9 +637,7 @@ pub enum DirectMessageRoomAction { room_name_id: RoomNameId, }, /// A direct message room didn't exist, and we didn't attempt to create a new one. - DidNotExist { - user_profile: UserProfile, - }, + DidNotExist { user_profile: UserProfile }, /// A direct message room didn't exist, but we successfully created a new one. NewlyCreated { user_profile: UserProfile, @@ -598,7 +672,10 @@ impl TimelineKind { pub fn thread_root_event_id(&self) -> Option<&OwnedEventId> { match self { TimelineKind::MainRoom { .. } => None, - TimelineKind::Thread { thread_root_event_id, .. } => Some(thread_root_event_id), + TimelineKind::Thread { + thread_root_event_id, + .. + } => Some(thread_root_event_id), } } } @@ -606,7 +683,10 @@ impl std::fmt::Display for TimelineKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TimelineKind::MainRoom { room_id } => write!(f, "MainRoom({})", room_id), - TimelineKind::Thread { room_id, thread_root_event_id } => { + TimelineKind::Thread { + room_id, + thread_root_event_id, + } => { write!(f, "Thread({}, {})", room_id, thread_root_event_id) } } @@ -619,9 +699,7 @@ pub enum MatrixRequest { /// Request from the login screen to log in with the given credentials. Login(LoginRequest), /// Request to logout. - Logout { - is_desktop: bool, - }, + Logout { is_desktop: bool }, /// Request to paginate the older (or newer) events of a room or thread timeline. PaginateTimeline { timeline_kind: TimelineKind, @@ -653,9 +731,7 @@ pub enum MatrixRequest { /// /// Even though it operates on a room itself, this accepts a `TimelineKind` /// in order to be able to send the fetched room member list to a specific timeline UI. - SyncRoomMemberList { - timeline_kind: TimelineKind, - }, + SyncRoomMemberList { timeline_kind: TimelineKind }, /// Request to create a thread timeline focused on the given thread root event in the given room. CreateThreadTimeline { room_id: OwnedRoomId, @@ -673,14 +749,16 @@ pub enum MatrixRequest { room_id: OwnedRoomId, user_id: OwnedUserId, }, - /// Request to join the given room. - JoinRoom { + /// Request to bind or unbind the configured botfather for the given room. + SetRoomBotBinding { room_id: OwnedRoomId, + bound: bool, + bot_user_id: OwnedUserId, }, + /// Request to join the given room. + JoinRoom { room_id: OwnedRoomId }, /// Request to leave the given room. - LeaveRoom { - room_id: OwnedRoomId, - }, + LeaveRoom { room_id: OwnedRoomId }, /// Request to get the actual list of members in a room. /// /// This returns the list of members that can be displayed in the UI. @@ -703,9 +781,7 @@ pub enum MatrixRequest { via: Vec, }, /// Request to fetch the full details (the room preview) of a tombstoned room. - GetSuccessorRoomDetails { - tombstoned_room_id: OwnedRoomId, - }, + GetSuccessorRoomDetails { tombstoned_room_id: OwnedRoomId }, /// Request to create or open a direct message room with the given user. /// /// If there is no existing DM room with the given user, this will create a new DM room @@ -730,9 +806,7 @@ pub enum MatrixRequest { local_only: bool, }, /// Request to fetch the number of unread messages in the given room. - GetNumberUnreadMessages { - timeline_kind: TimelineKind, - }, + GetNumberUnreadMessages { timeline_kind: TimelineKind }, /// Request to set the unread flag for the given room. SetUnreadFlag { room_id: OwnedRoomId, @@ -817,15 +891,12 @@ pub enum MatrixRequest { /// This request does not return a response or notify the UI thread, and /// furthermore, there is no need to send a follow-up request to stop typing /// (though you certainly can do so). - SendTypingNotice { - room_id: OwnedRoomId, - typing: bool, - }, + SendTypingNotice { room_id: OwnedRoomId, typing: bool }, /// Spawn an async task to login to the given Matrix homeserver using the given SSO identity provider ID. /// /// While an SSO request is in flight, the login screen will temporarily prevent the user /// from submitting another redundant request, until this request has succeeded or failed. - SpawnSSOServer{ + SpawnSSOServer { brand: String, homeserver_url: String, identity_provider_id: String, @@ -870,9 +941,7 @@ pub enum MatrixRequest { /// /// Even though it operates on a room itself, this accepts a `TimelineKind` /// in order to be able to send the fetched room member list to a specific timeline UI. - GetRoomPowerLevels { - timeline_kind: TimelineKind, - }, + GetRoomPowerLevels { timeline_kind: TimelineKind }, /// Toggles the given reaction to the given event in the given room. ToggleReaction { timeline_kind: TimelineKind, @@ -898,7 +967,7 @@ pub enum MatrixRequest { /// The MatrixLinkPillInfo::Loaded variant is sent back to the main UI thread via. GetMatrixRoomLinkPillInfo { matrix_id: MatrixId, - via: Vec + via: Vec, }, /// Request to fetch URL preview from the Matrix homeserver. GetUrlPreview { @@ -912,19 +981,19 @@ pub enum MatrixRequest { /// Submits a request to the worker thread to be executed asynchronously. pub fn submit_async_request(req: MatrixRequest) { if let Some(sender) = REQUEST_SENDER.lock().unwrap().as_ref() { - sender.send(req) + sender + .send(req) .expect("BUG: matrix worker task receiver has died!"); } } /// Details of a login request that get submitted within [`MatrixRequest::Login`]. -pub enum LoginRequest{ +pub enum LoginRequest { LoginByPassword(LoginByPassword), Register(RegisterAccount), LoginBySSOSuccess(Client, ClientSessionPersisted), LoginByCli, HomeserverLoginTypesQuery(String), - } /// Information needed to log in to a Matrix homeserver. pub struct LoginByPassword { @@ -941,7 +1010,6 @@ pub struct RegisterAccount { pub homeserver: Option, } - /// The entry point for the worker task that runs Matrix-related operations. /// /// All this task does is wait for [`MatrixRequests`] from the main UI thread @@ -952,7 +1020,8 @@ async fn matrix_worker_task( ) -> Result<()> { log!("Started matrix_worker_task."); // The async tasks that are spawned to subscribe to changes in our own user's read receipts for each timeline. - let mut subscribers_own_user_read_receipts: HashMap> = HashMap::new(); + let mut subscribers_own_user_read_receipts: HashMap> = + HashMap::new(); // The async tasks that are spawned to subscribe to changes in the pinned events for each room. let mut subscribers_pinned_events: HashMap> = HashMap::new(); @@ -962,7 +1031,7 @@ async fn matrix_worker_task( if let Err(e) = login_sender.send(login_request).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to login worker task." + "BUG: failed to send login request to login worker task.", ))); } } @@ -975,7 +1044,7 @@ async fn matrix_worker_task( match logout_with_state_machine(is_desktop).await { Ok(()) => { log!("Logout completed successfully via state machine"); - }, + } Err(e) => { error!("Logout failed: {e:?}"); } @@ -983,7 +1052,11 @@ async fn matrix_worker_task( }); } - MatrixRequest::PaginateTimeline {timeline_kind, num_events, direction} => { + MatrixRequest::PaginateTimeline { + timeline_kind, + num_events, + direction, + } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("Skipping pagination request for unknown {timeline_kind}"); continue; @@ -1025,7 +1098,11 @@ async fn matrix_worker_task( }); } - MatrixRequest::EditMessage { timeline_kind, timeline_event_item_id, edited_content } => { + MatrixRequest::EditMessage { + timeline_kind, + timeline_event_item_id, + edited_content, + } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for edit request"); continue; @@ -1047,7 +1124,10 @@ async fn matrix_worker_task( }); } - MatrixRequest::FetchDetailsForEvent { timeline_kind, event_id } => { + MatrixRequest::FetchDetailsForEvent { + timeline_kind, + event_id, + } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for fetch details for event request"); continue; @@ -1064,7 +1144,10 @@ async fn matrix_worker_task( // error!("Error fetching details for event {event_id} in {timeline_kind}: {_e:?}"); } } - if sender.send(TimelineUpdate::EventDetailsFetched { event_id, result }).is_err() { + if sender + .send(TimelineUpdate::EventDetailsFetched { event_id, result }) + .is_err() + { error!("Failed to send fetched event details to UI for {timeline_kind}"); } SignalToUI::set_ui_signal(); @@ -1118,17 +1201,27 @@ async fn matrix_worker_task( }); } - MatrixRequest::CreateThreadTimeline { room_id, thread_root_event_id } => { + MatrixRequest::CreateThreadTimeline { + room_id, + thread_root_event_id, + } => { let main_room_timeline = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - error!("BUG: room info not found for create thread timeline request, room {room_id}"); + error!( + "BUG: room info not found for create thread timeline request, room {room_id}" + ); continue; }; - if room_info.thread_timelines.contains_key(&thread_root_event_id) { + if room_info + .thread_timelines + .contains_key(&thread_root_event_id) + { continue; } - let newly_pending = room_info.pending_thread_timelines.insert(thread_root_event_id.clone()); + let newly_pending = room_info + .pending_thread_timelines + .insert(thread_root_event_id.clone()); if !newly_pending { continue; } @@ -1200,11 +1293,18 @@ async fn matrix_worker_task( }); } - MatrixRequest::Knock { room_or_alias_id, reason, server_names } => { + MatrixRequest::Knock { + room_or_alias_id, + reason, + server_names, + } => { let Some(client) = get_client() else { continue }; let _knock_room_task = Handle::current().spawn(async move { log!("Sending request to knock on room {room_or_alias_id}..."); - match client.knock(room_or_alias_id.clone(), reason, server_names).await { + match client + .knock(room_or_alias_id.clone(), reason, server_names) + .await + { Ok(room) => { let _ = room.display_name().await; // populate this room's display name cache Cx::post_action(KnockResultAction::Knocked { @@ -1228,23 +1328,21 @@ async fn matrix_worker_task( if let Some(room) = client.get_room(&room_id) { log!("Sending request to invite user {user_id} to room {room_id}..."); match room.invite_user_by_id(&user_id).await { - Ok(_) => Cx::post_action(InviteResultAction::Sent { - room_id, - user_id, - }), + Ok(_) => Cx::post_action(InviteResultAction::Sent { room_id, user_id }), Err(error) => Cx::post_action(InviteResultAction::Failed { room_id, user_id, error, }), } - } - else { + } else { error!("Room/Space not found for invite user request {room_id}, {user_id}"); Cx::post_action(InviteResultAction::Failed { room_id, user_id, - error: matrix_sdk::Error::UnknownError("Room/Space not found in client's known list.".into()), + error: matrix_sdk::Error::UnknownError( + "Room/Space not found in client's known list.".into(), + ), }) } }); @@ -1265,8 +1363,7 @@ async fn matrix_worker_task( JoinRoomResultAction::Failed { room_id, error: e } } } - } - else { + } else { match client.join_room_by_id(&room_id).await { Ok(_room) => { log!("Successfully joined new unknown room {room_id}."); @@ -1301,14 +1398,20 @@ async fn matrix_worker_task( error!("BUG: client could not get room with ID {room_id}"); LeaveRoomResultAction::Failed { room_id, - error: matrix_sdk::Error::UnknownError("Client couldn't locate room to leave it.".into()), + error: matrix_sdk::Error::UnknownError( + "Client couldn't locate room to leave it.".into(), + ), } }; Cx::post_action(result_action); }); } - MatrixRequest::GetRoomMembers { timeline_kind, memberships, local_only } => { + MatrixRequest::GetRoomMembers { + timeline_kind, + memberships, + local_only, + } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for get room members request"); continue; @@ -1317,7 +1420,9 @@ async fn matrix_worker_task( let _get_members_task = Handle::current().spawn(async move { let send_update = |members: Vec, source: &str| { log!("{} {} members for {timeline_kind}", source, members.len()); - sender.send(TimelineUpdate::RoomMembersListFetched { members }).unwrap(); + sender + .send(TimelineUpdate::RoomMembersListFetched { members }) + .unwrap(); SignalToUI::set_ui_signal(); }; @@ -1334,7 +1439,10 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetRoomPreview { room_or_alias_id, via } => { + MatrixRequest::GetRoomPreview { + room_or_alias_id, + via, + } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { let res = fetch_room_preview_with_avatar(&client, &room_or_alias_id, via).await; @@ -1342,12 +1450,80 @@ async fn matrix_worker_task( }); } + MatrixRequest::SetRoomBotBinding { + room_id, + bound, + bot_user_id, + } => { + let Some(client) = get_client() else { continue }; + let _bot_binding_task = Handle::current().spawn(async move { + let Some(room) = client.get_room(&room_id) else { + let error_message = + format!("Room {room_id} was not found for the bot binding request."); + error!("{error_message}"); + enqueue_popup_notification(error_message, PopupKind::Error, None); + return; + }; + + let membership_result = if bound { + room.invite_user_by_id(&bot_user_id).await + } else { + room.kick_user(&bot_user_id, Some("Robrix app service unbind")).await + }; + + match membership_result { + Ok(()) => { + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: None, + }); + } + Err(error) => { + let membership_exists = room + .get_member_no_sync(&bot_user_id) + .await + .ok() + .flatten() + .is_some(); + let should_mark_bound = if bound { membership_exists } else { false }; + + if should_mark_bound != bound { + error!( + "Failed to {} BotFather {bot_user_id} for room {room_id}: {error:?}", + if bound { "invite" } else { "remove" } + ); + enqueue_popup_notification( + format!( + "Failed to {} BotFather {bot_user_id}: {error}", + if bound { "invite" } else { "remove" } + ), + PopupKind::Error, + None, + ); + return; + } + + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: Some(error.to_string()), + }); + } + } + }); + } + MatrixRequest::GetSuccessorRoomDetails { tombstoned_room_id } => { let Some(client) = get_client() else { continue }; let (sender, successor_room) = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&tombstoned_room_id) else { - error!("BUG: tombstoned room {tombstoned_room_id} info not found for get successor room details request"); + error!( + "BUG: tombstoned room {tombstoned_room_id} info not found for get successor room details request" + ); continue; }; ( @@ -1363,7 +1539,10 @@ async fn matrix_worker_task( ); } - MatrixRequest::OpenOrCreateDirectMessage { user_profile, allow_create } => { + MatrixRequest::OpenOrCreateDirectMessage { + user_profile, + allow_create, + } => { let Some(client) = get_client() else { continue }; let _create_dm_task = Handle::current().spawn(async move { if let Some(room) = client.get_dm_room(&user_profile.user_id) { @@ -1386,7 +1565,7 @@ async fn matrix_worker_task( user_profile, room_name_id: RoomNameId::from_room(&room).await, }); - }, + } Err(error) => { error!("Failed to create DM with {user_profile:?}: {error}"); Cx::post_action(DirectMessageRoomAction::FailedToCreate { @@ -1398,7 +1577,11 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetUserProfile { user_id, room_id, local_only } => { + MatrixRequest::GetUserProfile { + user_id, + room_id, + local_only, + } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { // log!("Sending get user profile request: user: {user_id}, \ @@ -1492,7 +1675,10 @@ async fn matrix_worker_task( }); } - MatrixRequest::SetUnreadFlag { room_id, mark_as_unread } => { + MatrixRequest::SetUnreadFlag { + room_id, + mark_as_unread, + } => { let Some(main_timeline) = get_room_timeline(&room_id) else { log!("BUG: skipping set unread flag request for not-yet-known room {room_id}"); continue; @@ -1501,35 +1687,64 @@ async fn matrix_worker_task( let result = main_timeline.room().set_unread_flag(mark_as_unread).await; match result { Ok(_) => log!("Set unread flag to {} for room {}", mark_as_unread, room_id), - Err(e) => error!("Failed to set unread flag to {} for room {}: {:?}", mark_as_unread, room_id, e), + Err(e) => error!( + "Failed to set unread flag to {} for room {}: {:?}", + mark_as_unread, room_id, e + ), } }); } - MatrixRequest::SetIsFavorite { room_id, is_favorite } => { + MatrixRequest::SetIsFavorite { + room_id, + is_favorite, + } => { let Some(main_timeline) = get_room_timeline(&room_id) else { - log!("BUG: skipping set favorite flag request for not-yet-known room {room_id}"); + log!( + "BUG: skipping set favorite flag request for not-yet-known room {room_id}" + ); continue; }; let _set_favorite_task = Handle::current().spawn(async move { - let result = main_timeline.room().set_is_favourite(is_favorite, None).await; + let result = main_timeline + .room() + .set_is_favourite(is_favorite, None) + .await; match result { Ok(_) => log!("Set favorite to {} for room {}", is_favorite, room_id), - Err(e) => error!("Failed to set favorite to {} for room {}: {:?}", is_favorite, room_id, e), + Err(e) => error!( + "Failed to set favorite to {} for room {}: {:?}", + is_favorite, room_id, e + ), } }); } - MatrixRequest::SetIsLowPriority { room_id, is_low_priority } => { + MatrixRequest::SetIsLowPriority { + room_id, + is_low_priority, + } => { let Some(main_timeline) = get_room_timeline(&room_id) else { - log!("BUG: skipping set low priority flag request for not-yet-known room {room_id}"); + log!( + "BUG: skipping set low priority flag request for not-yet-known room {room_id}" + ); continue; }; let _set_lp_task = Handle::current().spawn(async move { - let result = main_timeline.room().set_is_low_priority(is_low_priority, None).await; + let result = main_timeline + .room() + .set_is_low_priority(is_low_priority, None) + .await; match result { - Ok(_) => log!("Set low priority to {} for room {}", is_low_priority, room_id), - Err(e) => error!("Failed to set low priority to {} for room {}: {:?}", is_low_priority, room_id, e), + Ok(_) => log!( + "Set low priority to {} for room {}", + is_low_priority, + room_id + ), + Err(e) => error!( + "Failed to set low priority to {} for room {}: {:?}", + is_low_priority, room_id, e + ), } }); } @@ -1538,15 +1753,24 @@ async fn matrix_worker_task( let Some(client) = get_client() else { continue }; let _set_avatar_task = Handle::current().spawn(async move { let is_removing = avatar_url.is_none(); - log!("Sending request to {} avatar...", if is_removing { "remove" } else { "set" }); + log!( + "Sending request to {} avatar...", + if is_removing { "remove" } else { "set" } + ); let result = client.account().set_avatar_url(avatar_url.as_deref()).await; match result { Ok(_) => { - log!("Successfully {} avatar.", if is_removing { "removed" } else { "set" }); + log!( + "Successfully {} avatar.", + if is_removing { "removed" } else { "set" } + ); Cx::post_action(AccountDataAction::AvatarChanged(avatar_url)); } Err(e) => { - let err_msg = format!("Failed to {} avatar: {e}", if is_removing { "remove" } else { "set" }); + let err_msg = format!( + "Failed to {} avatar: {e}", + if is_removing { "remove" } else { "set" } + ); Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); } } @@ -1557,57 +1781,87 @@ async fn matrix_worker_task( let Some(client) = get_client() else { continue }; let _set_display_name_task = Handle::current().spawn(async move { let is_removing = new_display_name.is_none(); - log!("Sending request to {} display name{}...", + log!( + "Sending request to {} display name{}...", if is_removing { "remove" } else { "set" }, - new_display_name.as_ref().map(|n| format!(" to '{n}'")).unwrap_or_default() + new_display_name + .as_ref() + .map(|n| format!(" to '{n}'")) + .unwrap_or_default() ); - let result = client.account().set_display_name(new_display_name.as_deref()).await; + let result = client + .account() + .set_display_name(new_display_name.as_deref()) + .await; match result { Ok(_) => { - log!("Successfully {} display name.", if is_removing { "removed" } else { "set" }); - Cx::post_action(AccountDataAction::DisplayNameChanged(new_display_name)); + log!( + "Successfully {} display name.", + if is_removing { "removed" } else { "set" } + ); + Cx::post_action(AccountDataAction::DisplayNameChanged( + new_display_name, + )); } Err(e) => { - let err_msg = format!("Failed to {} display name: {e}", if is_removing { "remove" } else { "set" }); + let err_msg = format!( + "Failed to {} display name: {e}", + if is_removing { "remove" } else { "set" } + ); Cx::post_action(AccountDataAction::DisplayNameChangeFailed(err_msg)); } } }); } - MatrixRequest::GenerateMatrixLink { room_id, event_id, use_matrix_scheme, join_on_click } => { + MatrixRequest::GenerateMatrixLink { + room_id, + event_id, + use_matrix_scheme, + join_on_click, + } => { let Some(client) = get_client() else { continue }; let _gen_link_task = Handle::current().spawn(async move { if let Some(room) = client.get_room(&room_id) { let result = if use_matrix_scheme { if let Some(event_id) = event_id { - room.matrix_event_permalink(event_id).await + room.matrix_event_permalink(event_id) + .await .map(MatrixLinkAction::MatrixUri) } else { - room.matrix_permalink(join_on_click).await + room.matrix_permalink(join_on_click) + .await .map(MatrixLinkAction::MatrixUri) } } else { if let Some(event_id) = event_id { - room.matrix_to_event_permalink(event_id).await + room.matrix_to_event_permalink(event_id) + .await .map(MatrixLinkAction::MatrixToUri) } else { - room.matrix_to_permalink().await + room.matrix_to_permalink() + .await .map(MatrixLinkAction::MatrixToUri) } }; - + match result { Ok(action) => Cx::post_action(action), Err(e) => Cx::post_action(MatrixLinkAction::Error(e.to_string())), } } else { - Cx::post_action(MatrixLinkAction::Error(format!("Room {room_id} not found"))); + Cx::post_action(MatrixLinkAction::Error(format!( + "Room {room_id} not found" + ))); } }); } - MatrixRequest::IgnoreUser { ignore, room_member, room_id } => { + MatrixRequest::IgnoreUser { + ignore, + room_member, + room_id, + } => { let Some(client) = get_client() else { continue }; let _ignore_task = Handle::current().spawn(async move { let user_id = room_member.user_id(); @@ -1662,7 +1916,9 @@ async fn matrix_worker_task( MatrixRequest::SendTypingNotice { room_id, typing } => { let Some(main_room_timeline) = get_room_timeline(&room_id) else { - log!("BUG: skipping send typing notice request for not-yet-known room {room_id}"); + log!( + "BUG: skipping send typing notice request for not-yet-known room {room_id}" + ); continue; }; let _typing_task = Handle::current().spawn(async move { @@ -1676,16 +1932,21 @@ async fn matrix_worker_task( let (main_timeline, timeline_update_sender, mut typing_notice_receiver) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(jrd) = all_joined_rooms.get_mut(&room_id) else { - log!("BUG: room info not found for subscribe to typing notices request, room {room_id}"); + log!( + "BUG: room info not found for subscribe to typing notices request, room {room_id}" + ); continue; }; let (main_timeline, receiver) = if subscribe { if jrd.typing_notice_subscriber.is_some() { - warning!("Note: room {room_id} is already subscribed to typing notices."); + warning!( + "Note: room {room_id} is already subscribed to typing notices." + ); continue; } else { let main_timeline = jrd.main_timeline.timeline.clone(); - let (drop_guard, receiver) = main_timeline.room().subscribe_to_typing_notifications(); + let (drop_guard, receiver) = + main_timeline.room().subscribe_to_typing_notifications(); jrd.typing_notice_subscriber = Some(drop_guard); (main_timeline, receiver) } @@ -1694,7 +1955,11 @@ async fn matrix_worker_task( continue; }; // Here: we don't have an existing subscriber running, so we fall through and start one. - (main_timeline, jrd.main_timeline.timeline_update_sender.clone(), receiver) + ( + main_timeline, + jrd.main_timeline.timeline_update_sender.clone(), + receiver, + ) }; let _typing_notices_task = Handle::current().spawn(async move { @@ -1721,15 +1986,22 @@ async fn matrix_worker_task( }); } - MatrixRequest::SubscribeToOwnUserReadReceiptsChanged { timeline_kind, subscribe } => { + MatrixRequest::SubscribeToOwnUserReadReceiptsChanged { + timeline_kind, + subscribe, + } => { if !subscribe { - if let Some(task_handler) = subscribers_own_user_read_receipts.remove(&timeline_kind) { + if let Some(task_handler) = + subscribers_own_user_read_receipts.remove(&timeline_kind) + { task_handler.abort(); } continue; } let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { - log!("BUG: skipping subscribe to own user read receipts changed request for {timeline_kind}"); + log!( + "BUG: skipping subscribe to own user read receipts changed request for {timeline_kind}" + ); continue; }; @@ -1771,7 +2043,8 @@ async fn matrix_worker_task( } } }); - subscribers_own_user_read_receipts.insert(timeline_kind_clone, subscribe_own_read_receipt_task); + subscribers_own_user_read_receipts + .insert(timeline_kind_clone, subscribe_own_read_receipt_task); } MatrixRequest::SubscribeToPinnedEvents { room_id, subscribe } => { @@ -1781,9 +2054,13 @@ async fn matrix_worker_task( } continue; } - let kind = TimelineKind::MainRoom { room_id: room_id.clone() }; + let kind = TimelineKind::MainRoom { + room_id: room_id.clone(), + }; let Some((main_timeline, sender)) = get_timeline_and_sender(&kind) else { - log!("BUG: skipping subscribe to pinned events request for unknown room {room_id}"); + log!( + "BUG: skipping subscribe to pinned events request for unknown room {room_id}" + ); continue; }; let subscribe_pinned_events_task = Handle::current().spawn(async move { @@ -1805,8 +2082,18 @@ async fn matrix_worker_task( subscribers_pinned_events.insert(room_id, subscribe_pinned_events_task); } - MatrixRequest::SpawnSSOServer { brand, homeserver_url, identity_provider_id} => { - spawn_sso_server(brand, homeserver_url, identity_provider_id, login_sender.clone()).await; + MatrixRequest::SpawnSSOServer { + brand, + homeserver_url, + identity_provider_id, + } => { + spawn_sso_server( + brand, + homeserver_url, + identity_provider_id, + login_sender.clone(), + ) + .await; } MatrixRequest::ResolveRoomAlias(room_alias) => { @@ -1819,7 +2106,10 @@ async fn matrix_worker_task( }); } - MatrixRequest::FetchAvatar { mxc_uri, on_fetched } => { + MatrixRequest::FetchAvatar { + mxc_uri, + on_fetched, + } => { let Some(client) = get_client() else { continue }; Handle::current().spawn(async move { // log!("Sending fetch avatar request for {mxc_uri:?}..."); @@ -1829,13 +2119,21 @@ async fn matrix_worker_task( }; let res = client.media().get_media_content(&media_request, true).await; // log!("Fetched avatar for {mxc_uri:?}, succeeded? {}", res.is_ok()); - on_fetched(AvatarUpdate { mxc_uri, avatar_data: res.map(|v| v.into()) }); + on_fetched(AvatarUpdate { + mxc_uri, + avatar_data: res.map(|v| v.into()), + }); }); } - MatrixRequest::FetchMedia { media_request, on_fetched, destination, update_sender } => { + MatrixRequest::FetchMedia { + media_request, + on_fetched, + destination, + update_sender, + } => { let Some(client) = get_client() else { continue }; - + let _fetch_task = Handle::current().spawn(async move { // log!("Sending fetch media request for {media_request:?}..."); let res = client.media().get_media_content(&media_request, true).await; @@ -1940,7 +2238,11 @@ async fn matrix_worker_task( }); } - MatrixRequest::ReadReceipt { timeline_kind, event_id, receipt_type } => { + MatrixRequest::ReadReceipt { + timeline_kind, + event_id, + receipt_type, + } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found when sending read receipt, {event_id}"); continue; @@ -1961,7 +2263,7 @@ async fn matrix_worker_task( }); } }); - }, + } MatrixRequest::GetRoomPowerLevels { timeline_kind } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { @@ -1969,15 +2271,21 @@ async fn matrix_worker_task( continue; }; - let Some(user_id) = current_user_id() else { continue }; + let Some(user_id) = current_user_id() else { + continue; + }; let _power_levels_task = Handle::current().spawn(async move { match timeline.room().power_levels().await { Ok(power_levels) => { log!("Successfully fetched power levels for {timeline_kind}."); - if sender.send(TimelineUpdate::UserPowerLevels( - UserPowerLevels::from(&power_levels, &user_id), - )).is_err() { + if sender + .send(TimelineUpdate::UserPowerLevels(UserPowerLevels::from( + &power_levels, + &user_id, + ))) + .is_err() + { error!("Failed to send room power levels to UI.") } SignalToUI::set_ui_signal(); @@ -1987,9 +2295,13 @@ async fn matrix_worker_task( } } }); - }, + } - MatrixRequest::ToggleReaction { timeline_kind, timeline_event_id, reaction } => { + MatrixRequest::ToggleReaction { + timeline_kind, + timeline_event_id, + reaction, + } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found for toggle reaction request"); continue; @@ -1997,17 +2309,26 @@ async fn matrix_worker_task( let _toggle_reaction_task = Handle::current().spawn(async move { log!("Sending toggle reaction {reaction:?} to {timeline_kind}: ..."); - match timeline.toggle_reaction(&timeline_event_id, &reaction).await { + match timeline + .toggle_reaction(&timeline_event_id, &reaction) + .await + { Ok(_send_handle) => { log!("Sent toggle reaction {reaction:?} to {timeline_kind}."); SignalToUI::set_ui_signal(); - }, - Err(_e) => error!("Failed to send toggle reaction to {timeline_kind}; error: {_e:?}"), + } + Err(_e) => error!( + "Failed to send toggle reaction to {timeline_kind}; error: {_e:?}" + ), } }); - }, + } - MatrixRequest::RedactMessage { timeline_kind, timeline_event_id, reason } => { + MatrixRequest::RedactMessage { + timeline_kind, + timeline_event_id, + reason, + } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found for redact message request"); continue; @@ -2026,9 +2347,13 @@ async fn matrix_worker_task( } } }); - }, + } - MatrixRequest::PinEvent { timeline_kind, event_id, pin } => { + MatrixRequest::PinEvent { + timeline_kind, + event_id, + pin, + } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for pin event request"); continue; @@ -2040,7 +2365,11 @@ async fn matrix_worker_task( } else { timeline.unpin_event(&event_id).await }; - match sender.send(TimelineUpdate::PinResult { event_id, pin, result }) { + match sender.send(TimelineUpdate::PinResult { + event_id, + pin, + result, + }) { Ok(_) => SignalToUI::set_ui_signal(), Err(_) => log!("Failed to send UI update for pin event."), } @@ -2074,7 +2403,12 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetUrlPreview { url, on_fetched, destination, update_sender } => { + MatrixRequest::GetUrlPreview { + url, + on_fetched, + destination, + update_sender, + } => { // const MAX_LOG_RESPONSE_BODY_LENGTH: usize = 1000; // log!("Starting URL preview fetch for: {}", url); let _fetch_url_preview_task = Handle::current().spawn(async move { @@ -2084,17 +2418,19 @@ async fn matrix_worker_task( // error!("Matrix client not available for URL preview: {}", url); UrlPreviewError::ClientNotAvailable })?; - + let token = client.access_token().ok_or_else(|| { // error!("Access token not available for URL preview: {}", url); UrlPreviewError::AccessTokenNotAvailable })?; // Official Doc: https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv1mediapreview_url // Element desktop is using /_matrix/media/v3/preview_url - let endpoint_url = client.homeserver().join("/_matrix/client/v1/media/preview_url") + let endpoint_url = client + .homeserver() + .join("/_matrix/client/v1/media/preview_url") .map_err(UrlPreviewError::UrlParse)?; // log!("Fetching URL preview from endpoint: {} for URL: {}", endpoint_url, url); - + let response = client .http_client() .get(endpoint_url.clone()) @@ -2107,20 +2443,20 @@ async fn matrix_worker_task( // error!("HTTP request failed for URL preview {}: {}", url, e); UrlPreviewError::Request(e) })?; - + let status = response.status(); // log!("URL preview response status for {}: {}", url, status); - + if !status.is_success() && status.as_u16() != 429 { // error!("URL preview request failed with status {} for URL: {}", status, url); return Err(UrlPreviewError::HttpStatus(status.as_u16())); } - + let text = response.text().await.map_err(|e| { // error!("Failed to read response text for URL preview {}: {}", url, e); UrlPreviewError::Request(e) })?; - + // log!("URL preview response body length for {}: {} bytes", url, text.len()); // if text.len() > MAX_LOG_RESPONSE_BODY_LENGTH { // log!("URL preview response body preview for {}: {}...", url, &text[..MAX_LOG_RESPONSE_BODY_LENGTH]); @@ -2129,22 +2465,25 @@ async fn matrix_worker_task( // } // This request is rate limited, retry after a duration we get from the server. if status.as_u16() == 429 { - let link_preview_429_res = serde_json::from_str::(&text) - .map_err(|e| { - // error!("Failed to parse as LinkPreviewRateLimitResponse for URL preview {}: {}", url, e); - UrlPreviewError::Json(e) - }); + let link_preview_429_res = + serde_json::from_str::(&text) + .map_err(|e| { + // error!("Failed to parse as LinkPreviewRateLimitResponse for URL preview {}: {}", url, e); + UrlPreviewError::Json(e) + }); match link_preview_429_res { Ok(link_preview_429_res) => { if let Some(retry_after) = link_preview_429_res.retry_after_ms { - tokio::time::sleep(Duration::from_millis(retry_after.into())).await; - submit_async_request(MatrixRequest::GetUrlPreview{ + tokio::time::sleep(Duration::from_millis( + retry_after.into(), + )) + .await; + submit_async_request(MatrixRequest::GetUrlPreview { url: url.clone(), on_fetched, destination: destination.clone(), update_sender: update_sender.clone(), }); - } } Err(_e) => { @@ -2164,11 +2503,12 @@ async fn matrix_worker_task( // error!("Response body that failed to parse: {}", text); UrlPreviewError::Json(e) }) - }.await; + } + .await; // match &result { // Ok(preview_data) => { - // log!("Successfully fetched URL preview for {}: title: {:?}, site_name: {:?}", + // log!("Successfully fetched URL preview for {}: title: {:?}, site_name: {:?}", // url, preview_data.title, preview_data.site_name); // } // Err(e) => { @@ -2187,7 +2527,6 @@ async fn matrix_worker_task( bail!("matrix_worker_task task ended unexpectedly") } - /// The single global Tokio runtime that is used by all async tasks. static TOKIO_RUNTIME: Mutex> = Mutex::new(None); @@ -2200,7 +2539,8 @@ static REQUEST_SENDER: Mutex>> = Mutex::ne static DEFAULT_SSO_CLIENT: Mutex> = Mutex::new(None); /// Used to notify the SSO login task that the async creation of the `DEFAULT_SSO_CLIENT` has finished. -static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = LazyLock::new(|| Arc::new(Notify::new())); +static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = + LazyLock::new(|| Arc::new(Notify::new())); /// Blocks the current thread until the given future completes. /// @@ -2211,36 +2551,45 @@ pub fn block_on_async_with_timeout( timeout: Option, async_future: impl Future, ) -> Result { - let rt = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - ).handle().clone(); + let rt = TOKIO_RUNTIME + .lock() + .unwrap() + .get_or_insert_with(|| { + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + }) + .handle() + .clone(); if let Some(timeout) = timeout { - rt.block_on(async { - tokio::time::timeout(timeout, async_future).await - }) + rt.block_on(async { tokio::time::timeout(timeout, async_future).await }) } else { Ok(rt.block_on(async_future)) } } - /// The primary initialization routine for starting the Matrix client sync /// and the async tokio runtime. /// /// Returns a handle to the Tokio runtime that is used to run async background tasks. pub fn start_matrix_tokio() -> Result { // Create a Tokio runtime, and save it in a static variable to ensure it isn't dropped. - let rt_handle = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| { - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - }).handle().clone(); + let rt_handle = TOKIO_RUNTIME + .lock() + .unwrap() + .get_or_insert_with(|| { + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + }) + .handle() + .clone(); // Proactively build a Matrix Client in the background so that the SSO Server // can have a quicker start if needed (as it's rather slow to build this client). rt_handle.spawn(async move { match build_client(&Cli::default(), app_data_dir()).await { Ok(client_and_session) => { - DEFAULT_SSO_CLIENT.lock().unwrap() + DEFAULT_SSO_CLIENT + .lock() + .unwrap() .get_or_insert(client_and_session); } Err(e) => error!("Error: could not create DEFAULT_SSO_CLIENT object: {e}"), @@ -2257,7 +2606,6 @@ pub fn start_matrix_tokio() -> Result { Ok(rt_handle) } - /// A tokio::watch channel sender for sending requests from the RoomScreen UI widget /// to the corresponding background async task for that room (its `timeline_subscriber_handler`). pub type TimelineRequestSender = watch::Sender>; @@ -2324,13 +2672,13 @@ impl Drop for JoinedRoomDetails { } } - /// A const-compatible hasher, used for `static` items containing `HashMap`s or `HashSet`s. type ConstHasher = BuildHasherDefault; /// Information about all joined rooms that our client currently know about. /// We use a `HashMap` for O(1) lookups, as this is accessed frequently (e.g. every timeline update). -static ALL_JOINED_ROOMS: Mutex> = Mutex::new(HashMap::with_hasher(BuildHasherDefault::new())); +static ALL_JOINED_ROOMS: Mutex> = + Mutex::new(HashMap::with_hasher(BuildHasherDefault::new())); /// Returns the timeline and timeline update sender for the given joined room/thread timeline. fn get_per_timeline_details<'a>( @@ -2340,7 +2688,10 @@ fn get_per_timeline_details<'a>( let room_info = all_joined_rooms.get_mut(kind.room_id())?; match kind { TimelineKind::MainRoom { .. } => Some(&mut room_info.main_timeline), - TimelineKind::Thread { thread_root_event_id, .. } => room_info.thread_timelines.get_mut(thread_root_event_id), + TimelineKind::Thread { + thread_root_event_id, + .. + } => room_info.thread_timelines.get_mut(thread_root_event_id), } } @@ -2351,14 +2702,22 @@ fn get_timeline(kind: &TimelineKind) -> Option> { } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the timeline and timeline update sender for the given timeline kind. -fn get_timeline_and_sender(kind: &TimelineKind) -> Option<(Arc, crossbeam_channel::Sender)> { - get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind) - .map(|details| (details.timeline.clone(), details.timeline_update_sender.clone())) +fn get_timeline_and_sender( + kind: &TimelineKind, +) -> Option<(Arc, crossbeam_channel::Sender)> { + get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind).map(|details| { + ( + details.timeline.clone(), + details.timeline_update_sender.clone(), + ) + }) } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the main timeline for the given room. fn get_room_timeline(room_id: &RoomId) -> Option> { - ALL_JOINED_ROOMS.lock().unwrap() + ALL_JOINED_ROOMS + .lock() + .unwrap() .get(room_id) .map(|jrd| jrd.main_timeline.timeline.clone()) } @@ -2372,15 +2731,16 @@ pub fn get_client() -> Option { /// Returns the user ID of the currently logged-in user, if any. pub fn current_user_id() -> Option { - CLIENT.lock().unwrap().as_ref().and_then(|c| - c.session_meta().map(|m| m.user_id.clone()) - ) + CLIENT + .lock() + .unwrap() + .as_ref() + .and_then(|c| c.session_meta().map(|m| m.user_id.clone())) } /// The singleton sync service. static SYNC_SERVICE: Mutex>> = Mutex::new(None); - /// Get a reference to the current sync service, if available. pub fn get_sync_service() -> Option> { SYNC_SERVICE.lock().ok()?.as_ref().cloned() @@ -2389,7 +2749,8 @@ pub fn get_sync_service() -> Option> { /// The list of users that the current user has chosen to ignore. /// Ideally we shouldn't have to maintain this list ourselves, /// but the Matrix SDK doesn't currently properly maintain the list of ignored users. -static IGNORED_USERS: Mutex> = Mutex::new(HashSet::with_hasher(BuildHasherDefault::new())); +static IGNORED_USERS: Mutex> = + Mutex::new(HashSet::with_hasher(BuildHasherDefault::new())); /// Returns a deep clone of the current list of ignored users. pub fn get_ignored_users() -> HashSet { @@ -2401,7 +2762,6 @@ pub fn is_user_ignored(user_id: &UserId) -> bool { IGNORED_USERS.lock().unwrap().contains(user_id) } - /// Returns three channel endpoints related to the timeline for the given joined room or thread. /// /// 1. A timeline update sender. @@ -2415,7 +2775,10 @@ pub fn take_timeline_endpoints(kind: &TimelineKind) -> Option let jrd = all_joined_rooms.get_mut(kind.room_id())?; let details = match kind { TimelineKind::MainRoom { .. } => &mut jrd.main_timeline, - TimelineKind::Thread { thread_root_event_id, .. } => jrd.thread_timelines.get_mut(thread_root_event_id)?, + TimelineKind::Thread { + thread_root_event_id, + .. + } => jrd.thread_timelines.get_mut(thread_root_event_id)?, }; let (update_receiver, request_sender) = details.timeline_singleton_endpoints.take()?; Some(TimelineEndpoints { @@ -2428,25 +2791,18 @@ pub fn take_timeline_endpoints(kind: &TimelineKind) -> Option const DEFAULT_HOMESERVER: &str = "matrix.org"; -fn username_to_full_user_id( - username: &str, - homeserver: Option<&str>, -) -> Option { - username - .try_into() - .ok() - .or_else(|| { - let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); - let user_id_str = if username.starts_with("@") { - format!("{}:{}", username, homeserver_url) - } else { - format!("@{}:{}", username, homeserver_url) - }; - user_id_str.as_str().try_into().ok() - }) +fn username_to_full_user_id(username: &str, homeserver: Option<&str>) -> Option { + username.try_into().ok().or_else(|| { + let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); + let user_id_str = if username.starts_with("@") { + format!("{}:{}", username, homeserver_url) + } else { + format!("@{}:{}", username, homeserver_url) + }; + user_id_str.as_str().try_into().ok() + }) } - /// Info we store about a room received by the room list service. /// /// This struct is necessary in order for us to track the previous state @@ -2474,18 +2830,14 @@ struct RoomListServiceRoomInfo { impl RoomListServiceRoomInfo { async fn from_room(room: matrix_sdk::Room, current_user_id: &Option) -> Self { // Parallelize fetching of independent room data. - let (is_direct, tags, display_name, user_power_levels) = tokio::join!( - room.is_direct(), - room.tags(), - room.display_name(), - async { + let (is_direct, tags, display_name, user_power_levels) = + tokio::join!(room.is_direct(), room.tags(), room.display_name(), async { if let Some(user_id) = current_user_id { UserPowerLevels::from_room(&room, user_id.deref()).await } else { None } - } - ); + }); Self { room_id: room.room_id().to_owned(), @@ -2527,26 +2879,26 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let most_recent_user_id = persistence::most_recent_user_id().await; log!("Most recent user ID: {most_recent_user_id:?}"); let cli_parse_result = Cli::try_parse(); - let cli_has_valid_username_password = cli_parse_result.as_ref() + let cli_has_valid_username_password = cli_parse_result + .as_ref() .is_ok_and(|cli| !cli.user_id.is_empty() && !cli.password.is_empty()); - log!("CLI parsing succeeded? {}. CLI has valid UN+PW? {}", + log!( + "CLI parsing succeeded? {}. CLI has valid UN+PW? {}", cli_parse_result.as_ref().is_ok(), cli_has_valid_username_password, ); - let wait_for_login = !cli_has_valid_username_password && ( - most_recent_user_id.is_none() - || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login") - ); + let wait_for_login = !cli_has_valid_username_password + && (most_recent_user_id.is_none() + || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login")); log!("Waiting for login? {}", wait_for_login); let new_login_opt: Option<(Client, Option, bool)> = if !wait_for_login { - let specified_username = cli_parse_result.as_ref().ok().and_then(|cli| - username_to_full_user_id( - &cli.user_id, - cli.homeserver.as_deref(), - ) - ); - log!("Trying to restore session for user: {:?}", + let specified_username = cli_parse_result + .as_ref() + .ok() + .and_then(|cli| username_to_full_user_id(&cli.user_id, cli.homeserver.as_deref())); + log!( + "Trying to restore session for user: {:?}", specified_username.as_ref().or(most_recent_user_id.as_ref()) ); match persistence::restore_session(specified_username.clone()).await { @@ -2563,7 +2915,10 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { Cx::post_action(LoginAction::LoginFailure(status_err.to_string())); if let Ok(cli) = &cli_parse_result { - log!("Attempting auto-login from CLI arguments as user '{}'...", cli.user_id); + log!( + "Attempting auto-login from CLI arguments as user '{}'...", + cli.user_id + ); Cx::post_action(LoginAction::CliAutoLogin { user_id: cli.user_id.clone(), homeserver: cli.homeserver.clone(), @@ -2572,9 +2927,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { Ok((client, sync_token)) => Some((client, sync_token, false)), Err(e) => { error!("CLI-based login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure( - format!("Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}") - )); + Cx::post_action(LoginAction::LoginFailure(format!( + "Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}" + ))); enqueue_rooms_list_update(RoomsListUpdate::Status { status: format!("Login failed: {e:?}"), }); @@ -2599,34 +2954,30 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let (client, sync_service, logged_in_user_id) = 'login_loop: loop { let (client, _sync_token, validate_session) = match initial_client_opt.take() { Some(login) => login, - None => { - loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => { - match login(&cli, login_request).await { - Ok((client, sync_token)) => break (client, sync_token, false), - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), - }); - } - } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); - Cx::post_action(LoginAction::LoginFailure(err.clone())); + None => loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => match login(&cli, login_request).await { + Ok((client, sync_token)) => break (client, sync_token, false), + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err, + status: format!("Login failed: {e}"), }); - return; } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + let err = String::from( + "Please restart Robrix.\n\nUnable to listen for login requests.", + ); + Cx::post_action(LoginAction::LoginFailure(err.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err }); + return; } } - } + }, }; if validate_session { @@ -2634,7 +2985,8 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { Ok(_) => {} Err(e) if is_invalid_token_http_error(&e) => { clear_persisted_session(client.user_id()).await; - let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; + let err_msg = + "Your login token is no longer valid.\n\nPlease log in again."; Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.to_string(), @@ -2642,7 +2994,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { continue 'login_loop; } Err(e) => { - warning!("Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}"); + warning!( + "Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}" + ); } } } @@ -2652,7 +3006,8 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let _ = client_opt.take(); } - let logged_in_user_id: OwnedUserId = client.user_id() + let logged_in_user_id: OwnedUserId = client + .user_id() .expect("BUG: Client::user_id() returned None after successful login!") .to_owned(); let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); @@ -2660,7 +3015,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // Store this active client in our global Client state so that other tasks can access it. if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { - error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); + error!( + "BUG: unexpectedly replaced an existing client when initializing the matrix client." + ); } // Listen for changes to our verification status and incoming verification requests. @@ -2684,7 +3041,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let err_msg = if is_invalid_token_error(&e) { "Your login token is no longer valid.\n\nPlease log in again.".to_string() } else { - format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") + format!( + "Please restart Robrix.\n\nFailed to create Matrix sync service: {e}." + ) }; if is_invalid_token_error(&e) { clear_persisted_session(client.user_id()).await; @@ -2722,7 +3081,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let room_list_service = sync_service.room_list_service(); if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { - error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); + error!( + "BUG: unexpectedly replaced an existing sync service when initializing the matrix client." + ); } let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); @@ -2839,7 +3200,6 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { } } - /// The main async task that listens for changes to all rooms. async fn room_list_service_loop(room_list_service: Arc) -> Result<()> { let all_rooms_list = room_list_service.all_rooms().await?; @@ -2853,13 +3213,13 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu // 1. not spaces (those are handled by the SpaceService), // 2. not left (clients don't typically show rooms that the user has already left), // 3. not outdated (don't show tombstoned rooms whose successor is already joined). - room_list_dynamic_entries_controller.set_filter(Box::new( - filters::new_filter_all(vec![ - Box::new(filters::new_filter_not(Box::new(filters::new_filter_space()))), - Box::new(filters::new_filter_non_left()), - Box::new(filters::new_filter_deduplicate_versions()), - ]) - )); + room_list_dynamic_entries_controller.set_filter(Box::new(filters::new_filter_all(vec![ + Box::new(filters::new_filter_not(Box::new( + filters::new_filter_space(), + ))), + Box::new(filters::new_filter_non_left()), + Box::new(filters::new_filter_deduplicate_versions()), + ]))); let mut all_known_rooms: Vector = Vector::new(); let current_user_id = current_user_id(); @@ -2875,7 +3235,13 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu // Append and Reset are identical, except for Reset first clears all rooms. let _num_new_rooms = new_rooms.len(); if is_reset { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Reset, old length {}, new length {}", all_known_rooms.len(), new_rooms.len()); } + if LOG_ROOM_LIST_DIFFS { + log!( + "room_list: diff Reset, old length {}, new length {}", + all_known_rooms.len(), + new_rooms.len() + ); + } // Iterate manually so we can know which rooms are being removed. while let Some(room) = all_known_rooms.pop_back() { remove_room(&room); @@ -2886,20 +3252,35 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); } else { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Append, old length {}, adding {} new items", all_known_rooms.len(), _num_new_rooms); } + if LOG_ROOM_LIST_DIFFS { + log!( + "room_list: diff Append, old length {}, adding {} new items", + all_known_rooms.len(), + _num_new_rooms + ); + } } // Parallelize creating each room's RoomListServiceRoomInfo and adding that new room. // We combine `from_room` and `add_new_room` into a single async task per room. - let new_room_infos: Vec = join_all( - new_rooms.into_iter().map(|room| async { - let room_info = RoomListServiceRoomInfo::from_room(room.into_inner(), ¤t_user_id).await; - if let Err(e) = add_new_room(&room_info, &room_list_service, false).await { - error!("Failed to add new room: {:?} ({}); error: {:?}", room_info.display_name, room_info.room_id, e); + let new_room_infos: Vec = + join_all(new_rooms.into_iter().map(|room| async { + let room_info = RoomListServiceRoomInfo::from_room( + room.into_inner(), + ¤t_user_id, + ) + .await; + if let Err(e) = + add_new_room(&room_info, &room_list_service, false).await + { + error!( + "Failed to add new room: {:?} ({}); error: {:?}", + room_info.display_name, room_info.room_id, e + ); } room_info - }) - ).await; + })) + .await; // Send room order update with the new room IDs let (room_id_refs, room_ids) = { @@ -2913,43 +3294,57 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu }; if !room_ids.is_empty() { enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Append { values: room_ids } + VecDiff::Append { values: room_ids }, )); room_list_service.subscribe_to_rooms(&room_id_refs).await; all_known_rooms.extend(new_room_infos); } } VectorDiff::Clear => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Clear"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Clear"); + } all_known_rooms.clear(); ALL_JOINED_ROOMS.lock().unwrap().clear(); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); } VectorDiff::PushFront { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushFront"); } - let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PushFront"); + } + let new_room = + RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) + .await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushFront { value: room_id } + VecDiff::PushFront { value: room_id }, )); all_known_rooms.push_front(new_room); } VectorDiff::PushBack { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushBack"); } - let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PushBack"); + } + let new_room = + RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) + .await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushBack { value: room_id } + VecDiff::PushBack { value: room_id }, )); all_known_rooms.push_back(new_room); } remove_diff @ VectorDiff::PopFront => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopFront"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PopFront"); + } if let Some(room) = all_known_rooms.pop_front() { - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PopFront)); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::PopFront, + )); optimize_remove_then_add_into_update( remove_diff, &room, @@ -2957,13 +3352,18 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ).await?; + ) + .await?; } } remove_diff @ VectorDiff::PopBack => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopBack"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff PopBack"); + } if let Some(room) = all_known_rooms.pop_back() { - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PopBack)); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::PopBack, + )); optimize_remove_then_add_into_update( remove_diff, &room, @@ -2971,38 +3371,61 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ).await?; + ) + .await?; } } - VectorDiff::Insert { index, value: new_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Insert at {index}"); } - let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; + VectorDiff::Insert { + index, + value: new_room, + } => { + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Insert at {index}"); + } + let new_room = + RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) + .await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Insert { index, value: room_id } - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Insert { + index, + value: room_id, + })); all_known_rooms.insert(index, new_room); } - VectorDiff::Set { index, value: changed_room } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Set at {index}"); } - let changed_room = RoomListServiceRoomInfo::from_room(changed_room.into_inner(), ¤t_user_id).await; + VectorDiff::Set { + index, + value: changed_room, + } => { + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Set at {index}"); + } + let changed_room = RoomListServiceRoomInfo::from_room( + changed_room.into_inner(), + ¤t_user_id, + ) + .await; if let Some(old_room) = all_known_rooms.get(index) { update_room(old_room, &changed_room, &room_list_service).await?; } else { error!("BUG: room list diff: Set index {index} was out of bounds."); } // Send order update (room ID at this index may have changed) - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Set { index, value: changed_room.room_id.clone() } - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Set { + index, + value: changed_room.room_id.clone(), + })); all_known_rooms.set(index, changed_room); } remove_diff @ VectorDiff::Remove { index } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Remove at {index}"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Remove at {index}"); + } if index < all_known_rooms.len() { let room = all_known_rooms.remove(index); - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Remove { index })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::Remove { index }, + )); optimize_remove_then_add_into_update( remove_diff, &room, @@ -3010,13 +3433,19 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ).await?; + ) + .await?; } else { - error!("BUG: room_list: diff Remove index {index} out of bounds, len {}", all_known_rooms.len()); + error!( + "BUG: room_list: diff Remove index {index} out of bounds, len {}", + all_known_rooms.len() + ); } } VectorDiff::Truncate { length } => { - if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Truncate to {length}"); } + if LOG_ROOM_LIST_DIFFS { + log!("room_list: diff Truncate to {length}"); + } // Iterate manually so we can know which rooms are being removed. while all_known_rooms.len() > length { if let Some(room) = all_known_rooms.pop_back() { @@ -3025,7 +3454,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu } all_known_rooms.truncate(length); // sanity check enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Truncate { length } + VecDiff::Truncate { length }, )); } } @@ -3035,7 +3464,6 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu bail!("room list service sync loop ended unexpectedly") } - /// Attempts to optimize a common RoomListService operation of remove + add. /// /// If a `Remove` diff (or `PopBack` or `PopFront`) is immediately followed by @@ -3055,48 +3483,58 @@ async fn optimize_remove_then_add_into_update( ) -> Result<()> { let next_diff_was_handled: bool; match peekable_diffs.peek() { - Some(VectorDiff::Insert { index: insert_index, value: new_room }) - if room.room_id == new_room.room_id() => - { + Some(VectorDiff::Insert { + index: insert_index, + value: new_room, + }) if room.room_id == new_room.room_id() => { if LOG_ROOM_LIST_DIFFS { - log!("Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", room.room_id); + log!( + "Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", + room.room_id + ); } - let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = + RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the insert - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Insert { index: *insert_index, value: new_room.room_id.clone() } - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Insert { + index: *insert_index, + value: new_room.room_id.clone(), + })); all_known_rooms.insert(*insert_index, new_room); next_diff_was_handled = true; } - Some(VectorDiff::PushFront { value: new_room }) - if room.room_id == new_room.room_id() => - { + Some(VectorDiff::PushFront { value: new_room }) if room.room_id == new_room.room_id() => { if LOG_ROOM_LIST_DIFFS { - log!("Optimizing {remove_diff:?} + PushFront into Update for room {}", room.room_id); + log!( + "Optimizing {remove_diff:?} + PushFront into Update for room {}", + room.room_id + ); } - let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = + RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the push front - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushFront { value: new_room.room_id.clone() } - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PushFront { + value: new_room.room_id.clone(), + })); all_known_rooms.push_front(new_room); next_diff_was_handled = true; } - Some(VectorDiff::PushBack { value: new_room }) - if room.room_id == new_room.room_id() => - { + Some(VectorDiff::PushBack { value: new_room }) if room.room_id == new_room.room_id() => { if LOG_ROOM_LIST_DIFFS { - log!("Optimizing {remove_diff:?} + PushBack into Update for room {}", room.room_id); + log!( + "Optimizing {remove_diff:?} + PushBack into Update for room {}", + room.room_id + ); } - let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = + RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the push back - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushBack { value: new_room.room_id.clone() } - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PushBack { + value: new_room.room_id.clone(), + })); all_known_rooms.push_back(new_room); next_diff_was_handled = true; } @@ -3110,7 +3548,6 @@ async fn optimize_remove_then_add_into_update( Ok(()) } - /// Invoked when the room list service has received an update that changes an existing room. async fn update_room( old_room: &RoomListServiceRoomInfo, @@ -3121,18 +3558,29 @@ async fn update_room( if old_room.room_id == new_room_id { // Handle state transitions for a room. if LOG_ROOM_LIST_DIFFS { - log!("Room {:?} ({new_room_id}) state went from {:?} --> {:?}", new_room.display_name, old_room.state, new_room.state); + log!( + "Room {:?} ({new_room_id}) state went from {:?} --> {:?}", + new_room.display_name, + old_room.state, + new_room.state + ); } if old_room.state != new_room.state { match new_room.state { RoomState::Banned => { // TODO: handle rooms that this user has been banned from. - log!("Removing Banned room: {:?} ({new_room_id})", new_room.display_name); + log!( + "Removing Banned room: {:?} ({new_room_id})", + new_room.display_name + ); remove_room(new_room); return Ok(()); } RoomState::Left => { - log!("Removing Left room: {:?} ({new_room_id})", new_room.display_name); + log!( + "Removing Left room: {:?} ({new_room_id})", + new_room.display_name + ); // TODO: instead of removing this, we could optionally add it to // a separate list of left rooms, which would be collapsed by default. // Upon clicking a left room, we could show a splash page @@ -3142,11 +3590,17 @@ async fn update_room( return Ok(()); } RoomState::Joined => { - log!("update_room(): adding new Joined room: {:?} ({new_room_id})", new_room.display_name); + log!( + "update_room(): adding new Joined room: {:?} ({new_room_id})", + new_room.display_name + ); return add_new_room(new_room, room_list_service, true).await; } RoomState::Invited => { - log!("update_room(): adding new Invited room: {:?} ({new_room_id})", new_room.display_name); + log!( + "update_room(): adding new Invited room: {:?} ({new_room_id})", + new_room.display_name + ); return add_new_room(new_room, room_list_service, true).await; } RoomState::Knocked => { @@ -3164,7 +3618,12 @@ async fn update_room( spawn_fetch_room_avatar(new_room); } if old_room.display_name != new_room.display_name { - log!("Updating room {} name: {:?} --> {:?}", new_room_id, old_room.display_name, new_room.display_name); + log!( + "Updating room {} name: {:?} --> {:?}", + new_room_id, + old_room.display_name, + new_room.display_name + ); enqueue_rooms_list_update(RoomsListUpdate::UpdateRoomName { new_room_name: (new_room.display_name.clone(), new_room_id.clone()).into(), @@ -3174,12 +3633,15 @@ async fn update_room( // Then, we check for changes to room data that is only relevant to joined rooms: // including the latest event, tags, unread counts, is_direct, tombstoned state, power levels, etc. // Invited or left rooms don't care about these details. - if matches!(new_room.state, RoomState::Joined) { + if matches!(new_room.state, RoomState::Joined) { // For some reason, the latest event API does not reliably catch *all* changes // to the latest event in a given room, such as redactions. // Thus, we have to re-obtain the latest event on *every* update, regardless of timestamp. // - let update_latest = match (old_room.latest_event_timestamp, new_room.room.latest_event_timestamp()) { + let update_latest = match ( + old_room.latest_event_timestamp, + new_room.room.latest_event_timestamp(), + ) { (Some(old_ts), Some(new_ts)) => new_ts >= old_ts, (None, Some(_)) => true, _ => false, @@ -3188,9 +3650,13 @@ async fn update_room( update_latest_event(&new_room.room).await; } - if old_room.tags != new_room.tags { - log!("Updating room {} tags from {:?} to {:?}", new_room_id, old_room.tags, new_room.tags); + log!( + "Updating room {} tags from {:?} to {:?}", + new_room_id, + old_room.tags, + new_room.tags + ); enqueue_rooms_list_update(RoomsListUpdate::Tags { room_id: new_room_id.clone(), new_tags: new_room.tags.clone().unwrap_or_default(), @@ -3201,11 +3667,15 @@ async fn update_room( || old_room.num_unread_messages != new_room.num_unread_messages || old_room.num_unread_mentions != new_room.num_unread_mentions { - log!("Updating room {}, marked unread {} --> {}, unread messages {} --> {}, unread mentions {} --> {}", + log!( + "Updating room {}, marked unread {} --> {}, unread messages {} --> {}, unread mentions {} --> {}", new_room_id, - old_room.is_marked_unread, new_room.is_marked_unread, - old_room.num_unread_messages, new_room.num_unread_messages, - old_room.num_unread_mentions, new_room.num_unread_mentions, + old_room.is_marked_unread, + new_room.is_marked_unread, + old_room.num_unread_messages, + new_room.num_unread_messages, + old_room.num_unread_mentions, + new_room.num_unread_mentions, ); enqueue_rooms_list_update(RoomsListUpdate::UpdateNumUnreadMessages { room_id: new_room_id.clone(), @@ -3216,7 +3686,8 @@ async fn update_room( } if old_room.is_direct != new_room.is_direct { - log!("Updating room {} is_direct from {} to {}", + log!( + "Updating room {} is_direct from {} to {}", new_room_id, old_room.is_direct, new_room.is_direct, @@ -3231,7 +3702,8 @@ async fn update_room( let mut get_timeline_update_sender = |room_id| { if __timeline_update_sender_opt.is_none() { if let Some(jrd) = ALL_JOINED_ROOMS.lock().unwrap().get(room_id) { - __timeline_update_sender_opt = Some(jrd.main_timeline.timeline_update_sender.clone()); + __timeline_update_sender_opt = + Some(jrd.main_timeline.timeline_update_sender.clone()); } } __timeline_update_sender_opt.clone() @@ -3240,7 +3712,9 @@ async fn update_room( if !old_room.is_tombstoned && new_room.is_tombstoned { let successor_room = new_room.room.successor_room(); log!("Updating room {new_room_id} to be tombstoned, {successor_room:?}"); - enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { room_id: new_room_id.clone() }); + enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { + room_id: new_room_id.clone(), + }); if let Some(timeline_update_sender) = get_timeline_update_sender(&new_room_id) { spawn_fetch_successor_room_preview( room_list_service.client().clone(), @@ -3249,7 +3723,9 @@ async fn update_room( timeline_update_sender, ); } else { - error!("BUG: could not find JoinedRoomDetails for newly-tombstoned room {new_room_id}"); + error!( + "BUG: could not find JoinedRoomDetails for newly-tombstoned room {new_room_id}" + ); } } @@ -3260,37 +3736,38 @@ async fn update_room( log!("Updating room {new_room_id} user power levels."); match timeline_update_sender.send(TimelineUpdate::UserPowerLevels(nupl)) { Ok(_) => SignalToUI::set_ui_signal(), - Err(_) => error!("Failed to send the UserPowerLevels update to room {new_room_id}"), + Err(_) => error!( + "Failed to send the UserPowerLevels update to room {new_room_id}" + ), } } else { - error!("BUG: could not find JoinedRoomDetails for room {new_room_id} where power levels changed."); + error!( + "BUG: could not find JoinedRoomDetails for room {new_room_id} where power levels changed." + ); } } } Ok(()) - } - else { - warning!("UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", - old_room.room_id, new_room_id, + } else { + warning!( + "UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", + old_room.room_id, + new_room_id, ); remove_room(old_room); add_new_room(new_room, room_list_service, true).await } } - /// Invoked when the room list service has received an update to remove an existing room. fn remove_room(room: &RoomListServiceRoomInfo) { ALL_JOINED_ROOMS.lock().unwrap().remove(&room.room_id); - enqueue_rooms_list_update( - RoomsListUpdate::RemoveRoom { - room_id: room.room_id.clone(), - new_state: room.state, - } - ); + enqueue_rooms_list_update(RoomsListUpdate::RemoveRoom { + room_id: room.room_id.clone(), + new_state: room.state, + }); } - /// Invoked when the room list service has received an update with a brand new room. async fn add_new_room( new_room: &RoomListServiceRoomInfo, @@ -3299,26 +3776,39 @@ async fn add_new_room( ) -> Result<()> { match new_room.state { RoomState::Knocked => { - log!("Got new Knocked room: {:?} ({})", new_room.display_name, new_room.room_id); + log!( + "Got new Knocked room: {:?} ({})", + new_room.display_name, + new_room.room_id + ); // Note: here we could optionally display Knocked rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Banned => { - log!("Got new Banned room: {:?} ({})", new_room.display_name, new_room.room_id); + log!( + "Got new Banned room: {:?} ({})", + new_room.display_name, + new_room.room_id + ); // Note: here we could optionally display Banned rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Left => { - log!("Got new Left room: {:?} ({:?})", new_room.display_name, new_room.room_id); + log!( + "Got new Left room: {:?} ({:?})", + new_room.display_name, + new_room.room_id + ); // Note: here we could optionally display Left rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Invited => { let invite_details = new_room.room.invite_details().await.ok(); - let room_name_id = RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); + let room_name_id = + RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); // Start with a basic text avatar; the avatar image will be fetched asynchronously below. let room_avatar = avatar_from_room_name(room_name_id.name_for_avatar()); let inviter_info = if let Some(inviter) = invite_details.and_then(|d| d.inviter) { @@ -3335,18 +3825,20 @@ async fn add_new_room( } else { None }; - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom(InvitedRoomInfo { - room_name_id: room_name_id.clone(), - inviter_info, - room_avatar, - canonical_alias: new_room.room.canonical_alias(), - alt_aliases: new_room.room.alt_aliases(), - // we don't actually display the latest event for Invited rooms, so don't bother. - latest: None, - invite_state: Default::default(), - is_selected: false, - is_direct: new_room.is_direct, - })); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom( + InvitedRoomInfo { + room_name_id: room_name_id.clone(), + inviter_info, + room_avatar, + canonical_alias: new_room.room.canonical_alias(), + alt_aliases: new_room.room.alt_aliases(), + // we don't actually display the latest event for Invited rooms, so don't bother. + latest: None, + invite_state: Default::default(), + is_selected: false, + is_direct: new_room.is_direct, + }, + )); Cx::post_action(AppStateAction::RoomLoadedSuccessfully { room_name_id, is_invite: true, @@ -3354,17 +3846,21 @@ async fn add_new_room( spawn_fetch_room_avatar(new_room); return Ok(()); } - RoomState::Joined => { } // Fall through to adding the joined room below. + RoomState::Joined => {} // Fall through to adding the joined room below. } // If we didn't already subscribe to this room, do so now. // This ensures we will properly receive all of its states and latest event. if subscribe { - room_list_service.subscribe_to_rooms(&[&new_room.room_id]).await; + room_list_service + .subscribe_to_rooms(&[&new_room.room_id]) + .await; } let timeline = Arc::new( - new_room.room.timeline_builder() + new_room + .room + .timeline_builder() .with_focus(TimelineFocus::Live { // we show threads as separate timelines in their own RoomScreen hide_threaded_events: true, @@ -3372,7 +3868,12 @@ async fn add_new_room( .track_read_marker_and_receipts(TimelineReadReceiptTracking::AllEvents) .build() .await - .map_err(|e| anyhow::anyhow!("BUG: Failed to build timeline for room {}: {e}", new_room.room_id))?, + .map_err(|e| { + anyhow::anyhow!( + "BUG: Failed to build timeline for room {}: {e}", + new_room.room_id + ) + })?, ); let (timeline_update_sender, timeline_update_receiver) = crossbeam_channel::unbounded(); @@ -3388,7 +3889,11 @@ async fn add_new_room( // We need to add the room to the `ALL_JOINED_ROOMS` list before we can send // an `AddJoinedRoom` update to the RoomsList widget, because that widget might // immediately issue a `MatrixRequest` that relies on that room being in `ALL_JOINED_ROOMS`. - log!("Adding new joined room {}, name: {:?}", new_room.room_id, new_room.display_name); + log!( + "Adding new joined room {}, name: {:?}", + new_room.room_id, + new_room.display_name + ); ALL_JOINED_ROOMS.lock().unwrap().insert( new_room.room_id.clone(), JoinedRoomDetails { @@ -3409,7 +3914,8 @@ async fn add_new_room( let latest = get_latest_event_details( &new_room.room.latest_event().await, room_list_service.client(), - ).await; + ) + .await; let room_name_id = RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); // Start with a basic text avatar; the avatar image will be fetched asynchronously below. let room_avatar = avatar_from_room_name(room_name_id.name_for_avatar()); @@ -3440,7 +3946,8 @@ async fn add_new_room( #[allow(unused)] async fn current_ignore_user_list(client: &Client) -> Option> { use matrix_sdk::ruma::events::ignored_user_list::IgnoredUserListEventContent; - let ignored_users = client.account() + let ignored_users = client + .account() .account_data::() .await .ok()?? @@ -3504,7 +4011,9 @@ fn handle_load_app_state(user_id: OwnedUserId) { && !app_state.saved_dock_state_home.dock_items.is_empty() { log!("Loaded room panel state from app data directory. Restoring now..."); - Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState(app_state)); + Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState( + app_state, + )); } } Err(_e) => { @@ -3523,12 +4032,12 @@ fn handle_load_app_state(user_id: OwnedUserId) { fn is_invalid_token_error(e: &sync_service::Error) -> bool { use matrix_sdk::ruma::api::client::error::ErrorKind; let sdk_error = match e { - sync_service::Error::RoomList( - matrix_sdk_ui::room_list_service::Error::SlidingSync(err) - ) => err, - sync_service::Error::EncryptionSync( - encryption_sync_service::Error::SlidingSync(err) - ) => err, + sync_service::Error::RoomList(matrix_sdk_ui::room_list_service::Error::SlidingSync( + err, + )) => err, + sync_service::Error::EncryptionSync(encryption_sync_service::Error::SlidingSync(err)) => { + err + } _ => return false, }; matches!( @@ -3610,14 +4119,12 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { const SYNC_INDICATOR_DELAY: Duration = Duration::from_millis(100); /// Duration for sync indicator delay before hiding const SYNC_INDICATOR_HIDE_DELAY: Duration = Duration::from_millis(200); - let sync_indicator_stream = sync_service.room_list_service() - .sync_indicator( - SYNC_INDICATOR_DELAY, - SYNC_INDICATOR_HIDE_DELAY - ); - + let sync_indicator_stream = sync_service + .room_list_service() + .sync_indicator(SYNC_INDICATOR_DELAY, SYNC_INDICATOR_HIDE_DELAY); + Handle::current().spawn(async move { - let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); + let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); while let Some(indicator) = sync_indicator_stream.next().await { let is_syncing = match indicator { @@ -3630,7 +4137,10 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { } fn handle_room_list_service_loading_state(mut loading_state: Subscriber) { - log!("Initial room list loading state is {:?}", loading_state.get()); + log!( + "Initial room list loading state is {:?}", + loading_state.get() + ); Handle::current().spawn(async move { while let Some(state) = loading_state.next().await { log!("Received a room list loading state update: {state:?}"); @@ -3638,8 +4148,12 @@ fn handle_room_list_service_loading_state(mut loading_state: Subscriber { enqueue_rooms_list_update(RoomsListUpdate::NotLoaded); } - RoomListLoadingState::Loaded { maximum_number_of_rooms } => { - enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { max_rooms: maximum_number_of_rooms }); + RoomListLoadingState::Loaded { + maximum_number_of_rooms, + } => { + enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { + max_rooms: maximum_number_of_rooms, + }); // The SDK docs state that we cannot move from the `Loaded` state // back to the `NotLoaded` state, so we can safely exit this task here. return; @@ -3662,12 +4176,12 @@ fn spawn_fetch_successor_room_preview( Handle::current().spawn(async move { log!("Updating room {tombstoned_room_id} to be tombstoned, {successor_room:?}"); let srd = if let Some(SuccessorRoom { room_id, reason }) = successor_room { - match fetch_room_preview_with_avatar( - &client, - room_id.deref().into(), - Vec::new(), - ).await { - Ok(room_preview) => SuccessorRoomDetails::Full { room_preview, reason }, + match fetch_room_preview_with_avatar(&client, room_id.deref().into(), Vec::new()).await + { + Ok(room_preview) => SuccessorRoomDetails::Full { + room_preview, + reason, + }, Err(e) => { log!("Failed to fetch preview of successor room {room_id}, error: {e:?}"); SuccessorRoomDetails::Basic(SuccessorRoom { room_id, reason }) @@ -3701,12 +4215,18 @@ async fn fetch_room_preview_with_avatar( }; match client.media().get_media_content(&media_request, true).await { Ok(avatar_content) => { - log!("Fetched avatar for room preview {:?} ({})", room_preview.name, room_preview.room_id); + log!( + "Fetched avatar for room preview {:?} ({})", + room_preview.name, + room_preview.room_id + ); FetchedRoomAvatar::Image(avatar_content.into()) } Err(e) => { - log!("Failed to fetch avatar for room preview {:?} ({}), error: {e:?}", - room_preview.name, room_preview.room_id + log!( + "Failed to fetch avatar for room preview {:?} ({}), error: {e:?}", + room_preview.name, + room_preview.room_id ); avatar_from_room_name(room_preview.name.as_deref()) } @@ -3726,7 +4246,10 @@ async fn fetch_room_preview_with_avatar( async fn fetch_thread_summary_details( room: &Room, thread_root_event_id: &EventId, -) -> (u32, Option) { +) -> ( + u32, + Option, +) { let mut num_replies = 0; let mut latest_reply_event = None; @@ -3784,10 +4307,7 @@ async fn fetch_latest_thread_reply_event( } /// Counts all replies in the given thread by paginating `/relations` in batches. -async fn count_thread_replies( - room: &Room, - thread_root_event_id: &EventId, -) -> Option { +async fn count_thread_replies(room: &Room, thread_root_event_id: &EventId) -> Option { let mut total_replies: u32 = 0; let mut next_batch_token = None; @@ -3800,7 +4320,10 @@ async fn count_thread_replies( ..Default::default() }; - let relations = room.relations(thread_root_event_id.to_owned(), options).await.ok()?; + let relations = room + .relations(thread_root_event_id.to_owned(), options) + .await + .ok()?; if relations.chunk.is_empty() { break; } @@ -3826,7 +4349,8 @@ async fn text_preview_of_latest_thread_reply( Ok(Some(rm)) => Some(rm), _ => room.get_member(&sender_id).await.ok().flatten(), }; - let sender_name = sender_room_member.as_ref() + let sender_name = sender_room_member + .as_ref() .and_then(|rm| rm.display_name()) .unwrap_or(sender_id.as_str()); let text_preview = text_preview_of_raw_timeline_event(raw, sender_name).unwrap_or_else(|| { @@ -3843,7 +4367,6 @@ async fn text_preview_of_latest_thread_reply( } } - /// Returns the timestamp and an HTML-formatted text preview of the given `latest_event`. /// /// If the sender profile of the event is not yet available, this function will @@ -3867,29 +4390,37 @@ async fn get_latest_event_details( match latest_event_value { LatestEventValue::None => None, - LatestEventValue::Remote { timestamp, sender, is_own, profile, content } => { + LatestEventValue::Remote { + timestamp, + sender, + is_own, + profile, + content, + } => { let sender_username = get_sender_username!(profile, sender, *is_own); - let latest_message_text = text_preview_of_timeline_item( - content, - sender, - &sender_username, - ).format_with(&sender_username, true); + let latest_message_text = + text_preview_of_timeline_item(content, sender, &sender_username) + .format_with(&sender_username, true); Some((*timestamp, latest_message_text)) } - LatestEventValue::Local { timestamp, sender, profile, content, state: _ } => { + LatestEventValue::Local { + timestamp, + sender, + profile, + content, + state: _, + } => { // TODO: use the `state` enum to augment the preview text with more details. // Example: "Sending... {msg}" or // "Failed to send {msg}" let is_own = current_user_id().is_some_and(|id| &id == sender); let sender_username = get_sender_username!(profile, sender, is_own); - let latest_message_text = text_preview_of_timeline_item( - content, - sender, - &sender_username, - ).format_with(&sender_username, true); + let latest_message_text = + text_preview_of_timeline_item(content, sender, &sender_username) + .format_with(&sender_username, true); Some((*timestamp, latest_message_text)) } - } + } } /// Handles the given updated latest event for the given room. @@ -3897,10 +4428,9 @@ async fn get_latest_event_details( /// This function sends a `RoomsListUpdate::UpdateLatestEvent` /// to update the latest event in the RoomsListEntry for the given room. async fn update_latest_event(room: &Room) { - if let Some((timestamp, latest_message_text)) = get_latest_event_details( - &room.latest_event().await, - &room.client(), - ).await { + if let Some((timestamp, latest_message_text)) = + get_latest_event_details(&room.latest_event().await, &room.client()).await + { enqueue_rooms_list_update(RoomsListUpdate::UpdateLatestEvent { room_id: room.room_id().to_owned(), timestamp, @@ -3937,7 +4467,6 @@ async fn timeline_subscriber_handler( mut request_receiver: watch::Receiver>, thread_root_event_id: Option, ) { - /// An inner function that searches the given new timeline items for a target event. /// /// If the target event is found, it is removed from the `target_event_id_opt` and returned, @@ -3946,14 +4475,13 @@ async fn timeline_subscriber_handler( target_event_id_opt: &mut Option, mut new_items_iter: impl Iterator>, ) -> Option<(usize, OwnedEventId)> { - let found_index = target_event_id_opt - .as_ref() - .and_then(|target_event_id| new_items_iter - .position(|new_item| new_item + let found_index = target_event_id_opt.as_ref().and_then(|target_event_id| { + new_items_iter.position(|new_item| { + new_item .as_event() .is_some_and(|new_ev| new_ev.event_id() == Some(target_event_id)) - ) - ); + }) + }); if let Some(index) = found_index { target_event_id_opt.take().map(|ev| (index, ev)) @@ -3962,11 +4490,13 @@ async fn timeline_subscriber_handler( } } - let room_id = room.room_id().to_owned(); log!("Starting timeline subscriber for room {room_id}, thread {thread_root_event_id:?}..."); let (mut timeline_items, mut subscriber) = timeline.subscribe().await; - log!("Received initial timeline update of {} items for room {room_id}, thread {thread_root_event_id:?}.", timeline_items.len()); + log!( + "Received initial timeline update of {} items for room {room_id}, thread {thread_root_event_id:?}.", + timeline_items.len() + ); timeline_update_sender.send(TimelineUpdate::FirstUpdate { initial_items: timeline_items.clone(), @@ -3979,262 +4509,266 @@ async fn timeline_subscriber_handler( // the timeline index and event ID of the target event, if it has been found. let mut found_target_event_id: Option<(usize, OwnedEventId)> = None; - loop { tokio::select! { - // we should check for new requests before handling new timeline updates, - // because the request might influence how we handle a timeline update. - biased; - - // Handle updates to the current backwards pagination requests. - Ok(()) = request_receiver.changed() => { - let prev_target_event_id = target_event_id.clone(); - let new_request_details = request_receiver - .borrow_and_update() - .iter() - .find_map(|req| req.room_id - .eq(&room_id) - .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) - ); - - target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); + loop { + tokio::select! { + // we should check for new requests before handling new timeline updates, + // because the request might influence how we handle a timeline update. + biased; + + // Handle updates to the current backwards pagination requests. + Ok(()) = request_receiver.changed() => { + let prev_target_event_id = target_event_id.clone(); + let new_request_details = request_receiver + .borrow_and_update() + .iter() + .find_map(|req| req.room_id + .eq(&room_id) + .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) + ); - // If we received a new request, start searching backwards for the target event. - if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { - if prev_target_event_id.as_ref() != Some(&new_target_event_id) { - let starting_index = if current_tl_len == timeline_items.len() { - starting_index - } else { - // The timeline has changed since the request was made, so we can't rely on the `starting_index`. - // Instead, we have no choice but to start from the end of the timeline. - timeline_items.len() - }; - // log!("Received new request to search for event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} starting from index {starting_index} (tl len {}).", timeline_items.len()); - // Search backwards for the target event in the timeline, starting from the given index. - if let Some(target_event_tl_index) = timeline_items - .focus() - .narrow(..starting_index) - .into_iter() - .rev() - .position(|i| i.as_event() - .and_then(|e| e.event_id()) - .is_some_and(|ev_id| ev_id == new_target_event_id) - ) - .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) - { - // log!("Found existing target event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} at index {target_event_tl_index}."); + target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); - // Nice! We found the target event in the current timeline items, - // so there's no need to actually proceed with backwards pagination; - // thus, we can clear the locally-tracked target event ID. - target_event_id = None; - found_target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: new_target_event_id.clone(), - index: target_event_tl_index, - } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}, thread {thread_root_event_id:?}!") - ); - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); - } - else { - log!("Target event not in timeline. Starting backwards pagination \ - in room {room_id}, thread {thread_root_event_id:?} to find target event \ - {new_target_event_id} starting from index {starting_index}.", - ); - // If we didn't find the target event in the current timeline items, - // we need to start loading previous items into the timeline. - submit_async_request(MatrixRequest::PaginateTimeline { - timeline_kind: if let Some(thread_root_event_id) = thread_root_event_id.clone() { - TimelineKind::Thread { - room_id: room_id.clone(), - thread_root_event_id, - } - } else { - TimelineKind::MainRoom { - room_id: room_id.clone(), + // If we received a new request, start searching backwards for the target event. + if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { + if prev_target_event_id.as_ref() != Some(&new_target_event_id) { + let starting_index = if current_tl_len == timeline_items.len() { + starting_index + } else { + // The timeline has changed since the request was made, so we can't rely on the `starting_index`. + // Instead, we have no choice but to start from the end of the timeline. + timeline_items.len() + }; + // log!("Received new request to search for event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} starting from index {starting_index} (tl len {}).", timeline_items.len()); + // Search backwards for the target event in the timeline, starting from the given index. + if let Some(target_event_tl_index) = timeline_items + .focus() + .narrow(..starting_index) + .into_iter() + .rev() + .position(|i| i.as_event() + .and_then(|e| e.event_id()) + .is_some_and(|ev_id| ev_id == new_target_event_id) + ) + .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) + { + // log!("Found existing target event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} at index {target_event_tl_index}."); + + // Nice! We found the target event in the current timeline items, + // so there's no need to actually proceed with backwards pagination; + // thus, we can clear the locally-tracked target event ID. + target_event_id = None; + found_target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: new_target_event_id.clone(), + index: target_event_tl_index, } - }, - num_events: 50, - direction: PaginationDirection::Backwards, - }); + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}, thread {thread_root_event_id:?}!") + ); + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); + } + else { + log!("Target event not in timeline. Starting backwards pagination \ + in room {room_id}, thread {thread_root_event_id:?} to find target event \ + {new_target_event_id} starting from index {starting_index}.", + ); + // If we didn't find the target event in the current timeline items, + // we need to start loading previous items into the timeline. + submit_async_request(MatrixRequest::PaginateTimeline { + timeline_kind: if let Some(thread_root_event_id) = thread_root_event_id.clone() { + TimelineKind::Thread { + room_id: room_id.clone(), + thread_root_event_id, + } + } else { + TimelineKind::MainRoom { + room_id: room_id.clone(), + } + }, + num_events: 50, + direction: PaginationDirection::Backwards, + }); + } } } } - } - // Handle updates to the actual timeline content. - batch_opt = subscriber.next() => { - let Some(batch) = batch_opt else { break }; - let mut num_updates = 0; - let mut index_of_first_change = usize::MAX; - let mut index_of_last_change = usize::MIN; - // whether to clear the entire cache of drawn items - let mut clear_cache = false; - // whether the changes include items being appended to the end of the timeline - let mut is_append = false; - for diff in batch { - num_updates += 1; - match diff { - VectorDiff::Append { values } => { - let _values_len = values.len(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.extend(values); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } - is_append = true; - } - VectorDiff::Clear => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Clear"); } - clear_cache = true; - timeline_items.clear(); - } - VectorDiff::PushFront { value } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushFront"); } - if let Some((index, _ev)) = found_target_event_id.as_mut() { - *index += 1; // account for this new `value` being prepended. - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); + // Handle updates to the actual timeline content. + batch_opt = subscriber.next() => { + let Some(batch) = batch_opt else { break }; + let mut num_updates = 0; + let mut index_of_first_change = usize::MAX; + let mut index_of_last_change = usize::MIN; + // whether to clear the entire cache of drawn items + let mut clear_cache = false; + // whether the changes include items being appended to the end of the timeline + let mut is_append = false; + for diff in batch { + num_updates += 1; + match diff { + VectorDiff::Append { values } => { + let _values_len = values.len(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.extend(values); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } + is_append = true; } - - clear_cache = true; - timeline_items.push_front(value); - } - VectorDiff::PushBack { value } => { - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.push_back(value); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } - is_append = true; - } - VectorDiff::PopFront => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopFront"); } - clear_cache = true; - timeline_items.pop_front(); - if let Some((i, _ev)) = found_target_event_id.as_mut() { - *i = i.saturating_sub(1); // account for the first item being removed. + VectorDiff::Clear => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Clear"); } + clear_cache = true; + timeline_items.clear(); } - // This doesn't affect whether we should reobtain the latest event. - } - VectorDiff::PopBack => { - timeline_items.pop_back(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); - index_of_last_change = usize::MAX; - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Insert { index, value } => { - if index == 0 { + VectorDiff::PushFront { value } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushFront"); } + if let Some((index, _ev)) = found_target_event_id.as_mut() { + *index += 1; // account for this new `value` being prepended. + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); + } + clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = usize::MAX; + timeline_items.push_front(value); } - if index >= timeline_items.len() { + VectorDiff::PushBack { value } => { + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.push_back(value); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } is_append = true; } - - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for this new `value` being inserted before the previously-found target event's index. - if index <= *i { - *i += 1; + VectorDiff::PopFront => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopFront"); } + clear_cache = true; + timeline_items.pop_front(); + if let Some((i, _ev)) = found_target_event_id.as_mut() { + *i = i.saturating_sub(1); // account for the first item being removed. } - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) - .map(|(i, ev)| (i + index, ev)); + // This doesn't affect whether we should reobtain the latest event. } - - timeline_items.insert(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Set { index, value } => { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = max(index_of_last_change, index.saturating_add(1)); - timeline_items.set(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Remove { index } => { - if index == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); + VectorDiff::PopBack => { + timeline_items.pop_back(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); index_of_last_change = usize::MAX; + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } } - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for an item being removed before the previously-found target event's index. - if index <= *i { - *i = i.saturating_sub(1); + VectorDiff::Insert { index, value } => { + if index == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, index); + index_of_last_change = usize::MAX; + } + if index >= timeline_items.len() { + is_append = true; } + + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for this new `value` being inserted before the previously-found target event's index. + if index <= *i { + *i += 1; + } + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) + .map(|(i, ev)| (i + index, ev)); + } + + timeline_items.insert(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } } - timeline_items.remove(index); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Truncate { length } => { - if length == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); - index_of_last_change = usize::MAX; + VectorDiff::Set { index, value } => { + index_of_first_change = min(index_of_first_change, index); + index_of_last_change = max(index_of_last_change, index.saturating_add(1)); + timeline_items.set(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Remove { index } => { + if index == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); + index_of_last_change = usize::MAX; + } + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for an item being removed before the previously-found target event's index. + if index <= *i { + *i = i.saturating_sub(1); + } + } + timeline_items.remove(index); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Truncate { length } => { + if length == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); + index_of_last_change = usize::MAX; + } + timeline_items.truncate(length); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Reset { values } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Reset, new length {}", values.len()); } + clear_cache = true; // we must assume all items have changed. + timeline_items = values; } - timeline_items.truncate(length); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Reset { values } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Reset, new length {}", values.len()); } - clear_cache = true; // we must assume all items have changed. - timeline_items = values; } } - } - if num_updates > 0 { - // Handle the case where back pagination inserts items at the beginning of the timeline - // (meaning the entire timeline needs to be re-drawn), - // but there is a virtual event at index 0 (e.g., a day divider). - // When that happens, we want the RoomScreen to treat this as if *all* events changed. - if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { - index_of_first_change = 0; - clear_cache = true; - } + if num_updates > 0 { + // Handle the case where back pagination inserts items at the beginning of the timeline + // (meaning the entire timeline needs to be re-drawn), + // but there is a virtual event at index 0 (e.g., a day divider). + // When that happens, we want the RoomScreen to treat this as if *all* events changed. + if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { + index_of_first_change = 0; + clear_cache = true; + } - let changed_indices = index_of_first_change..index_of_last_change; + let changed_indices = index_of_first_change..index_of_last_change; - if LOG_TIMELINE_DIFFS { - log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, thread {thread_root_event_id:?}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); - } - timeline_update_sender.send(TimelineUpdate::NewItems { - new_items: timeline_items.clone(), - changed_indices, - clear_cache, - is_append, - }).expect("Error: timeline update sender couldn't send update with new items!"); - - // We must send this update *after* the actual NewItems update, - // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. - if let Some((index, found_event_id)) = found_target_event_id.take() { - target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: found_event_id.clone(), - index, - } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}, thread {thread_root_event_id:?}!") - ); - } + if LOG_TIMELINE_DIFFS { + log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, thread {thread_root_event_id:?}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); + } + timeline_update_sender.send(TimelineUpdate::NewItems { + new_items: timeline_items.clone(), + changed_indices, + clear_cache, + is_append, + }).expect("Error: timeline update sender couldn't send update with new items!"); + + // We must send this update *after* the actual NewItems update, + // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. + if let Some((index, found_event_id)) = found_target_event_id.take() { + target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: found_event_id.clone(), + index, + } + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}, thread {thread_root_event_id:?}!") + ); + } - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); + } } - } - else => { - break; + else => { + break; + } } - } } + } - error!("Error: unexpectedly ended timeline subscriber for room {room_id}, thread {thread_root_event_id:?}."); + error!( + "Error: unexpectedly ended timeline subscriber for room {room_id}, thread {thread_root_event_id:?}." + ); } /// Spawn a new async task to fetch the room's new avatar. @@ -4259,8 +4793,13 @@ async fn room_avatar(room: &Room, room_name_id: &RoomNameId) -> FetchedRoomAvata _ => { if let Ok(room_members) = room.members(RoomMemberships::ACTIVE).await { if room_members.len() == 2 { - if let Some(non_account_member) = room_members.iter().find(|m| !m.is_account_user()) { - if let Ok(Some(avatar)) = non_account_member.avatar(AVATAR_THUMBNAIL_FORMAT.into()).await { + if let Some(non_account_member) = + room_members.iter().find(|m| !m.is_account_user()) + { + if let Ok(Some(avatar)) = non_account_member + .avatar(AVATAR_THUMBNAIL_FORMAT.into()) + .await + { return FetchedRoomAvatar::Image(avatar.into()); } } @@ -4289,7 +4828,8 @@ async fn spawn_sso_server( // Post a status update to inform the user that we're waiting for the client to be built. Cx::post_action(LoginAction::Status { title: "Initializing client...".into(), - status: "Please wait while Matrix builds and configures the client object for login.".into(), + status: "Please wait while Matrix builds and configures the client object for login." + .into(), }); // Wait for the notification that the client has been built @@ -4310,19 +4850,21 @@ async fn spawn_sso_server( // or if the homeserver_url is *not* empty and isn't the default, // we cannot use the DEFAULT_SSO_CLIENT, so we must build a new one. let mut build_client_error = None; - if client_and_session.is_none() || ( - !homeserver_url.is_empty() + if client_and_session.is_none() + || (!homeserver_url.is_empty() && homeserver_url != "matrix.org" && Url::parse(&homeserver_url) != Url::parse("https://matrix-client.matrix.org/") - && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/") - ) { + && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/")) + { match build_client( &Cli { homeserver: homeserver_url.is_empty().not().then_some(homeserver_url), ..Default::default() }, app_data_dir(), - ).await { + ) + .await + { Ok(success) => client_and_session = Some(success), Err(e) => build_client_error = Some(e), } @@ -4331,10 +4873,12 @@ async fn spawn_sso_server( let Some((client, client_session)) = client_and_session else { Cx::post_action(LoginAction::LoginFailure( if let Some(err) = build_client_error { - format!("Could not create client object. Please try to login again.\n\nError: {err}") + format!( + "Could not create client object. Please try to login again.\n\nError: {err}" + ) } else { String::from("Could not create client object. Please try to login again.") - } + }, )); // This ensures that the called to `DEFAULT_SSO_CLIENT_NOTIFIER.notified()` // at the top of this function will not block upon the next login attempt. @@ -4346,7 +4890,8 @@ async fn spawn_sso_server( let mut is_logged_in = false; Cx::post_action(LoginAction::Status { title: "Opening your browser...".into(), - status: "Please finish logging in using your browser, and then come back to Robrix.".into(), + status: "Please finish logging in using your browser, and then come back to Robrix." + .into(), }); match client .matrix_auth() @@ -4356,12 +4901,15 @@ async fn spawn_sso_server( if key == "redirectUrl" { let redirect_url = Url::parse(&value)?; Cx::post_action(LoginAction::SsoSetRedirectUrl(redirect_url)); - break + break; } } - Uri::new(&sso_url).open().map_err(|err| - Error::Io(io::Error::other(format!("Unable to open SSO login url. Error: {:?}", err))) - ) + Uri::new(&sso_url).open().map_err(|err| { + Error::Io(io::Error::other(format!( + "Unable to open SSO login url. Error: {:?}", + err + ))) + }) }) .identity_provider_id(&identity_provider_id) .initial_device_display_name(&format!("robrix-sso-{brand}")) @@ -4376,10 +4924,13 @@ async fn spawn_sso_server( }) { Ok(identity_provider_res) => { if !is_logged_in { - if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session)).await { + if let Err(e) = login_sender + .send(LoginRequest::LoginBySSOSuccess(client, client_session)) + .await + { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to matrix worker thread." + "BUG: failed to send login request to matrix worker thread.", ))); } enqueue_rooms_list_update(RoomsListUpdate::Status { @@ -4405,7 +4956,6 @@ async fn spawn_sso_server( }); } - bitflags! { /// The powers that a user has in a given room. #[derive(Copy, Clone, PartialEq, Eq)] @@ -4483,14 +5033,38 @@ impl UserPowerLevels { retval.set(UserPowerLevels::Invite, user_power >= power_levels.invite); retval.set(UserPowerLevels::Kick, user_power >= power_levels.kick); retval.set(UserPowerLevels::Redact, user_power >= power_levels.redact); - retval.set(UserPowerLevels::NotifyRoom, user_power >= power_levels.notifications.room); - retval.set(UserPowerLevels::Location, user_power >= power_levels.for_message(MessageLikeEventType::Location)); - retval.set(UserPowerLevels::Message, user_power >= power_levels.for_message(MessageLikeEventType::Message)); - retval.set(UserPowerLevels::Reaction, user_power >= power_levels.for_message(MessageLikeEventType::Reaction)); - retval.set(UserPowerLevels::RoomMessage, user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage)); - retval.set(UserPowerLevels::RoomRedaction, user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction)); - retval.set(UserPowerLevels::Sticker, user_power >= power_levels.for_message(MessageLikeEventType::Sticker)); - retval.set(UserPowerLevels::RoomPinnedEvents, user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents)); + retval.set( + UserPowerLevels::NotifyRoom, + user_power >= power_levels.notifications.room, + ); + retval.set( + UserPowerLevels::Location, + user_power >= power_levels.for_message(MessageLikeEventType::Location), + ); + retval.set( + UserPowerLevels::Message, + user_power >= power_levels.for_message(MessageLikeEventType::Message), + ); + retval.set( + UserPowerLevels::Reaction, + user_power >= power_levels.for_message(MessageLikeEventType::Reaction), + ); + retval.set( + UserPowerLevels::RoomMessage, + user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage), + ); + retval.set( + UserPowerLevels::RoomRedaction, + user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction), + ); + retval.set( + UserPowerLevels::Sticker, + user_power >= power_levels.for_message(MessageLikeEventType::Sticker), + ); + retval.set( + UserPowerLevels::RoomPinnedEvents, + user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents), + ); retval } @@ -4536,8 +5110,7 @@ impl UserPowerLevels { } pub fn can_send_message(self) -> bool { - self.contains(UserPowerLevels::RoomMessage) - || self.contains(UserPowerLevels::Message) + self.contains(UserPowerLevels::RoomMessage) || self.contains(UserPowerLevels::Message) } pub fn can_send_reaction(self) -> bool { @@ -4554,7 +5127,6 @@ impl UserPowerLevels { } } - /// Shuts down the current Tokio runtime completely and takes ownership to ensure proper cleanup. pub fn shutdown_background_tasks() { if let Some(runtime) = TOKIO_RUNTIME.lock().unwrap().take() { @@ -4572,9 +5144,16 @@ pub async fn clear_app_state(config: &LogoutConfig) -> Result<()> { ALL_JOINED_ROOMS.lock().unwrap().clear(); let on_clear_appstate = Arc::new(Notify::new()); - Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); - - match tokio::time::timeout(config.app_state_cleanup_timeout, on_clear_appstate.notified()).await { + Cx::post_action(LogoutAction::ClearAppState { + on_clear_appstate: on_clear_appstate.clone(), + }); + + match tokio::time::timeout( + config.app_state_cleanup_timeout, + on_clear_appstate.notified(), + ) + .await + { Ok(_) => { log!("Received signal that UI-side app state was cleaned successfully"); Ok(()) From 6a01687f14c52e481df3b05ed0a26f1c5b4607f2 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: Thu, 26 Mar 2026 01:16:55 +0800 Subject: [PATCH 28/66] Finish app service and botfather --- src/app.rs | 442 ++----- src/home/home_screen.rs | 633 +++++----- src/home/room_context_menu.rs | 98 +- src/home/room_screen.rs | 1860 +++++++++++---------------- src/home/rooms_list.rs | 682 ++++------ src/room/room_input_bar.rs | 296 ++--- src/settings/settings_screen.rs | 46 +- src/sliding_sync.rs | 2105 ++++++++++++------------------- 8 files changed, 2342 insertions(+), 3820 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0ed4de033..2897cffc8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,47 +4,17 @@ use std::{cell::RefCell, collections::HashMap}; use makepad_widgets::*; -use matrix_sdk::{ - RoomState, - ruma::{OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId}, -}; +use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId}}; use serde::{Deserialize, Serialize}; use crate::{ - avatar_cache::clear_avatar_cache, - home::{ - event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, - invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, - invite_screen::InviteScreenWidgetRefExt, - main_desktop_ui::MainDesktopUiAction, - navigation_tab_bar::{NavigationBarAction, SelectedTab}, - new_message_context_menu::NewMessageContextMenuWidgetRefExt, - room_context_menu::RoomContextMenuWidgetRefExt, - room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, - rooms_list::{ - RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, - enqueue_rooms_list_update, - }, - space_lobby::SpaceLobbyScreenWidgetRefExt, - }, - join_leave_room_modal::{ - JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt, - }, - login::login_screen::LoginAction, - logout::logout_confirm_modal::{ - LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt, - }, - persistence, - profile::user_profile_cache::clear_user_profile_cache, - room::BasicRoomDetails, - shared::{ - confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, - image_viewer::{ImageViewerAction, LoadState}, - popup_list::{PopupKind, enqueue_popup_notification}, - }, - sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, - utils::RoomNameId, - verification::VerificationAction, - verification_modal::{VerificationModalAction, VerificationModalWidgetRefExt}, + avatar_cache::clear_avatar_cache, home::{ + event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, invite_screen::InviteScreenWidgetRefExt, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, space_lobby::SpaceLobbyScreenWidgetRefExt + }, join_leave_room_modal::{ + JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt + }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ + VerificationModalAction, + VerificationModalWidgetRefExt, + } }; script_mod! { @@ -81,7 +51,7 @@ script_mod! { close +: { draw_bg +: {color: #0, color_hover: #E81123, color_down: #FF0015} } } } - + body +: { padding: 0, @@ -110,7 +80,7 @@ script_mod! { image_viewer_modal_inner := ImageViewer {} } } - + // Context menus should be shown in front of other UI elements, // but behind verification modals. new_message_context_menu := NewMessageContextMenu { } @@ -194,20 +164,16 @@ app_main!(App); #[derive(Script)] pub struct App { - #[live] - ui: WidgetRef, + #[live] ui: WidgetRef, /// The top-level app state, shared across various parts of the app. - #[rust] - app_state: AppState, + #[rust] app_state: AppState, /// The details of a room we're waiting on to be loaded so that we can navigate to it. /// This can be either a room we're waiting to join, or one we're waiting to be invited to. /// Also includes an optional room ID to be closed once the awaited room has been loaded. - #[rust] - waiting_to_navigate_to_room: Option<(BasicRoomDetails, Option)>, + #[rust] waiting_to_navigate_to_room: Option<(BasicRoomDetails, Option)>, /// A stack of previously-selected rooms for mobile navigation. /// When a view is popped off the stack, the previous `selected_room` is restored from here. - #[rust] - mobile_room_nav_stack: Vec, + #[rust] mobile_room_nav_stack: Vec, } impl ScriptHook for App { @@ -232,27 +198,15 @@ impl MatchEvent for App { let _ = tracing_subscriber::fmt::try_init(); // Override Makepad's new default-JSON logger. We just want regular formatting. - fn regular_log( - file_name: &str, - line_start: u32, - column_start: u32, - _line_end: u32, - _column_end: u32, - message: String, - level: LogLevel, - ) { + fn regular_log(file_name: &str, line_start: u32, column_start: u32, _line_end: u32, _column_end: u32, message: String, level: LogLevel) { let l = match level { - LogLevel::Panic => "[!]", - LogLevel::Error => "[E]", + LogLevel::Panic => "[!]", + LogLevel::Error => "[E]", LogLevel::Warning => "[W]", - LogLevel::Log => "[I]", - LogLevel::Wait => "[.]", + LogLevel::Log => "[I]", + LogLevel::Wait => "[.]", }; - println!( - "{l} {file_name}:{}:{}: {message}", - line_start + 1, - column_start + 1 - ); + println!("{l} {file_name}:{}:{}: {message}", line_start + 1, column_start + 1); } *LOG_WITH_LEVEL.write().unwrap() = regular_log; @@ -267,10 +221,7 @@ impl MatchEvent for App { // Hide the caption bar on macOS and Linux, which use native window chrome. // On Windows (with custom chrome), the caption bar is needed. - if matches!( - cx.os_type(), - OsType::Macos | OsType::LinuxWindow(_) | OsType::LinuxDirect - ) { + if matches!(cx.os_type(), OsType::Macos | OsType::LinuxWindow(_) | OsType::LinuxDirect) { let mut window = self.ui.window(cx, ids!(main_window)); script_apply_eval!(cx, window, { show_caption_bar: false @@ -282,52 +233,41 @@ impl MatchEvent for App { log!("App::Startup: starting matrix sdk loop"); let _tokio_rt_handle = crate::sliding_sync::start_matrix_tokio().unwrap(); - #[cfg(feature = "tsp")] - { + #[cfg(feature = "tsp")] { log!("App::Startup: initializing TSP (Trust Spanning Protocol) module."); crate::tsp::tsp_init(_tokio_rt_handle).unwrap(); } } fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { - let invite_confirmation_modal_inner = self - .ui - .confirmation_modal(cx, ids!(invite_confirmation_modal_inner)); + let invite_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(invite_confirmation_modal_inner)); if let Some(_accepted) = invite_confirmation_modal_inner.closed(actions) { self.ui.modal(cx, ids!(invite_confirmation_modal)).close(cx); } - let delete_confirmation_modal_inner = self - .ui - .confirmation_modal(cx, ids!(delete_confirmation_modal_inner)); + let delete_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(delete_confirmation_modal_inner)); if let Some(_accepted) = delete_confirmation_modal_inner.closed(actions) { self.ui.modal(cx, ids!(delete_confirmation_modal)).close(cx); } - let positive_confirmation_modal_inner = self - .ui - .confirmation_modal(cx, ids!(positive_confirmation_modal_inner)); + let positive_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(positive_confirmation_modal_inner)); if let Some(_accepted) = positive_confirmation_modal_inner.closed(actions) { - self.ui - .modal(cx, ids!(positive_confirmation_modal)) - .close(cx); + self.ui.modal(cx, ids!(positive_confirmation_modal)).close(cx); } for action in actions { match action.downcast_ref() { Some(LogoutConfirmModalAction::Open) => { - self.ui - .logout_confirm_modal(cx, ids!(logout_confirm_modal_inner)) - .reset_state(cx); + self.ui.logout_confirm_modal(cx, ids!(logout_confirm_modal_inner)).reset_state(cx); self.ui.modal(cx, ids!(logout_confirm_modal)).open(cx); continue; - } + }, Some(LogoutConfirmModalAction::Close { was_internal, .. }) => { if *was_internal { self.ui.modal(cx, ids!(logout_confirm_modal)).close(cx); } continue; - } + }, _ => {} } @@ -339,8 +279,8 @@ impl MatchEvent for App { self.ui.redraw(cx); continue; } - Some(LogoutAction::ClearAppState { on_clear_appstate }) => { - // Clear user profile cache, invited_rooms timeline states + Some(LogoutAction::ClearAppState { on_clear_appstate }) => { + // Clear user profile cache, invited_rooms timeline states clear_all_app_state(cx); // Reset all app state to its default. self.app_state = Default::default(); @@ -363,9 +303,7 @@ impl MatchEvent for App { // When not yet logged in, the login_screen widget handles displaying the failure modal. if let Some(LoginAction::LoginFailure(_)) = action.downcast_ref() { if self.app_state.logged_in { - log!( - "Received LoginAction::LoginFailure while logged in; showing login screen." - ); + log!("Received LoginAction::LoginFailure while logged in; showing login screen."); self.app_state.logged_in = false; self.update_login_visibility(cx); self.ui.redraw(cx); @@ -374,13 +312,9 @@ impl MatchEvent for App { } // Handle an action requesting to open the new message context menu. - if let MessageAction::OpenMessageContextMenu { details, abs_pos } = - action.as_widget_action().cast() - { + if let MessageAction::OpenMessageContextMenu { details, abs_pos } = action.as_widget_action().cast() { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); - let new_message_context_menu = self - .ui - .new_message_context_menu(cx, ids!(new_message_context_menu)); + let new_message_context_menu = self.ui.new_message_context_menu(cx, ids!(new_message_context_menu)); let expected_dimensions = new_message_context_menu.show(cx, details); // Ensure the context menu does not spill over the window's bounds. let rect = self.ui.window(cx, ids!(main_window)).area().rect(cx); @@ -401,9 +335,7 @@ impl MatchEvent for App { } // Handle an action requesting to open the room context menu. - if let RoomsListAction::OpenRoomContextMenu { details, pos } = - action.as_widget_action().cast() - { + if let RoomsListAction::OpenRoomContextMenu { details, pos } = action.as_widget_action().cast() { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); let room_context_menu = self.ui.room_context_menu(cx, ids!(room_context_menu)); let expected_dimensions = room_context_menu.show(cx, details); @@ -437,9 +369,7 @@ impl MatchEvent for App { // An invite was accepted; upgrade the selected room from invite to joined. // In Desktop mode, MainDesktopUI also handles this (harmless duplicate). RoomsListAction::InviteAccepted { room_name_id } => { - cx.action(AppStateAction::UpgradedInviteToJoinedRoom( - room_name_id.room_id().clone(), - )); + cx.action(AppStateAction::UpgradedInviteToJoinedRoom(room_name_id.room_id().clone())); continue; } _ => {} @@ -489,9 +419,7 @@ impl MatchEvent for App { bot_user_id, warning, }) => { - self.app_state - .bot_settings - .set_room_bound(room_id.clone(), *bound); + self.app_state.bot_settings.set_room_bound(room_id.clone(), *bound); let kind = if warning.is_some() { PopupKind::Warning } else { @@ -499,33 +427,25 @@ impl MatchEvent for App { }; let message = match (*bound, bot_user_id.as_ref(), warning.as_deref()) { (true, Some(bot_user_id), Some(warning)) => { - format!( - "BotFather {bot_user_id} is available for room {room_id}, but inviting it reported a warning: {warning}" - ) + format!("BotFather {bot_user_id} is available for room {room_id}, but inviting it reported a warning: {warning}") } (true, Some(bot_user_id), None) => { format!("Bound room {room_id} to BotFather {bot_user_id}.") } (false, Some(bot_user_id), Some(warning)) => { - format!( - "Unbound BotFather {bot_user_id} from room {room_id}, with warning: {warning}" - ) + format!("Unbound BotFather {bot_user_id} from room {room_id}, with warning: {warning}") } (false, Some(bot_user_id), None) => { format!("Unbound BotFather {bot_user_id} from room {room_id}.") } (false, None, Some(warning)) => { - format!( - "Unbound room {room_id} from BotFather, with warning: {warning}" - ) + format!("Unbound room {room_id} from BotFather, with warning: {warning}") } (false, None, None) => { format!("Unbound room {room_id} from BotFather.") } (true, None, Some(warning)) => { - format!( - "BotFather is available for room {room_id}, with warning: {warning}" - ) + format!("BotFather is available for room {room_id}, with warning: {warning}") } (true, None, None) => { format!("Bound room {room_id} to BotFather.") @@ -535,25 +455,18 @@ impl MatchEvent for App { self.ui.redraw(cx); continue; } - Some(AppStateAction::NavigateToRoom { - room_to_close, - destination_room, - }) => { + Some(AppStateAction::NavigateToRoom { room_to_close, destination_room }) => { self.navigate_to_room(cx, room_to_close.as_ref(), destination_room); continue; } // If we successfully loaded a room that we were waiting on, // we can now navigate to it and optionally close a previous room. - Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) - if self - .waiting_to_navigate_to_room - .as_ref() + Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) if + self.waiting_to_navigate_to_room.as_ref() .is_some_and(|(dr, _)| dr.room_id() == room_name_id.room_id()) => { log!("Loaded awaited room {room_name_id:?}, navigating to it now..."); - if let Some((dest_room, room_to_close)) = - self.waiting_to_navigate_to_room.take() - { + if let Some((dest_room, room_to_close)) = self.waiting_to_navigate_to_room.take() { self.navigate_to_room(cx, room_to_close.as_ref(), &dest_room); } continue; @@ -563,22 +476,18 @@ impl MatchEvent for App { // Handle actions for showing or hiding the tooltip. match action.as_widget_action().cast() { - TooltipAction::HoverIn { - text, - widget_rect, - options, - } => { + TooltipAction::HoverIn { text, widget_rect, options } => { // Don't show any tooltips if the message context menu is currently shown. - if self - .ui - .new_message_context_menu(cx, ids!(new_message_context_menu)) - .is_currently_shown(cx) - { + if self.ui.new_message_context_menu(cx, ids!(new_message_context_menu)).is_currently_shown(cx) { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); - } else { - self.ui - .callout_tooltip(cx, ids!(app_tooltip)) - .show_with_options(cx, &text, widget_rect, options); + } + else { + self.ui.callout_tooltip(cx, ids!(app_tooltip)).show_with_options( + cx, + &text, + widget_rect, + options, + ); } continue; } @@ -612,8 +521,7 @@ impl MatchEvent for App { // // Note: other verification actions are handled by the verification modal itself. if let Some(VerificationAction::RequestReceived(state)) = action.downcast_ref() { - self.ui - .verification_modal(cx, ids!(verification_modal_inner)) + self.ui.verification_modal(cx, ids!(verification_modal_inner)) .initialize_with_data(cx, state.clone()); self.ui.modal(cx, ids!(verification_modal)).open(cx); continue; @@ -634,23 +542,12 @@ impl MatchEvent for App { _ => {} } // Handle actions to open/close the TSP verification modal. - #[cfg(feature = "tsp")] - { + #[cfg(feature = "tsp")] { use std::ops::Deref; - use crate::tsp::{ - tsp_verification_modal::{ - TspVerificationModalAction, TspVerificationModalWidgetRefExt, - }, - TspIdentityAction, - }; + use crate::tsp::{tsp_verification_modal::{TspVerificationModalAction, TspVerificationModalWidgetRefExt}, TspIdentityAction}; - if let Some(TspIdentityAction::ReceivedDidAssociationRequest { - details, - wallet_db, - }) = action.downcast_ref() - { - self.ui - .tsp_verification_modal(cx, ids!(tsp_verification_modal_inner)) + if let Some(TspIdentityAction::ReceivedDidAssociationRequest { details, wallet_db }) = action.downcast_ref() { + self.ui.tsp_verification_modal(cx, ids!(tsp_verification_modal_inner)) .initialize_with_details(cx, details.clone(), wallet_db.deref().clone()); self.ui.modal(cx, ids!(tsp_verification_modal)).open(cx); continue; @@ -662,9 +559,7 @@ impl MatchEvent for App { } // Handle a request to show the invite confirmation modal. - if let Some(InviteAction::ShowInviteConfirmationModal(content_opt)) = - action.downcast_ref() - { + if let Some(InviteAction::ShowInviteConfirmationModal(content_opt)) = action.downcast_ref() { if let Some(content) = content_opt.borrow_mut().take() { invite_confirmation_modal_inner.show(cx, content); self.ui.modal(cx, ids!(invite_confirmation_modal)).open(cx); @@ -673,13 +568,10 @@ impl MatchEvent for App { } // Handle a request to show the generic positive confirmation modal. - if let Some(PositiveConfirmationModalAction::Show(content_opt)) = action.downcast_ref() - { + if let Some(PositiveConfirmationModalAction::Show(content_opt)) = action.downcast_ref() { if let Some(content) = content_opt.borrow_mut().take() { positive_confirmation_modal_inner.show(cx, content); - self.ui - .modal(cx, ids!(positive_confirmation_modal)) - .open(cx); + self.ui.modal(cx, ids!(positive_confirmation_modal)).open(cx); } continue; } @@ -687,9 +579,7 @@ impl MatchEvent for App { // Handle a request to show the delete confirmation modal. if let Some(ConfirmDeleteAction::Show(content_opt)) = action.downcast_ref() { if let Some(content) = content_opt.borrow_mut().take() { - self.ui - .confirmation_modal(cx, ids!(delete_confirmation_modal_inner)) - .show(cx, content); + self.ui.confirmation_modal(cx, ids!(delete_confirmation_modal_inner)).show(cx, content); self.ui.modal(cx, ids!(delete_confirmation_modal)).open(cx); } continue; @@ -698,10 +588,8 @@ impl MatchEvent for App { // Handle InviteModalAction to open/close the invite modal. match action.downcast_ref() { Some(InviteModalAction::Open(room_name_id)) => { - self.ui - .invite_modal(cx, ids!(invite_modal_inner)) - .show(cx, room_name_id.clone()); - self.ui.modal(cx, ids!(invite_modal)).open(cx); + self.ui.invite_modal(cx, ids!(invite_modal_inner)).show(cx, room_name_id.clone()); + self.ui.modal(cx, ids!(invite_modal)).open(cx); continue; } Some(InviteModalAction::Close) => { @@ -713,13 +601,8 @@ impl MatchEvent for App { // Handle EventSourceModalAction to open/close the event source modal. match action.downcast_ref() { - Some(EventSourceModalAction::Open { - room_id, - event_id, - original_json, - }) => { - self.ui - .event_source_modal(cx, ids!(event_source_modal_inner)) + Some(EventSourceModalAction::Open { room_id, event_id, original_json }) => { + self.ui.event_source_modal(cx, ids!(event_source_modal_inner)) .show(cx, room_id.clone(), event_id.clone(), original_json.clone()); self.ui.modal(cx, ids!(event_source_modal)).open(cx); continue; @@ -734,11 +617,7 @@ impl MatchEvent for App { // Handle DirectMessageRoomActions match action.downcast_ref() { Some(DirectMessageRoomAction::FoundExisting { room_name_id, .. }) => { - self.navigate_to_room( - cx, - None, - &BasicRoomDetails::RoomId(room_name_id.clone()), - ); + self.navigate_to_room(cx, None, &BasicRoomDetails::RoomId(room_name_id.clone())); } Some(DirectMessageRoomAction::DidNotExist { user_profile }) => { let user_profile = user_profile.clone(); @@ -746,7 +625,8 @@ impl MatchEvent for App { Some(un) if !un.is_empty() => format!( "You don't have an existing direct message room with {} ({}).\n\n\ Would you like to create one now?", - un, user_profile.user_id, + un, + user_profile.user_id, ), _ => format!( "You don't have an existing direct message room with {}.\n\n\ @@ -774,29 +654,17 @@ impl MatchEvent for App { ..Default::default() }, ); - self.ui - .modal(cx, ids!(positive_confirmation_modal)) - .open(cx); + self.ui.modal(cx, ids!(positive_confirmation_modal)).open(cx); } - Some(DirectMessageRoomAction::FailedToCreate { - user_profile, - error, - }) => { + Some(DirectMessageRoomAction::FailedToCreate { user_profile, error }) => { enqueue_popup_notification( - format!( - "Failed to create a new DM room with {}.\n\nError: {error}", - user_profile.displayable_name() - ), + format!("Failed to create a new DM room with {}.\n\nError: {error}", user_profile.displayable_name()), PopupKind::Error, None, ); } Some(DirectMessageRoomAction::NewlyCreated { room_name_id, .. }) => { - self.navigate_to_room( - cx, - None, - &BasicRoomDetails::RoomId(room_name_id.clone()), - ); + self.navigate_to_room(cx, None, &BasicRoomDetails::RoomId(room_name_id.clone())); } _ => {} } @@ -805,7 +673,7 @@ impl MatchEvent for App { } /// Clears all thread-local UI caches (user profiles, invited rooms, and timeline states). -/// The `cx` parameter ensures that these thread-local caches are cleared on the main UI thread, +/// The `cx` parameter ensures that these thread-local caches are cleared on the main UI thread, fn clear_all_app_state(cx: &mut Cx) { clear_user_profile_cache(cx); clear_all_invited_rooms(cx); @@ -857,34 +725,27 @@ impl AppMain for App { error!("Failed to save app state. Error: {e}"); } } - #[cfg(feature = "tsp")] - { + #[cfg(feature = "tsp")] { // Save the TSP wallet state, if it exists, with a 3-second timeout. let tsp_state = std::mem::take(&mut *crate::tsp::tsp_state_ref().lock().unwrap()); let res = crate::sliding_sync::block_on_async_with_timeout( Some(std::time::Duration::from_secs(3)), async move { match tsp_state.close_and_serialize().await { - Ok(saved_state) => { - match persistence::save_tsp_state_async(saved_state).await { - Ok(_) => {} - Err(e) => error!("Failed to save TSP wallet state. Error: {e}"), - } - } - Err(e) => { - error!("Failed to close and serialize TSP wallet state. Error: {e}") + Ok(saved_state) => match persistence::save_tsp_state_async(saved_state).await { + Ok(_) => { } + Err(e) => error!("Failed to save TSP wallet state. Error: {e}"), } + Err(e) => error!("Failed to close and serialize TSP wallet state. Error: {e}"), } }, ); if let Err(_e) = res { - error!( - "Failed to save TSP wallet state before app shutdown. Error: Timed Out." - ); + error!("Failed to save TSP wallet state before app shutdown. Error: Timed Out."); } } } - + // Forward events to the MatchEvent trait implementation. self.match_event(cx, event); let scope = &mut Scope::with_data(&mut self.app_state); @@ -932,12 +793,8 @@ impl App { .modal(cx, ids!(login_screen_view.login_screen.login_status_modal)) .close(cx); } - self.ui - .view(cx, ids!(login_screen_view)) - .set_visible(cx, show_login); - self.ui - .view(cx, ids!(home_screen_view)) - .set_visible(cx, !show_login); + self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, show_login); + self.ui.view(cx, ids!(home_screen_view)).set_visible(cx, !show_login); } /// Navigates to the given `destination_room`, optionally closing the `room_to_close`. @@ -952,17 +809,16 @@ impl App { let tab_id = LiveId::from_str(to_close.as_str()); let widget_uid = self.ui.widget_uid(); move |cx: &mut Cx| { - cx.widget_action(widget_uid, DockAction::TabCloseWasPressed(tab_id)); - enqueue_rooms_list_update(RoomsListUpdate::HideRoom { - room_id: to_close.clone(), - }); + cx.widget_action( + widget_uid, + DockAction::TabCloseWasPressed(tab_id), + ); + enqueue_rooms_list_update(RoomsListUpdate::HideRoom { room_id: to_close.clone() }); } }); let destination_room_id = destination_room.room_id(); - let room_state = cx - .get_global::() - .get_room_state(destination_room_id); + let room_state = cx.get_global::().get_room_state(destination_room_id); let new_selected_room = match room_state { Some(RoomState::Joined) => SelectedRoom::JoinedRoom { room_name_id: destination_room.room_name_id().clone(), @@ -972,12 +828,11 @@ impl App { }, // If the destination room is not yet loaded, show a join modal. _ => { - log!( - "Destination room {:?} not loaded, showing join modal...", - destination_room.room_name_id() - ); - self.waiting_to_navigate_to_room = - Some((destination_room.clone(), room_to_close.cloned())); + log!("Destination room {:?} not loaded, showing join modal...", destination_room.room_name_id()); + self.waiting_to_navigate_to_room = Some(( + destination_room.clone(), + room_to_close.cloned(), + )); cx.action(JoinLeaveRoomModalAction::Open { kind: JoinLeaveModalKind::JoinRoom { details: destination_room.clone(), @@ -989,8 +844,8 @@ impl App { } }; - log!( - "Navigating to destination room {:?}, closing room {:?}", + + log!("Navigating to destination room {:?}, closing room {:?}", destination_room.room_name_id(), room_to_close, ); @@ -1001,7 +856,7 @@ impl App { cx.action(NavigationBarAction::GoToHome); } cx.widget_action( - self.ui.widget_uid(), + self.ui.widget_uid(), RoomsListAction::Selected(new_selected_room), ); // Select and scroll to the destination room in the rooms list. @@ -1017,43 +872,27 @@ impl App { /// Each depth gets its own dedicated view widget to avoid /// complex state save/restore when views would otherwise be reused. const ROOM_VIEW_IDS: [LiveId; 16] = [ - live_id!(room_view_0), - live_id!(room_view_1), - live_id!(room_view_2), - live_id!(room_view_3), - live_id!(room_view_4), - live_id!(room_view_5), - live_id!(room_view_6), - live_id!(room_view_7), - live_id!(room_view_8), - live_id!(room_view_9), - live_id!(room_view_10), - live_id!(room_view_11), - live_id!(room_view_12), - live_id!(room_view_13), - live_id!(room_view_14), - live_id!(room_view_15), + live_id!(room_view_0), live_id!(room_view_1), + live_id!(room_view_2), live_id!(room_view_3), + live_id!(room_view_4), live_id!(room_view_5), + live_id!(room_view_6), live_id!(room_view_7), + live_id!(room_view_8), live_id!(room_view_9), + live_id!(room_view_10), live_id!(room_view_11), + live_id!(room_view_12), live_id!(room_view_13), + live_id!(room_view_14), live_id!(room_view_15), ]; /// The RoomScreen widget IDs inside each room view, /// corresponding 1:1 with [`Self::ROOM_VIEW_IDS`]. const ROOM_SCREEN_IDS: [LiveId; 16] = [ - live_id!(room_screen_0), - live_id!(room_screen_1), - live_id!(room_screen_2), - live_id!(room_screen_3), - live_id!(room_screen_4), - live_id!(room_screen_5), - live_id!(room_screen_6), - live_id!(room_screen_7), - live_id!(room_screen_8), - live_id!(room_screen_9), - live_id!(room_screen_10), - live_id!(room_screen_11), - live_id!(room_screen_12), - live_id!(room_screen_13), - live_id!(room_screen_14), - live_id!(room_screen_15), + live_id!(room_screen_0), live_id!(room_screen_1), + live_id!(room_screen_2), live_id!(room_screen_3), + live_id!(room_screen_4), live_id!(room_screen_5), + live_id!(room_screen_6), live_id!(room_screen_7), + live_id!(room_screen_8), live_id!(room_screen_9), + live_id!(room_screen_10), live_id!(room_screen_11), + live_id!(room_screen_12), live_id!(room_screen_13), + live_id!(room_screen_14), live_id!(room_screen_15), ]; /// Returns the room view and room screen LiveIds for the given stack depth. @@ -1087,11 +926,7 @@ impl App { | SelectedRoom::Thread { room_name_id, .. } => { let (view_id, room_screen_id) = Self::room_ids_for_depth(new_depth); - let thread_root = if let SelectedRoom::Thread { - thread_root_event_id, - .. - } = &selected_room - { + let thread_root = if let SelectedRoom::Thread { thread_root_event_id, .. } = &selected_room { Some(thread_root_event_id.clone()) } else { None @@ -1117,16 +952,8 @@ impl App { }; // Set the header title for the view being pushed. - let title_path = &[ - view_id, - live_id!(header), - live_id!(content), - live_id!(title_container), - live_id!(title), - ]; - self.ui - .label(cx, title_path) - .set_text(cx, &selected_room.display_name()); + let title_path = &[view_id, live_id!(header), live_id!(content), live_id!(title_container), live_id!(title)]; + self.ui.label(cx, title_path).set_text(cx, &selected_room.display_name()); // Save the current selected_room onto the navigation stack before replacing it. if let Some(prev) = self.app_state.selected_room.take() { @@ -1136,11 +963,10 @@ impl App { self.app_state.selected_room = Some(selected_room); // Push the view onto the mobile navigation stack. - self.ui - .stack_navigation(cx, ids!(view_stack)) - .push(cx, view_id); + self.ui.stack_navigation(cx, ids!(view_stack)).push(cx, view_id); self.ui.redraw(cx); } + } /// App-wide state that is stored persistently across multiple app runs @@ -1208,8 +1034,7 @@ impl BotSettingsState { if bound { if !self.is_room_bound(&room_id) { self.bound_rooms.push(room_id); - self.bound_rooms - .sort_by(|lhs, rhs| lhs.as_str().cmp(rhs.as_str())); + self.bound_rooms.sort_by(|lhs, rhs| lhs.as_str().cmp(rhs.as_str())); } } else { self.bound_rooms @@ -1219,10 +1044,7 @@ impl BotSettingsState { /// Returns the configured botfather user ID, resolving a localpart against /// the current user's homeserver when needed. - pub fn resolved_bot_user_id( - &self, - current_user_id: Option<&UserId>, - ) -> Result { + pub fn resolved_bot_user_id(&self, current_user_id: Option<&UserId>) -> Result { let raw = self.botfather_user_id.trim(); if raw.starts_with('@') || raw.contains(':') { let full_user_id = if raw.starts_with('@') { @@ -1267,6 +1089,7 @@ pub struct SavedDockState { pub selected_room: Option, } + /// Represents a room currently or previously selected by the user. /// /// ## PartialEq/Eq equality comparison behavior @@ -1323,7 +1146,9 @@ impl SelectedRoom { match self { SelectedRoom::InvitedRoom { room_name_id } if room_name_id.room_id() == room_id => { let name = room_name_id.clone(); - *self = SelectedRoom::JoinedRoom { room_name_id: name }; + *self = SelectedRoom::JoinedRoom { + room_name_id: name, + }; true } _ => false, @@ -1333,14 +1158,11 @@ impl SelectedRoom { /// Returns the `LiveId` of the room tab corresponding to this `SelectedRoom`. pub fn tab_id(&self) -> LiveId { match self { - SelectedRoom::Thread { - room_name_id, - thread_root_event_id, - } => LiveId::from_str(&format!( - "{}##{}", - room_name_id.room_id(), - thread_root_event_id - )), + SelectedRoom::Thread { room_name_id, thread_root_event_id } => { + LiveId::from_str( + &format!("{}##{}", room_name_id.room_id(), thread_root_event_id) + ) + } other => LiveId::from_str(other.room_id().as_str()), } } diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index c45c7309f..418c5214c 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -1,371 +1,365 @@ use makepad_widgets::*; -use crate::{ - app::AppState, - home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, - settings::settings_screen::SettingsScreenWidgetRefExt, -}; +use crate::{app::AppState, home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, settings::settings_screen::SettingsScreenWidgetRefExt}; script_mod! { -use mod.prelude.widgets.* -use mod.widgets.* - - -// Defines the total height of the StackNavigationView's header. -// This has to be set in multiple places because of how StackNavigation -// uses an Overlay view internally. -mod.widgets.STACK_VIEW_HEADER_HEIGHT = 75 - -// A reusable base for StackNavigationView children in the mobile layout. -// Each specific content view (room, invite, space lobby) extends this -// and places its own screen widget inside the body. -mod.widgets.RobrixContentView = StackNavigationView { - width: Fill, height: Fill - draw_bg.color: (COLOR_PRIMARY) - header +: { - clip_x: false, - clip_y: false, - show_bg: true, - draw_bg +: { - color: instance((COLOR_PRIMARY_DARKER)) - color_dither: uniform(1.0) - gradient_border_horizontal: uniform(0.0) - gradient_fill_horizontal: uniform(0.0) - color_2: instance(vec4(-1)) - - border_radius: uniform(4.0) - border_size: uniform(0.0) - border_color: instance(#0000) - border_color_2: instance(vec4(-1)) - - shadow_color: instance(#0005) - shadow_radius: uniform(9.0) - shadow_offset: uniform(vec2(1.0, 0.0)) - - rect_size2: varying(vec2(0)) - rect_size3: varying(vec2(0)) - rect_pos2: varying(vec2(0)) - rect_shift: varying(vec2(0)) - sdf_rect_pos: varying(vec2(0)) - sdf_rect_size: varying(vec2(0)) - - vertex: fn() { - let min_offset = min(self.shadow_offset vec2(0)) - self.rect_size2 = self.rect_size + 2.0*vec2(self.shadow_radius) - self.rect_size3 = self.rect_size2 + abs(self.shadow_offset) - self.rect_pos2 = self.rect_pos - vec2(self.shadow_radius) + min_offset - self.sdf_rect_size = self.rect_size2 - vec2(self.shadow_radius * 2.0 + self.border_size * 2.0) - self.sdf_rect_pos = -min_offset + vec2(self.border_size + self.shadow_radius) - self.rect_shift = -min_offset - - return self.clip_and_transform_vertex(self.rect_pos2 self.rect_size3) - } + use mod.prelude.widgets.* + use mod.widgets.* + + + // Defines the total height of the StackNavigationView's header. + // This has to be set in multiple places because of how StackNavigation + // uses an Overlay view internally. + mod.widgets.STACK_VIEW_HEADER_HEIGHT = 75 + + // A reusable base for StackNavigationView children in the mobile layout. + // Each specific content view (room, invite, space lobby) extends this + // and places its own screen widget inside the body. + mod.widgets.RobrixContentView = StackNavigationView { + width: Fill, height: Fill + draw_bg.color: (COLOR_PRIMARY) + header +: { + clip_x: false, + clip_y: false, + show_bg: true, + draw_bg +: { + color: instance((COLOR_PRIMARY_DARKER)) + color_dither: uniform(1.0) + gradient_border_horizontal: uniform(0.0) + gradient_fill_horizontal: uniform(0.0) + color_2: instance(vec4(-1)) + + border_radius: uniform(4.0) + border_size: uniform(0.0) + border_color: instance(#0000) + border_color_2: instance(vec4(-1)) + + shadow_color: instance(#0005) + shadow_radius: uniform(9.0) + shadow_offset: uniform(vec2(1.0, 0.0)) + + rect_size2: varying(vec2(0)) + rect_size3: varying(vec2(0)) + rect_pos2: varying(vec2(0)) + rect_shift: varying(vec2(0)) + sdf_rect_pos: varying(vec2(0)) + sdf_rect_size: varying(vec2(0)) + + vertex: fn() { + let min_offset = min(self.shadow_offset vec2(0)) + self.rect_size2 = self.rect_size + 2.0*vec2(self.shadow_radius) + self.rect_size3 = self.rect_size2 + abs(self.shadow_offset) + self.rect_pos2 = self.rect_pos - vec2(self.shadow_radius) + min_offset + self.sdf_rect_size = self.rect_size2 - vec2(self.shadow_radius * 2.0 + self.border_size * 2.0) + self.sdf_rect_pos = -min_offset + vec2(self.border_size + self.shadow_radius) + self.rect_shift = -min_offset + + return self.clip_and_transform_vertex(self.rect_pos2 self.rect_size3) + } - pixel: fn() { - let sdf = Sdf2d.viewport(self.pos * self.rect_size3) + pixel: fn() { + let sdf = Sdf2d.viewport(self.pos * self.rect_size3) - let mut fill_color = self.color - if self.color_2.x > -0.5 { - let dither = Math.random_2d(self.pos.xy) * 0.04 * self.color_dither - let dir = if self.gradient_fill_horizontal > 0.5 self.pos.x else self.pos.y - fill_color = mix(self.color self.color_2 dir + dither) - } + let mut fill_color = self.color + if self.color_2.x > -0.5 { + let dither = Math.random_2d(self.pos.xy) * 0.04 * self.color_dither + let dir = if self.gradient_fill_horizontal > 0.5 self.pos.x else self.pos.y + fill_color = mix(self.color self.color_2 dir + dither) + } - let mut stroke_color = self.border_color - if self.border_color_2.x > -0.5 { - let dither = Math.random_2d(self.pos.xy) * 0.04 * self.color_dither - let dir = if self.gradient_border_horizontal > 0.5 self.pos.x else self.pos.y - stroke_color = mix(self.border_color self.border_color_2 dir + dither) - } + let mut stroke_color = self.border_color + if self.border_color_2.x > -0.5 { + let dither = Math.random_2d(self.pos.xy) * 0.04 * self.color_dither + let dir = if self.gradient_border_horizontal > 0.5 self.pos.x else self.pos.y + stroke_color = mix(self.border_color self.border_color_2 dir + dither) + } - sdf.box( - self.sdf_rect_pos.x - self.sdf_rect_pos.y - self.sdf_rect_size.x - self.sdf_rect_size.y - max(1.0 self.border_radius) - ) - if sdf.shape > -1.0 { - let m = self.shadow_radius - let o = self.shadow_offset + self.rect_shift - let v = GaussShadow.rounded_box_shadow(vec2(m) + o self.rect_size2+o self.pos * (self.rect_size3+vec2(m)) self.shadow_radius*0.5 self.border_radius*2.0) - sdf.clear(self.shadow_color*v) - } + sdf.box( + self.sdf_rect_pos.x + self.sdf_rect_pos.y + self.sdf_rect_size.x + self.sdf_rect_size.y + max(1.0 self.border_radius) + ) + if sdf.shape > -1.0 { + let m = self.shadow_radius + let o = self.shadow_offset + self.rect_shift + let v = GaussShadow.rounded_box_shadow(vec2(m) + o self.rect_size2+o self.pos * (self.rect_size3+vec2(m)) self.shadow_radius*0.5 self.border_radius*2.0) + sdf.clear(self.shadow_color*v) + } - sdf.fill_keep(fill_color) + sdf.fill_keep(fill_color) - if self.border_size > 0.0 { - sdf.stroke(stroke_color self.border_size) + if self.border_size > 0.0 { + sdf.stroke(stroke_color self.border_size) + } + return sdf.result } - return sdf.result } - } - - padding: Inset{top: 30, bottom: 0} - height: (mod.widgets.STACK_VIEW_HEADER_HEIGHT), - content +: { - height: (mod.widgets.STACK_VIEW_HEADER_HEIGHT) - button_container +: { - padding: 0, - margin: 0 - left_button +: { - width: Fit, height: Fit, - padding: Inset{left: 20, right: 23, top: 10, bottom: 10} - margin: Inset{left: 8, right: 0, top: 0, bottom: 0} - draw_icon +: { color: (ROOM_NAME_TEXT_COLOR) } - icon_walk: Walk{width: 13, height: Fit} - spacing: 0 - text: "" + padding: Inset{top: 30, bottom: 0} + height: (mod.widgets.STACK_VIEW_HEADER_HEIGHT), + + content +: { + height: (mod.widgets.STACK_VIEW_HEADER_HEIGHT) + button_container +: { + padding: 0, + margin: 0 + left_button +: { + width: Fit, height: Fit, + padding: Inset{left: 20, right: 23, top: 10, bottom: 10} + margin: Inset{left: 8, right: 0, top: 0, bottom: 0} + draw_icon +: { color: (ROOM_NAME_TEXT_COLOR) } + icon_walk: Walk{width: 13, height: Fit} + spacing: 0 + text: "" + } } - } - title_container +: { - padding: Inset{top: 8} - title +: { - draw_text +: { - color: (ROOM_NAME_TEXT_COLOR) + title_container +: { + padding: Inset{top: 8} + title +: { + draw_text +: { + color: (ROOM_NAME_TEXT_COLOR) + } } } } } + body +: { + margin: Inset{top: (mod.widgets.STACK_VIEW_HEADER_HEIGHT)} + } } - body +: { - margin: Inset{top: (mod.widgets.STACK_VIEW_HEADER_HEIGHT)} - } -} -// A wrapper view around the SpacesBar that lets us show/hide it via animation. -mod.widgets.SpacesBarWrapper = set_type_default() do #(SpacesBarWrapper::register_widget(vm)) { - ..mod.widgets.RoundedShadowView - - width: Fill, - height: (NAVIGATION_TAB_BAR_SIZE) - margin: Inset{left: 4, right: 4} - show_bg: true - draw_bg +: { - color: (COLOR_PRIMARY_DARKER) - border_radius: 4.0 - border_size: 0.0 - shadow_color: #0005 - shadow_radius: 15.0 - shadow_offset: vec2(1.0, 0.0) - } + // A wrapper view around the SpacesBar that lets us show/hide it via animation. + mod.widgets.SpacesBarWrapper = set_type_default() do #(SpacesBarWrapper::register_widget(vm)) { + ..mod.widgets.RoundedShadowView - CachedWidget { - root_spaces_bar := mod.widgets.SpacesBar {} - } - - animator: Animator{ - spaces_bar_animator: { - default: @hide - show: AnimatorState{ - redraw: true - from: { all: Forward { duration: (mod.widgets.SPACES_BAR_ANIMATION_DURATION_SECS) } } - apply: { height: (NAVIGATION_TAB_BAR_SIZE), draw_bg: { shadow_color: #x00000055 } } - } - hide: AnimatorState{ - redraw: true - from: { all: Forward { duration: (mod.widgets.SPACES_BAR_ANIMATION_DURATION_SECS) } } - apply: { height: 0, draw_bg: { shadow_color: (COLOR_TRANSPARENT) } } - } + width: Fill, + height: (NAVIGATION_TAB_BAR_SIZE) + margin: Inset{left: 4, right: 4} + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY_DARKER) + border_radius: 4.0 + border_size: 0.0 + shadow_color: #0005 + shadow_radius: 15.0 + shadow_offset: vec2(1.0, 0.0) } - } -} -// The home screen widget contains the main content: -// rooms list, room screens, and the settings screen as an overlay. -// It adapts to both desktop and mobile layouts. -mod.widgets.HomeScreen = #(HomeScreen::register_widget(vm)) { - AdaptiveView { - // NOTE: within each of these sub views, we used `CachedWidget` wrappers - // to ensure that there is only a single global instance of each - // of those widgets, which means they maintain their state - // across transitions between the Desktop and Mobile variant. - Desktop := SolidView { - width: Fill, height: Fill - flow: Right - align: Align{x: 0.0, y: 0.0} - padding: 0, - margin: 0, - - show_bg: true - draw_bg +: { - color: (COLOR_SECONDARY) - } + CachedWidget { + root_spaces_bar := mod.widgets.SpacesBar {} + } - // On the left, show the navigation tab bar vertically. - CachedWidget { - navigation_tab_bar := mod.widgets.NavigationTabBar {} + animator: Animator{ + spaces_bar_animator: { + default: @hide + show: AnimatorState{ + redraw: true + from: { all: Forward { duration: (mod.widgets.SPACES_BAR_ANIMATION_DURATION_SECS) } } + apply: { height: (NAVIGATION_TAB_BAR_SIZE), draw_bg: { shadow_color: #x00000055 } } + } + hide: AnimatorState{ + redraw: true + from: { all: Forward { duration: (mod.widgets.SPACES_BAR_ANIMATION_DURATION_SECS) } } + apply: { height: 0, draw_bg: { shadow_color: (COLOR_TRANSPARENT) } } + } } + } + } - // To the right of that, we use the PageFlip widget to show either - // the main desktop UI or the settings screen. - home_screen_page_flip := PageFlip { + // The home screen widget contains the main content: + // rooms list, room screens, and the settings screen as an overlay. + // It adapts to both desktop and mobile layouts. + mod.widgets.HomeScreen = #(HomeScreen::register_widget(vm)) { + AdaptiveView { + // NOTE: within each of these sub views, we used `CachedWidget` wrappers + // to ensure that there is only a single global instance of each + // of those widgets, which means they maintain their state + // across transitions between the Desktop and Mobile variant. + Desktop := SolidView { width: Fill, height: Fill + flow: Right + align: Align{x: 0.0, y: 0.0} + padding: 0, + margin: 0, + + show_bg: true + draw_bg +: { + color: (COLOR_SECONDARY) + } - lazy_init: true, - active_page: @home_page + // On the left, show the navigation tab bar vertically. + CachedWidget { + navigation_tab_bar := mod.widgets.NavigationTabBar {} + } - home_page := View { + // To the right of that, we use the PageFlip widget to show either + // the main desktop UI or the settings screen. + home_screen_page_flip := PageFlip { width: Fill, height: Fill - flow: Down - View { - width: Fill, - height: 39, - flow: Right - padding: Inset{top: 2, bottom: 2} - margin: Inset{right: 2} - spacing: 2 - align: Align{y: 0.5} + lazy_init: true, + active_page: @home_page - CachedWidget { - room_filter_input_bar := RoomFilterInputBar {} - } + home_page := View { + width: Fill, height: Fill + flow: Down - search_messages_button := SearchMessagesButton { - // make this button match/align with the RoomFilterInputBar - height: 32.5, + View { + width: Fill, + height: 39, + flow: Right + padding: Inset{top: 2, bottom: 2} margin: Inset{right: 2} + spacing: 2 + align: Align{y: 0.5} + + CachedWidget { + room_filter_input_bar := RoomFilterInputBar {} + } + + search_messages_button := SearchMessagesButton { + // make this button match/align with the RoomFilterInputBar + height: 32.5, + margin: Inset{right: 2} + } } - } - mod.widgets.MainDesktopUI {} - } + mod.widgets.MainDesktopUI {} + } - settings_page := SolidView { - width: Fill, height: Fill - show_bg: true, - draw_bg.color: (COLOR_PRIMARY) + settings_page := SolidView { + width: Fill, height: Fill + show_bg: true, + draw_bg.color: (COLOR_PRIMARY) - CachedWidget { - settings_screen := mod.widgets.SettingsScreen {} + CachedWidget { + settings_screen := mod.widgets.SettingsScreen {} + } } - } - add_room_page := SolidView { - width: Fill, height: Fill - show_bg: true, - draw_bg.color: (COLOR_PRIMARY) + add_room_page := SolidView { + width: Fill, height: Fill + show_bg: true, + draw_bg.color: (COLOR_PRIMARY) - CachedWidget { - add_room_screen := mod.widgets.AddRoomScreen {} + CachedWidget { + add_room_screen := mod.widgets.AddRoomScreen {} + } } } } - } - - Mobile := SolidView { - width: Fill, height: Fill - flow: Down - show_bg: true - draw_bg.color: (COLOR_PRIMARY) + Mobile := SolidView { + width: Fill, height: Fill + flow: Down - view_stack := StackNavigation { - root_view +: { - flow: Down - width: Fill, height: Fill + show_bg: true + draw_bg.color: (COLOR_PRIMARY) - // At the top of the root view, we use the PageFlip widget to show either - // the main list of rooms or the settings screen. - home_screen_page_flip := PageFlip { + view_stack := StackNavigation { + root_view +: { + flow: Down width: Fill, height: Fill - lazy_init: true, - active_page: @home_page - - home_page := View { + // At the top of the root view, we use the PageFlip widget to show either + // the main list of rooms or the settings screen. + home_screen_page_flip := PageFlip { width: Fill, height: Fill - // Note: while the other page views have top padding, we do NOT add that here - // because it is added in the `RoomsSideBar`'s `RoundedShadowView` itself. - flow: Down - mod.widgets.RoomsSideBar {} - } + lazy_init: true, + active_page: @home_page - settings_page := View { - width: Fill, height: Fill - padding: Inset{top: 20} + home_page := View { + width: Fill, height: Fill + // Note: while the other page views have top padding, we do NOT add that here + // because it is added in the `RoomsSideBar`'s `RoundedShadowView` itself. + flow: Down - CachedWidget { - settings_screen := mod.widgets.SettingsScreen {} + mod.widgets.RoomsSideBar {} } - } - add_room_page := View { - width: Fill, height: Fill - padding: Inset{top: 20} + settings_page := View { + width: Fill, height: Fill + padding: Inset{top: 20} - CachedWidget { - add_room_screen := mod.widgets.AddRoomScreen {} + CachedWidget { + settings_screen := mod.widgets.SettingsScreen {} + } + } + + add_room_page := View { + width: Fill, height: Fill + padding: Inset{top: 20} + + CachedWidget { + add_room_screen := mod.widgets.AddRoomScreen {} + } } } - } - // Show the SpacesBar right above the navigation tab bar. - // We wrap it in the SpacesBarWrapper in order to animate it in or out, - // and wrap *that* in a CachedWidget in order to maintain its shown/hidden state - // across AdaptiveView transitions between Mobile view mode and Desktop view mode. - // - // ... Then we wrap *that* in a ... - CachedWidget { - spaces_bar_wrapper := mod.widgets.SpacesBarWrapper {} - } + // Show the SpacesBar right above the navigation tab bar. + // We wrap it in the SpacesBarWrapper in order to animate it in or out, + // and wrap *that* in a CachedWidget in order to maintain its shown/hidden state + // across AdaptiveView transitions between Mobile view mode and Desktop view mode. + // + // ... Then we wrap *that* in a ... + CachedWidget { + spaces_bar_wrapper := mod.widgets.SpacesBarWrapper {} + } - // At the bottom of the root view, show the navigation tab bar horizontally. - CachedWidget { - navigation_tab_bar := mod.widgets.NavigationTabBar {} + // At the bottom of the root view, show the navigation tab bar horizontally. + CachedWidget { + navigation_tab_bar := mod.widgets.NavigationTabBar {} + } } - } - // Room views: multiple instances to support deep stacking - // (e.g., room -> thread -> room -> thread -> ...). - // Each stack depth gets its own dedicated view widget, - // avoiding complex state save/restore when views are reused. - room_view_0 := mod.widgets.RobrixContentView { body +: { room_screen_0 := mod.widgets.RoomScreen {} } } - room_view_1 := mod.widgets.RobrixContentView { body +: { room_screen_1 := mod.widgets.RoomScreen {} } } - room_view_2 := mod.widgets.RobrixContentView { body +: { room_screen_2 := mod.widgets.RoomScreen {} } } - room_view_3 := mod.widgets.RobrixContentView { body +: { room_screen_3 := mod.widgets.RoomScreen {} } } - room_view_4 := mod.widgets.RobrixContentView { body +: { room_screen_4 := mod.widgets.RoomScreen {} } } - room_view_5 := mod.widgets.RobrixContentView { body +: { room_screen_5 := mod.widgets.RoomScreen {} } } - room_view_6 := mod.widgets.RobrixContentView { body +: { room_screen_6 := mod.widgets.RoomScreen {} } } - room_view_7 := mod.widgets.RobrixContentView { body +: { room_screen_7 := mod.widgets.RoomScreen {} } } - room_view_8 := mod.widgets.RobrixContentView { body +: { room_screen_8 := mod.widgets.RoomScreen {} } } - room_view_9 := mod.widgets.RobrixContentView { body +: { room_screen_9 := mod.widgets.RoomScreen {} } } - room_view_10 := mod.widgets.RobrixContentView { body +: { room_screen_10 := mod.widgets.RoomScreen {} } } - room_view_11 := mod.widgets.RobrixContentView { body +: { room_screen_11 := mod.widgets.RoomScreen {} } } - room_view_12 := mod.widgets.RobrixContentView { body +: { room_screen_12 := mod.widgets.RoomScreen {} } } - room_view_13 := mod.widgets.RobrixContentView { body +: { room_screen_13 := mod.widgets.RoomScreen {} } } - room_view_14 := mod.widgets.RobrixContentView { body +: { room_screen_14 := mod.widgets.RoomScreen {} } } - room_view_15 := mod.widgets.RobrixContentView { body +: { room_screen_15 := mod.widgets.RoomScreen {} } } - - invite_view := mod.widgets.RobrixContentView { - body +: { - invite_screen := mod.widgets.InviteScreen {} + // Room views: multiple instances to support deep stacking + // (e.g., room -> thread -> room -> thread -> ...). + // Each stack depth gets its own dedicated view widget, + // avoiding complex state save/restore when views are reused. + room_view_0 := mod.widgets.RobrixContentView { body +: { room_screen_0 := mod.widgets.RoomScreen {} } } + room_view_1 := mod.widgets.RobrixContentView { body +: { room_screen_1 := mod.widgets.RoomScreen {} } } + room_view_2 := mod.widgets.RobrixContentView { body +: { room_screen_2 := mod.widgets.RoomScreen {} } } + room_view_3 := mod.widgets.RobrixContentView { body +: { room_screen_3 := mod.widgets.RoomScreen {} } } + room_view_4 := mod.widgets.RobrixContentView { body +: { room_screen_4 := mod.widgets.RoomScreen {} } } + room_view_5 := mod.widgets.RobrixContentView { body +: { room_screen_5 := mod.widgets.RoomScreen {} } } + room_view_6 := mod.widgets.RobrixContentView { body +: { room_screen_6 := mod.widgets.RoomScreen {} } } + room_view_7 := mod.widgets.RobrixContentView { body +: { room_screen_7 := mod.widgets.RoomScreen {} } } + room_view_8 := mod.widgets.RobrixContentView { body +: { room_screen_8 := mod.widgets.RoomScreen {} } } + room_view_9 := mod.widgets.RobrixContentView { body +: { room_screen_9 := mod.widgets.RoomScreen {} } } + room_view_10 := mod.widgets.RobrixContentView { body +: { room_screen_10 := mod.widgets.RoomScreen {} } } + room_view_11 := mod.widgets.RobrixContentView { body +: { room_screen_11 := mod.widgets.RoomScreen {} } } + room_view_12 := mod.widgets.RobrixContentView { body +: { room_screen_12 := mod.widgets.RoomScreen {} } } + room_view_13 := mod.widgets.RobrixContentView { body +: { room_screen_13 := mod.widgets.RoomScreen {} } } + room_view_14 := mod.widgets.RobrixContentView { body +: { room_screen_14 := mod.widgets.RoomScreen {} } } + room_view_15 := mod.widgets.RobrixContentView { body +: { room_screen_15 := mod.widgets.RoomScreen {} } } + + invite_view := mod.widgets.RobrixContentView { + body +: { + invite_screen := mod.widgets.InviteScreen {} + } } - } - space_lobby_view := mod.widgets.RobrixContentView { - body +: { - space_lobby_screen := mod.widgets.SpaceLobbyScreen {} + space_lobby_view := mod.widgets.RobrixContentView { + body +: { + space_lobby_screen := mod.widgets.SpaceLobbyScreen {} + } } } } } } } -} + /// A simple wrapper around the SpacesBar that allows us to animate showing or hiding it. #[derive(Script, ScriptHook, Widget, Animator)] pub struct SpacesBarWrapper { - #[source] - source: ScriptObjectRef, - #[deref] - view: View, - #[apply_default] - animator: Animator, + #[source] source: ScriptObjectRef, + #[deref] view: View, + #[apply_default] animator: Animator, } impl Widget for SpacesBarWrapper { @@ -390,9 +384,7 @@ impl Widget for SpacesBarWrapper { impl SpacesBarWrapperRef { /// Shows or hides the spaces bar by animating it in or out. fn show_or_hide(&self, cx: &mut Cx, show: bool) { - let Some(mut inner) = self.borrow_mut() else { - return; - }; + let Some(mut inner) = self.borrow_mut() else { return }; if show { inner.animator_play(cx, ids!(spaces_bar_animator.show)); } else { @@ -402,20 +394,18 @@ impl SpacesBarWrapperRef { } } + #[derive(Script, ScriptHook, Widget)] pub struct HomeScreen { - #[deref] - view: View, + #[deref] view: View, /// The previously-selected navigation tab, used to determine which tab /// and top-level view we return to after closing the settings screen. /// /// Note that the current selected tap is stored in `AppState` so that /// other widgets can easily access it. - #[rust] - previous_selection: SelectedTab, - #[rust] - is_spaces_bar_shown: bool, + #[rust] previous_selection: SelectedTab, + #[rust] is_spaces_bar_shown: bool, } impl Widget for HomeScreen { @@ -428,9 +418,7 @@ impl Widget for HomeScreen { if !matches!(app_state.selected_tab, SelectedTab::Home) { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = SelectedTab::Home; - cx.action(NavigationBarAction::TabSelected( - app_state.selected_tab.clone(), - )); + cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } @@ -439,23 +427,17 @@ impl Widget for HomeScreen { if !matches!(app_state.selected_tab, SelectedTab::AddRoom) { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = SelectedTab::AddRoom; - cx.action(NavigationBarAction::TabSelected( - app_state.selected_tab.clone(), - )); + cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } } Some(NavigationBarAction::GoToSpace { space_name_id }) => { - let new_space_selection = SelectedTab::Space { - space_name_id: space_name_id.clone(), - }; + let new_space_selection = SelectedTab::Space { space_name_id: space_name_id.clone() }; if app_state.selected_tab != new_space_selection { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = new_space_selection; - cx.action(NavigationBarAction::TabSelected( - app_state.selected_tab.clone(), - )); + cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } @@ -465,12 +447,8 @@ impl Widget for HomeScreen { if !matches!(app_state.selected_tab, SelectedTab::Settings) { self.previous_selection = app_state.selected_tab.clone(); app_state.selected_tab = SelectedTab::Settings; - cx.action(NavigationBarAction::TabSelected( - app_state.selected_tab.clone(), - )); - if let Some(settings_page) = - self.update_active_page_from_selection(cx, app_state) - { + cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); + if let Some(settings_page) = self.update_active_page_from_selection(cx, app_state) { settings_page .settings_screen(cx, ids!(settings_screen)) .populate(cx, None, &app_state.bot_settings); @@ -483,21 +461,19 @@ impl Widget for HomeScreen { Some(NavigationBarAction::CloseSettings) => { if matches!(app_state.selected_tab, SelectedTab::Settings) { app_state.selected_tab = self.previous_selection.clone(); - cx.action(NavigationBarAction::TabSelected( - app_state.selected_tab.clone(), - )); + cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } } Some(NavigationBarAction::ToggleSpacesBar) => { self.is_spaces_bar_shown = !self.is_spaces_bar_shown; - self.view - .spaces_bar_wrapper(cx, ids!(spaces_bar_wrapper)) + self.view.spaces_bar_wrapper(cx, ids!(spaces_bar_wrapper)) .show_or_hide(cx, self.is_spaces_bar_shown); } // We're the ones who emitted this action, so we don't need to handle it again. - Some(NavigationBarAction::TabSelected(_)) | None => {} + Some(NavigationBarAction::TabSelected(_)) + | None => { } } } } @@ -528,7 +504,8 @@ impl HomeScreen { .set_active_page( cx, match app_state.selected_tab { - SelectedTab::Space { .. } | SelectedTab::Home => id!(home_page), + SelectedTab::Space { .. } + | SelectedTab::Home => id!(home_page), SelectedTab::Settings => id!(settings_page), SelectedTab::AddRoom => id!(add_room_page), }, diff --git a/src/home/room_context_menu.rs b/src/home/room_context_menu.rs index 9a048b91f..b2aaf90aa 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -75,7 +75,7 @@ script_mod! { } priority_button := mod.widgets.RoomContextMenuButton { - draw_icon +: { svg: (ICON_TOMBSTONE) } + draw_icon +: { svg: (ICON_TOMBSTONE) } text: "Set Low Priority" } @@ -83,7 +83,7 @@ script_mod! { draw_icon +: { svg: (ICON_LINK) } text: "Copy Link to Room" } - + divider1 := LineH { margin: Inset{top: 3, bottom: 3} width: Fill, @@ -150,12 +150,9 @@ pub enum RoomContextMenuAction { #[derive(Script, ScriptHook, Widget)] pub struct RoomContextMenu { - #[deref] - view: View, - #[source] - source: ScriptObjectRef, - #[rust] - details: Option, + #[deref] view: View, + #[source] source: ScriptObjectRef, + #[rust] details: Option, } impl Widget for RoomContextMenu { @@ -167,25 +164,21 @@ impl Widget for RoomContextMenu { } fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { - if !self.visible { - return; - } + if !self.visible { return; } self.view.handle_event(cx, event, scope); // Close logic similar to NewMessageContextMenu let area = self.view.area(); let close_menu = { event.back_pressed() - || match event.hits_with_capture_overload(cx, area, true) { - Hit::KeyUp(key) => key.key_code == KeyCode::Escape, - Hit::FingerUp(fue) if fue.is_over => !self - .view(cx, ids!(main_content)) - .area() - .rect(cx) - .contains(fue.abs), - Hit::FingerScroll(_) => true, - _ => false, + || match event.hits_with_capture_overload(cx, area, true) { + Hit::KeyUp(key) => key.key_code == KeyCode::Escape, + Hit::FingerUp(fue) if fue.is_over => { + !self.view(cx, ids!(main_content)).area().rect(cx).contains(fue.abs) } + Hit::FingerScroll(_) => true, + _ => false, + } }; if close_menu { @@ -199,30 +192,31 @@ impl Widget for RoomContextMenu { impl WidgetMatchEvent for RoomContextMenu { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, scope: &mut Scope) { - let Some(details) = self.details.as_ref() else { - return; - }; + let Some(details) = self.details.as_ref() else { return }; let mut close_menu = false; - + if self.button(cx, ids!(mark_unread_button)).clicked(actions) { submit_async_request(MatrixRequest::SetUnreadFlag { room_id: details.room_name_id.room_id().clone(), mark_as_unread: !details.is_marked_unread, }); close_menu = true; - } else if self.button(cx, ids!(favorite_button)).clicked(actions) { + } + else if self.button(cx, ids!(favorite_button)).clicked(actions) { submit_async_request(MatrixRequest::SetIsFavorite { room_id: details.room_name_id.room_id().clone(), is_favorite: !details.is_favorite, }); close_menu = true; - } else if self.button(cx, ids!(priority_button)).clicked(actions) { + } + else if self.button(cx, ids!(priority_button)).clicked(actions) { submit_async_request(MatrixRequest::SetIsLowPriority { room_id: details.room_name_id.room_id().clone(), is_low_priority: !details.is_low_priority, }); close_menu = true; - } else if self.button(cx, ids!(copy_link_button)).clicked(actions) { + } + else if self.button(cx, ids!(copy_link_button)).clicked(actions) { submit_async_request(MatrixRequest::GenerateMatrixLink { room_id: details.room_name_id.room_id().clone(), event_id: None, @@ -230,7 +224,8 @@ impl WidgetMatchEvent for RoomContextMenu { join_on_click: false, }); close_menu = true; - } else if self.button(cx, ids!(room_settings_button)).clicked(actions) { + } + else if self.button(cx, ids!(room_settings_button)).clicked(actions) { // TODO: handle/implement this enqueue_popup_notification( "The room settings page is not yet implemented.", @@ -238,7 +233,8 @@ impl WidgetMatchEvent for RoomContextMenu { Some(5.0), ); close_menu = true; - } else if self.button(cx, ids!(notifications_button)).clicked(actions) { + } + else if self.button(cx, ids!(notifications_button)).clicked(actions) { // TODO: handle/implement this enqueue_popup_notification( "The room notifications page is not yet implemented.", @@ -246,16 +242,15 @@ impl WidgetMatchEvent for RoomContextMenu { Some(5.0), ); close_menu = true; - } else if self.button(cx, ids!(invite_button)).clicked(actions) { + } + else if self.button(cx, ids!(invite_button)).clicked(actions) { cx.action(InviteModalAction::Open(details.room_name_id.clone())); close_menu = true; - } else if self.button(cx, ids!(bot_binding_button)).clicked(actions) { + } + else if self.button(cx, ids!(bot_binding_button)).clicked(actions) { if let Some(app_state) = scope.data.get::() { let room_id = details.room_name_id.room_id().clone(); - match app_state - .bot_settings - .resolved_bot_user_id(current_user_id().as_deref()) - { + match app_state.bot_settings.resolved_bot_user_id(current_user_id().as_deref()) { Ok(bot_user_id) => { if details.is_bot_bound { submit_async_request(MatrixRequest::SetRoomBotBinding { @@ -293,7 +288,8 @@ impl WidgetMatchEvent for RoomContextMenu { ); } close_menu = true; - } else if self.button(cx, ids!(leave_button)).clicked(actions) { + } + else if self.button(cx, ids!(leave_button)).clicked(actions) { use crate::join_leave_room_modal::{JoinLeaveRoomModalAction, JoinLeaveModalKind}; use crate::room::BasicRoomDetails; let room_details = BasicRoomDetails::Name(details.room_name_id.clone()); @@ -322,7 +318,7 @@ impl RoomContextMenu { cx.set_key_focus(self.view.area()); dvec2(MENU_WIDTH, height) } - + fn update_buttons(&mut self, cx: &mut Cx, details: &RoomContextMenuDetails) -> f64 { let mark_unread_button = self.button(cx, ids!(mark_unread_button)); if details.is_marked_unread { @@ -330,12 +326,12 @@ impl RoomContextMenu { } else { mark_unread_button.set_text(cx, "Mark as Unread"); } - + let favorite_button = self.button(cx, ids!(favorite_button)); if details.is_favorite { favorite_button.set_text(cx, "Un-favorite"); } else { - favorite_button.set_text(cx, "Favorite"); + favorite_button.set_text(cx, "Favorite"); } let priority_button = self.button(cx, ids!(priority_button)); @@ -352,7 +348,7 @@ impl RoomContextMenu { } else { bot_binding_button.set_text(cx, "Bind BotFather"); } - + // Reset hover states mark_unread_button.reset_hover(cx); favorite_button.reset_hover(cx); @@ -363,16 +359,12 @@ impl RoomContextMenu { self.button(cx, ids!(invite_button)).reset_hover(cx); bot_binding_button.reset_hover(cx); self.button(cx, ids!(leave_button)).reset_hover(cx); - + self.redraw(cx); - - // Calculate height (rudimentary) - sum of visible buttons + padding. - let button_count = if details.app_service_enabled { - 9.0 - } else { - 8.0 - }; - (button_count * BUTTON_HEIGHT) + 20.0 + 10.0 // approx + + // Calculate height (rudimentary) - sum of visible buttons + padding + // 8 or 9 buttons * 35.0 + 2 dividers * ~10.0 + padding + ((if details.app_service_enabled { 9.0 } else { 8.0 }) * BUTTON_HEIGHT) + 20.0 + 10.0 // approx } fn close(&mut self, cx: &mut Cx) { @@ -385,16 +377,12 @@ impl RoomContextMenu { impl RoomContextMenuRef { pub fn is_currently_shown(&self, cx: &mut Cx) -> bool { - let Some(inner) = self.borrow() else { - return false; - }; + let Some(inner) = self.borrow() else { return false }; inner.is_currently_shown(cx) } pub fn show(&self, cx: &mut Cx, details: RoomContextMenuDetails) -> DVec2 { - let Some(mut inner) = self.borrow_mut() else { - return DVec2::default(); - }; + let Some(mut inner) = self.borrow_mut() else { return DVec2::default()}; inner.show(cx, details) } } diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 1b4ddc171..e3e9d7ab0 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1,106 +1,40 @@ //! The `RoomScreen` widget is the UI view that displays a single room or thread's timeline //! of events (messages,state changes, etc.), along with an input bar at the bottom. -use std::{ - borrow::Cow, - cell::RefCell, - ops::{DerefMut, Range}, - sync::Arc, -}; +use std::{borrow::Cow, cell::RefCell, ops::{DerefMut, Range}, sync::Arc}; use bytesize::ByteSize; use hashbrown::{HashMap, HashSet}; use imbl::Vector; use makepad_widgets::{image_cache::ImageBuffer, *}; use matrix_sdk::{ - OwnedServerName, RoomDisplayName, - media::{MediaFormat, MediaRequestParameters}, - room::RoomMember, - ruma::{ - EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, - events::{ + OwnedServerName, RoomDisplayName, media::{MediaFormat, MediaRequestParameters}, room::RoomMember, ruma::{ + EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, events::{ receipt::Receipt, room::{ - ImageInfo, MediaSource, - message::{ - AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, - FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, - LocationMessageEventContent, MessageFormat, MessageType, - NoticeMessageEventContent, RoomMessageEventContent, TextMessageEventContent, - VideoMessageEventContent, - }, + ImageInfo, MediaSource, message::{ + AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, RoomMessageEventContent, TextMessageEventContent, VideoMessageEventContent + } }, sticker::{StickerEventContent, StickerMediaSource}, - }, - matrix_uri::MatrixId, - uint, - }, + }, matrix_uri::MatrixId, uint + } }; use matrix_sdk_ui::timeline::{ - self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, - MemberProfileChange, MembershipChange, MsgLikeContent, MsgLikeKind, OtherMessageLike, - PollState, RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, - TimelineItemContent, TimelineItemKind, VirtualTimelineItem, -}; -use ruma::{ - OwnedUserId, - api::client::receipt::create_receipt::v3::ReceiptType, - events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent}, - owned_room_id, + self, EmbeddedEvent, EncryptedMessage, EventTimelineItem, InReplyToDetails, MemberProfileChange, MembershipChange, MsgLikeContent, MsgLikeKind, OtherMessageLike, PollState, RoomMembershipChange, TimelineDetails, TimelineEventItemId, TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem }; +use ruma::{OwnedUserId, api::client::receipt::create_receipt::v3::ReceiptType, events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent}, owned_room_id}; use crate::{ - app::{AppState, 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::{ - create_bot_modal::{CreateBotModalAction, CreateBotModalWidgetExt}, - delete_bot_modal::{DeleteBotModalAction, DeleteBotModalWidgetExt}, - 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, - }, + app::{AppState, 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::{create_bot_modal::{CreateBotModalAction, CreateBotModalWidgetExt}, delete_bot_modal::{DeleteBotModalAction, DeleteBotModalWidgetExt}, 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, }, - room::{ - BasicRoomDetails, - room_input_bar::{RoomInputBarState, RoomInputBarWidgetRefExt}, - typing_notice::TypingNoticeWidgetExt, - }, + room::{BasicRoomDetails, room_input_bar::{RoomInputBarState, RoomInputBarWidgetRefExt}, typing_notice::TypingNoticeWidgetExt}, shared::{ - avatar::{AvatarState, AvatarWidgetRefExt}, - confirmation_modal::ConfirmationModalContent, - html_or_plaintext::{ - HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction, - }, - image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, - jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, - popup_list::{PopupKind, enqueue_popup_notification}, - restore_status_view::RestoreStatusViewWidgetExt, - styles::*, - text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, - timestamp::TimestampWidgetRefExt, - }, - sliding_sync::{ - BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, - TimelineKind, TimelineRequestSender, UserPowerLevels, current_user_id, get_client, - submit_async_request, take_timeline_endpoints, + avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::ConfirmationModalContent, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt }, - utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime}, + sliding_sync::{BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, current_user_id, get_client, submit_async_request, take_timeline_endpoints}, utils::{self, ImageFormat, MEDIA_THUMBNAIL_FORMAT, RoomNameId, unix_time_millis_to_datetime} }; use crate::home::event_reaction_list::ReactionListWidgetRefExt; use crate::home::room_read_receipt::AvatarRowWidgetRefExt; @@ -109,12 +43,7 @@ use crate::shared::mentionable_text_input::MentionableTextInputAction; use rangemap::RangeSet; -use super::{ - event_reaction_list::ReactionData, - loading_pane::LoadingPaneRef, - new_message_context_menu::{MessageAbilities, MessageDetails}, - room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}, -}; +use super::{event_reaction_list::ReactionData, loading_pane::LoadingPaneRef, new_message_context_menu::{MessageAbilities, MessageDetails}, room_read_receipt::{self, populate_read_receipts, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT}}; /// The maximum number of timeline items to search through /// when looking for a particular event. @@ -190,6 +119,7 @@ fn resolve_delete_bot_user_id( .map_err(|_| format!("Invalid Matrix user ID: {full_user_id}")) } + script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -934,30 +864,22 @@ script_mod! { /// The main widget that displays a single Matrix room. #[derive(Script, Widget)] pub struct RoomScreen { - #[deref] - view: View, + #[deref] view: View, /// The name and ID of the currently-shown room, if any. - #[rust] - room_name_id: Option, + #[rust] room_name_id: Option, /// The timeline currently displayed by this RoomScreen, if any. - #[rust] - timeline_kind: Option, + #[rust] timeline_kind: Option, /// The persistent UI-relevant states for the room that this widget is currently displaying. - #[rust] - tl_state: Option, + #[rust] tl_state: Option, /// The set of pinned events in this room. - #[rust] - pinned_events: Vec, + #[rust] pinned_events: Vec, /// Whether this room has been successfully loaded (received from the homeserver). - #[rust] - is_loaded: bool, + #[rust] is_loaded: bool, /// Whether or not all rooms have been loaded (received from the homeserver). - #[rust] - all_rooms_loaded: bool, + #[rust] all_rooms_loaded: bool, /// Whether the in-room app service quick actions card is currently visible. - #[rust] - show_app_service_actions: bool, + #[rust] show_app_service_actions: bool, } impl Drop for RoomScreen { @@ -989,8 +911,7 @@ impl Widget for RoomScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { let room_screen_widget_uid = self.widget_uid(); let portal_list = self.portal_list(cx, ids!(timeline.list)); - let user_profile_sliding_pane = - self.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)); + let user_profile_sliding_pane = self.user_profile_sliding_pane(cx, ids!(user_profile_sliding_pane)); let loading_pane = self.loading_pane(cx, ids!(loading_pane)); // Handle actions here before processing timeline updates. @@ -1005,13 +926,9 @@ impl Widget for RoomScreen { if let RoomScreenTooltipActions::HoverInReactionButton { widget_rect, reaction_data, - } = reaction_list.hovered_in(actions) - { - let Some(_tl_state) = self.tl_state.as_ref() else { - continue; - }; - let tooltip_text_arr: Vec = reaction_data - .reaction_senders + } = reaction_list.hovered_in(actions) { + let Some(_tl_state) = self.tl_state.as_ref() else { continue }; + let tooltip_text_arr: Vec = reaction_data.reaction_senders .iter() .map(|(sender, _react_info)| { user_profile_cache::get_user_display_name_for_room( @@ -1025,13 +942,10 @@ impl Widget for RoomScreen { }) .collect(); - let mut tooltip_text = utils::human_readable_list( - &tooltip_text_arr, - MAX_VISIBLE_AVATARS_IN_READ_RECEIPT, - ); + let mut tooltip_text = utils::human_readable_list(&tooltip_text_arr, MAX_VISIBLE_AVATARS_IN_READ_RECEIPT); tooltip_text.push_str(&format!(" reacted with: {}", reaction_data.reaction)); cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, TooltipAction::HoverIn { text: tooltip_text, widget_rect, @@ -1045,23 +959,24 @@ impl Widget for RoomScreen { // Handle a hover-out action on the reaction list or avatar row. let avatar_row_ref = wr.avatar_row(cx, ids!(avatar_row)); - if reaction_list.hovered_out(actions) || avatar_row_ref.hover_out(actions) { - cx.widget_action(room_screen_widget_uid, TooltipAction::HoverOut); + if reaction_list.hovered_out(actions) + || avatar_row_ref.hover_out(actions) + { + cx.widget_action( + room_screen_widget_uid, + TooltipAction::HoverOut, + ); } // Handle a hover-in action on the avatar row: show a read receipts summary. if let RoomScreenTooltipActions::HoverInReadReceipt { widget_rect, - read_receipts, - } = avatar_row_ref.hover_in(actions) - { - let Some(room_id) = self.room_id() else { - return; - }; - let tooltip_text = - room_read_receipt::populate_tooltip(cx, read_receipts, room_id); + read_receipts + } = avatar_row_ref.hover_in(actions) { + let Some(room_id) = self.room_id() else { return; }; + let tooltip_text= room_read_receipt::populate_tooltip(cx, read_receipts, room_id); cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, TooltipAction::HoverIn { text: tooltip_text, widget_rect, @@ -1075,27 +990,23 @@ impl Widget for RoomScreen { // Handle an image within the message being clicked. let content_message = wr.text_or_image(cx, ids!(content.message)); - if let TextOrImageAction::Clicked(mxc_uri) = actions - .find_widget_action(content_message.widget_uid()) - .cast() - { + if let TextOrImageAction::Clicked(mxc_uri) = actions.find_widget_action(content_message.widget_uid()).cast() { let texture = content_message.get_texture(cx); - self.handle_image_click(cx, mxc_uri, texture, index); + self.handle_image_click( + cx, + mxc_uri, + texture, + index, + ); continue; } // Handle the invite_user_button (in a SmallStateEvent) being clicked. if wr.button(cx, ids!(invite_user_button)).clicked(actions) { - let Some(tl) = self.tl_state.as_ref() else { - continue; - }; - if let Some(event_tl_item) = - tl.items.get(index).and_then(|item| item.as_event()) - { + let Some(tl) = self.tl_state.as_ref() else { continue }; + if let Some(event_tl_item) = tl.items.get(index).and_then(|item| item.as_event()) { let user_id = event_tl_item.sender().to_owned(); - let username = if let TimelineDetails::Ready(profile) = - event_tl_item.sender_profile() - { + let username = if let TimelineDetails::Ready(profile) = event_tl_item.sender_profile() { profile.display_name.as_deref().unwrap_or(user_id.as_str()) } else { user_id.as_str() @@ -1103,22 +1014,14 @@ impl Widget for RoomScreen { let room_id = tl.kind.room_id().clone(); let content = ConfirmationModalContent { title_text: "Send Invitation".into(), - body_text: format!( - "Are you sure you want to invite {username} to this room?" - ) - .into(), + body_text: format!("Are you sure you want to invite {username} to this room?").into(), accept_button_text: Some("Invite".into()), on_accept_clicked: Some(Box::new(move |_cx| { - submit_async_request(MatrixRequest::InviteUser { - room_id, - user_id, - }); + submit_async_request(MatrixRequest::InviteUser { room_id, user_id }); })), ..Default::default() }; - cx.action(InviteAction::ShowInviteConfirmationModal(RefCell::new( - Some(content), - ))); + cx.action(InviteAction::ShowInviteConfirmationModal(RefCell::new(Some(content)))); } } } @@ -1127,19 +1030,11 @@ impl Widget for RoomScreen { for action in actions { // Handle actions related to restoring the previously-saved state of rooms. - if let Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, .. }) = - action.downcast_ref() - { - if self - .room_name_id - .as_ref() - .is_some_and(|rn| rn.room_id() == room_name_id.room_id()) - { + if let Some(AppStateAction::RoomLoadedSuccessfully { room_name_id, ..}) = action.downcast_ref() { + if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_name_id.room_id()) { // `set_displayed_room()` does nothing if the room_name_id is unchanged, so we clear it first. self.room_name_id = None; - let thread_root_event_id = self - .timeline_kind - .as_ref() + let thread_root_event_id = self.timeline_kind.as_ref() .and_then(|k| k.thread_root_event_id().cloned()); self.set_displayed_room(cx, room_name_id, thread_root_event_id); return; @@ -1149,11 +1044,7 @@ impl Widget for RoomScreen { // Handle InviteResultAction to show popup notifications. if let Some(InviteResultAction::Sent { room_id, .. }) = action.downcast_ref() { // Only handle if this is for the current room. - if self - .room_name_id - .as_ref() - .is_some_and(|rn| rn.room_id() == room_id) - { + if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_id) { enqueue_popup_notification( "Sent invite successfully.", PopupKind::Success, @@ -1161,15 +1052,9 @@ impl Widget for RoomScreen { ); } } - if let Some(InviteResultAction::Failed { room_id, error, .. }) = - action.downcast_ref() - { + if let Some(InviteResultAction::Failed { room_id, error, .. }) = action.downcast_ref() { // Only handle if this is for the current room. - if self - .room_name_id - .as_ref() - .is_some_and(|rn| rn.room_id() == room_id) - { + if self.room_name_id.as_ref().is_some_and(|rn| rn.room_id() == room_id) { enqueue_popup_notification( format!("Failed to send invite.\n\nError: {error}"), PopupKind::Error, @@ -1179,15 +1064,11 @@ impl Widget for RoomScreen { } // Handle the highlight animation for a message. - let Some(tl) = self.tl_state.as_mut() else { - continue; - }; - if let MessageHighlightAnimationState::Pending { item_id } = - tl.message_highlight_animation_state - { + let Some(tl) = self.tl_state.as_mut() else { continue }; + if let MessageHighlightAnimationState::Pending { item_id } = tl.message_highlight_animation_state { if portal_list.smooth_scroll_reached(actions) { cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, MessageAction::HighlightMessage(item_id), ); tl.message_highlight_animation_state = MessageHighlightAnimationState::Off; @@ -1211,25 +1092,22 @@ impl Widget for RoomScreen { self.send_user_read_receipts_based_on_scroll_pos(cx, actions, &portal_list); // Handle the jump to bottom button: update its visibility, and handle clicks. - self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)) - .update_from_actions(cx, &portal_list, actions); + self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)).update_from_actions( + cx, + &portal_list, + actions, + ); } // Currently, a Signal event is only used to tell this widget: // 1. to check if the room has been loaded from the homeserver yet, or // 2. that its timeline events have been updated in the background. if let Event::Signal = event { - if let (false, Some(room_name_id), true) = ( - self.is_loaded, - self.room_name_id.as_ref(), - cx.has_global::(), - ) { + if let (false, Some(room_name_id), true) = (self.is_loaded, self.room_name_id.as_ref(), cx.has_global::()) { let rooms_list_ref = cx.get_global::(); if rooms_list_ref.is_room_loaded(room_name_id.room_id()) { let room_name_clone = room_name_id.clone(); - let thread_root_event_id = self - .timeline_kind - .as_ref() + let thread_root_event_id = self.timeline_kind.as_ref() .and_then(|k| k.thread_root_event_id().cloned()); // This room has been loaded now, so we call `set_displayed_room()`. // We first clear the `room_name_id`, otherwise that function will do nothing. @@ -1271,12 +1149,14 @@ impl Widget for RoomScreen { if is_interactive_hit { loading_pane.handle_event(cx, event, scope); } - } else if user_profile_sliding_pane.is_currently_shown(cx) { + } + else if user_profile_sliding_pane.is_currently_shown(cx) { is_pane_shown = true; if is_interactive_hit { user_profile_sliding_pane.handle_event(cx, event, scope); } - } else { + } + else { is_pane_shown = false; } @@ -1305,12 +1185,10 @@ impl Widget for RoomScreen { // Fetch room data once to avoid duplicate expensive lookups let (room_display_name, room_avatar_url) = get_client() .and_then(|client| client.get_room(&room_id)) - .map(|room| { - ( - room.cached_display_name().unwrap_or(RoomDisplayName::Empty), - room.avatar_url(), - ) - }) + .map(|room| ( + room.cached_display_name().unwrap_or(RoomDisplayName::Empty), + room.avatar_url() + )) .unwrap_or((RoomDisplayName::Empty, None)); RoomScreenProps { @@ -1327,9 +1205,7 @@ impl Widget for RoomScreen { RoomScreenProps { room_screen_widget_uid, room_name_id: room_name.clone(), - timeline_kind: self - .timeline_kind - .clone() + timeline_kind: self.timeline_kind.clone() .expect("BUG: room_name_id was set but timeline_kind was missing"), room_members: None, room_avatar_url: None, @@ -1341,9 +1217,7 @@ impl Widget for RoomScreen { if !is_pane_shown || !is_interactive_hit { return; } - log!( - "RoomScreen handling event with no room_name_id and no tl_state, skipping room-dependent event handling" - ); + log!("RoomScreen handling event with no room_name_id and no tl_state, skipping room-dependent event handling"); // Use a dummy room props for non-room-specific events let room_id = owned_room_id!("!dummy:matrix.org"); RoomScreenProps { @@ -1358,11 +1232,13 @@ impl Widget for RoomScreen { }; let mut room_scope = Scope::with_props(&room_props); + // Forward the event to the inner timeline view, but capture any actions it produces // such that we can handle the ones relevant to only THIS RoomScreen widget right here and now, // ensuring they are not mistakenly handled by other RoomScreen widget instances. - let mut actions_generated_within_this_room_screen = - cx.capture_actions(|cx| self.view.handle_event(cx, event, &mut room_scope)); + let mut actions_generated_within_this_room_screen = cx.capture_actions(|cx| + self.view.handle_event(cx, event, &mut room_scope) + ); // Here, we handle and remove any general actions that are relevant to only this RoomScreen. // Removing the handled actions ensures they are not mistakenly handled by other RoomScreen widget instances. actions_generated_within_this_room_screen.retain(|action| { @@ -1649,6 +1525,7 @@ impl Widget for RoomScreen { } } + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { // If the room isn't loaded yet, we show the restore status label only. if !self.is_loaded { @@ -1656,8 +1533,7 @@ impl Widget for RoomScreen { // No room selected yet, nothing to show. return DrawStep::done(); }; - let mut restore_status_view = - self.view.restore_status_view(cx, ids!(restore_status_view)); + let mut restore_status_view = self.view.restore_status_view(cx, ids!(restore_status_view)); restore_status_view.set_content(cx, self.all_rooms_loaded, room_name); return restore_status_view.draw(cx, scope); } @@ -1667,14 +1543,13 @@ impl Widget for RoomScreen { return DrawStep::done(); } + let room_screen_widget_uid = self.widget_uid(); while let Some(subview) = self.view.draw_walk(cx, scope, walk).step() { // Here, we only need to handle drawing the portal list. let portal_list_ref = subview.as_portal_list(); let Some(mut list_ref) = portal_list_ref.borrow_mut() else { - error!( - "!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else" - ); + error!("!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else"); continue; }; let Some(tl_state) = self.tl_state.as_mut() else { @@ -1694,170 +1569,143 @@ impl Widget for RoomScreen { if self.show_app_service_actions && tl_idx == tl_items.len() { list.item(cx, item_id, id!(AppServicePanel)) } else { - let Some(timeline_item) = tl_items.get(tl_idx) else { - // This shouldn't happen (unless the timeline gets corrupted or some other weird error), - // but we can always safely fill the item with an empty widget that takes up no space. - list.item(cx, item_id, id!(Empty)); - continue; - }; + let Some(timeline_item) = tl_items.get(tl_idx) else { + // This shouldn't happen (unless the timeline gets corrupted or some other weird error), + // but we can always safely fill the item with an empty widget that takes up no space. + list.item(cx, item_id, id!(Empty)); + continue; + }; - // Determine whether this item's content and profile have been drawn since the last update. - // Pass this state to each of the `populate_*` functions so they can attempt to re-use - // an item in the timeline's portallist that was previously populated, if one exists. - let item_drawn_status = ItemDrawnStatus { - content_drawn: tl_state - .content_drawn_since_last_update - .contains(&tl_idx), - profile_drawn: tl_state - .profile_drawn_since_last_update - .contains(&tl_idx), - }; - let (item, item_new_draw_status) = match timeline_item.kind() { - TimelineItemKind::Event(event_tl_item) => match event_tl_item.content() - { - TimelineItemContent::MsgLike(msg_like_content) => { - if tl_state.kind.thread_root_event_id().is_none() - && msg_like_content.thread_root.is_some() - { - // Hide threaded replies from the main room timeline UI. - ( - list.item(cx, item_id, id!(Empty)), - ItemDrawnStatus::both_drawn(), - ) - } else { - match &msg_like_content.kind { - MsgLikeKind::Message(_) - | MsgLikeKind::Sticker(_) - | MsgLikeKind::Redacted => { - let prev_event = tl_idx - .checked_sub(1) - .and_then(|i| tl_items.get(i)); - populate_message_view( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - msg_like_content, - prev_event, - &mut tl_state.media_cache, - &mut tl_state.link_preview_cache, - &tl_state.fetched_thread_summaries, - &mut tl_state.pending_thread_summary_fetches, - &tl_state.user_power, - &self.pinned_events, - item_drawn_status, - room_screen_widget_uid, - ) - } - // TODO: properly implement `Poll` as a regular Message-like timeline item. - MsgLikeKind::Poll(poll_state) => { - populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - poll_state, - item_drawn_status, - ) - } - MsgLikeKind::UnableToDecrypt(utd) => { - populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - utd, - item_drawn_status, - ) - } - MsgLikeKind::Other(other) => { - populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - other, - item_drawn_status, - ) - } - } + // Determine whether this item's content and profile have been drawn since the last update. + // Pass this state to each of the `populate_*` functions so they can attempt to re-use + // an item in the timeline's portallist that was previously populated, if one exists. + let item_drawn_status = ItemDrawnStatus { + content_drawn: tl_state.content_drawn_since_last_update.contains(&tl_idx), + profile_drawn: tl_state.profile_drawn_since_last_update.contains(&tl_idx), + }; + let (item, item_new_draw_status) = match timeline_item.kind() { + TimelineItemKind::Event(event_tl_item) => match event_tl_item.content() { + TimelineItemContent::MsgLike(msg_like_content) => { + if tl_state.kind.thread_root_event_id().is_none() + && msg_like_content.thread_root.is_some() + { + // Hide threaded replies from the main room timeline UI. + (list.item(cx, item_id, id!(Empty)), ItemDrawnStatus::both_drawn()) + } else { + match &msg_like_content.kind { + MsgLikeKind::Message(_) + | MsgLikeKind::Sticker(_) + | MsgLikeKind::Redacted => { + let prev_event = tl_idx.checked_sub(1).and_then(|i| tl_items.get(i)); + populate_message_view( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + msg_like_content, + prev_event, + &mut tl_state.media_cache, + &mut tl_state.link_preview_cache, + &tl_state.fetched_thread_summaries, + &mut tl_state.pending_thread_summary_fetches, + &tl_state.user_power, + &self.pinned_events, + item_drawn_status, + room_screen_widget_uid, + ) + }, + // TODO: properly implement `Poll` as a regular Message-like timeline item. + MsgLikeKind::Poll(poll_state) => populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + poll_state, + item_drawn_status, + ), + MsgLikeKind::UnableToDecrypt(utd) => populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + utd, + item_drawn_status, + ), + MsgLikeKind::Other(other) => populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + other, + item_drawn_status, + ), } } - TimelineItemContent::MembershipChange(membership_change) => { - populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - membership_change, - item_drawn_status, - ) - } - TimelineItemContent::ProfileChange(profile_change) => { - populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - profile_change, - item_drawn_status, - ) - } - TimelineItemContent::OtherState(other) => { - populate_small_state_event( - cx, - list, - item_id, - &tl_state.kind, - event_tl_item, - other, - item_drawn_status, - ) - } - unhandled => { - let item = list.item(cx, item_id, id!(SmallStateEvent)); - item.label(cx, ids!(content)) - .set_text(cx, &format!("[Unsupported] {:?}", unhandled)); - (item, ItemDrawnStatus::both_drawn()) - } }, - TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(millis)) => { - let item = list.item(cx, item_id, id!(DateDivider)); - let text = unix_time_millis_to_datetime(*millis) - // format the time as a shortened date (Sat, Sept 5, 2021) - .map(|dt| format!("{}", dt.date_naive().format("%a %b %-d, %Y"))) - .unwrap_or_else(|| format!("{:?}", millis)); - item.label(cx, ids!(date)).set_text(cx, &text); - (item, ItemDrawnStatus::both_drawn()) - } - TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => { - let item = list.item(cx, item_id, id!(ReadMarker)); - (item, ItemDrawnStatus::both_drawn()) - } - TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => { - let item = list.item(cx, item_id, id!(Empty)); + TimelineItemContent::MembershipChange(membership_change) => populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + membership_change, + item_drawn_status, + ), + TimelineItemContent::ProfileChange(profile_change) => populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + profile_change, + item_drawn_status, + ), + TimelineItemContent::OtherState(other) => populate_small_state_event( + cx, + list, + item_id, + &tl_state.kind, + event_tl_item, + other, + item_drawn_status, + ), + unhandled => { + let item = list.item(cx, item_id, id!(SmallStateEvent)); + item.label(cx, ids!(content)).set_text(cx, &format!("[Unsupported] {:?}", unhandled)); (item, ItemDrawnStatus::both_drawn()) } - }; - - // Now that we've drawn the item, add its index to the set of drawn items. - if item_new_draw_status.content_drawn { - tl_state - .content_drawn_since_last_update - .insert(tl_idx..tl_idx + 1); } - if item_new_draw_status.profile_drawn { - tl_state - .profile_drawn_since_last_update - .insert(tl_idx..tl_idx + 1); + TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(millis)) => { + let item = list.item(cx, item_id, id!(DateDivider)); + let text = unix_time_millis_to_datetime(*millis) + // format the time as a shortened date (Sat, Sept 5, 2021) + .map(|dt| format!("{}", dt.date_naive().format("%a %b %-d, %Y"))) + .unwrap_or_else(|| format!("{:?}", millis)); + item.label(cx, ids!(date)).set_text(cx, &text); + (item, ItemDrawnStatus::both_drawn()) + } + TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => { + let item = list.item(cx, item_id, id!(ReadMarker)); + (item, ItemDrawnStatus::both_drawn()) } - item + TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => { + let item = list.item(cx, item_id, id!(Empty)); + (item, ItemDrawnStatus::both_drawn()) + } + }; + + // Now that we've drawn the item, add its index to the set of drawn items. + if item_new_draw_status.content_drawn { + tl_state.content_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); + } + if item_new_draw_status.profile_drawn { + tl_state.profile_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); + } + item } }; item.draw_all(cx, scope); @@ -1866,10 +1714,7 @@ impl Widget for RoomScreen { // If the list is not filling the viewport, we need to back paginate the timeline // until we have enough events items to fill the viewport. if !tl_state.fully_paginated && !list.is_filling_viewport() { - log!( - "Automatically paginating timeline to fill viewport for room {:?}", - self.room_name_id - ); + log!("Automatically paginating timeline to fill viewport for room {:?}", self.room_name_id); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl_state.kind.clone(), num_events: 50, @@ -2072,9 +1917,7 @@ impl RoomScreen { let jump_to_bottom_button = self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)); let curr_first_id = portal_list.first_id(); let ui = self.widget_uid(); - let Some(tl) = self.tl_state.as_mut() else { - return; - }; + let Some(tl) = self.tl_state.as_mut() else { return }; let mut done_loading = false; let mut should_continue_backwards_pagination = false; @@ -2095,19 +1938,10 @@ impl RoomScreen { tl.items = initial_items; done_loading = true; } - TimelineUpdate::NewItems { - new_items, - changed_indices, - is_append, - clear_cache, - } => { + TimelineUpdate::NewItems { new_items, changed_indices, is_append, clear_cache } => { if new_items.is_empty() { if !tl.items.is_empty() { - log!( - "process_timeline_updates(): timeline (had {} items) was cleared for room {}", - tl.items.len(), - tl.kind.room_id() - ); + log!("process_timeline_updates(): timeline (had {} items) was cleared for room {}", tl.items.len(), tl.kind.room_id()); // For now, we paginate a cleared timeline in order to be able to show something at least. // A proper solution would be what's described below, which would be to save a few event IDs // and then either focus on them (if we're not close to the end of the timeline) @@ -2141,12 +1975,9 @@ impl RoomScreen { if new_items.len() == tl.items.len() { // log!("process_timeline_updates(): no jump necessary for updated timeline of same length: {}", items.len()); - } else if curr_first_id > new_items.len() { - log!( - "process_timeline_updates(): jumping to bottom: curr_first_id {} is out of bounds for {} new items", - curr_first_id, - new_items.len() - ); + } + else if curr_first_id > new_items.len() { + log!("process_timeline_updates(): jumping to bottom: curr_first_id {} is out of bounds for {} new items", curr_first_id, new_items.len()); portal_list.set_first_id_and_scroll(new_items.len().saturating_sub(1), 0.0); portal_list.set_tail_range(true); jump_to_bottom_button.update_visibility(cx, true); @@ -2155,28 +1986,19 @@ impl RoomScreen { // in the timeline viewport so that we can maintain the scroll position of that item, // which ensures that the timeline doesn't jump around unexpectedly and ruin the user's experience. else if let Some((curr_item_idx, new_item_idx, new_item_scroll, _event_id)) = - prior_items_changed - .then(|| { - find_new_item_matching_current_item( - cx, - portal_list, - curr_first_id, - &tl.items, - &new_items, - ) - }) - .flatten() + prior_items_changed.then(|| + find_new_item_matching_current_item(cx, portal_list, curr_first_id, &tl.items, &new_items) + ) + .flatten() { if curr_item_idx != new_item_idx { - log!( - "process_timeline_updates(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}" - ); + log!("process_timeline_updates(): jumping view from event index {curr_item_idx} to new index {new_item_idx}, scroll {new_item_scroll}, event ID {_event_id}"); portal_list.set_first_id_and_scroll(new_item_idx, new_item_scroll); tl.prev_first_index = Some(new_item_idx); // Set scrolled_past_read_marker false when we jump to a new event tl.scrolled_past_read_marker = false; // Hide the tooltip when the timeline jumps, as a hover-out event won't occur. - cx.widget_action(ui, RoomScreenTooltipActions::HoverOut); + cx.widget_action(ui, RoomScreenTooltipActions::HoverOut); } } // @@ -2192,9 +2014,8 @@ impl RoomScreen { // because the matrix SDK doesn't currently support querying unread message counts for threads. if matches!(tl.kind, TimelineKind::MainRoom { .. }) { // Immediately show the unread badge with no count while we fetch the actual count in the background. - jump_to_bottom_button - .show_unread_message_badge(cx, UnreadMessageCount::Unknown); - submit_async_request(MatrixRequest::GetNumberUnreadMessages { + jump_to_bottom_button.show_unread_message_badge(cx, UnreadMessageCount::Unknown); + submit_async_request(MatrixRequest::GetNumberUnreadMessages{ timeline_kind: tl.kind.clone(), }); } @@ -2208,15 +2029,10 @@ impl RoomScreen { let loading_pane = self.view.loading_pane(cx, ids!(loading_pane)); let mut loading_pane_state = loading_pane.take_state(); if let LoadingPaneState::BackwardsPaginateUntilEvent { - events_paginated, - target_event_id, - .. - } = &mut loading_pane_state - { + events_paginated, target_event_id, .. + } = &mut loading_pane_state { *events_paginated += new_items.len().saturating_sub(tl.items.len()); - log!( - "While finding target event {target_event_id}, we have now loaded {events_paginated} messages..." - ); + log!("While finding target event {target_event_id}, we have now loaded {events_paginated} messages..."); // Here, we assume that we have not yet found the target event, // so we need to continue paginating backwards. // If the target event has already been found, it will be handled @@ -2233,10 +2049,8 @@ impl RoomScreen { tl.profile_drawn_since_last_update.clear(); tl.fully_paginated = false; } else { - tl.content_drawn_since_last_update - .remove(changed_indices.clone()); - tl.profile_drawn_since_last_update - .remove(changed_indices.clone()); + tl.content_drawn_since_last_update.remove(changed_indices.clone()); + tl.profile_drawn_since_last_update.remove(changed_indices.clone()); // log!("process_timeline_updates(): changed_indices: {changed_indices:?}, items len: {}\ncontent drawn: {:#?}\nprofile drawn: {:#?}", items.len(), tl.content_drawn_since_last_update, tl.profile_drawn_since_last_update); } tl.items = new_items; @@ -2249,10 +2063,7 @@ impl RoomScreen { jump_to_bottom_button.show_unread_message_badge(cx, unread_messages_count); } } - TimelineUpdate::TargetEventFound { - target_event_id, - index, - } => { + TimelineUpdate::TargetEventFound { target_event_id, index } => { // log!("Target event found in room {}: {target_event_id}, index: {index}", tl.kind.room_id()); tl.request_sender.send_if_modified(|requests| { requests.retain(|r| &r.room_id != tl.kind.room_id()); @@ -2262,10 +2073,10 @@ impl RoomScreen { // sanity check: ensure the target event is in the timeline at the given `index`. let item = tl.items.get(index); - let is_valid = item.is_some_and(|item| { + let is_valid = item.is_some_and(|item| item.as_event() .is_some_and(|ev| ev.event_id() == Some(&target_event_id)) - }); + ); let loading_pane = self.view.loading_pane(cx, ids!(loading_pane)); // log!("TargetEventFound: is_valid? {is_valid}. room {}, event {target_event_id}, index {index} of {}\n --> item: {item:?}", tl.kind.room_id(), tl.items.len()); @@ -2284,24 +2095,19 @@ impl RoomScreen { // appear beneath the top of the viewport. portal_list.smooth_scroll_to(cx, index.saturating_sub(1), speed, None); // start highlight animation. - tl.message_highlight_animation_state = - MessageHighlightAnimationState::Pending { item_id: index }; - } else { + tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { + item_id: index + }; + } + else { // Here, the target event was not found in the current timeline, // or we found it previously but it is no longer in the timeline (or has moved), // which means we encountered an error and are unable to jump to the target event. - error!( - "Target event index {index} of {} is out of bounds for room {}", - tl.items.len(), - tl.kind.room_id() - ); + error!("Target event index {index} of {} is out of bounds for room {}", tl.items.len(), tl.kind.room_id()); // Show this error in the loading pane, which should already be open. - loading_pane.set_state( - cx, - LoadingPaneState::Error(String::from( - "Unable to find related message; it may have been deleted.", - )), - ); + loading_pane.set_state(cx, LoadingPaneState::Error( + String::from("Unable to find related message; it may have been deleted.") + )); } should_continue_backwards_pagination = false; @@ -2318,25 +2124,16 @@ impl RoomScreen { } } TimelineUpdate::PaginationError { error, direction } => { - error!( - "Pagination error ({direction}) in {:?}: {error:?}", - self.room_name_id - ); + error!("Pagination error ({direction}) in {:?}: {error:?}", self.room_name_id); let room_name = self.room_name_id.as_ref().map(|r| r.to_string()); enqueue_popup_notification( - utils::stringify_pagination_error( - &error, - room_name.as_deref().unwrap_or(UNNAMED_ROOM), - ), + utils::stringify_pagination_error(&error, room_name.as_deref().unwrap_or(UNNAMED_ROOM)), PopupKind::Error, Some(10.0), ); done_loading = true; } - TimelineUpdate::PaginationIdle { - fully_paginated, - direction, - } => { + TimelineUpdate::PaginationIdle { fully_paginated, direction } => { if direction == PaginationDirection::Backwards { // Don't set `done_loading` to `true` here, because we want to keep the top space visible // (with the "loading" message) until the corresponding `NewItems` update is received. @@ -2348,12 +2145,9 @@ impl RoomScreen { error!("Unexpected PaginationIdle update in the Forwards direction"); } } - TimelineUpdate::EventDetailsFetched { event_id, result } => { + TimelineUpdate::EventDetailsFetched {event_id, result } => { if let Err(_e) = result { - error!( - "Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}", - tl.kind.room_id() - ); + error!("Failed to fetch details fetched for event {event_id} in room {}. Error: {_e:?}", tl.kind.room_id()); } // Here, to be most efficient, we could redraw only the updated event, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. @@ -2364,8 +2158,7 @@ impl RoomScreen { num_replies, latest_reply_preview_text, } => { - tl.pending_thread_summary_fetches - .remove(&thread_root_event_id); + tl.pending_thread_summary_fetches.remove(&thread_root_event_id); tl.fetched_thread_summaries.insert( thread_root_event_id.clone(), FetchedThreadSummary { @@ -2373,15 +2166,14 @@ impl RoomScreen { latest_reply_preview_text, }, ); - let event_id_matches_at_index = tl - .items + let event_id_matches_at_index = tl.items .get(timeline_item_index) .and_then(|item| item.as_event()) .and_then(|ev| ev.event_id()) .is_some_and(|id| id == thread_root_event_id); if event_id_matches_at_index { tl.content_drawn_since_last_update - .remove(timeline_item_index..timeline_item_index + 1); + .remove(timeline_item_index .. timeline_item_index + 1); } else { tl.content_drawn_since_last_update.clear(); } @@ -2394,12 +2186,9 @@ impl RoomScreen { TimelineUpdate::RoomMembersListFetched { members } => { // Store room members directly in TimelineUiState tl.room_members = Some(Arc::new(members)); - } + }, TimelineUpdate::MediaFetched(request) => { - log!( - "process_timeline_updates(): media fetched for room {}", - tl.kind.room_id() - ); + log!("process_timeline_updates(): media fetched for room {}", tl.kind.room_id()); // Set Image to image viewer modal if the media is not a thumbnail. if let (MediaFormat::File, media_source) = (request.format, request.source) { populate_matrix_image_modal(cx, media_source, &mut tl.media_cache); @@ -2407,39 +2196,26 @@ impl RoomScreen { // Here, to be most efficient, we could redraw only the media items in the timeline, // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. } - TimelineUpdate::MessageEdited { - timeline_event_item_id: timeline_event_id, - result, - } => { - self.view - .room_input_bar(cx, ids!(room_input_bar)) + TimelineUpdate::MessageEdited { timeline_event_item_id: timeline_event_id, result } => { + self.view.room_input_bar(cx, ids!(room_input_bar)) .handle_edit_result(cx, timeline_event_id, result); } TimelineUpdate::PinResult { result, pin, .. } => { let (message, auto_dismissal_duration, kind) = match &result { Ok(true) => ( - format!( - "Successfully {} event.", - if pin { "pinned" } else { "unpinned" } - ), + format!("Successfully {} event.", if pin { "pinned" } else { "unpinned" }), Some(4.0), - PopupKind::Success, + PopupKind::Success ), Ok(false) => ( - format!( - "Message was already {}.", - if pin { "pinned" } else { "unpinned" } - ), + format!("Message was already {}.", if pin { "pinned" } else { "unpinned" }), Some(4.0), - PopupKind::Info, + PopupKind::Info ), Err(e) => ( - format!( - "Failed to {} event. Error: {e}", - if pin { "pin" } else { "unpin" } - ), + format!("Failed to {} event. Error: {e}", if pin { "pin" } else { "unpin" }), None, - PopupKind::Error, + PopupKind::Error ), }; enqueue_popup_notification(message, kind, auto_dismissal_duration); @@ -2463,8 +2239,7 @@ impl RoomScreen { } TimelineUpdate::UserPowerLevels(user_power_levels) => { tl.user_power = user_power_levels; - self.view - .room_input_bar(cx, ids!(room_input_bar)) + self.view.room_input_bar(cx, ids!(room_input_bar)) .update_user_power_levels(cx, user_power_levels); // Update the @room mention capability based on the user's power level cx.action(MentionableTextInputAction::PowerLevelsUpdated { @@ -2480,13 +2255,8 @@ impl RoomScreen { tl.latest_own_user_receipt = Some(receipt); } TimelineUpdate::Tombstoned(successor_room_details) => { - self.view - .room_input_bar(cx, ids!(room_input_bar)) - .update_tombstone_footer( - cx, - tl.kind.room_id(), - Some(&successor_room_details), - ); + self.view.room_input_bar(cx, ids!(room_input_bar)) + .update_tombstone_footer(cx, tl.kind.room_id(), Some(&successor_room_details)); tl.tombstone_info = Some(successor_room_details); } TimelineUpdate::LinkPreviewFetched => {} @@ -2517,6 +2287,7 @@ impl RoomScreen { } } + /// Handles a link being clicked in any child widgets of this RoomScreen. /// /// Returns `true` if the given `action` was handled as a link click. @@ -2560,11 +2331,7 @@ impl RoomScreen { true } MatrixId::Room(room_id) => { - if self - .room_name_id - .as_ref() - .is_some_and(|r| r.room_id() == room_id) - { + if self.room_name_id.as_ref().is_some_and(|r| r.room_id() == room_id) { enqueue_popup_notification( "You are already viewing that room.", PopupKind::Info, @@ -2572,9 +2339,7 @@ impl RoomScreen { ); return true; } - if let Some(room_name_id) = - cx.get_global::().get_room_name(room_id) - { + if let Some(room_name_id) = cx.get_global::().get_room_name(room_id) { cx.action(AppStateAction::NavigateToRoom { room_to_close: None, destination_room: BasicRoomDetails::Name(room_name_id), @@ -2608,7 +2373,8 @@ impl RoomScreen { let mut link_was_handled = false; if let Ok(matrix_to_uri) = MatrixToUri::parse(&url) { link_was_handled |= handle_matrix_link(matrix_to_uri.id(), matrix_to_uri.via()); - } else if let Ok(matrix_uri) = MatrixUri::parse(&url) { + } + else if let Ok(matrix_uri) = MatrixUri::parse(&url) { link_was_handled |= handle_matrix_link(matrix_uri.id(), matrix_uri.via()); } @@ -2624,13 +2390,8 @@ impl RoomScreen { } } true - } else if let RobrixHtmlLinkAction::ClickedMatrixLink { - url, - matrix_id, - via, - .. - } = action.as_widget_action().cast() - { + } + else if let RobrixHtmlLinkAction::ClickedMatrixLink { url, matrix_id, via, .. } = action.as_widget_action().cast() { let link_was_handled = handle_matrix_link(&matrix_id, &via); if !link_was_handled { log!("Opening URL \"{}\"", url); @@ -2644,7 +2405,8 @@ impl RoomScreen { } } true - } else { + } + else { false } } @@ -2660,13 +2422,8 @@ impl RoomScreen { let Some(media_source) = mxc_uri else { return; }; - let Some(tl_state) = self.tl_state.as_mut() else { - return; - }; - let Some(event_tl_item) = tl_state.items.get(item_id).and_then(|item| item.as_event()) - else { - return; - }; + let Some(tl_state) = self.tl_state.as_mut() else { return }; + let Some(event_tl_item) = tl_state.items.get(item_id).and_then(|item| item.as_event()) else { return }; let timestamp_millis = event_tl_item.timestamp(); let (image_name, image_file_size) = get_image_name_and_filesize(event_tl_item); @@ -2676,7 +2433,10 @@ impl RoomScreen { image_name, image_file_size, timestamp: unix_time_millis_to_datetime(timestamp_millis), - avatar_parameter: Some((tl_state.kind.clone(), event_tl_item.clone())), + avatar_parameter: Some(( + tl_state.kind.clone(), + event_tl_item.clone(), + )), }), ))); @@ -2697,15 +2457,13 @@ impl RoomScreen { details: &MessageDetails, ) -> Option<&'a EventTimelineItem> { let target_event_id = details.event_id()?; - if let Some(event) = items - .get(details.item_id) + if let Some(event) = items.get(details.item_id) .and_then(|item| item.as_event()) .filter(|ev| ev.event_id().is_some_and(|id| id == target_event_id)) { return Some(event); } - items - .iter() + items.iter() .rev() .take(MAX_ITEMS_TO_SEARCH_THROUGH) .filter_map(|item| item.as_event()) @@ -2722,15 +2480,9 @@ impl RoomScreen { ) { let room_screen_widget_uid = self.widget_uid(); for action in actions { - match action - .as_widget_action() - .widget_uid_eq(room_screen_widget_uid) - .cast_ref() - { + match action.as_widget_action().widget_uid_eq(room_screen_widget_uid).cast_ref() { MessageAction::React { details, reaction } => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; submit_async_request(MatrixRequest::ToggleReaction { timeline_kind: tl.kind.clone(), timeline_event_id: details.timeline_event_id.clone(), @@ -2738,24 +2490,19 @@ impl RoomScreen { }); } MessageAction::Reply(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; - if let Some(event_tl_item) = - Self::find_event_in_timeline(&tl.items, details).cloned() - { + let Some(tl) = self.tl_state.as_ref() else { return }; + if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details).cloned() { let replied_to_info = EmbeddedEvent::from_timeline_item(&event_tl_item); - self.view - .room_input_bar(cx, ids!(room_input_bar)) + self.view.room_input_bar(cx, ids!(room_input_bar)) .show_replying_to(cx, (event_tl_item, replied_to_info), &tl.kind); - } else { + } + else { enqueue_popup_notification( "Could not find message in timeline to reply to. Please try again.", PopupKind::Error, Some(5.0), ); - error!( - "MessageAction::Reply: couldn't find event [{}] {:?} to reply to in room {:?}", + error!("MessageAction::Reply: couldn't find event [{}] {:?} to reply to in room {:?}", details.item_id, details.timeline_event_id, self.room_id(), @@ -2763,21 +2510,22 @@ impl RoomScreen { } } MessageAction::Edit(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { - self.view - .room_input_bar(cx, ids!(room_input_bar)) - .show_editing_pane(cx, event_tl_item.clone(), tl.kind.clone()); - } else { + self.view.room_input_bar(cx, ids!(room_input_bar)) + .show_editing_pane( + cx, + event_tl_item.clone(), + tl.kind.clone(), + ); + } + else { enqueue_popup_notification( "Could not find message in timeline to edit. Please try again.", PopupKind::Error, Some(5.0), ); - error!( - "MessageAction::Edit: couldn't find event [{}] {:?} to edit in room {:?}", + error!("MessageAction::Edit: couldn't find event [{}] {:?} to edit in room {:?}", details.item_id, details.timeline_event_id, self.room_id(), @@ -2785,20 +2533,21 @@ impl RoomScreen { } } MessageAction::EditLatest => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; - if let Some(latest_sent_msg) = tl - .items + let Some(tl) = self.tl_state.as_ref() else { return }; + if let Some(latest_sent_msg) = tl.items .iter() .rev() .take(MAX_ITEMS_TO_SEARCH_THROUGH) .find_map(|item| item.as_event().filter(|ev| ev.is_editable()).cloned()) { - self.view - .room_input_bar(cx, ids!(room_input_bar)) - .show_editing_pane(cx, latest_sent_msg, tl.kind.clone()); - } else { + self.view.room_input_bar(cx, ids!(room_input_bar)) + .show_editing_pane( + cx, + latest_sent_msg, + tl.kind.clone(), + ); + } + else { enqueue_popup_notification( "No recent message available to edit. Please manually select a message to edit.", PopupKind::Warning, @@ -2807,9 +2556,7 @@ impl RoomScreen { } } MessageAction::Pin(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; if let Some(event_id) = details.event_id() { submit_async_request(MatrixRequest::PinEvent { timeline_kind: tl.kind.clone(), @@ -2825,9 +2572,7 @@ impl RoomScreen { } } MessageAction::Unpin(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; if let Some(event_id) = details.event_id() { submit_async_request(MatrixRequest::PinEvent { timeline_kind: tl.kind.clone(), @@ -2843,19 +2588,17 @@ impl RoomScreen { } } MessageAction::CopyText(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { cx.copy_to_clipboard(&plaintext_body_of_timeline_item(event_tl_item)); - } else { + } + else { enqueue_popup_notification( "Could not find message in timeline to copy text from. Please try again.", PopupKind::Error, Some(5.0), ); - error!( - "MessageAction::CopyText: couldn't find event [{}] {:?} to copy text from in room {}", + error!("MessageAction::CopyText: couldn't find event [{}] {:?} to copy text from in room {}", details.item_id, details.timeline_event_id, tl.kind.room_id(), @@ -2863,49 +2606,22 @@ impl RoomScreen { } } MessageAction::CopyHtml(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; // The logic for getting the formatted body of a message is the same // as the logic used in `populate_message_view()`. let mut success = false; if let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) { if let Some(message) = event_tl_item.content().as_message() { match message.msgtype() { - MessageType::Text(TextMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::Notice(NoticeMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::Emote(EmoteMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::Image(ImageMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::File(FileMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::Audio(AudioMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::Video(VideoMessageEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }) - | MessageType::VerificationRequest( - KeyVerificationRequestEventContent { - formatted: Some(FormattedBody { body, .. }), - .. - }, - ) => { + MessageType::Text(TextMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::Notice(NoticeMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::Emote(EmoteMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::Image(ImageMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::File(FileMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::Audio(AudioMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::Video(VideoMessageEventContent { formatted: Some(FormattedBody { body, .. }), .. }) + | MessageType::VerificationRequest(KeyVerificationRequestEventContent { formatted: Some(FormattedBody { body, .. }), .. }) => + { cx.copy_to_clipboard(body); success = true; } @@ -2919,8 +2635,7 @@ impl RoomScreen { PopupKind::Error, Some(5.0), ); - error!( - "MessageAction::CopyHtml: couldn't find event [{}] {:?} to copy HTML from in room {}", + error!("MessageAction::CopyHtml: couldn't find event [{}] {:?} to copy HTML from in room {}", details.item_id, details.timeline_event_id, tl.kind.room_id(), @@ -2928,9 +2643,7 @@ impl RoomScreen { } } MessageAction::CopyLink(details) => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; if let Some(event_id) = details.event_id() { let matrix_to_uri = tl.kind.room_id().matrix_to_event_uri(event_id.clone()); cx.copy_to_clipboard(&matrix_to_uri.to_string()); @@ -2940,8 +2653,7 @@ impl RoomScreen { PopupKind::Error, Some(5.0), ); - error!( - "MessageAction::CopyLink: no `event_id`: [{}] {:?} in room {}", + error!("MessageAction::CopyLink: no `event_id`: [{}] {:?} in room {}", details.item_id, details.timeline_event_id, tl.kind.room_id(), @@ -2949,11 +2661,8 @@ impl RoomScreen { } } MessageAction::ViewSource(details) => { - let Some(tl) = self.tl_state.as_ref() else { - continue; - }; - let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) - else { + let Some(tl) = self.tl_state.as_ref() else { continue }; + let Some(event_tl_item) = Self::find_event_in_timeline(&tl.items, details) else { enqueue_popup_notification( "Could not find message in timeline to view source.", PopupKind::Error, @@ -2977,9 +2686,7 @@ impl RoomScreen { } MessageAction::JumpToRelated(details) => { let Some(related_event_id) = details.related_event_id.as_ref() else { - error!( - "BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}" - ); + error!("BUG: MessageAction::JumpToRelated had no related event ID.\n{details:#?}"); enqueue_popup_notification( "Could not find related message or event in timeline.", PopupKind::Error, @@ -2992,21 +2699,25 @@ impl RoomScreen { related_event_id, Some(details.item_id), portal_list, - loading_pane, + loading_pane ); } MessageAction::JumpToEvent(event_id) => { - self.jump_to_event(cx, event_id, None, portal_list, loading_pane); + self.jump_to_event( + cx, + event_id, + None, + portal_list, + loading_pane + ); } MessageAction::OpenThread(thread_root_event_id) => { let Some(room_name_id) = self.room_name_id.as_ref().cloned() else { - error!( - "### ERROR: MessageAction::OpenThread: thread_root_event_id: {thread_root_event_id}, but room_name_id was None!" - ); - continue; + error!("### ERROR: MessageAction::OpenThread: thread_root_event_id: {thread_root_event_id}, but room_name_id was None!"); + continue }; cx.widget_action( - room_screen_widget_uid, + room_screen_widget_uid, RoomsListAction::Selected(SelectedRoom::Thread { room_name_id, thread_root_event_id: thread_root_event_id.clone(), @@ -3014,17 +2725,13 @@ impl RoomScreen { ); } MessageAction::Redact { details, reason } => { - let Some(tl) = self.tl_state.as_ref() else { - return; - }; + let Some(tl) = self.tl_state.as_ref() else { return }; let timeline_event_id = details.timeline_event_id.clone(); let timeline_kind = tl.kind.clone(); let reason = reason.clone(); let content = ConfirmationModalContent { title_text: "Delete Message".into(), - body_text: - "Are you sure you want to delete this message? This cannot be undone." - .into(), + body_text: "Are you sure you want to delete this message? This cannot be undone.".into(), accept_button_text: Some("Delete".into()), on_accept_clicked: Some(Box::new(move |_cx| { submit_async_request(MatrixRequest::RedactMessage { @@ -3042,15 +2749,15 @@ impl RoomScreen { // } // This is handled within the Message widget itself. - MessageAction::HighlightMessage(..) => {} + MessageAction::HighlightMessage(..) => { } // This is handled by the top-level App itself. - MessageAction::OpenMessageContextMenu { .. } => {} + MessageAction::OpenMessageContextMenu { .. } => { } // This isn't yet handled, as we need to completely redesign it. - MessageAction::ActionBarOpen { .. } => {} + MessageAction::ActionBarOpen { .. } => { } // This isn't yet handled, as we need to completely redesign it. - MessageAction::ActionBarClose => {} - MessageAction::ToggleAppServiceActions => {} - MessageAction::None => {} + MessageAction::ActionBarClose => { } + MessageAction::ToggleAppServiceActions => { } + MessageAction::None => { } } } } @@ -3068,17 +2775,14 @@ impl RoomScreen { portal_list: &PortalListRef, loading_pane: &LoadingPaneRef, ) { - let Some(tl) = self.tl_state.as_mut() else { - return; - }; + let Some(tl) = self.tl_state.as_mut() else { return }; let max_tl_idx = max_tl_idx.unwrap_or_else(|| tl.items.len()); // Attempt to find the index of replied-to message in the timeline. // Start from the current item's index (`tl_idx`) and search backwards, // since we know the related message must come before the current item. let mut num_items_searched = 0; - let related_msg_tl_index = tl - .items + let related_msg_tl_index = tl.items .focus() .narrow(..max_tl_idx) .into_iter() @@ -3101,13 +2805,11 @@ impl RoomScreen { // appear beneath the top of the viewport. portal_list.smooth_scroll_to(cx, index.saturating_sub(1), speed, None); // start highlight animation. - tl.message_highlight_animation_state = - MessageHighlightAnimationState::Pending { item_id: index }; + tl.message_highlight_animation_state = MessageHighlightAnimationState::Pending { + item_id: index + }; } else { - log!( - "The related event {target_event_id} wasn't immediately available in room {}, searching for it in the background...", - tl.kind.room_id() - ); + log!("The related event {target_event_id} wasn't immediately available in room {}, searching for it in the background...", tl.kind.room_id()); // Here, we set the state of the loading pane and display it to the user. // The main logic will be handled in `process_timeline_updates()`, which is the only // place where we can receive updates to the timeline from the background tasks. @@ -3160,9 +2862,7 @@ impl RoomScreen { /// Invoke this when this timeline is being shown, /// e.g., when the user navigates to this timeline. fn show_timeline(&mut self, cx: &mut Cx) { - let kind = self - .timeline_kind - .clone() + let kind = self.timeline_kind.clone() .expect("BUG: Timeline::show_timeline(): no timeline_kind was set."); let room_id = kind.room_id().clone(); @@ -3179,10 +2879,8 @@ impl RoomScreen { return; } if !self.is_loaded && self.all_rooms_loaded { - panic!( - "BUG: timeline {kind} is not loaded, but its RoomScreen \ - was not waiting for its timeline to be loaded either." - ); + panic!("BUG: timeline {kind} is not loaded, but its RoomScreen \ + was not waiting for its timeline to be loaded either."); } return; }; @@ -3255,19 +2953,14 @@ impl RoomScreen { self.is_loaded = is_loaded_now; } - self.view - .restore_status_view(cx, ids!(restore_status_view)) - .set_visible(cx, !self.is_loaded); + self.view.restore_status_view(cx, ids!(restore_status_view)).set_visible(cx, !self.is_loaded); // Kick off a back pagination request if it's the first time loading this room, // because we want to show the user some messages as soon as possible // when they first open the room, and there might not be any messages yet. if is_first_time_being_loaded { if !tl_state.fully_paginated { - log!( - "Sending a first-time backwards pagination request for {}", - tl_state.kind - ); + log!("Sending a first-time backwards pagination request for {}", tl_state.kind); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl_state.kind.clone(), num_events: 50, @@ -3336,9 +3029,7 @@ impl RoomScreen { /// Invoke this when this RoomScreen/timeline is being hidden or no longer being shown. fn hide_timeline(&mut self) { - let Some(timeline_kind) = self.timeline_kind.clone() else { - return; - }; + let Some(timeline_kind) = self.timeline_kind.clone() else { return }; self.save_state(); @@ -3370,23 +3061,13 @@ impl RoomScreen { /// Note: after calling this function, the widget's `tl_state` will be `None`. fn save_state(&mut self) { let Some(mut tl) = self.tl_state.take() else { - error!( - "Timeline::save_state(): skipping due to missing state, room {:?}, {:?}", - self.timeline_kind, - self.room_name_id.as_ref().map(|r| r.display_name()) - ); + error!("Timeline::save_state(): skipping due to missing state, room {:?}, {:?}", self.timeline_kind, self.room_name_id.as_ref().map(|r| r.display_name())); return; }; let portal_list = self.child_by_path(ids!(timeline.list)).as_portal_list(); let room_input_bar = self.child_by_path(ids!(room_input_bar)).as_room_input_bar(); - log!( - "Saving state for room {:?}\n\t{:?}\n\tfirst_id: {:?}, scroll: {}", - self.room_name_id.as_ref().map(|r| r.display_name()), - self.timeline_kind, - portal_list.first_id(), - portal_list.scroll_position() - ); + log!("Saving state for room {:?}\n\t{:?}\n\tfirst_id: {:?}, scroll: {}", self.room_name_id.as_ref().map(|r| r.display_name()), self.timeline_kind, portal_list.first_id(), portal_list.scroll_position()); let state = SavedState { first_index_and_scroll: Some((portal_list.first_id(), portal_list.scroll_position())), room_input_bar_state: room_input_bar.save_state(), @@ -3411,12 +3092,7 @@ impl RoomScreen { // 1. Restore the position of the timeline. let portal_list = self.portal_list(cx, ids!(timeline.list)); if let Some((first_index, scroll_from_first_id)) = first_index_and_scroll { - log!( - "Restoring state for room {:?}: first_id: {:?}, scroll: {}", - self.room_name_id, - first_index, - scroll_from_first_id - ); + log!("Restoring state for room {:?}: first_id: {:?}, scroll: {}", self.room_name_id, first_index, scroll_from_first_id); portal_list.set_first_id_and_scroll(*first_index, *scroll_from_first_id); portal_list.set_tail_range(false); } else { @@ -3425,10 +3101,7 @@ impl RoomScreen { // The explicit reset is necessary when the same RoomScreen widget is reused for a // different room (e.g., via stack navigation view alternation), otherwise the portal list // would retain the previous room's scroll position which may be out of bounds. - log!( - "Restoring state for room {:?}: first_id: None, scroll: None", - self.room_name_id - ); + log!("Restoring state for room {:?}: first_id: None, scroll: None", self.room_name_id); portal_list.set_first_id_and_scroll(0, 0.0); portal_list.set_tail_range(true); } @@ -3465,11 +3138,7 @@ impl RoomScreen { // If this timeline is already displayed, we don't need to do anything major, // but we do need update the `room_name_id` in case it has changed, or it has been cleared. - if self - .timeline_kind - .as_ref() - .is_some_and(|kind| kind == &timeline_kind) - { + if self.timeline_kind.as_ref().is_some_and(|kind| kind == &timeline_kind) { self.room_name_id = Some(room_name_id.clone()); return; } @@ -3505,9 +3174,7 @@ impl RoomScreen { return; } let first_index = portal_list.first_id(); - let Some(tl_state) = self.tl_state.as_mut() else { - return; - }; + let Some(tl_state) = self.tl_state.as_mut() else { return }; if let Some(ref mut index) = tl_state.prev_first_index { // to detect change of scroll when scroll ends @@ -3518,7 +3185,7 @@ impl RoomScreen { .items .get(std::cmp::min( first_index + portal_list.visible_items(), - tl_state.items.len().saturating_sub(1), + tl_state.items.len().saturating_sub(1) )) .and_then(|f| f.as_event()) .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) @@ -3538,20 +3205,17 @@ impl RoomScreen { receipt_type: ReceiptType::FullyRead, }); } else { - if let Some(own_user_receipt_timestamp) = &tl_state - .latest_own_user_receipt - .clone() - .and_then(|receipt| receipt.ts) - { + if let Some(own_user_receipt_timestamp) = &tl_state.latest_own_user_receipt.clone() + .and_then(|receipt| receipt.ts) { let Some((_first_event_id, first_timestamp)) = tl_state .items .get(first_index) .and_then(|f| f.as_event()) .and_then(|f| f.event_id().map(|e| (e, f.timestamp()))) - else { - *index = first_index; - return; - }; + else { + *index = first_index; + return; + }; if own_user_receipt_timestamp >= &first_timestamp && own_user_receipt_timestamp <= &last_timestamp { @@ -3562,6 +3226,7 @@ impl RoomScreen { receipt_type: ReceiptType::FullyRead, }); } + } } } @@ -3580,22 +3245,14 @@ impl RoomScreen { actions: &ActionsBuf, portal_list: &PortalListRef, ) { - let Some(tl) = self.tl_state.as_mut() else { - return; - }; - if tl.fully_paginated { - return; - }; - if !portal_list.scrolled(actions) { - return; - }; + let Some(tl) = self.tl_state.as_mut() else { return }; + if tl.fully_paginated { return }; + if !portal_list.scrolled(actions) { return }; let first_index = portal_list.first_id(); if first_index == 0 && tl.last_scrolled_index > 0 { - log!( - "Scrolled up from item {} --> 0, sending back pagination request for room {}", - tl.last_scrolled_index, - tl.kind, + log!("Scrolled up from item {} --> 0, sending back pagination request for room {}", + tl.last_scrolled_index, tl.kind, ); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl.kind.clone(), @@ -3615,9 +3272,7 @@ impl RoomScreenRef { room_name_id: &RoomNameId, thread_root_event_id: Option, ) { - let Some(mut inner) = self.borrow_mut() else { - return; - }; + let Some(mut inner) = self.borrow_mut() else { return }; inner.set_displayed_room(cx, room_name_id, thread_root_event_id); } } @@ -3634,6 +3289,7 @@ pub struct RoomScreenProps { pub app_service_room_bound: bool, } + /// Actions for the room screen's tooltip. #[derive(Clone, Debug, Default)] pub enum RoomScreenTooltipActions { @@ -3732,7 +3388,9 @@ pub enum TimelineUpdate { /// includes a complete list of room members that can be shared across components. /// This is different from RoomMembersSynced which only indicates members were fetched /// but doesn't provide the actual data. - RoomMembersListFetched { members: Vec }, + RoomMembersListFetched { + members: Vec, + }, /// A notice with an option of Media Request Parameters that one or more requested media items (images, videos, etc.) /// that should be displayed in this timeline have now been fetched and are available. MediaFetched(MediaRequestParameters), @@ -3764,7 +3422,7 @@ thread_local! { /// The global set of all timeline states, one entry per room. /// /// This is only useful when accessed from the main UI thread. - static TIMELINE_STATES: RefCell> = + static TIMELINE_STATES: RefCell> = RefCell::new(HashMap::new()); } @@ -3880,9 +3538,7 @@ struct TimelineUiState { #[derive(Default, Debug)] enum MessageHighlightAnimationState { - Pending { - item_id: usize, - }, + Pending { item_id: usize }, #[default] Off, } @@ -3919,8 +3575,9 @@ fn find_new_item_matching_current_item( ) -> Option<(usize, usize, f64, OwnedEventId)> { let mut curr_item_focus = curr_items.focus(); let mut idx_curr = starting_at_curr_idx; - let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = - Vec::with_capacity(portal_list.visible_items()); + let mut curr_items_with_ids: Vec<(usize, OwnedEventId)> = Vec::with_capacity( + portal_list.visible_items() + ); // Find all items with real event IDs that are currently visible in the portal list. // TODO: if this is slow, we could limit it to 3-5 events at the most. @@ -3949,9 +3606,7 @@ fn find_new_item_matching_current_item( // some may be zeroed-out, so we need to account for that possibility by only // using events that have a real non-zero area if let Some(pos_offset) = portal_list.position_of_item(cx, *idx_curr) { - log!( - "Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}" - ); + log!("Found matching event ID {event_id} at index {idx_new} in new items list, corresponding to current item index {idx_curr} at pos offset {pos_offset}"); return Some((*idx_curr, idx_new, pos_offset, event_id.to_owned())); } } @@ -4025,8 +3680,7 @@ fn populate_message_view( TimelineItemContent::MsgLike(_msg_like_content) => { let prev_msg_sender = prev_event_tl_item.sender(); prev_msg_sender == event_tl_item.sender() - && ts_millis - .0 + && ts_millis.0 .checked_sub(prev_event_tl_item.timestamp().0) .is_some_and(|d| d < uint!(600000)) // 10 mins in millis } @@ -4043,12 +3697,8 @@ fn populate_message_view( let (item, used_cached_item) = match &msg_like_content.kind { MsgLikeKind::Message(msg) => { match msg.msgtype() { - MessageType::Text(TextMessageEventContent { - body, formatted, .. - }) => { - has_html_body = formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + MessageType::Text(TextMessageEventContent { body, formatted, .. }) => { + has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -4076,13 +3726,9 @@ fn populate_message_view( } // A notice message is just a message sent by an automated bot, // so we treat it just like a message but use a different font color. - MessageType::Notice(NoticeMessageEventContent { - body, formatted, .. - }) => { + MessageType::Notice(NoticeMessageEventContent{body, formatted, ..}) => { is_notice = true; - has_html_body = formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -4092,8 +3738,7 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - let html_or_plaintext_ref = - item.html_or_plaintext(cx, ids!(content.message)); + let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); // Apply gray color to all text styles for notice messages. let mut html_widget = html_or_plaintext_ref.html(cx, ids!(html_view.html)); script_apply_eval!(cx, html_widget, { @@ -4123,8 +3768,7 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - let html_or_plaintext_ref = - item.html_or_plaintext(cx, ids!(content.message)); + let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); // Apply red color to all text styles for server notices. let mut html_widget = html_or_plaintext_ref.html(cx, ids!(html_view.html)); script_apply_eval!(cx, html_widget, { @@ -4139,12 +3783,10 @@ fn populate_message_view( "Server notice: {}\n\nNotice type:: {}{}{}", sn.body, sn.server_notice_type.as_str(), - sn.limit_type - .as_ref() + sn.limit_type.as_ref() .map(|l| format!("\nLimit type: {}", l.as_str())) .unwrap_or_default(), - sn.admin_contact - .as_ref() + sn.admin_contact.as_ref() .map(|c| format!("\nAdmin contact: {}", c)) .unwrap_or_default(), ); @@ -4167,12 +3809,8 @@ fn populate_message_view( } // An emote is just like a message but is prepended with the user's name // to indicate that it's an "action" that the user is performing. - MessageType::Emote(EmoteMessageEventContent { - body, formatted, .. - }) => { - has_html_body = formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + MessageType::Emote(EmoteMessageEventContent { body, formatted, .. }) => { + has_html_body = formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -4183,16 +3821,14 @@ fn populate_message_view( (item, true) } else { // Draw the profile up front here because we need the username for the emote body. - let (username, profile_drawn) = item - .avatar(cx, ids!(profile.avatar)) - .set_avatar_and_get_username( - cx, - timeline_kind, - event_tl_item.sender(), - Some(event_tl_item.sender_profile()), - event_tl_item.event_id(), - true, - ); + let (username, profile_drawn) = item.avatar(cx, ids!(profile.avatar)).set_avatar_and_get_username( + cx, + timeline_kind, + event_tl_item.sender(), + Some(event_tl_item.sender_profile()), + event_tl_item.event_id(), + true, + ); // Prepend a "* " to the emote body, as suggested by the Matrix spec. let (body, formatted) = if let Some(fb) = formatted.as_ref() { @@ -4201,7 +3837,7 @@ fn populate_message_view( Some(FormattedBody { format: fb.format.clone(), body: format!("* {} {}", &username, &fb.body), - }), + }) ) } else { (Cow::from(format!("* {} {}", &username, body)), None) @@ -4225,9 +3861,7 @@ fn populate_message_view( } } MessageType::Image(image) => { - has_html_body = image - .formatted - .as_ref() + has_html_body = image.formatted.as_ref() .is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedImageMessage) @@ -4265,17 +3899,17 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - let is_location_fully_drawn = - populate_location_message_content(cx, &html_or_plaintext_ref, location); + let is_location_fully_drawn = populate_location_message_content( + cx, + &html_or_plaintext_ref, + location, + ); new_drawn_status.content_drawn = is_location_fully_drawn; (item, false) } } MessageType::File(file_content) => { - has_html_body = file_content - .formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = file_content.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -4287,16 +3921,16 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - new_drawn_status.content_drawn = - populate_file_message_content(cx, &html_or_plaintext_ref, file_content); + new_drawn_status.content_drawn = populate_file_message_content( + cx, + &html_or_plaintext_ref, + file_content, + ); (item, false) } } MessageType::Audio(audio) => { - has_html_body = audio - .formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = audio.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -4308,16 +3942,16 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - new_drawn_status.content_drawn = - populate_audio_message_content(cx, &html_or_plaintext_ref, audio); + new_drawn_status.content_drawn = populate_audio_message_content( + cx, + &html_or_plaintext_ref, + audio, + ); (item, false) } } MessageType::Video(video) => { - has_html_body = video - .formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = video.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = if use_compact_view { id!(CondensedMessage) } else { @@ -4329,16 +3963,16 @@ fn populate_message_view( } else { let html_or_plaintext_ref = item.html_or_plaintext(cx, ids!(content.message)); - new_drawn_status.content_drawn = - populate_video_message_content(cx, &html_or_plaintext_ref, video); + new_drawn_status.content_drawn = populate_video_message_content( + cx, + &html_or_plaintext_ref, + video, + ); (item, false) } } MessageType::VerificationRequest(verification) => { - has_html_body = verification - .formatted - .as_ref() - .is_some_and(|f| f.format == MessageFormat::Html); + has_html_body = verification.formatted.as_ref().is_some_and(|f| f.format == MessageFormat::Html); let template = id!(Message); let (item, existed) = list.item_with_existed(cx, item_id, template); if existed && item_drawn_status.content_drawn { @@ -4350,8 +3984,7 @@ fn populate_message_view( body: format!( "Sent a verification request to {}.
(Supported methods: {})
", verification.to, - verification - .methods + verification.methods .iter() .map(|m| m.as_str()) .collect::>() @@ -4381,8 +4014,10 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - item.label(cx, ids!(content.message)) - .set_text(cx, &format!("[Unsupported {:?}]", msg_like_content.kind)); + item.label(cx, ids!(content.message)).set_text( + cx, + &format!("[Unsupported {:?}]", msg_like_content.kind), + ); new_drawn_status.content_drawn = true; (item, false) } @@ -4392,9 +4027,7 @@ fn populate_message_view( // Handle sticker messages that are static images. MsgLikeKind::Sticker(sticker) => { has_html_body = false; - let StickerEventContent { - body, info, source, .. - } = sticker.content(); + let StickerEventContent { body, info, source, .. } = sticker.content(); let template = if use_compact_view { id!(CondensedImageMessage) @@ -4423,7 +4056,7 @@ fn populate_message_view( (item, true) } } - } + } // Handle messages that have been redacted (deleted). MsgLikeKind::Redacted => { has_html_body = false; @@ -4462,8 +4095,10 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { - item.label(cx, ids!(content.message)) - .set_text(cx, &format!("[Unsupported {:?}] ", other)); + item.label(cx, ids!(content.message)).set_text( + cx, + &format!("[Unsupported {:?}] ", other), + ); new_drawn_status.content_drawn = true; (item, false) } @@ -4475,14 +4110,13 @@ fn populate_message_view( // If we didn't use a cached item, we need to draw all other message content: // the reactions, the read receipts avatar row, the reply preview. if !used_cached_item { - item.reaction_list(cx, ids!(content.reaction_list)) - .set_list( - cx, - event_tl_item.content().reactions(), - timeline_kind.clone(), - timeline_event_id.clone(), - item_id, - ); + item.reaction_list(cx, ids!(content.reaction_list)).set_list( + cx, + event_tl_item.content().reactions(), + timeline_kind.clone(), + timeline_event_id.clone(), + item_id, + ); populate_read_receipts(&item, cx, timeline_kind, event_tl_item); let is_reply_fully_drawn = draw_replied_to_message( cx, @@ -4509,21 +4143,17 @@ fn populate_message_view( new_drawn_status.content_drawn &= is_thread_summary_fully_drawn; } + // We must always re-set the message details, even when re-using a cached portallist item, // because the item type might be the same but for a different message entirely. let message_details = MessageDetails { thread_root_event_id: msg_like_content.thread_root.clone().or_else(|| { - msg_like_content - .thread_summary - .as_ref() + msg_like_content.thread_summary.as_ref() .and_then(|_| event_tl_item.event_id().map(|id| id.to_owned())) }), timeline_event_id, item_id, - related_event_id: msg_like_content - .in_reply_to - .as_ref() - .map(|r| r.event_id.clone()), + related_event_id: msg_like_content.in_reply_to.as_ref().map(|r| r.event_id.clone()), room_screen_widget_uid, abilities: MessageAbilities::from_user_power_and_event( user_power_levels, @@ -4536,6 +4166,7 @@ fn populate_message_view( }; item.as_message().set_data(message_details); + // If `used_cached_item` is false, we should always redraw the profile, even if profile_drawn is true. let skip_draw_profile = use_compact_view || (used_cached_item && item_drawn_status.profile_drawn); @@ -4546,20 +4177,17 @@ fn populate_message_view( // log!("\t --> populate_message_view(): DRAWING profile draw for item_id: {item_id}"); let mut username_label = item.label(cx, ids!(content.username)); - if !is_server_notice { - // the normal case - let (username, profile_drawn) = - set_username_and_get_avatar_retval.unwrap_or_else(|| { - item.avatar(cx, ids!(profile.avatar)) - .set_avatar_and_get_username( - cx, - timeline_kind, - event_tl_item.sender(), - Some(event_tl_item.sender_profile()), - event_tl_item.event_id(), - true, - ) - }); + if !is_server_notice { // the normal case + let (username, profile_drawn) = set_username_and_get_avatar_retval.unwrap_or_else(|| + item.avatar(cx, ids!(profile.avatar)).set_avatar_and_get_username( + cx, + timeline_kind, + event_tl_item.sender(), + Some(event_tl_item.sender_profile()), + event_tl_item.event_id(), + true, + ) + ); if is_notice { script_apply_eval!(cx, username_label, { draw_text +: { @@ -4569,7 +4197,8 @@ fn populate_message_view( } username_label.set_text(cx, &username); new_drawn_status.profile_drawn = profile_drawn; - } else { + } + else { // Server notices are drawn with a red color avatar background and username. let avatar = item.avatar(cx, ids!(profile.avatar)); avatar.show_text(cx, Some(COLOR_FG_DANGER_RED), None, "⚠"); @@ -4590,46 +4219,33 @@ fn populate_message_view( // Set the timestamp. if let Some(dt) = unix_time_millis_to_datetime(ts_millis) { - item.timestamp(cx, ids!(profile.timestamp)) - .set_date_time(cx, dt); + item.timestamp(cx, ids!(profile.timestamp)).set_date_time(cx, dt); } // Set the "edited" indicator if this message was edited. if msg_like_content.as_message().is_some_and(|m| m.is_edited()) { - item.edited_indicator(cx, ids!(profile.edited_indicator)) - .set_latest_edit(cx, event_tl_item); + item.edited_indicator(cx, ids!(profile.edited_indicator)).set_latest_edit( + cx, + event_tl_item, + ); } - #[cfg(feature = "tsp")] - { + #[cfg(feature = "tsp")] { use matrix_sdk::ruma::serde::Base64; - use crate::tsp::{ - self, - tsp_sign_indicator::{TspSignState, TspSignIndicatorWidgetRefExt}, - }; + use crate::tsp::{self, tsp_sign_indicator::{TspSignState, TspSignIndicatorWidgetRefExt}}; - if let Some(mut tsp_sig) = event_tl_item - .latest_json() + if let Some(mut tsp_sig) = event_tl_item.latest_json() .and_then(|raw| raw.get_field::("content").ok()) .flatten() .and_then(|content_obj| content_obj.get("org.robius.tsp_signature").cloned()) .and_then(|tsp_sig_value| serde_json::from_value::(tsp_sig_value).ok()) .map(|b64| b64.into_inner()) { - log!( - "Found event {:?} with TSP signature.", - event_tl_item.event_id() - ); - let tsp_sign_state = if let Some(sender_vid) = tsp::tsp_state_ref() - .lock() - .unwrap() + log!("Found event {:?} with TSP signature.", event_tl_item.event_id()); + let tsp_sign_state = if let Some(sender_vid) = tsp::tsp_state_ref().lock().unwrap() .get_verified_vid_for(event_tl_item.sender()) { - log!( - "Found verified VID for sender {}: \"{}\"", - event_tl_item.sender(), - sender_vid.identifier() - ); + log!("Found verified VID for sender {}: \"{}\"", event_tl_item.sender(), sender_vid.identifier()); tsp_sdk::crypto::verify(&*sender_vid, &mut tsp_sig).map_or( TspSignState::WrongSignature, |(msg, msg_type)| { @@ -4641,11 +4257,7 @@ fn populate_message_view( TspSignState::Unknown }; - log!( - "TSP signature state for event {:?} is {:?}", - event_tl_item.event_id(), - tsp_sign_state - ); + log!("TSP signature state for event {:?} is {:?}", event_tl_item.event_id(), tsp_sign_state); item.tsp_sign_indicator(cx, ids!(profile.tsp_sign_indicator)) .show_with_state(cx, tsp_sign_state); } @@ -4668,8 +4280,7 @@ fn populate_text_message_content( ) -> bool { // The message was HTML-formatted rich text. let mut links = Vec::new(); - if let Some(fb) = formatted_body - .as_ref() + if let Some(fb) = formatted_body.as_ref() .and_then(|fb| (fb.format == MessageFormat::Html).then_some(fb)) { let linkified_html = utils::linkify_get_urls( @@ -4689,7 +4300,7 @@ fn populate_text_message_content( }; // Populate link previews if all required parameters are provided - if let (Some(link_preview_ref), Some(media_cache), Some(link_preview_cache)) = + if let (Some(link_preview_ref), Some(media_cache), Some(link_preview_cache)) = (link_preview_ref, media_cache, link_preview_cache) { link_preview_ref.populate_below_message( @@ -4717,8 +4328,7 @@ fn populate_image_message_content( ) -> bool { // We don't use thumbnails, as their resolution is too low to be visually useful. // We also don't trust the provided mimetype, as it can be incorrect. - let (mimetype, _width, _height) = image_info_source - .as_ref() + let (mimetype, _width, _height) = image_info_source.as_ref() .map(|info| (info.mimetype.as_deref(), info.width, info.height)) .unwrap_or_default(); @@ -4726,7 +4336,10 @@ fn populate_image_message_content( // then show a message about it being unsupported (e.g., for animated gifs). if let Some(mime) = mimetype.as_ref() { if ImageFormat::from_mimetype(mime).is_none() { - text_or_image_ref.show_text(cx, format!("{body}\n\nUnsupported type {mime:?}")); + text_or_image_ref.show_text( + cx, + format!("{body}\n\nUnsupported type {mime:?}"), + ); return true; // consider this as fully drawn } } @@ -4735,132 +4348,102 @@ fn populate_image_message_content( // A closure that fetches and shows the image from the given `mxc_uri`, // marking it as fully drawn if the image was available. - let mut fetch_and_show_image_uri = - |cx: &mut Cx, mxc_uri: OwnedMxcUri, image_info: Box| { - match media_cache.try_get_media_or_fetch(&mxc_uri, MEDIA_THUMBNAIL_FORMAT.into()) { - (MediaCacheEntry::Loaded(data), _media_format) => { - let show_image_result = text_or_image_ref.show_image( - cx, - Some(MediaSource::Plain(mxc_uri)), - |cx, img| { - utils::load_png_or_jpg(&img, cx, &data) - .map(|()| img.size_in_pixels(cx).unwrap_or_default()) - }, - ); + let mut fetch_and_show_image_uri = |cx: &mut Cx, mxc_uri: OwnedMxcUri, image_info: Box| { + match media_cache.try_get_media_or_fetch(&mxc_uri, MEDIA_THUMBNAIL_FORMAT.into()) { + (MediaCacheEntry::Loaded(data), _media_format) => { + let show_image_result = text_or_image_ref.show_image(cx, Some(MediaSource::Plain(mxc_uri)),|cx, img| { + utils::load_png_or_jpg(&img, cx, &data) + .map(|()| img.size_in_pixels(cx).unwrap_or_default()) + }); + if let Err(e) = show_image_result { + let err_str = format!("{body}\n\nFailed to display image: {e:?}"); + error!("{err_str}"); + text_or_image_ref.show_text(cx, &err_str); + } + + // We're done drawing the image, so mark it as fully drawn. + fully_drawn = true; + } + (MediaCacheEntry::Requested, _media_format) => { + // If the image is being fetched, we try to show its blurhash. + if let (Some(ref blurhash), Some(width), Some(height)) = (image_info.blurhash.clone(), image_info.width, image_info.height) { + let show_image_result = text_or_image_ref.show_image(cx, Some(MediaSource::Plain(mxc_uri)), |cx, img| { + let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) else { + return Err(image_cache::ImageError::EmptyData) + }; + let (width, height): (u32, u32) = (width, height); + if width == 0 || height == 0 { + warning!("Image had an invalid aspect ratio (width or height of 0)."); + return Err(image_cache::ImageError::EmptyData); + } + let aspect_ratio: f32 = width as f32 / height as f32; + // Cap the blurhash to a max size of 500 pixels in each dimension + // because the `blurhash::decode()` function can be rather expensive. + let (mut capped_width, mut capped_height) = (width, height); + if capped_height > BLURHASH_IMAGE_MAX_SIZE { + capped_height = BLURHASH_IMAGE_MAX_SIZE; + capped_width = (capped_height as f32 * aspect_ratio).floor() as u32; + } + if capped_width > BLURHASH_IMAGE_MAX_SIZE { + capped_width = BLURHASH_IMAGE_MAX_SIZE; + capped_height = (capped_width as f32 / aspect_ratio).floor() as u32; + } + + match blurhash::decode(blurhash, capped_width, capped_height, 1.0) { + Ok(data) => { + ImageBuffer::new(&data, capped_width as usize, capped_height as usize).map(|img_buff| { + let texture = Some(img_buff.into_new_texture(cx)); + img.set_texture(cx, texture); + img.size_in_pixels(cx).unwrap_or_default() + }) + } + Err(e) => { + error!("Failed to decode blurhash {e:?}"); + Err(image_cache::ImageError::EmptyData) + } + } + }); if let Err(e) = show_image_result { let err_str = format!("{body}\n\nFailed to display image: {e:?}"); error!("{err_str}"); text_or_image_ref.show_text(cx, &err_str); } - - // We're done drawing the image, so mark it as fully drawn. - fully_drawn = true; - } - (MediaCacheEntry::Requested, _media_format) => { - // If the image is being fetched, we try to show its blurhash. - if let (Some(ref blurhash), Some(width), Some(height)) = ( - image_info.blurhash.clone(), - image_info.width, - image_info.height, - ) { - let show_image_result = text_or_image_ref.show_image( - cx, - Some(MediaSource::Plain(mxc_uri)), - |cx, img| { - let (Ok(width), Ok(height)) = (width.try_into(), height.try_into()) - else { - return Err(image_cache::ImageError::EmptyData); - }; - let (width, height): (u32, u32) = (width, height); - if width == 0 || height == 0 { - warning!( - "Image had an invalid aspect ratio (width or height of 0)." - ); - return Err(image_cache::ImageError::EmptyData); - } - let aspect_ratio: f32 = width as f32 / height as f32; - // Cap the blurhash to a max size of 500 pixels in each dimension - // because the `blurhash::decode()` function can be rather expensive. - let (mut capped_width, mut capped_height) = (width, height); - if capped_height > BLURHASH_IMAGE_MAX_SIZE { - capped_height = BLURHASH_IMAGE_MAX_SIZE; - capped_width = - (capped_height as f32 * aspect_ratio).floor() as u32; - } - if capped_width > BLURHASH_IMAGE_MAX_SIZE { - capped_width = BLURHASH_IMAGE_MAX_SIZE; - capped_height = - (capped_width as f32 / aspect_ratio).floor() as u32; - } - - match blurhash::decode(blurhash, capped_width, capped_height, 1.0) { - Ok(data) => ImageBuffer::new( - &data, - capped_width as usize, - capped_height as usize, - ) - .map(|img_buff| { - let texture = Some(img_buff.into_new_texture(cx)); - img.set_texture(cx, texture); - img.size_in_pixels(cx).unwrap_or_default() - }), - Err(e) => { - error!("Failed to decode blurhash {e:?}"); - Err(image_cache::ImageError::EmptyData) - } - } - }, - ); - if let Err(e) = show_image_result { - let err_str = format!("{body}\n\nFailed to display image: {e:?}"); - error!("{err_str}"); - text_or_image_ref.show_text(cx, &err_str); - } - } - fully_drawn = false; } - (MediaCacheEntry::Failed(_status_code), _media_format) => { - if text_or_image_ref - .view(cx, ids!(default_image_view)) - .visible() - { - fully_drawn = true; - return; - } - text_or_image_ref.show_text( - cx, - format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri), - ); - // For now, we consider this as being "complete". In the future, we could support - // retrying to fetch thumbnail of the image on a user click/tap. + fully_drawn = false; + } + (MediaCacheEntry::Failed(_status_code), _media_format) => { + if text_or_image_ref.view(cx, ids!(default_image_view)).visible() { fully_drawn = true; + return; } + text_or_image_ref + .show_text(cx, format!("{body}\n\nFailed to fetch image from {:?}", mxc_uri)); + // For now, we consider this as being "complete". In the future, we could support + // retrying to fetch thumbnail of the image on a user click/tap. + fully_drawn = true; } - }; + } + }; - let mut fetch_and_show_media_source = - |cx: &mut Cx, media_source: MediaSource, image_info: Box| { - match media_source { - MediaSource::Encrypted(encrypted) => { - // We consider this as "fully drawn" since we don't yet support encryption. - text_or_image_ref.show_text( - cx, - format!( - "{body}\n\n[TODO] fetch encrypted image at {:?}", - encrypted.url - ), - ); - } - MediaSource::Plain(mxc_uri) => fetch_and_show_image_uri(cx, mxc_uri, image_info), + let mut fetch_and_show_media_source = |cx: &mut Cx, media_source: MediaSource, image_info: Box| { + match media_source { + MediaSource::Encrypted(encrypted) => { + // We consider this as "fully drawn" since we don't yet support encryption. + text_or_image_ref.show_text( + cx, + format!("{body}\n\n[TODO] fetch encrypted image at {:?}", encrypted.url) + ); + }, + MediaSource::Plain(mxc_uri) => { + fetch_and_show_image_uri(cx, mxc_uri, image_info) } - }; + } + }; match image_info_source { Some(image_info) => { // Use the provided thumbnail URI if it exists; otherwise use the original URI. - let media_source = image_info - .thumbnail_source - .clone() + let media_source = image_info.thumbnail_source.clone() .unwrap_or(original_source); fetch_and_show_media_source(cx, media_source, image_info); } @@ -4873,6 +4456,7 @@ fn populate_image_message_content( fully_drawn } + /// Draws a file message's content into the given `message_content_widget`. /// /// Returns whether the file message content was fully drawn. @@ -4889,8 +4473,7 @@ fn populate_file_message_content( .and_then(|info| info.size) .map(|bytes| format!(" ({})", ByteSize::b(bytes.into()))) .unwrap_or_default(); - let caption = file_content - .formatted_caption() + let caption = file_content.formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| file_content.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -4917,23 +4500,20 @@ fn populate_audio_message_content( let (duration, mime, size) = audio .info .as_ref() - .map(|info| { - ( - info.duration - .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) - .unwrap_or_default(), - info.mimetype - .as_ref() - .map(|m| format!(" {m},")) - .unwrap_or_default(), - info.size - .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) - .unwrap_or_default(), - ) - }) + .map(|info| ( + info.duration + .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) + .unwrap_or_default(), + info.mimetype + .as_ref() + .map(|m| format!(" {m},")) + .unwrap_or_default(), + info.size + .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) + .unwrap_or_default(), + )) .unwrap_or_default(); - let caption = audio - .formatted_caption() + let caption = audio.formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| audio.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -4947,6 +4527,7 @@ fn populate_audio_message_content( true } + /// Draws a video message's content into the given `message_content_widget`. /// /// Returns whether the video message content was fully drawn. @@ -4960,26 +4541,23 @@ fn populate_video_message_content( let (duration, mime, size, dimensions) = video .info .as_ref() - .map(|info| { - ( - info.duration - .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) - .unwrap_or_default(), - info.mimetype - .as_ref() - .map(|m| format!(" {m},")) - .unwrap_or_default(), - info.size - .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) - .unwrap_or_default(), - info.width - .and_then(|width| info.height.map(|height| format!(" {width}x{height},"))) - .unwrap_or_default(), - ) - }) + .map(|info| ( + info.duration + .map(|d| format!(" {:.2} sec,", d.as_secs_f64())) + .unwrap_or_default(), + info.mimetype + .as_ref() + .map(|m| format!(" {m},")) + .unwrap_or_default(), + info.size + .map(|bytes| format!(" ({}),", ByteSize::b(bytes.into()))) + .unwrap_or_default(), + info.width.and_then(|width| + info.height.map(|height| format!(" {width}x{height},")) + ).unwrap_or_default(), + )) .unwrap_or_default(); - let caption = video - .formatted_caption() + let caption = video.formatted_caption() .map(|fb| format!("
{}", fb.body)) .or_else(|| video.caption().map(|c| format!("
{c}"))) .unwrap_or_default(); @@ -4993,6 +4571,8 @@ fn populate_video_message_content( true } + + /// Draws the given location message's content into the `message_content_widget`. /// /// Returns whether the location message content was fully drawn. @@ -5001,9 +4581,8 @@ fn populate_location_message_content( message_content_widget: &HtmlOrPlaintextRef, location: &LocationMessageEventContent, ) -> bool { - let coords = location - .geo_uri - .get(utils::GEO_URI_SCHEME.len()..) + let coords = location.geo_uri + .get(utils::GEO_URI_SCHEME.len() ..) .and_then(|s| { let mut iter = s.split(','); if let (Some(lat), Some(long)) = (iter.next(), iter.next()) { @@ -5013,14 +4592,8 @@ fn populate_location_message_content( } }); if let Some((lat, long)) = coords { - let short_lat = lat - .find('.') - .and_then(|dot| lat.get(..dot + 7)) - .unwrap_or(lat); - let short_long = long - .find('.') - .and_then(|dot| long.get(..dot + 7)) - .unwrap_or(long); + let short_lat = lat.find('.').and_then(|dot| lat.get(..dot + 7)).unwrap_or(lat); + let short_long = long.find('.').and_then(|dot| long.get(..dot + 7)).unwrap_or(long); let safe_lat = htmlize::escape_attribute(lat); let safe_long = htmlize::escape_attribute(long); let safe_geo_uri = htmlize::escape_attribute(&location.geo_uri); @@ -5039,10 +4612,7 @@ fn populate_location_message_content( } else { message_content_widget.show_html( cx, - format!( - "[Location invalid] {}", - htmlize::escape_text(&location.body) - ), + format!("[Location invalid] {}", htmlize::escape_text(&location.body)) ); } @@ -5052,6 +4622,7 @@ fn populate_location_message_content( true } + /// Draws the given redacted message's content into the `message_content_widget`. /// /// Returns whether the redacted message content was fully drawn. @@ -5064,13 +4635,16 @@ fn populate_redacted_message_content( let fully_drawn: bool; let mut redactor_id_and_reason = None; if let Some(redacted_msg) = event_tl_item.latest_json() { - if let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage( - SyncMessageLikeEvent::Redacted(redaction), - ))) = redacted_msg.deserialize() - { + if let Ok(AnySyncTimelineEvent::MessageLike( + AnySyncMessageLikeEvent::RoomMessage( + SyncMessageLikeEvent::Redacted(redaction) + ) + )) = redacted_msg.deserialize() { if let Ok(redacted_because) = redaction.unsigned.redacted_because.deserialize() { - redactor_id_and_reason = - Some((redacted_because.sender, redacted_because.content.reason)); + redactor_id_and_reason = Some(( + redacted_because.sender, + redacted_because.content.reason, + )); } } } @@ -5079,10 +4653,7 @@ fn populate_redacted_message_content( if redactor == event_tl_item.sender() { fully_drawn = true; match reason { - Some(r) => format!( - "⛔ Deleted their own message. Reason: \"{}\".", - htmlize::escape_text(r) - ), + Some(r) => format!("⛔ Deleted their own message. Reason: \"{}\".", htmlize::escape_text(r)), None => String::from("⛔ Deleted their own message."), } } else { @@ -5094,11 +4665,9 @@ fn populate_redacted_message_content( true, ); fully_drawn = redactor_name.was_found(); - let redactor_name_esc = - htmlize::escape_text(redactor_name.as_deref().unwrap_or(redactor.as_str())); + let redactor_name_esc = htmlize::escape_text(redactor_name.as_deref().unwrap_or(redactor.as_str())); match reason { - Some(r) => format!( - "⛔ {} deleted this message. Reason: \"{}\".", + Some(r) => format!("⛔ {} deleted this message. Reason: \"{}\".", redactor_name_esc, htmlize::escape_text(r), ), @@ -5113,6 +4682,7 @@ fn populate_redacted_message_content( fully_drawn } + /// Draws a ReplyPreview above a message if it was in-reply to another message. /// /// ## Arguments @@ -5139,24 +4709,24 @@ fn draw_replied_to_message( show_reply = true; match &in_reply_to_details.event { TimelineDetails::Ready(replied_to_event) => { - let (in_reply_to_username, is_avatar_fully_drawn) = replied_to_message_view - .avatar(cx, ids!(replied_to_message_content.reply_preview_avatar)) - .set_avatar_and_get_username( - cx, - timeline_kind, - &replied_to_event.sender, - Some(&replied_to_event.sender_profile), - Some(in_reply_to_details.event_id.as_ref()), - true, - ); + let (in_reply_to_username, is_avatar_fully_drawn) = + replied_to_message_view + .avatar(cx, ids!(replied_to_message_content.reply_preview_avatar)) + .set_avatar_and_get_username( + cx, + timeline_kind, + &replied_to_event.sender, + Some(&replied_to_event.sender_profile), + Some(in_reply_to_details.event_id.as_ref()), + true, + ); fully_drawn = is_avatar_fully_drawn; replied_to_message_view .label(cx, ids!(replied_to_message_content.reply_preview_username)) .set_text(cx, in_reply_to_username.as_str()); - let msg_body = - replied_to_message_view.html_or_plaintext(cx, ids!(reply_preview_body)); + let msg_body = replied_to_message_view.html_or_plaintext(cx, ids!(reply_preview_body)); populate_preview_of_timeline_item( cx, &msg_body, @@ -5268,8 +4838,7 @@ fn populate_thread_root_summary( &embedded_event.content, &embedded_event.sender, sender_username, - ) - .format_with(sender_username, true); + ).format_with(sender_username, true); match utils::replace_linebreaks_separators(&preview, true) { Cow::Borrowed(_) => Cow::Owned(preview), Cow::Owned(replaced) => Cow::Owned(replaced), @@ -5280,11 +4849,9 @@ fn populate_thread_root_summary( if td.is_unavailable() && let Some(thread_root_event_id) = thread_root_event_id.clone() { - let needs_refresh = - fetched_summary.is_none_or(|fs| fs.latest_reply_preview_text.is_none()); - if needs_refresh - && pending_thread_summary_fetches.insert(thread_root_event_id.clone()) - { + let needs_refresh = fetched_summary + .is_none_or(|fs| fs.latest_reply_preview_text.is_none()); + if needs_refresh && pending_thread_summary_fetches.insert(thread_root_event_id.clone()) { submit_async_request(MatrixRequest::FetchThreadSummaryDetails { timeline_kind: timeline_kind.clone(), thread_root_event_id, @@ -5292,8 +4859,7 @@ fn populate_thread_root_summary( }); } } - fetched_summary - .and_then(|fs| fs.latest_reply_preview_text.as_deref()) + fetched_summary.and_then(|fs| fs.latest_reply_preview_text.as_deref()) .unwrap_or("Loading latest reply...") .into() } @@ -5305,7 +4871,7 @@ fn populate_thread_root_summary( let replies_count_text = match replies_count { 1 => Cow::Borrowed("1 reply"), - n => Cow::Owned(format!("{n} replies")), + n => Cow::Owned(format!("{n} replies")) }; item.label(cx, ids!(thread_summary_count)) .set_text(cx, &replies_count_text); @@ -5325,32 +4891,23 @@ pub fn populate_preview_of_timeline_item( ) { if let Some(m) = timeline_item_content.as_message() { match m.msgtype() { - MessageType::Text(TextMessageEventContent { - body, formatted, .. - }) - | MessageType::Notice(NoticeMessageEventContent { - body, formatted, .. - }) => { - let _ = populate_text_message_content( - cx, - widget_out, - body, - formatted.as_ref(), - None, - None, - None, - ); + MessageType::Text(TextMessageEventContent { body, formatted, .. }) + | MessageType::Notice(NoticeMessageEventContent { body, formatted, .. }) => { + let _ = populate_text_message_content(cx, widget_out, body, formatted.as_ref(), None, None, None); return; } - _ => {} // fall through to the general case for all timeline items below. + _ => { } // fall through to the general case for all timeline items below. } } - let html = - text_preview_of_timeline_item(timeline_item_content, sender_user_id, sender_username) - .format_with(sender_username, true); + let html = text_preview_of_timeline_item( + timeline_item_content, + sender_user_id, + sender_username, + ).format_with(sender_username, true); widget_out.show_html(cx, html); } + /// A trait for abstracting over the different types of timeline events /// that can be displayed in a `SmallStateEvent` widget. trait SmallStateEventContent { @@ -5441,9 +4998,7 @@ impl SmallStateEventContent for PollState { ) -> (WidgetRef, ItemDrawnStatus) { item.label(cx, ids!(content)).set_text( cx, - self.fallback_text() - .unwrap_or_else(|| self.results().question) - .as_str(), + self.fallback_text().unwrap_or_else(|| self.results().question).as_str(), ); new_drawn_status.content_drawn = true; (item, new_drawn_status) @@ -5512,15 +5067,20 @@ impl SmallStateEventContent for RoomMembershipChange { ) -> (WidgetRef, ItemDrawnStatus) { let Some(preview) = text_preview_of_room_membership_change(self, false) else { // Don't actually display anything for nonexistent/unimportant membership changes. - return (list.item(cx, item_id, id!(Empty)), ItemDrawnStatus::new()); + return ( + list.item(cx, item_id, id!(Empty)), + ItemDrawnStatus::new(), + ); }; item.label(cx, ids!(content)) .set_text(cx, &preview.format_with(username, false)); // The invite_user_button is only used for "Knocked" membership change events. - item.button(cx, ids!(invite_user_button)) - .set_visible(cx, matches!(self.change(), Some(MembershipChange::Knocked))); + item.button(cx, ids!(invite_user_button)).set_visible( + cx, + matches!(self.change(), Some(MembershipChange::Knocked)), + ); new_drawn_status.content_drawn = true; (item, new_drawn_status) @@ -5572,8 +5132,7 @@ fn populate_small_state_event( ); // Draw the timestamp as part of the profile. if let Some(dt) = unix_time_millis_to_datetime(event_tl_item.timestamp()) { - item.timestamp(cx, ids!(left_container.timestamp)) - .set_date_time(cx, dt); + item.timestamp(cx, ids!(left_container.timestamp)).set_date_time(cx, dt); } new_drawn_status.profile_drawn = profile_drawn; username @@ -5592,6 +5151,7 @@ fn populate_small_state_event( ) } + /// Returns the display name of the sender of the given `event_tl_item`, if available. fn get_profile_display_name(event_tl_item: &EventTimelineItem) -> Option { if let TimelineDetails::Ready(profile) = event_tl_item.sender_profile() { @@ -5601,6 +5161,7 @@ fn get_profile_display_name(event_tl_item: &EventTimelineItem) -> Option } } + /// Actions related to invites within a room. /// /// These are NOT widget actions, just regular actions. @@ -5635,6 +5196,7 @@ pub enum InviteResultAction { }, } + /// Actions related to a specific message within a room timeline. #[derive(Clone, Default, Debug)] pub enum MessageAction { @@ -5679,6 +5241,7 @@ pub enum MessageAction { // /// The user clicked the "report" button on a message. // Report(MessageDetails), + /// The message at the given item index in the timeline should be highlighted. HighlightMessage(usize), /// The user requested that we show a context menu with actions @@ -5732,8 +5295,7 @@ impl ActionDefaultRef for AppServicePanelAction { #[derive(Script, ScriptHook, Widget)] pub struct AppServicePanel { - #[deref] - view: View, + #[deref] view: View, } impl Widget for AppServicePanel { @@ -5822,15 +5384,11 @@ impl Widget for AppServicePanel { /// A widget representing a single message of any kind within a room timeline. #[derive(Script, ScriptHook, Widget, Animator)] pub struct Message { - #[source] - source: ScriptObjectRef, - #[deref] - view: View, - #[apply_default] - animator: Animator, - - #[rust] - details: Option, + #[source] source: ScriptObjectRef, + #[deref] view: View, + #[apply_default] animator: Animator, + + #[rust] details: Option, } impl Widget for Message { @@ -5845,9 +5403,7 @@ impl Widget for Message { self.animator_play(cx, ids!(highlight.off)); } - let Some(details) = self.details.clone() else { - return; - }; + let Some(details) = self.details.clone() else { return }; // We first handle a click on the replied-to message preview, if present, // because we don't want any widgets within the replied-to message to be @@ -5856,31 +5412,31 @@ impl Widget for Message { Hit::FingerDown(fe) => { if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - }, + } ); } } Hit::FingerLongPress(lp) => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - }, + } ); } // If the hit occurred on the replied-to message preview, jump to it. Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::JumpToRelated(details.clone()), ); } - _ => {} + _ => { } } // Handle clicks on the thread summary shown beneath a thread-root message. @@ -5897,11 +5453,11 @@ impl Widget for Message { apply_hover(cx, COLOR_THREAD_SUMMARY_BG_HOVER); if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - }, + } ); } } @@ -5913,23 +5469,23 @@ impl Widget for Message { } Hit::FingerLongPress(lp) => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - }, + } ); } Hit::FingerUp(fe) => { apply_hover(cx, COLOR_THREAD_SUMMARY_BG); if fe.is_over && fe.is_primary_hit() && fe.was_tap() { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenThread(thread_root_event_id.clone()), ); } } - _ => {} + _ => { } } } @@ -5948,21 +5504,21 @@ impl Widget for Message { // A right click means we should display the context menu. if fe.device.mouse_button().is_some_and(|b| b.is_secondary()) { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: fe.abs, - }, + } ); } } Hit::FingerLongPress(lp) => { cx.widget_action( - details.room_screen_widget_uid, + details.room_screen_widget_uid, MessageAction::OpenMessageContextMenu { details: details.clone(), abs_pos: lp.abs, - }, + } ); } Hit::FingerHoverIn(..) => { @@ -5973,16 +5529,12 @@ impl Widget for Message { self.animator_play(cx, ids!(hover.off)); // TODO: here, hide the "action bar" buttons upon hover-out } - _ => {} + _ => { } } if let Event::Actions(actions) = event { for action in actions { - match action - .as_widget_action() - .widget_uid_eq(details.room_screen_widget_uid) - .cast_ref() - { + match action.as_widget_action().widget_uid_eq(details.room_screen_widget_uid).cast_ref() { MessageAction::HighlightMessage(id) if id == &details.item_id => { self.animator_play(cx, ids!(highlight.on)); self.redraw(cx); @@ -5994,11 +5546,7 @@ impl Widget for Message { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - if self - .details - .as_ref() - .is_some_and(|d| d.should_be_highlighted) - { + if self.details.as_ref().is_some_and(|d| d.should_be_highlighted) { script_apply_eval!(cx, self, { draw_bg +: { color: #ffffd1, @@ -6019,9 +5567,7 @@ impl Message { impl MessageRef { fn set_data(&self, details: MessageDetails) { - let Some(mut inner) = self.borrow_mut() else { - return; - }; + let Some(mut inner) = self.borrow_mut() else { return }; inner.set_data(details); } } @@ -6030,7 +5576,7 @@ impl MessageRef { /// /// This function requires passing in a reference to `Cx`, /// which isn't used, but acts as a guarantee that this function -/// must only be called by the main UI thread. +/// must only be called by the main UI thread. pub fn clear_timeline_states(_cx: &mut Cx) { // Clear timeline states cache TIMELINE_STATES.with_borrow_mut(|states| { diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 0b1ae8c77..73ce9375e 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -16,50 +16,30 @@ //! so you can use it from other widgets or functions on the main UI thread //! that need to query basic info about a particular room or space. -use std::{ - cell::RefCell, - collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, - rc::Rc, - sync::Arc, -}; +use std::{cell::RefCell, collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, rc::Rc, sync::Arc}; use crossbeam_queue::SegQueue; 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, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, - }, -}; +use matrix_sdk::{RoomState, ruma::{events::tag::Tags, MilliSecondsSinceUnixEpoch, OwnedRoomAliasId, OwnedRoomId, OwnedUserId}}; use crate::{ app::{AppState, SelectedRoom}, home::{ - navigation_tab_bar::{NavigationBarAction, SelectedTab}, - room_context_menu::RoomContextMenuDetails, - rooms_list_entry::RoomsListEntryAction, - space_lobby::{SpaceLobbyAction, SpaceLobbyEntryWidgetExt}, + navigation_tab_bar::{NavigationBarAction, SelectedTab}, room_context_menu::RoomContextMenuDetails, rooms_list_entry::RoomsListEntryAction, space_lobby::{SpaceLobbyAction, SpaceLobbyEntryWidgetExt} }, room::{ FetchedRoomAvatar, - room_display_filter::{ - RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria, SortFn, - }, + room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria, SortFn}, }, shared::{ - collapsible_header::{ - CollapsibleHeaderAction, CollapsibleHeaderWidgetRefExt, HeaderCategory, - }, + collapsible_header::{CollapsibleHeaderAction, CollapsibleHeaderWidgetRefExt, HeaderCategory}, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction, }, - sliding_sync::{ - MatrixLinkAction, MatrixRequest, PaginationDirection, TimelineKind, submit_async_request, - }, - space_service_sync::{ParentChain, SpaceRequest, SpaceRoomListAction}, - utils::{RoomNameId, VecDiff}, + sliding_sync::{MatrixLinkAction, MatrixRequest, PaginationDirection, TimelineKind, submit_async_request}, + space_service_sync::{ParentChain, SpaceRequest, SpaceRoomListAction}, utils::{RoomNameId, VecDiff}, }; /// Whether to pre-paginate visible rooms at least once in order to @@ -91,10 +71,11 @@ pub fn get_invited_rooms(_cx: &mut Cx) -> Rc }, + LoadedRooms{ max_rooms: Option }, /// Add a new room to the list of rooms the user has been invited to. /// This will be maintained and displayed separately from joined rooms. AddInvitedRoom(InvitedRoomInfo), @@ -189,7 +171,9 @@ pub enum RoomsListUpdate { unread_mentions: u64, }, /// Update the displayable name for the given room. - UpdateRoomName { new_room_name: RoomNameId }, + UpdateRoomName { + new_room_name: RoomNameId, + }, /// Update the avatar (image) for the given room. UpdateRoomAvatar { room_id: OwnedRoomId, @@ -212,15 +196,21 @@ pub enum RoomsListUpdate { new_tags: Tags, }, /// Update the status label at the bottom of the list of all rooms. - Status { status: String }, + Status { + status: String, + }, /// Mark the given room as tombstoned. - TombstonedRoom { room_id: OwnedRoomId }, + TombstonedRoom { + room_id: OwnedRoomId + }, /// Hide the given room from being displayed. /// /// This is useful for temporarily preventing a room from being shown, /// e.g., after a room has been left but before the homeserver has registered /// that we left it and removed it via the RoomListService. - HideRoom { room_id: OwnedRoomId }, + HideRoom { + room_id: OwnedRoomId, + }, /// Scroll to the given room. ScrollToRoom(OwnedRoomId), /// The background space service is now listening for requests, @@ -247,7 +237,9 @@ pub enum RoomsListAction { /// A new room was joined from an accepted invite, /// meaning that the existing `InviteScreen` should be converted /// to a `RoomScreen` to display the now-joined room. - InviteAccepted { room_name_id: RoomNameId }, + InviteAccepted { + room_name_id: RoomNameId, + }, /// Instructs the top-level app to show the context menu for the given room. /// /// Emitted by the RoomsList when the user right-clicks or long-presses @@ -267,6 +259,7 @@ impl ActionDefaultRef for RoomsListAction { } } + /// UI-related info about a joined room. /// /// This includes info needed display a preview of that room in the RoomsList @@ -305,6 +298,7 @@ pub struct JoinedRoomInfo { pub is_direct: bool, /// Whether this room is tombstoned (shut down and replaced with a successor room). pub is_tombstoned: bool, + // TODO: we could store the parent chain(s) of this room, i.e., which spaces // they are children of. One room can be in multiple spaces. } @@ -396,34 +390,28 @@ struct SpaceMapValue { #[derive(Script, Widget)] pub struct RoomsList { - #[deref] - view: View, + #[deref] view: View, /// The list of all rooms that the user has been invited to. /// /// This is a shared reference to the thread-local [`ALL_INVITED_ROOMS`] variable. - #[rust] - invited_rooms: Rc>>, + #[rust] invited_rooms: Rc>>, /// The set of all joined rooms and their cached info. /// This includes both direct rooms and regular rooms, but not invited rooms. - #[rust] - all_joined_rooms: HashMap, + #[rust] all_joined_rooms: HashMap, /// The list of all room IDs in display order, matching the order from the room list service. - #[rust] - all_known_rooms_order: VecDeque, + #[rust] all_known_rooms_order: VecDeque, /// 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. - #[rust] - selected_space: Option, + #[rust] selected_space: Option, /// The sender used to send Space-related requests to the background service. - #[rust] - space_request_sender: Option>, + #[rust] space_request_sender: Option>, /// A flattened map of all spaces known to the client. /// @@ -431,66 +419,50 @@ pub struct RoomsList { /// and nested subspaces *directly* within that space. /// /// This can include both joined and non-joined spaces. - #[rust] - space_map: HashMap, + #[rust] space_map: HashMap, /// Rooms that are explicitly hidden and should never be shown in the rooms list. - #[rust] - hidden_rooms: HashSet, + #[rust] hidden_rooms: HashSet, /// The currently-active filter function for the list of rooms. /// /// ## Important Notes /// 1. Do not use this directly. Instead, use the `should_display_room!()` macro. /// 2. This does *not* get auto-applied when it changes, for performance reasons. - #[rust] - display_filter: RoomDisplayFilter, + #[rust] display_filter: RoomDisplayFilter, /// The currently-active sort function for the list of rooms. - #[rust] - sort_fn: Option>, + #[rust] sort_fn: Option>, /// The list of invited rooms currently displayed in the UI. - #[rust] - displayed_invited_rooms: Vec, - #[rust(false)] - is_invited_rooms_header_expanded: bool, - #[rust] - invited_rooms_indexes: RoomCategoryIndexes, + #[rust] displayed_invited_rooms: Vec, + #[rust(false)] is_invited_rooms_header_expanded: bool, + #[rust] invited_rooms_indexes: RoomCategoryIndexes, /// The list of direct rooms currently displayed in the UI. - #[rust] - displayed_direct_rooms: Vec, - #[rust(false)] - is_direct_rooms_header_expanded: bool, - #[rust] - direct_rooms_indexes: RoomCategoryIndexes, + #[rust] displayed_direct_rooms: Vec, + #[rust(false)] is_direct_rooms_header_expanded: bool, + #[rust] direct_rooms_indexes: RoomCategoryIndexes, /// The list of regular (non-direct) joined rooms currently displayed in the UI. /// /// **Direct rooms are excluded** from this; they are in `displayed_direct_rooms`. - #[rust] - displayed_regular_rooms: Vec, - #[rust(true)] - is_regular_rooms_header_expanded: bool, - #[rust] - regular_rooms_indexes: RoomCategoryIndexes, + #[rust] displayed_regular_rooms: Vec, + #[rust(true)] is_regular_rooms_header_expanded: bool, + #[rust] regular_rooms_indexes: RoomCategoryIndexes, /// The latest status message that should be displayed in the bottom status label. - #[rust] - status: String, + #[rust] status: String, /// The currently-selected room. - #[rust] - current_active_room: Option, + #[rust] current_active_room: Option, /// The maximum number of rooms that will ever be loaded. /// /// This should not be used to determine whether all requested rooms have been loaded, /// because we will likely never receive this many rooms due to the room list service /// excluding rooms that we have filtered out (e.g., left or tombstoned rooms, spaces, etc). - #[rust] - max_known_rooms: Option, + #[rust] max_known_rooms: Option, // /// Whether the room list service has loaded all requested rooms from the homeserver. // #[rust] all_rooms_loaded: bool, } @@ -513,16 +485,15 @@ macro_rules! should_display_room { ($self:expr, $room_id:expr, $room:expr) => { !$self.hidden_rooms.contains($room_id) && ($self.display_filter)($room) - && $self - .selected_space - .as_ref() + && $self.selected_space.as_ref() .is_none_or(|space| $self.is_room_indirectly_in_space(space.room_id(), $room_id)) }; } + impl RoomsList { /// Returns whether the homeserver has finished syncing all of the rooms - /// that should be synced to our client based on the currently-specified room list filter. + /// that should be synced to our client based on the currently-specified room list filter. pub fn all_rooms_loaded(&self) -> bool { // TODO: fix this: figure out a way to determine if // all requested rooms have been received from the homeserver. @@ -551,10 +522,7 @@ impl RoomsList { RoomsListUpdate::AddInvitedRoom(invited_room) => { let room_id = invited_room.room_name_id.room_id().clone(); let should_display = should_display_room!(self, &room_id, &invited_room); - let _replaced = self - .invited_rooms - .borrow_mut() - .insert(room_id.clone(), invited_room); + let _replaced = self.invited_rooms.borrow_mut().insert(room_id.clone(), invited_room); if should_display { self.displayed_invited_rooms.push(room_id); } @@ -580,29 +548,24 @@ impl RoomsList { // 3. Emit an action to inform other widgets that the InviteScreen // displaying the invite to this room should be converted to a // RoomScreen displaying the now-joined room. - if let Some(_accepted_invite) = self.invited_rooms.borrow_mut().remove(&room_id) - { + if let Some(_accepted_invite) = self.invited_rooms.borrow_mut().remove(&room_id) { log!("Removed room {room_id} from the list of invited rooms"); - self.displayed_invited_rooms - .iter() + self.displayed_invited_rooms.iter() .position(|r| r == &room_id) .map(|index| self.displayed_invited_rooms.remove(index)); if let Some(room) = self.all_joined_rooms.get(&room_id) { cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::InviteAccepted { room_name_id: room.room_name_id.clone(), - }, + } ); } } self.update_status(); SignalToUI::set_ui_signal(); // signal the RoomScreen to update itself } - RoomsListUpdate::UpdateRoomAvatar { - room_id, - room_avatar, - } => { + RoomsListUpdate::UpdateRoomAvatar { room_id, room_avatar } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.room_avatar = room_avatar; } else if let Some(room) = self.invited_rooms.borrow_mut().get_mut(&room_id) { @@ -611,23 +574,14 @@ impl RoomsList { error!("Error: couldn't find room {room_id} to update avatar"); } } - RoomsListUpdate::UpdateLatestEvent { - room_id, - timestamp, - latest_message_text, - } => { + RoomsListUpdate::UpdateLatestEvent { room_id, timestamp, latest_message_text } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.latest = Some((timestamp, latest_message_text)); } else { error!("Error: couldn't find room {room_id} to update latest event"); } } - RoomsListUpdate::UpdateNumUnreadMessages { - room_id, - is_marked_unread, - unread_messages, - unread_mentions, - } => { + RoomsListUpdate::UpdateNumUnreadMessages { room_id, is_marked_unread, unread_messages, unread_mentions } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.num_unread_messages = match unread_messages { UnreadMessageCount::Unknown => 0, @@ -636,13 +590,11 @@ impl RoomsList { room.num_unread_mentions = unread_mentions; room.is_marked_unread = is_marked_unread; } else { - warning!( - "Warning: couldn't find room {} to update unread messages count", - room_id - ); + warning!("Warning: couldn't find room {} to update unread messages count", room_id); } } RoomsListUpdate::UpdateRoomName { new_room_name } => { + // TODO: broadcast a new AppState action to ensure that this room's or space's new name // gets updated in all of the `SelectedRoom` instances throughout Robrix, // e.g., the name of the room in the Dock Tab or the StackNav header. @@ -655,16 +607,12 @@ impl RoomsList { let should_display = should_display_room!(self, &room_id, room); let (pos_in_list, displayed_list) = if is_direct { ( - self.displayed_direct_rooms - .iter() - .position(|r| r == &room_id), + self.displayed_direct_rooms.iter().position(|r| r == &room_id), &mut self.displayed_direct_rooms, ) } else { ( - self.displayed_regular_rooms - .iter() - .position(|r| r == &room_id), + self.displayed_regular_rooms.iter().position(|r| r == &room_id), &mut self.displayed_regular_rooms, ) }; @@ -682,9 +630,7 @@ impl RoomsList { if let Some(invited_room) = invited_rooms.get_mut(&room_id) { invited_room.room_name_id = new_room_name; let should_display = should_display_room!(self, &room_id, invited_room); - let pos_in_list = self - .displayed_invited_rooms - .iter() + let pos_in_list = self.displayed_invited_rooms.iter() .position(|r| r == &room_id); if should_display { if pos_in_list.is_none() { @@ -694,9 +640,7 @@ impl RoomsList { pos_in_list.map(|i| self.displayed_invited_rooms.remove(i)); } } else { - warning!( - "Warning: couldn't find room {new_room_name} to update its name." - ); + warning!("Warning: couldn't find room {new_room_name} to update its name."); } } } @@ -707,8 +651,7 @@ impl RoomsList { continue; } enqueue_popup_notification( - format!( - "{} was changed from {} to {}.", + format!("{} was changed from {} to {}.", room.room_name_id, if room.is_direct { "direct" } else { "regular" }, if is_direct { "direct" } else { "regular" } @@ -723,8 +666,7 @@ impl RoomsList { } else { &mut self.displayed_regular_rooms }; - list_to_remove_from - .iter() + list_to_remove_from.iter() .position(|r| r == &room_id) .map(|index| list_to_remove_from.remove(index)); @@ -748,23 +690,19 @@ impl RoomsList { // and then options/buttons for the user to re-join it if desired. if let Some(removed) = self.all_joined_rooms.remove(&room_id) { - log!( - "Removed room {room_id} from the list of all joined rooms, now has state {new_state:?}" - ); + log!("Removed room {room_id} from the list of all joined rooms, now has state {new_state:?}"); let list_to_remove_from = if removed.is_direct { &mut self.displayed_direct_rooms } else { &mut self.displayed_regular_rooms }; - list_to_remove_from - .iter() + list_to_remove_from.iter() .position(|r| r == &room_id) .map(|index| list_to_remove_from.remove(index)); - } else if let Some(_removed) = self.invited_rooms.borrow_mut().remove(&room_id) - { + } + else if let Some(_removed) = self.invited_rooms.borrow_mut().remove(&room_id) { log!("Removed room {room_id} from the list of all invited rooms"); - self.displayed_invited_rooms - .iter() + self.displayed_invited_rooms.iter() .position(|r| r == &room_id) .map(|index| self.displayed_invited_rooms.remove(index)); } @@ -785,7 +723,7 @@ impl RoomsList { } RoomsListUpdate::LoadedRooms { max_rooms } => { self.max_known_rooms = max_rooms; - } + }, RoomsListUpdate::Tags { room_id, new_tags } => { if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.tags = new_tags; @@ -805,16 +743,12 @@ impl RoomsList { let should_display = should_display_room!(self, &room_id, room); let (pos_in_list, displayed_list) = if is_direct { ( - self.displayed_direct_rooms - .iter() - .position(|r| r == &room_id), + self.displayed_direct_rooms.iter().position(|r| r == &room_id), &mut self.displayed_direct_rooms, ) } else { ( - self.displayed_regular_rooms - .iter() - .position(|r| r == &room_id), + self.displayed_regular_rooms.iter().position(|r| r == &room_id), &mut self.displayed_regular_rooms, ) }; @@ -826,32 +760,20 @@ impl RoomsList { pos_in_list.map(|i| displayed_list.remove(i)); } } else { - warning!( - "Warning: couldn't find room {room_id} to update the tombstone status" - ); + warning!("Warning: couldn't find room {room_id} to update the tombstone status"); } } RoomsListUpdate::HideRoom { room_id } => { self.hidden_rooms.insert(room_id.clone()); // Hiding a regular room is the most common case (e.g., after its successor is joined), // so we check that list first. - if let Some(i) = self - .displayed_regular_rooms - .iter() - .position(|r| r == &room_id) - { + if let Some(i) = self.displayed_regular_rooms.iter().position(|r| r == &room_id) { self.displayed_regular_rooms.remove(i); - } else if let Some(i) = self - .displayed_direct_rooms - .iter() - .position(|r| r == &room_id) - { + } + else if let Some(i) = self.displayed_direct_rooms.iter().position(|r| r == &room_id) { self.displayed_direct_rooms.remove(i); - } else if let Some(i) = self - .displayed_invited_rooms - .iter() - .position(|r| r == &room_id) - { + } + else if let Some(i) = self.displayed_invited_rooms.iter().position(|r| r == &room_id) { self.displayed_invited_rooms.remove(i); } } @@ -860,89 +782,75 @@ impl RoomsList { self.recalculate_indexes(); let portal_list = self.view.portal_list(cx, ids!(list)); let speed = 50.0; - let portal_list_index = if let Some(regular_index) = self - .displayed_regular_rooms - .iter() - .position(|r| r == &room_id) - { + let portal_list_index = if let Some(regular_index) = self.displayed_regular_rooms.iter().position(|r| r == &room_id) { self.regular_rooms_indexes.first_room_index + regular_index - } else if let Some(direct_index) = self - .displayed_direct_rooms - .iter() - .position(|r| r == &room_id) - { + } + else if let Some(direct_index) = self.displayed_direct_rooms.iter().position(|r| r == &room_id) { self.direct_rooms_indexes.first_room_index + direct_index - } else if let Some(invited_index) = self - .displayed_invited_rooms - .iter() - .position(|r| r == &room_id) - { + } + else if let Some(invited_index) = self.displayed_invited_rooms.iter().position(|r| r == &room_id) { self.invited_rooms_indexes.first_room_index + invited_index - } else { - continue; - }; + } + else { continue }; // 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), - ); + 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. + num_updates -= 1; // this does not require a redraw. } - RoomsListUpdate::RoomOrderUpdate(diff) => match diff { - VecDiff::Append { values } => { - self.all_known_rooms_order.extend(values); - needs_sort = true; - } - VecDiff::Clear => { - self.all_known_rooms_order.clear(); - needs_sort = true; - } - VecDiff::PushFront { value } => { - self.all_known_rooms_order.push_front(value); - needs_sort = true; - } - VecDiff::PushBack { value } => { - self.all_known_rooms_order.push_back(value); - needs_sort = true; - } - VecDiff::PopFront => { - self.all_known_rooms_order.pop_front(); - needs_sort = true; - } - VecDiff::PopBack => { - self.all_known_rooms_order.pop_back(); - needs_sort = true; - } - VecDiff::Insert { index, value } => { - if index <= self.all_known_rooms_order.len() { - self.all_known_rooms_order.insert(index, value); + RoomsListUpdate::RoomOrderUpdate(diff) => { + match diff { + VecDiff::Append { values } => { + self.all_known_rooms_order.extend(values); needs_sort = true; } - } - VecDiff::Set { index, value } => { - if let Some(existing) = self.all_known_rooms_order.get_mut(index) { - if *existing != value { - *existing = value; + VecDiff::Clear => { + self.all_known_rooms_order.clear(); + needs_sort = true; + } + VecDiff::PushFront { value } => { + self.all_known_rooms_order.push_front(value); + needs_sort = true; + } + VecDiff::PushBack { value } => { + self.all_known_rooms_order.push_back(value); + needs_sort = true; + } + VecDiff::PopFront => { + self.all_known_rooms_order.pop_front(); + needs_sort = true; + } + VecDiff::PopBack => { + self.all_known_rooms_order.pop_back(); + needs_sort = true; + } + VecDiff::Insert { index, value } => { + if index <= self.all_known_rooms_order.len() { + self.all_known_rooms_order.insert(index, value); needs_sort = true; } } - } - VecDiff::Remove { index } => { - if index < self.all_known_rooms_order.len() { - self.all_known_rooms_order.remove(index); + VecDiff::Set { index, value } => { + if let Some(existing) = self.all_known_rooms_order.get_mut(index) { + if *existing != value { + *existing = value; + needs_sort = true; + } + } + } + VecDiff::Remove { index } => { + if index < self.all_known_rooms_order.len() { + self.all_known_rooms_order.remove(index); + needs_sort = true; + } + } + VecDiff::Truncate { length } => { + self.all_known_rooms_order.truncate(length); needs_sort = true; } } - VecDiff::Truncate { length } => { - self.all_known_rooms_order.truncate(length); - needs_sort = true; - } - }, + } } } if needs_sort { @@ -967,9 +875,9 @@ impl RoomsList { + self.displayed_regular_rooms.len(); let mut text = match (self.display_filter.is_none(), num_rooms) { - (true, 0) => "No joined or invited rooms found".to_string(), - (true, 1) => "Loaded 1 room".to_string(), - (true, n) => format!("Loaded {n} rooms"), + (true, 0) => "No joined or invited rooms found".to_string(), + (true, 1) => "Loaded 1 room".to_string(), + (true, n) => format!("Loaded {n} rooms"), (false, 0) => "No matching rooms found".to_string(), (false, 1) => "Found 1 matching room".to_string(), (false, n) => format!("Found {n} matching rooms"), @@ -1018,6 +926,7 @@ impl RoomsList { self.redraw(cx); } + /// Generates a tuple of three kinds of displayed rooms (accounting for the current `display_filter`): /// 1. displayed_invited_rooms /// 2. displayed_regular_rooms @@ -1025,7 +934,7 @@ impl RoomsList { /// /// If `self.sort_fn` is `Some`, the rooms are ordered based on that function. /// Otherwise, the rooms are ordered based on `self.all_known_rooms_order` (the default). - fn generate_displayed_rooms(&self) -> (Vec, Vec, Vec) { + fn generate_displayed_rooms(&self) -> (Vec,Vec, Vec) { let mut new_displayed_invited_rooms = Vec::new(); let mut new_displayed_regular_rooms = Vec::new(); let mut new_displayed_direct_rooms = Vec::new(); @@ -1043,9 +952,7 @@ impl RoomsList { // If a sort function was provided, use it. if let Some(sort_fn) = self.sort_fn.as_deref() { - let mut filtered_joined_rooms = self - .all_joined_rooms - .iter() + let mut filtered_joined_rooms = self.all_joined_rooms.iter() .filter(|&(room_id, room)| should_display_room!(self, room_id, room)) .collect::>(); filtered_joined_rooms.sort_by(|(_, room_a), (_, room_b)| sort_fn(*room_a, *room_b)); @@ -1053,8 +960,7 @@ impl RoomsList { push_joined_room(room_id, jr) } - let mut filtered_invited_rooms = invited_rooms_ref - .iter() + let mut filtered_invited_rooms = invited_rooms_ref.iter() .filter(|&(room_id, room)| should_display_room!(self, room_id, room)) .collect::>(); filtered_invited_rooms.sort_by(|(_, room_a), (_, room_b)| sort_fn(*room_a, *room_b)); @@ -1077,11 +983,7 @@ impl RoomsList { } } - ( - new_displayed_invited_rooms, - new_displayed_regular_rooms, - new_displayed_direct_rooms, - ) + (new_displayed_invited_rooms, new_displayed_regular_rooms, new_displayed_direct_rooms) } /// Calculates the indexes in the PortalList where the headers and rooms should be drawn. @@ -1094,35 +996,35 @@ impl RoomsList { // Based on the various displayed room lists and is_expanded state of each room header, // calculate the indexes in the PortalList where the headers and rooms should be drawn. let should_show_invited_rooms_header = !self.displayed_invited_rooms.is_empty(); - let should_show_direct_rooms_header = !self.displayed_direct_rooms.is_empty(); + let should_show_direct_rooms_header = !self.displayed_direct_rooms.is_empty(); let should_show_regular_rooms_header = !self.displayed_regular_rooms.is_empty(); let index_of_invited_rooms_header = should_show_invited_rooms_header.then_some(0); let index_of_first_invited_room = should_show_invited_rooms_header as usize; - let index_after_invited_rooms = index_of_first_invited_room - + if self.is_invited_rooms_header_expanded { + let index_after_invited_rooms = index_of_first_invited_room + + if self.is_invited_rooms_header_expanded { self.displayed_invited_rooms.len() } else { 0 }; - let index_of_direct_rooms_header = - should_show_direct_rooms_header.then_some(index_after_invited_rooms); - let index_of_first_direct_room = - index_after_invited_rooms + should_show_direct_rooms_header as usize; - let index_after_direct_rooms = index_of_first_direct_room - + if self.is_direct_rooms_header_expanded { + let index_of_direct_rooms_header = should_show_direct_rooms_header + .then_some(index_after_invited_rooms); + let index_of_first_direct_room = index_after_invited_rooms + + should_show_direct_rooms_header as usize; + let index_after_direct_rooms = index_of_first_direct_room + + if self.is_direct_rooms_header_expanded { self.displayed_direct_rooms.len() } else { 0 }; - let index_of_regular_rooms_header = - should_show_regular_rooms_header.then_some(index_after_direct_rooms); - let index_of_first_regular_room = - index_after_direct_rooms + should_show_regular_rooms_header as usize; - let index_after_regular_rooms = index_of_first_regular_room - + if self.is_regular_rooms_header_expanded { + let index_of_regular_rooms_header = should_show_regular_rooms_header + .then_some(index_after_direct_rooms); + let index_of_first_regular_room = index_after_direct_rooms + + should_show_regular_rooms_header as usize; + let index_after_regular_rooms = index_of_first_regular_room + + if self.is_regular_rooms_header_expanded { self.displayed_regular_rooms.len() } else { 0 @@ -1148,43 +1050,32 @@ impl RoomsList { /// Handle any incoming updates to spaces' room lists and pagination state. fn handle_space_room_list_action(&mut self, cx: &mut Cx, action: &SpaceRoomListAction) { match action { - SpaceRoomListAction::UpdatedChildren { - space_id, - parent_chain, - direct_child_rooms, - direct_subspaces, - } => { + SpaceRoomListAction::UpdatedChildren { space_id, parent_chain, direct_child_rooms, direct_subspaces } => { match self.space_map.entry(space_id.clone()) { Entry::Occupied(mut occ) => { let occ_mut = occ.get_mut(); occ_mut.parent_chain = parent_chain.clone(); occ_mut.direct_child_rooms = Arc::clone(direct_child_rooms); - occ_mut.direct_subspaces = Arc::clone(direct_subspaces); + occ_mut.direct_subspaces = Arc::clone(direct_subspaces); } Entry::Vacant(vac) => { vac.insert_entry(SpaceMapValue { is_fully_paginated: false, parent_chain: parent_chain.clone(), direct_child_rooms: Arc::clone(direct_child_rooms), - direct_subspaces: Arc::clone(direct_subspaces), + direct_subspaces: Arc::clone(direct_subspaces), }); } } - if self.selected_space.as_ref().is_some_and(|sel_space| { - sel_space.room_id() == space_id || parent_chain.contains(sel_space.room_id()) - }) { + if self.selected_space.as_ref().is_some_and(|sel_space| + sel_space.room_id() == space_id + || parent_chain.contains(sel_space.room_id()) + ) { self.update_displayed_rooms(cx, false); } } - SpaceRoomListAction::PaginationState { - space_id, - parent_chain, - state, - } => { - let is_fully_paginated = matches!( - state, - SpaceRoomListPaginationState::Idle { end_reached: true } - ); + SpaceRoomListAction::PaginationState { space_id, parent_chain, state } => { + let is_fully_paginated = matches!(state, SpaceRoomListPaginationState::Idle { end_reached: true }); // Only re-fetch the list of rooms in this space if it was not already fully paginated. let should_fetch_rooms: bool; match self.space_map.entry(space_id.clone()) { @@ -1203,22 +1094,15 @@ impl RoomsList { } } let Some(sender) = self.space_request_sender.as_ref() else { - error!( - "BUG: RoomsList: no space request sender was available after pagination state update." - ); + error!("BUG: RoomsList: no space request sender was available after pagination state update."); return; }; if should_fetch_rooms { - if sender - .send(SpaceRequest::GetChildren { - space_id: space_id.clone(), - parent_chain: parent_chain.clone(), - }) - .is_err() - { - error!( - "BUG: RoomsList: failed to send GetRooms request for space {space_id}." - ); + if sender.send(SpaceRequest::GetChildren { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + }).is_err() { + error!("BUG: RoomsList: failed to send GetRooms request for space {space_id}."); } } @@ -1228,16 +1112,11 @@ impl RoomsList { // all of its children, such that we can see if any of them are subspaces, // and then we'll paginate those as well. if !is_fully_paginated { - if sender - .send(SpaceRequest::PaginateSpaceRoomList { - space_id: space_id.clone(), - parent_chain: parent_chain.clone(), - }) - .is_err() - { - error!( - "BUG: RoomsList: failed to send pagination request for space {space_id}." - ); + if sender.send(SpaceRequest::PaginateSpaceRoomList { + space_id: space_id.clone(), + parent_chain: parent_chain.clone(), + }).is_err() { + error!("BUG: RoomsList: failed to send pagination request for space {space_id}."); } } } @@ -1249,10 +1128,7 @@ impl RoomsList { None, ); } - SpaceRoomListAction::LeaveSpaceResult { - space_name_id, - result, - } => match result { + SpaceRoomListAction::LeaveSpaceResult { space_name_id, result } => match result { Ok(()) => { enqueue_popup_notification( format!("Successfully left space \"{}\".", space_name_id), @@ -1260,11 +1136,7 @@ impl RoomsList { Some(4.0), ); // If the space we left was the currently-selected one, go back to the main Home view. - if self - .selected_space - .as_ref() - .is_some_and(|s| s.room_id() == space_name_id.room_id()) - { + if self.selected_space.as_ref().is_some_and(|s| s.room_id() == space_name_id.room_id()) { cx.action(NavigationBarAction::GoToHome); } } @@ -1279,18 +1151,14 @@ impl RoomsList { }, // Details-related space actions are handled by SpaceLobbyScreen, not RoomsList. SpaceRoomListAction::DetailedChildren { .. } - | SpaceRoomListAction::TopLevelSpaceDetails(_) => {} + | SpaceRoomListAction::TopLevelSpaceDetails(_) => { } } } /// Returns whether the given target room or space is indirectly within the given parent space. /// /// This will recursively search all nested spaces within the given `parent_space`. - fn is_room_indirectly_in_space( - &self, - parent_space: &OwnedRoomId, - target: &OwnedRoomId, - ) -> bool { + fn is_room_indirectly_in_space(&self, parent_space: &OwnedRoomId, target: &OwnedRoomId) -> bool { if let Some(smv) = self.space_map.get(parent_space) { if smv.direct_child_rooms.contains(target) { return true; @@ -1318,14 +1186,12 @@ impl Widget for RoomsList { let props = RoomsListScopeProps { was_scrolling: self.view.portal_list(cx, ids!(list)).was_scrolling(), }; - let rooms_list_actions = cx.capture_actions(|cx| { - self.view - .handle_event(cx, event, &mut Scope::with_props(&props)) - }); + let rooms_list_actions = cx.capture_actions( + |cx| self.view.handle_event(cx, event, &mut Scope::with_props(&props)) + ); for action in rooms_list_actions { // Handle a regular room (joined or invited) being clicked. - if let RoomsListEntryAction::PrimaryClicked(room_id) = action.as_widget_action().cast() - { + if let RoomsListEntryAction::PrimaryClicked(room_id) = action.as_widget_action().cast() { let new_selected_room = if let Some(jr) = self.all_joined_rooms.get(&room_id) { SelectedRoom::JoinedRoom { room_name_id: jr.room_name_id.clone(), @@ -1341,15 +1207,13 @@ impl Widget for RoomsList { self.current_active_room = Some(new_selected_room.clone()); cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::Selected(new_selected_room), ); self.redraw(cx); } // Handle a room being right-clicked or long-pressed by opening the room context menu. - else if let RoomsListEntryAction::SecondaryClicked(room_id, pos) = - action.as_widget_action().cast() - { + else if let RoomsListEntryAction::SecondaryClicked(room_id, pos) = action.as_widget_action().cast() { // Determine details for the context menu let Some(jr) = self.all_joined_rooms.get(&room_id) else { error!("BUG: couldn't find right-clicked room details for room {room_id}"); @@ -1365,35 +1229,29 @@ impl Widget for RoomsList { is_bot_bound: app_state.bot_settings.is_room_bound(&room_id), }; cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::OpenRoomContextMenu { details, pos }, ); } // Handle the space lobby being clicked. else if let Some(SpaceLobbyAction::SpaceLobbyEntryClicked) = action.downcast_ref() { - let Some(space_name_id) = self.selected_space.clone() else { - continue; - }; + let Some(space_name_id) = self.selected_space.clone() else { continue }; let new_selected_space = SelectedRoom::Space { space_name_id }; self.current_active_room = Some(new_selected_space.clone()); cx.widget_action( - self.widget_uid(), + self.widget_uid(), RoomsListAction::Selected(new_selected_space), ); self.redraw(cx); } // Handle a collapsible header being clicked. - else if let CollapsibleHeaderAction::Toggled { category } = - 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; + self.is_invited_rooms_header_expanded = !self.is_invited_rooms_header_expanded; } HeaderCategory::RegularRooms => { - self.is_regular_rooms_header_expanded = - !self.is_regular_rooms_header_expanded; + self.is_regular_rooms_header_expanded = !self.is_regular_rooms_header_expanded; } HeaderCategory::DirectRooms => { self.is_direct_rooms_header_expanded = @@ -1418,73 +1276,47 @@ impl Widget for RoomsList { if let Some(NavigationBarAction::TabSelected(tab)) = action.downcast_ref() { match tab { SelectedTab::Space { space_name_id } => { - if self - .selected_space - .as_ref() - .is_some_and(|s| s.room_id() == space_name_id.room_id()) - { + if self.selected_space.as_ref().is_some_and(|s| s.room_id() == space_name_id.room_id()) { continue; } self.selected_space = Some(space_name_id.clone()); - self.view - .space_lobby_entry(cx, ids!(space_lobby_entry)) - .set_visible(cx, true); + self.view.space_lobby_entry(cx, ids!(space_lobby_entry)).set_visible(cx, true); // If we don't have the full list of children in this newly-selected space, then fetch it. - let (is_fully_paginated, parent_chain) = self - .space_map + let (is_fully_paginated, parent_chain) = self.space_map .get(space_name_id.room_id()) .map(|smv| (smv.is_fully_paginated, smv.parent_chain.clone())) .unwrap_or_default(); if !is_fully_paginated { let Some(sender) = self.space_request_sender.as_ref() else { - error!( - "BUG: RoomsList: no space request sender was available." - ); + error!("BUG: RoomsList: no space request sender was available."); continue; }; - if sender - .send(SpaceRequest::SubscribeToSpaceRoomList { - space_id: space_name_id.room_id().clone(), - parent_chain: parent_chain.clone(), - }) - .is_err() - { - error!( - "BUG: RoomsList: failed to send SubscribeToSpaceRoomList request for space {space_name_id}." - ); + if sender.send(SpaceRequest::SubscribeToSpaceRoomList { + space_id: space_name_id.room_id().clone(), + parent_chain: parent_chain.clone(), + }).is_err() { + error!("BUG: RoomsList: failed to send SubscribeToSpaceRoomList request for space {space_name_id}."); } - if sender - .send(SpaceRequest::PaginateSpaceRoomList { - space_id: space_name_id.room_id().clone(), - parent_chain: parent_chain.clone(), - }) - .is_err() - { - error!( - "BUG: RoomsList: failed to send PaginateSpaceRoomList request for space {space_name_id}." - ); + if sender.send(SpaceRequest::PaginateSpaceRoomList { + space_id: space_name_id.room_id().clone(), + parent_chain: parent_chain.clone(), + }).is_err() { + error!("BUG: RoomsList: failed to send PaginateSpaceRoomList request for space {space_name_id}."); } - if sender - .send(SpaceRequest::GetChildren { - space_id: space_name_id.room_id().clone(), - parent_chain, - }) - .is_err() - { - error!( - "BUG: RoomsList: failed to send GetRooms request for space {space_name_id}." - ); + if sender.send(SpaceRequest::GetChildren { + space_id: space_name_id.room_id().clone(), + parent_chain, + }).is_err() { + error!("BUG: RoomsList: failed to send GetRooms request for space {space_name_id}."); } } } _ => { self.selected_space = None; - self.view - .space_lobby_entry(cx, ids!(space_lobby_entry)) - .set_visible(cx, false); + self.view.space_lobby_entry(cx, ids!(space_lobby_entry)).set_visible(cx, false); } } @@ -1543,31 +1375,25 @@ impl Widget for RoomsList { let total_count = status_label_id + 1; let get_invited_room_id = |portal_list_index: usize| { - portal_list_index - .checked_sub(self.invited_rooms_indexes.first_room_index) - .and_then(|index| { - self.is_invited_rooms_header_expanded - .then(|| self.displayed_invited_rooms.get(index)) - }) + portal_list_index.checked_sub(self.invited_rooms_indexes.first_room_index) + .and_then(|index| self.is_invited_rooms_header_expanded + .then(|| self.displayed_invited_rooms.get(index)) + ) .flatten() }; let get_direct_room_id = |portal_list_index: usize| { - portal_list_index - .checked_sub(self.direct_rooms_indexes.first_room_index) - .and_then(|index| { - self.is_direct_rooms_header_expanded - .then(|| self.displayed_direct_rooms.get(index)) - }) + portal_list_index.checked_sub(self.direct_rooms_indexes.first_room_index) + .and_then(|index| self.is_direct_rooms_header_expanded + .then(|| self.displayed_direct_rooms.get(index)) + ) .flatten() }; let get_regular_room_id = |portal_list_index: usize| { - portal_list_index - .checked_sub(self.regular_rooms_indexes.first_room_index) - .and_then(|index| { - self.is_regular_rooms_header_expanded - .then(|| self.displayed_regular_rooms.get(index)) - }) + portal_list_index.checked_sub(self.regular_rooms_indexes.first_room_index) + .and_then(|index| self.is_regular_rooms_header_expanded + .then(|| self.displayed_regular_rooms.get(index)) + ) .flatten() }; @@ -1579,9 +1405,7 @@ impl Widget for RoomsList { portal_list_ref.set_first_id_and_scroll(status_label_id, 0.0); } // We only care about drawing the portal list. - let Some(mut list) = portal_list_ref.borrow_mut() else { - continue; - }; + let Some(mut list) = portal_list_ref.borrow_mut() else { continue }; list.set_item_range(cx, 0, total_count); @@ -1597,13 +1421,12 @@ impl Widget for RoomsList { self.displayed_invited_rooms.len() as u64, ); item.draw_all(cx, &mut scope); - } else if let Some(invited_room_id) = get_invited_room_id(portal_list_index) { + } + else if let Some(invited_room_id) = get_invited_room_id(portal_list_index) { let mut invited_rooms_mut = self.invited_rooms.borrow_mut(); if let Some(invited_room) = invited_rooms_mut.get_mut(invited_room_id) { let item = list.item(cx, portal_list_index, id!(rooms_list_entry)); - invited_room.is_selected = self - .current_active_room - .as_ref() + invited_room.is_selected = self.current_active_room.as_ref() .is_some_and(|sel_room| sel_room.room_id() == invited_room_id); // Pass the room info down to the RoomsListEntry widget via Scope. scope = Scope::with_props(&*invited_room); @@ -1612,7 +1435,8 @@ impl Widget for RoomsList { list.item(cx, portal_list_index, id!(empty)) .draw_all(cx, &mut scope); } - } else if self.direct_rooms_indexes.header_index == Some(portal_list_index) { + } + else if self.direct_rooms_indexes.header_index == Some(portal_list_index) { let item = list.item(cx, portal_list_index, id!(collapsible_header)); item.as_collapsible_header().set_details( cx, @@ -1623,12 +1447,11 @@ impl Widget for RoomsList { // NOTE: this might be really slow, so we should maintain a running total of mentions in this struct ); item.draw_all(cx, &mut scope); - } else if let Some(direct_room_id) = get_direct_room_id(portal_list_index) { + } + else if let Some(direct_room_id) = get_direct_room_id(portal_list_index) { if let Some(direct_room) = self.all_joined_rooms.get_mut(direct_room_id) { let item = list.item(cx, portal_list_index, id!(rooms_list_entry)); - direct_room.is_selected = self - .current_active_room - .as_ref() + direct_room.is_selected = self.current_active_room.as_ref() .is_some_and(|sel_room| sel_room.room_id() == direct_room_id); // Paginate the room if it hasn't been paginated yet. @@ -1649,7 +1472,8 @@ impl Widget for RoomsList { list.item(cx, portal_list_index, id!(empty)) .draw_all(cx, &mut scope); } - } else if self.regular_rooms_indexes.header_index == Some(portal_list_index) { + } + else if self.regular_rooms_indexes.header_index == Some(portal_list_index) { let item = list.item(cx, portal_list_index, id!(collapsible_header)); item.as_collapsible_header().set_details( cx, @@ -1660,12 +1484,11 @@ impl Widget for RoomsList { // NOTE: this might be really slow, so we should maintain a running total of mentions in this struct ); item.draw_all(cx, &mut scope); - } else if let Some(regular_room_id) = get_regular_room_id(portal_list_index) { + } + else if let Some(regular_room_id) = get_regular_room_id(portal_list_index) { if let Some(regular_room) = self.all_joined_rooms.get_mut(regular_room_id) { let item = list.item(cx, portal_list_index, id!(rooms_list_entry)); - regular_room.is_selected = self - .current_active_room - .as_ref() + regular_room.is_selected = self.current_active_room.as_ref() .is_some_and(|sel_room| sel_room.room_id() == regular_room_id); // Paginate the room if it hasn't been paginated yet. @@ -1683,8 +1506,7 @@ impl Widget for RoomsList { scope = Scope::with_props(&*regular_room); item.draw_all(cx, &mut scope); } else { - list.item(cx, portal_list_index, id!(empty)) - .draw_all(cx, &mut scope); + list.item(cx, portal_list_index, id!(empty)).draw_all(cx, &mut scope); } } // Draw the status label as the bottom entry. @@ -1708,9 +1530,7 @@ impl Widget for RoomsList { impl RoomsListRef { /// See [`RoomsList::all_rooms_loaded()`]. pub fn all_rooms_loaded(&self) -> bool { - let Some(inner) = self.borrow() else { - return false; - }; + let Some(inner) = self.borrow() else { return false; }; inner.all_rooms_loaded() } @@ -1727,17 +1547,14 @@ impl RoomsListRef { /// Returns the name of the given room, if it is known and loaded. pub fn get_room_name(&self, room_id: &OwnedRoomId) -> Option { let inner = self.borrow()?; - inner - .all_joined_rooms + inner.all_joined_rooms .get(room_id) .map(|jr| jr.room_name_id.clone()) - .or_else(|| { - inner - .invited_rooms - .borrow() + .or_else(|| + inner.invited_rooms.borrow() .get(room_id) .map(|ir| ir.room_name_id.clone()) - }) + ) } /// Returns the currently-selected space (the one selected in the SpacesBar). @@ -1747,10 +1564,7 @@ impl RoomsListRef { /// Same as [`Self::get_selected_space()`], but only returns the space ID. pub fn get_selected_space_id(&self) -> Option { - self.borrow()? - .selected_space - .as_ref() - .map(|ss| ss.room_id().clone()) + self.borrow()?.selected_space.as_ref().map(|ss| ss.room_id().clone()) } /// Returns a clone of the space request sender channel, if available. diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index 292ebc23b..bf4563d65 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -15,33 +15,12 @@ //! * A "cannot-send-message" notice, which is shown if the user cannot send messages to the room. //! + use makepad_widgets::*; use matrix_sdk::room::reply::{EnforceThread, Reply}; use matrix_sdk_ui::timeline::{EmbeddedEvent, EventTimelineItem, TimelineEventItemId}; -use ruma::{ - events::room::message::{ - LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent, - }, - OwnedRoomId, -}; -use crate::{ - home::{ - editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, - location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, - room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, - tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}, - }, - location::init_location_subscriber, - shared::{ - avatar::AvatarWidgetRefExt, - html_or_plaintext::HtmlOrPlaintextWidgetRefExt, - mentionable_text_input::MentionableTextInputWidgetExt, - popup_list::{PopupKind, enqueue_popup_notification}, - styles::*, - }, - sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, - utils, -}; +use ruma::{events::room::message::{LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent}, OwnedRoomId}; +use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}}, location::init_location_subscriber, shared::{avatar::AvatarWidgetRefExt, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; script_mod! { use mod.prelude.widgets.* @@ -182,18 +161,14 @@ script_mod! { /// Main component for message input with @mention support #[derive(Script, ScriptHook, Widget)] pub struct RoomInputBar { - #[source] - source: ScriptObjectRef, - #[deref] - view: View, + #[source] source: ScriptObjectRef, + #[deref] view: View, /// Whether the `ReplyingPreview` was visible when the `EditingPane` was shown. /// If true, when the `EditingPane` gets hidden, we need to re-show the `ReplyingPreview`. - #[rust] - was_replying_preview_visible: bool, + #[rust] was_replying_preview_visible: bool, /// Info about the message event that the user is currently replying to, if any. - #[rust] - replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, + #[rust] replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, } impl Widget for RoomInputBar { @@ -203,21 +178,14 @@ impl Widget for RoomInputBar { .get::() .expect("BUG: RoomScreenProps should be available in Scope::props for RoomInputBar"); - match event.hits( - cx, - self.view - .view(cx, ids!(replying_preview.reply_preview_content)) - .area(), - ) { + match event.hits(cx, self.view.view(cx, ids!(replying_preview.reply_preview_content)).area()) { // If the hit occurred on the replying message preview, jump to it. Hit::FingerUp(fe) if fe.is_over && fe.is_primary_hit() && fe.was_tap() => { - if let Some(event_id) = self - .replying_to - .as_ref() + if let Some(event_id) = self.replying_to.as_ref() .and_then(|(event_tl_item, _)| event_tl_item.event_id().map(ToOwned::to_owned)) { cx.widget_action( - room_screen_props.room_screen_widget_uid, + room_screen_props.room_screen_widget_uid, MessageAction::JumpToEvent(event_id), ); } else { @@ -273,56 +241,40 @@ impl RoomInputBar { None, ); } - self.view - .location_preview(cx, ids!(location_preview)) - .show(); + self.view.location_preview(cx, ids!(location_preview)).show(); self.redraw(cx); } // Handle the send location button being clicked. - if self - .button(cx, ids!(location_preview.send_location_button)) - .clicked(actions) - { + if self.button(cx, ids!(location_preview.send_location_button)).clicked(actions) { let location_preview = self.location_preview(cx, ids!(location_preview)); if let Some((coords, _system_time_opt)) = location_preview.get_current_data() { - let geo_uri = format!( - "{}{},{}", - utils::GEO_URI_SCHEME, - coords.latitude, - coords.longitude + let geo_uri = format!("{}{},{}", utils::GEO_URI_SCHEME, coords.latitude, coords.longitude); + let message = RoomMessageEventContent::new( + MessageType::Location( + LocationMessageEventContent::new(geo_uri.clone(), geo_uri) + ) ); - let message = RoomMessageEventContent::new(MessageType::Location( - LocationMessageEventContent::new(geo_uri.clone(), geo_uri), - )); - let replied_to = self - .replying_to - .take() - .and_then(|(event_tl_item, _emb)| { - event_tl_item.event_id().map(|event_id| { - let enforce_thread = if room_screen_props - .timeline_kind - .thread_root_event_id() - .is_some() - { - EnforceThread::Threaded(ReplyWithinThread::Yes) - } else { - EnforceThread::MaybeThreaded - }; - Reply { - event_id: event_id.to_owned(), - enforce_thread, - } - }) + let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| + event_tl_item.event_id().map(|event_id| { + let enforce_thread = if room_screen_props.timeline_kind.thread_root_event_id().is_some() { + EnforceThread::Threaded(ReplyWithinThread::Yes) + } else { + EnforceThread::MaybeThreaded + }; + Reply { + event_id: event_id.to_owned(), + enforce_thread, + } }) - .or_else(|| { - room_screen_props.timeline_kind.thread_root_event_id().map( - |thread_root_event_id| Reply { - event_id: thread_root_event_id.clone(), - enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), - }, - ) - }); + ).or_else(|| + room_screen_props.timeline_kind.thread_root_event_id().map(|thread_root_event_id| + Reply { + event_id: thread_root_event_id.clone(), + enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), + } + ) + ); submit_async_request(MatrixRequest::SendMessage { timeline_kind: room_screen_props.timeline_kind.clone(), message, @@ -339,9 +291,7 @@ impl RoomInputBar { // Handle the send message button being clicked or Cmd/Ctrl + Return being pressed. if self.button(cx, ids!(send_message_button)).clicked(actions) - || text_input - .returned(actions) - .is_some_and(|(_, m)| m.is_primary()) + || text_input.returned(actions).is_some_and(|(_, m)| m.is_primary()) { let entered_text = mentionable_text_input.text().trim().to_string(); if !entered_text.is_empty() { @@ -356,36 +306,27 @@ impl RoomInputBar { self.redraw(cx); return; } - let message = mentionable_text_input.create_message_with_mentions(&entered_text); - let replied_to = self - .replying_to - .take() - .and_then(|(event_tl_item, _emb)| { - event_tl_item.event_id().map(|event_id| { - let enforce_thread = if room_screen_props - .timeline_kind - .thread_root_event_id() - .is_some() - { - EnforceThread::Threaded(ReplyWithinThread::Yes) - } else { - EnforceThread::MaybeThreaded - }; - Reply { - event_id: event_id.to_owned(), - enforce_thread, - } - }) + let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| + event_tl_item.event_id().map(|event_id| { + let enforce_thread = if room_screen_props.timeline_kind.thread_root_event_id().is_some() { + EnforceThread::Threaded(ReplyWithinThread::Yes) + } else { + EnforceThread::MaybeThreaded + }; + Reply { + event_id: event_id.to_owned(), + enforce_thread, + } }) - .or_else(|| { - room_screen_props.timeline_kind.thread_root_event_id().map( - |thread_root_event_id| Reply { - event_id: thread_root_event_id.clone(), - enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), - }, - ) - }); + ).or_else(|| + room_screen_props.timeline_kind.thread_root_event_id().map(|thread_root_event_id| + Reply { + event_id: thread_root_event_id.clone(), + enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No), + } + ) + ); submit_async_request(MatrixRequest::SendMessage { timeline_kind: room_screen_props.timeline_kind.clone(), message, @@ -419,29 +360,18 @@ impl RoomInputBar { if is_text_input_empty { if let Some(KeyEvent { key_code: KeyCode::ArrowUp, - modifiers: - KeyModifiers { - shift: false, - control: false, - alt: false, - logo: false, - }, + modifiers: KeyModifiers { shift: false, control: false, alt: false, logo: false }, .. - }) = text_input.key_down_unhandled(actions) - { + }) = text_input.key_down_unhandled(actions) { cx.widget_action( - room_screen_props.room_screen_widget_uid, + room_screen_props.room_screen_widget_uid, MessageAction::EditLatest, ); } } // If the EditingPane has been hidden, handle that. - if self - .view - .editing_pane(cx, ids!(editing_pane)) - .was_hidden(actions) - { + if self.view.editing_pane(cx, ids!(editing_pane)).was_hidden(actions) { self.on_editing_pane_hidden(cx); } } @@ -489,15 +419,13 @@ impl RoomInputBar { // 2. Hide other views that are irrelevant to a reply, e.g., // the `EditingPane` would improperly cover up the ReplyPreview. - self.editing_pane(cx, ids!(editing_pane)) - .force_reset_hide(cx); + self.editing_pane(cx, ids!(editing_pane)).force_reset_hide(cx); self.on_editing_pane_hidden(cx); // 3. Automatically focus the keyboard on the message input box // so that the user can immediately start typing their reply // without having to manually click on the message input box. if grab_key_focus { - self.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)) - .set_key_focus(cx); + self.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)).set_key_focus(cx); } self.button(cx, ids!(cancel_reply_button)).reset_hover(cx); self.redraw(cx); @@ -527,9 +455,7 @@ impl RoomInputBar { let replying_preview = self.view.view(cx, ids!(replying_preview)); self.was_replying_preview_visible = replying_preview.visible(); replying_preview.set_visible(cx, false); - self.view - .location_preview(cx, ids!(location_preview)) - .clear(); + self.view.location_preview(cx, ids!(location_preview)).clear(); let editing_pane = self.view.editing_pane(cx, ids!(editing_pane)); match behavior { @@ -551,14 +477,12 @@ impl RoomInputBar { // Same goes for the replying_preview, if it was previously shown. self.view.view(cx, ids!(input_bar)).set_visible(cx, true); if self.was_replying_preview_visible && self.replying_to.is_some() { - self.view - .view(cx, ids!(replying_preview)) - .set_visible(cx, true); + self.view.view(cx, ids!(replying_preview)).set_visible(cx, true); } self.redraw(cx); // We don't need to do anything with the editing pane itself here, // because it has already been hidden by the time this function gets called. - } + } /// Updates (populates and shows or hides) this room's tombstone footer /// based on the given successor room details. @@ -576,10 +500,7 @@ impl RoomInputBar { input_bar.set_visible(cx, false); } else { tombstone_footer.hide(cx); - if !self - .editing_pane(cx, ids!(editing_pane)) - .is_currently_shown(cx) - { + if !self.editing_pane(cx, ids!(editing_pane)).is_currently_shown(cx) { input_bar.set_visible(cx, true); } } @@ -602,8 +523,6 @@ impl RoomInputBar { }); } - /// Intercepts `/bot` commands and opens the room-level app service actions UI instead - /// of sending the raw command text into the room. fn try_handle_bot_shortcut( &mut self, cx: &mut Cx, @@ -614,11 +533,7 @@ impl RoomInputBar { return false; } - let popup_message = if room_screen_props - .timeline_kind - .thread_root_event_id() - .is_some() - { + let popup_message = if room_screen_props.timeline_kind.thread_root_event_id().is_some() { Some(( "Bot commands are only supported in the main room timeline.", PopupKind::Warning, @@ -657,14 +572,14 @@ impl RoomInputBar { /// Updates the visibility of select views based on the user's new power levels. /// /// This will show/hide the `input_bar` and the `can_not_send_message_notice` views. - fn update_user_power_levels(&mut self, cx: &mut Cx, user_power_levels: UserPowerLevels) { + fn update_user_power_levels( + &mut self, + cx: &mut Cx, + user_power_levels: UserPowerLevels, + ) { let can_send = user_power_levels.can_send_message(); - self.view - .view(cx, ids!(input_bar)) - .set_visible(cx, can_send); - self.view - .view(cx, ids!(can_not_send_message_notice)) - .set_visible(cx, !can_send); + self.view.view(cx, ids!(input_bar)).set_visible(cx, can_send); + self.view.view(cx, ids!(can_not_send_message_notice)).set_visible(cx, !can_send); } /// Returns true if the TSP signing checkbox is checked, false otherwise. @@ -685,9 +600,7 @@ impl RoomInputBarRef { replying_to: (EventTimelineItem, EmbeddedEvent), timeline_kind: &TimelineKind, ) { - let Some(mut inner) = self.borrow_mut() else { - return; - }; + let Some(mut inner) = self.borrow_mut() else { return }; inner.show_replying_to(cx, replying_to, timeline_kind, true); } @@ -698,9 +611,7 @@ impl RoomInputBarRef { event_tl_item: EventTimelineItem, timeline_kind: TimelineKind, ) { - let Some(mut inner) = self.borrow_mut() else { - return; - }; + let Some(mut inner) = self.borrow_mut() else { return }; inner.show_editing_pane( cx, ShowEditingPaneBehavior::ShowNew { event_tl_item }, @@ -711,10 +622,12 @@ impl RoomInputBarRef { /// Updates the visibility of select views based on the user's new power levels. /// /// This will show/hide the `input_bar` and the `can_not_send_message_notice` views. - pub fn update_user_power_levels(&self, cx: &mut Cx, user_power_levels: UserPowerLevels) { - let Some(mut inner) = self.borrow_mut() else { - return; - }; + pub fn update_user_power_levels( + &self, + cx: &mut Cx, + user_power_levels: UserPowerLevels, + ) { + let Some(mut inner) = self.borrow_mut() else { return }; inner.update_user_power_levels(cx, user_power_levels); } @@ -725,9 +638,7 @@ impl RoomInputBarRef { tombstoned_room_id: &OwnedRoomId, successor_room_details: Option<&SuccessorRoomDetails>, ) { - let Some(mut inner) = self.borrow_mut() else { - return; - }; + let Some(mut inner) = self.borrow_mut() else { return }; inner.update_tombstone_footer(cx, tombstoned_room_id, successor_room_details); } @@ -739,36 +650,22 @@ impl RoomInputBarRef { timeline_event_item_id: TimelineEventItemId, edit_result: Result<(), matrix_sdk_ui::timeline::Error>, ) { - let Some(inner) = self.borrow_mut() else { - return; - }; - inner - .editing_pane(cx, ids!(editing_pane)) + let Some(inner) = self.borrow_mut() else { return }; + inner.editing_pane(cx, ids!(editing_pane)) .handle_edit_result(cx, timeline_event_item_id, edit_result); } /// Save a snapshot of the UI state of this `RoomInputBar`. pub fn save_state(&self) -> RoomInputBarState { - let Some(inner) = self.borrow() else { - return Default::default(); - }; + let Some(inner) = self.borrow() else { return Default::default() }; // Clear the location preview. We don't save this state because the // current location might change by the next time the user opens this same room. - inner - .child_by_path(ids!(location_preview)) - .as_location_preview() - .clear(); + inner.child_by_path(ids!(location_preview)).as_location_preview().clear(); RoomInputBarState { was_replying_preview_visible: inner.was_replying_preview_visible, replying_to: inner.replying_to.clone(), - editing_pane_state: inner - .child_by_path(ids!(editing_pane)) - .as_editing_pane() - .save_state(), - text_input_state: inner - .child_by_path(ids!(input_bar.mentionable_text_input.text_input)) - .as_text_input() - .save_state(), + editing_pane_state: inner.child_by_path(ids!(editing_pane)).as_editing_pane().save_state(), + text_input_state: inner.child_by_path(ids!(input_bar.mentionable_text_input.text_input)).as_text_input().save_state(), } } @@ -781,9 +678,7 @@ impl RoomInputBarRef { user_power_levels: UserPowerLevels, tombstone_info: Option<&SuccessorRoomDetails>, ) { - let Some(mut inner) = self.borrow_mut() else { - return; - }; + let Some(mut inner) = self.borrow_mut() else { return }; let RoomInputBarState { was_replying_preview_visible, text_input_state, @@ -799,8 +694,7 @@ impl RoomInputBarRef { inner.update_user_power_levels(cx, user_power_levels); // 1. Restore the state of the TextInput within the MentionableTextInput. - inner - .text_input(cx, ids!(input_bar.mentionable_text_input.text_input)) + inner.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)) .restore_state(cx, text_input_state); // 2. Restore the state of the replying-to preview. @@ -819,9 +713,7 @@ impl RoomInputBarRef { timeline_kind.clone(), ); } else { - inner - .editing_pane(cx, ids!(editing_pane)) - .force_reset_hide(cx); + inner.editing_pane(cx, ids!(editing_pane)).force_reset_hide(cx); inner.on_editing_pane_hidden(cx); } @@ -847,7 +739,9 @@ pub struct RoomInputBarState { /// Defines what to do when showing the `EditingPane` from the `RoomInputBar`. enum ShowEditingPaneBehavior { /// Show a new edit session, e.g., when first clicking "edit" on a message. - ShowNew { event_tl_item: EventTimelineItem }, + ShowNew { + event_tl_item: EventTimelineItem, + }, /// Restore the state of an `EditingPane` that already existed, e.g., when /// reopening a room that had an `EditingPane` open when it was closed. RestoreExisting { diff --git a/src/settings/settings_screen.rs b/src/settings/settings_screen.rs index 38246560c..79c690997 100644 --- a/src/settings/settings_screen.rs +++ b/src/settings/settings_screen.rs @@ -1,3 +1,4 @@ + use makepad_widgets::*; use crate::{ @@ -92,11 +93,11 @@ script_mod! { } } + /// The top-level widget showing all app and user settings/preferences. #[derive(Script, ScriptHook, Widget)] pub struct SettingsScreen { - #[deref] - view: View, + #[deref] view: View, } impl Widget for SettingsScreen { @@ -113,15 +114,16 @@ impl Widget for SettingsScreen { matches!( event, Event::Actions(actions) if self.button(cx, ids!(close_button)).clicked(actions) - ) || event.back_pressed() - || match event.hits(cx, area) { - Hit::KeyUp(key) => key.key_code == KeyCode::Escape, - Hit::FingerDown(_fde) => { - cx.set_key_focus(area); - false - } - _ => false, + ) + || event.back_pressed() + || match event.hits(cx, area) { + Hit::KeyUp(key) => key.key_code == KeyCode::Escape, + Hit::FingerDown(_fde) => { + cx.set_key_focus(area); + false } + _ => false, + } }; if close_pane { cx.action(NavigationBarAction::CloseSettings); @@ -139,30 +141,26 @@ impl Widget for SettingsScreen { match action.downcast_ref() { Some(CreateWalletModalAction::Open) => { use crate::tsp::create_wallet_modal::CreateWalletModalWidgetExt; - self.view - .create_wallet_modal(cx, ids!(create_wallet_modal_inner)) - .show(cx); + self.view.create_wallet_modal(cx, ids!(create_wallet_modal_inner)).show(cx); self.view.modal(cx, ids!(create_wallet_modal)).open(cx); } Some(CreateWalletModalAction::Close) => { self.view.modal(cx, ids!(create_wallet_modal)).close(cx); } - None => {} + None => { } } // Handle the create DID modal being opened or closed. match action.downcast_ref() { Some(CreateDidModalAction::Open) => { use crate::tsp::create_did_modal::CreateDidModalWidgetExt; - self.view - .create_did_modal(cx, ids!(create_did_modal_inner)) - .show(cx); + self.view.create_did_modal(cx, ids!(create_did_modal_inner)).show(cx); self.view.modal(cx, ids!(create_did_modal)).open(cx); } Some(CreateDidModalAction::Close) => { self.view.modal(cx, ids!(create_did_modal)).close(cx); } - None => {} + None => { } } } } @@ -185,12 +183,8 @@ impl SettingsScreen { error!("Failed to get own profile for settings screen."); return; }; - self.view - .account_settings(cx, ids!(account_settings)) - .populate(cx, profile); - self.view - .bot_settings(cx, ids!(bot_settings)) - .populate(cx, bot_settings); + self.view.account_settings(cx, ids!(account_settings)).populate(cx, profile); + self.view.bot_settings(cx, ids!(bot_settings)).populate(cx, bot_settings); self.view.button(cx, ids!(close_button)).reset_hover(cx); cx.set_key_focus(self.view.area()); self.redraw(cx); @@ -205,9 +199,7 @@ impl SettingsScreenRef { own_profile: Option, bot_settings: &BotSettingsState, ) { - let Some(mut inner) = self.borrow_mut() else { - return; - }; + let Some(mut inner) = self.borrow_mut() else { return; }; inner.populate(cx, own_profile, bot_settings); } } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 1ffdf7de6..255332260 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -8,110 +8,43 @@ use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ - config::RequestConfig, - encryption::EncryptionSettings, - event_handler::EventHandlerDropGuard, - media::MediaRequestParameters, - room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, - ruma::{ - api::{ - Direction, - client::{ - account::register::v3::Request as RegistrationRequest, - error::ErrorKind, - profile::{AvatarUrl, DisplayName}, - receipt::create_receipt::v3::ReceiptType, - uiaa::{AuthData, AuthType, Dummy}, - }, - }, - events::{ + config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ + api::{Direction, client::{ + account::register::v3::Request as RegistrationRequest, + error::ErrorKind, + profile::{AvatarUrl, DisplayName}, + receipt::create_receipt::v3::ReceiptType, + uiaa::{AuthData, AuthType, Dummy}, + }}, events::{ relation::RelationType, - room::{message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource}, - MessageLikeEventType, StateEventType, - }, - matrix_uri::MatrixId, - EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, - OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, uint, - }, - sliding_sync::VersionBuilder, - Client, ClientBuildError, Error, OwnedServerName, Room, RoomDisplayName, RoomMemberships, - RoomState, SessionChange, SuccessorRoom, + room::{ + message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource + }, MessageLikeEventType, StateEventType + }, matrix_uri::MatrixId, EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomOrAliasId, UserId, uint + }, sliding_sync::VersionBuilder, Client, ClientBuildError, Error, OwnedServerName, Room, RoomDisplayName, RoomMemberships, RoomState, SessionChange, SuccessorRoom }; use matrix_sdk_ui::{ - RoomListService, Timeline, encryption_sync_service, - room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator, filters}, - sync_service::{self, SyncService}, - timeline::{ - LatestEventValue, RoomExt, TimelineEventItemId, TimelineFocus, TimelineItem, - TimelineReadReceiptTracking, TimelineDetails, - }, + RoomListService, Timeline, encryption_sync_service, room_list_service::{RoomListItem, RoomListLoadingState, SyncIndicator, filters}, sync_service::{self, SyncService}, timeline::{LatestEventValue, RoomExt, TimelineEventItemId, TimelineFocus, TimelineItem, TimelineReadReceiptTracking, TimelineDetails} }; use robius_open::Uri; use ruma::{OwnedRoomOrAliasId, RoomId, events::tag::Tags}; use tokio::{ runtime::Handle, - sync::{ - broadcast, - mpsc::{Sender, UnboundedReceiver, UnboundedSender}, - watch, Notify, - }, - task::JoinHandle, - time::error::Elapsed, + sync::{broadcast, mpsc::{Sender, UnboundedReceiver, UnboundedSender}, watch, Notify}, task::JoinHandle, time::error::Elapsed, }; use url::Url; -use std::{ - borrow::Cow, - cmp::{max, min}, - future::Future, - hash::{BuildHasherDefault, DefaultHasher}, - iter::Peekable, - ops::{Deref, DerefMut, Not}, - path::Path, - sync::{Arc, LazyLock, Mutex}, - time::Duration, -}; +use std::{borrow::Cow, cmp::{max, min}, future::Future, hash::{BuildHasherDefault, DefaultHasher}, iter::Peekable, ops::{Deref, DerefMut, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; use std::io; use hashbrown::{HashMap, HashSet}; use crate::{ - app::AppStateAction, - app_data_dir, - avatar_cache::AvatarUpdate, - event_preview::{ - BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item, - }, - home::{ - add_room::KnockResultAction, - invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, - link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, - room_screen::{InviteResultAction, TimelineUpdate}, - rooms_list::{ - self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, - enqueue_rooms_list_update, - }, - rooms_list_header::RoomsListHeaderAction, - tombstone_footer::SuccessorRoomDetails, - }, - login::login_screen::LoginAction, - logout::{ - logout_confirm_modal::LogoutAction, - logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}, - }, - media_cache::{MediaCacheEntry, MediaCacheEntryRef}, - persistence::{self, ClientSessionPersisted, load_app_state}, - profile::{ + app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ + add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails + }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, - }, - room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, - shared::{ - avatar::AvatarState, - html_or_plaintext::MatrixLinkPillState, - jump_to_bottom_button::UnreadMessageCount, - popup_list::{PopupKind, enqueue_popup_notification}, - }, - space_service_sync::space_service_loop, - utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, - verification::add_verification_event_handlers_and_sync_client, + }, room::{FetchedRoomAvatar, FetchedRoomPreview, RoomPreviewAction}, shared::{ + avatar::AvatarState, html_or_plaintext::MatrixLinkPillState, jump_to_bottom_button::UnreadMessageCount, popup_list::{PopupKind, enqueue_popup_notification} + }, space_service_sync::space_service_loop, utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, verification::add_verification_event_handlers_and_sync_client }; #[derive(Parser, Default)] @@ -159,8 +92,7 @@ impl From for Cli { Self { user_id: login.user_id.trim().to_owned(), password: login.password, - homeserver: login - .homeserver + homeserver: login.homeserver .map(|homeserver| homeserver.trim().to_owned()) .filter(|homeserver| !homeserver.is_empty()), proxy: None, @@ -175,8 +107,7 @@ impl From for Cli { Self { user_id: registration.user_id.trim().to_owned(), password: registration.password, - homeserver: registration - .homeserver + homeserver: registration.homeserver .map(|homeserver| homeserver.trim().to_owned()) .filter(|homeserver| !homeserver.is_empty()), proxy: None, @@ -197,8 +128,7 @@ async fn finalize_authenticated_client( fallback_user_id: &str, ) -> Result<(Client, Option)> { if client.matrix_auth().logged_in() { - let logged_in_user_id = client - .user_id() + let logged_in_user_id = client.user_id() .map(ToString::to_string) .unwrap_or_else(|| fallback_user_id.to_owned()); log!("Logged in successfully."); @@ -215,9 +145,7 @@ async fn finalize_authenticated_client( "Authentication succeeded for {fallback_user_id}, but the homeserver did not return a login session." ); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err_msg.clone(), - }); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } } @@ -233,8 +161,7 @@ fn registration_localpart(user_id: &str) -> Result { } let localpart = trimmed.trim_start_matches('@'); - if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) - { + if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) { bail!("Please enter a valid username or full Matrix user ID."); } @@ -341,14 +268,9 @@ async fn reset_runtime_state_for_relogin() { ALL_JOINED_ROOMS.lock().unwrap().clear(); let on_clear_appstate = Arc::new(Notify::new()); - Cx::post_action(LogoutAction::ClearAppState { - on_clear_appstate: on_clear_appstate.clone(), - }); + Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); - if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()) - .await - .is_err() - { + if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()).await.is_err() { warning!("Timed out waiting for UI-side app state cleanup during re-login reset"); } } @@ -360,6 +282,7 @@ fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { ) } + /// Build a new client. async fn build_client( cli: &Cli, @@ -382,13 +305,11 @@ async fn build_client( }; let inferred_homeserver = infer_homeserver_from_user_id(&cli.user_id); - let homeserver_url = cli - .homeserver - .as_deref() + let homeserver_url = cli.homeserver.as_deref() .filter(|homeserver| !homeserver.trim().is_empty()) .or(inferred_homeserver.as_deref()) .unwrap_or("https://matrix-client.matrix.org/"); - // .unwrap_or("https://matrix.org/"); + // .unwrap_or("https://matrix.org/"); let mut builder = Client::builder() .server_name_or_homeserver_url(homeserver_url) @@ -416,11 +337,13 @@ async fn build_client( // Use a 60 second timeout for all requests to the homeserver. // Yes, this is a long timeout, but the standard matrix homeserver is often very slow. - builder = - builder.request_config(RequestConfig::new().timeout(std::time::Duration::from_secs(60))); + builder = builder.request_config( + RequestConfig::new() + .timeout(std::time::Duration::from_secs(60)) + ); let client = builder.build().await?; - let homeserver_url = client.homeserver().to_string(); + let homeserver_url = client.homeserver().to_string(); Ok(( client, ClientSessionPersisted { @@ -436,7 +359,10 @@ async fn build_client( /// This function is used by the login screen to log in to the Matrix server. /// /// Upon success, this function returns the logged-in client and an optional sync token. -async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option)> { +async fn login( + cli: &Cli, + login_request: LoginRequest, +) -> Result<(Client, Option)> { match login_request { LoginRequest::LoginByCli | LoginRequest::LoginByPassword(_) => { let cli = if let LoginRequest::LoginByPassword(login_by_password) = login_request { @@ -459,9 +385,7 @@ async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option if !client.matrix_auth().logged_in() { let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err_msg.clone(), - }); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } finalize_authenticated_client(client, client_session, &cli.user_id).await @@ -517,9 +441,7 @@ async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option register_result.user_id, ); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err_msg.clone(), - }); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } @@ -539,6 +461,7 @@ async fn login(cli: &Cli, login_request: LoginRequest) -> Result<(Client, Option } } + /// Which direction to paginate in. /// /// * `Forwards` will retrieve later events (towards the end of the timeline), @@ -607,6 +530,7 @@ pub type OnLinkPreviewFetchedFn = fn( Option>, ); + /// Actions emitted in response to a [`MatrixRequest::GenerateMatrixLink`]. #[derive(Clone, Debug)] pub enum MatrixLinkAction { @@ -637,7 +561,9 @@ pub enum DirectMessageRoomAction { room_name_id: RoomNameId, }, /// A direct message room didn't exist, and we didn't attempt to create a new one. - DidNotExist { user_profile: UserProfile }, + DidNotExist { + user_profile: UserProfile, + }, /// A direct message room didn't exist, but we successfully created a new one. NewlyCreated { user_profile: UserProfile, @@ -672,10 +598,7 @@ impl TimelineKind { pub fn thread_root_event_id(&self) -> Option<&OwnedEventId> { match self { TimelineKind::MainRoom { .. } => None, - TimelineKind::Thread { - thread_root_event_id, - .. - } => Some(thread_root_event_id), + TimelineKind::Thread { thread_root_event_id, .. } => Some(thread_root_event_id), } } } @@ -683,10 +606,7 @@ impl std::fmt::Display for TimelineKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { TimelineKind::MainRoom { room_id } => write!(f, "MainRoom({})", room_id), - TimelineKind::Thread { - room_id, - thread_root_event_id, - } => { + TimelineKind::Thread { room_id, thread_root_event_id } => { write!(f, "Thread({}, {})", room_id, thread_root_event_id) } } @@ -699,7 +619,9 @@ pub enum MatrixRequest { /// Request from the login screen to log in with the given credentials. Login(LoginRequest), /// Request to logout. - Logout { is_desktop: bool }, + Logout { + is_desktop: bool, + }, /// Request to paginate the older (or newer) events of a room or thread timeline. PaginateTimeline { timeline_kind: TimelineKind, @@ -731,7 +653,9 @@ pub enum MatrixRequest { /// /// Even though it operates on a room itself, this accepts a `TimelineKind` /// in order to be able to send the fetched room member list to a specific timeline UI. - SyncRoomMemberList { timeline_kind: TimelineKind }, + SyncRoomMemberList { + timeline_kind: TimelineKind, + }, /// Request to create a thread timeline focused on the given thread root event in the given room. CreateThreadTimeline { room_id: OwnedRoomId, @@ -756,9 +680,13 @@ pub enum MatrixRequest { bot_user_id: OwnedUserId, }, /// Request to join the given room. - JoinRoom { room_id: OwnedRoomId }, + JoinRoom { + room_id: OwnedRoomId, + }, /// Request to leave the given room. - LeaveRoom { room_id: OwnedRoomId }, + LeaveRoom { + room_id: OwnedRoomId, + }, /// Request to get the actual list of members in a room. /// /// This returns the list of members that can be displayed in the UI. @@ -781,7 +709,9 @@ pub enum MatrixRequest { via: Vec, }, /// Request to fetch the full details (the room preview) of a tombstoned room. - GetSuccessorRoomDetails { tombstoned_room_id: OwnedRoomId }, + GetSuccessorRoomDetails { + tombstoned_room_id: OwnedRoomId, + }, /// Request to create or open a direct message room with the given user. /// /// If there is no existing DM room with the given user, this will create a new DM room @@ -806,7 +736,9 @@ pub enum MatrixRequest { local_only: bool, }, /// Request to fetch the number of unread messages in the given room. - GetNumberUnreadMessages { timeline_kind: TimelineKind }, + GetNumberUnreadMessages { + timeline_kind: TimelineKind, + }, /// Request to set the unread flag for the given room. SetUnreadFlag { room_id: OwnedRoomId, @@ -891,12 +823,15 @@ pub enum MatrixRequest { /// This request does not return a response or notify the UI thread, and /// furthermore, there is no need to send a follow-up request to stop typing /// (though you certainly can do so). - SendTypingNotice { room_id: OwnedRoomId, typing: bool }, + SendTypingNotice { + room_id: OwnedRoomId, + typing: bool, + }, /// Spawn an async task to login to the given Matrix homeserver using the given SSO identity provider ID. /// /// While an SSO request is in flight, the login screen will temporarily prevent the user /// from submitting another redundant request, until this request has succeeded or failed. - SpawnSSOServer { + SpawnSSOServer{ brand: String, homeserver_url: String, identity_provider_id: String, @@ -941,7 +876,9 @@ pub enum MatrixRequest { /// /// Even though it operates on a room itself, this accepts a `TimelineKind` /// in order to be able to send the fetched room member list to a specific timeline UI. - GetRoomPowerLevels { timeline_kind: TimelineKind }, + GetRoomPowerLevels { + timeline_kind: TimelineKind, + }, /// Toggles the given reaction to the given event in the given room. ToggleReaction { timeline_kind: TimelineKind, @@ -967,7 +904,7 @@ pub enum MatrixRequest { /// The MatrixLinkPillInfo::Loaded variant is sent back to the main UI thread via. GetMatrixRoomLinkPillInfo { matrix_id: MatrixId, - via: Vec, + via: Vec }, /// Request to fetch URL preview from the Matrix homeserver. GetUrlPreview { @@ -981,19 +918,19 @@ pub enum MatrixRequest { /// Submits a request to the worker thread to be executed asynchronously. pub fn submit_async_request(req: MatrixRequest) { if let Some(sender) = REQUEST_SENDER.lock().unwrap().as_ref() { - sender - .send(req) + sender.send(req) .expect("BUG: matrix worker task receiver has died!"); } } /// Details of a login request that get submitted within [`MatrixRequest::Login`]. -pub enum LoginRequest { +pub enum LoginRequest{ LoginByPassword(LoginByPassword), Register(RegisterAccount), LoginBySSOSuccess(Client, ClientSessionPersisted), LoginByCli, HomeserverLoginTypesQuery(String), + } /// Information needed to log in to a Matrix homeserver. pub struct LoginByPassword { @@ -1010,6 +947,7 @@ pub struct RegisterAccount { pub homeserver: Option, } + /// The entry point for the worker task that runs Matrix-related operations. /// /// All this task does is wait for [`MatrixRequests`] from the main UI thread @@ -1020,8 +958,7 @@ async fn matrix_worker_task( ) -> Result<()> { log!("Started matrix_worker_task."); // The async tasks that are spawned to subscribe to changes in our own user's read receipts for each timeline. - let mut subscribers_own_user_read_receipts: HashMap> = - HashMap::new(); + let mut subscribers_own_user_read_receipts: HashMap> = HashMap::new(); // The async tasks that are spawned to subscribe to changes in the pinned events for each room. let mut subscribers_pinned_events: HashMap> = HashMap::new(); @@ -1031,7 +968,7 @@ async fn matrix_worker_task( if let Err(e) = login_sender.send(login_request).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to login worker task.", + "BUG: failed to send login request to login worker task." ))); } } @@ -1044,7 +981,7 @@ async fn matrix_worker_task( match logout_with_state_machine(is_desktop).await { Ok(()) => { log!("Logout completed successfully via state machine"); - } + }, Err(e) => { error!("Logout failed: {e:?}"); } @@ -1052,11 +989,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::PaginateTimeline { - timeline_kind, - num_events, - direction, - } => { + MatrixRequest::PaginateTimeline {timeline_kind, num_events, direction} => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("Skipping pagination request for unknown {timeline_kind}"); continue; @@ -1098,11 +1031,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::EditMessage { - timeline_kind, - timeline_event_item_id, - edited_content, - } => { + MatrixRequest::EditMessage { timeline_kind, timeline_event_item_id, edited_content } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for edit request"); continue; @@ -1124,10 +1053,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::FetchDetailsForEvent { - timeline_kind, - event_id, - } => { + MatrixRequest::FetchDetailsForEvent { timeline_kind, event_id } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for fetch details for event request"); continue; @@ -1144,10 +1070,7 @@ async fn matrix_worker_task( // error!("Error fetching details for event {event_id} in {timeline_kind}: {_e:?}"); } } - if sender - .send(TimelineUpdate::EventDetailsFetched { event_id, result }) - .is_err() - { + if sender.send(TimelineUpdate::EventDetailsFetched { event_id, result }).is_err() { error!("Failed to send fetched event details to UI for {timeline_kind}"); } SignalToUI::set_ui_signal(); @@ -1201,27 +1124,17 @@ async fn matrix_worker_task( }); } - MatrixRequest::CreateThreadTimeline { - room_id, - thread_root_event_id, - } => { + MatrixRequest::CreateThreadTimeline { room_id, thread_root_event_id } => { let main_room_timeline = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { - error!( - "BUG: room info not found for create thread timeline request, room {room_id}" - ); + error!("BUG: room info not found for create thread timeline request, room {room_id}"); continue; }; - if room_info - .thread_timelines - .contains_key(&thread_root_event_id) - { + if room_info.thread_timelines.contains_key(&thread_root_event_id) { continue; } - let newly_pending = room_info - .pending_thread_timelines - .insert(thread_root_event_id.clone()); + let newly_pending = room_info.pending_thread_timelines.insert(thread_root_event_id.clone()); if !newly_pending { continue; } @@ -1293,18 +1206,11 @@ async fn matrix_worker_task( }); } - MatrixRequest::Knock { - room_or_alias_id, - reason, - server_names, - } => { + MatrixRequest::Knock { room_or_alias_id, reason, server_names } => { let Some(client) = get_client() else { continue }; let _knock_room_task = Handle::current().spawn(async move { log!("Sending request to knock on room {room_or_alias_id}..."); - match client - .knock(room_or_alias_id.clone(), reason, server_names) - .await - { + match client.knock(room_or_alias_id.clone(), reason, server_names).await { Ok(room) => { let _ = room.display_name().await; // populate this room's display name cache Cx::post_action(KnockResultAction::Knocked { @@ -1328,26 +1234,90 @@ async fn matrix_worker_task( if let Some(room) = client.get_room(&room_id) { log!("Sending request to invite user {user_id} to room {room_id}..."); match room.invite_user_by_id(&user_id).await { - Ok(_) => Cx::post_action(InviteResultAction::Sent { room_id, user_id }), + Ok(_) => Cx::post_action(InviteResultAction::Sent { + room_id, + user_id, + }), Err(error) => Cx::post_action(InviteResultAction::Failed { room_id, user_id, error, }), } - } else { + } + else { error!("Room/Space not found for invite user request {room_id}, {user_id}"); Cx::post_action(InviteResultAction::Failed { room_id, user_id, - error: matrix_sdk::Error::UnknownError( - "Room/Space not found in client's known list.".into(), - ), + error: matrix_sdk::Error::UnknownError("Room/Space not found in client's known list.".into()), }) } }); } + MatrixRequest::SetRoomBotBinding { + room_id, + bound, + bot_user_id, + } => { + let Some(client) = get_client() else { continue }; + let _bot_binding_task = Handle::current().spawn(async move { + let Some(room) = client.get_room(&room_id) else { + let error_message = + format!("Room {room_id} was not found for the bot binding request."); + error!("{error_message}"); + enqueue_popup_notification(error_message, PopupKind::Error, None); + return; + }; + + let membership_result = if bound { + room.invite_user_by_id(&bot_user_id).await + } else { + room.kick_user(&bot_user_id, Some("Robrix app service unbind")).await + }; + + match membership_result { + Ok(()) => { + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: None, + }); + } + Err(error) => { + let membership_exists = + room.get_member_no_sync(&bot_user_id).await.ok().flatten().is_some(); + let should_mark_bound = if bound { membership_exists } else { false }; + + if should_mark_bound != bound { + error!( + "Failed to {} BotFather {bot_user_id} for room {room_id}: {error:?}", + if bound { "invite" } else { "remove" } + ); + enqueue_popup_notification( + format!( + "Failed to {} BotFather {bot_user_id}: {error}", + if bound { "invite" } else { "remove" } + ), + PopupKind::Error, + None, + ); + return; + } + + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: Some(error.to_string()), + }); + } + } + }); + } + MatrixRequest::JoinRoom { room_id } => { let Some(client) = get_client() else { continue }; let _join_room_task = Handle::current().spawn(async move { @@ -1363,7 +1333,8 @@ async fn matrix_worker_task( JoinRoomResultAction::Failed { room_id, error: e } } } - } else { + } + else { match client.join_room_by_id(&room_id).await { Ok(_room) => { log!("Successfully joined new unknown room {room_id}."); @@ -1398,20 +1369,14 @@ async fn matrix_worker_task( error!("BUG: client could not get room with ID {room_id}"); LeaveRoomResultAction::Failed { room_id, - error: matrix_sdk::Error::UnknownError( - "Client couldn't locate room to leave it.".into(), - ), + error: matrix_sdk::Error::UnknownError("Client couldn't locate room to leave it.".into()), } }; Cx::post_action(result_action); }); } - MatrixRequest::GetRoomMembers { - timeline_kind, - memberships, - local_only, - } => { + MatrixRequest::GetRoomMembers { timeline_kind, memberships, local_only } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for get room members request"); continue; @@ -1420,9 +1385,7 @@ async fn matrix_worker_task( let _get_members_task = Handle::current().spawn(async move { let send_update = |members: Vec, source: &str| { log!("{} {} members for {timeline_kind}", source, members.len()); - sender - .send(TimelineUpdate::RoomMembersListFetched { members }) - .unwrap(); + sender.send(TimelineUpdate::RoomMembersListFetched { members }).unwrap(); SignalToUI::set_ui_signal(); }; @@ -1439,10 +1402,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetRoomPreview { - room_or_alias_id, - via, - } => { + MatrixRequest::GetRoomPreview { room_or_alias_id, via } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { let res = fetch_room_preview_with_avatar(&client, &room_or_alias_id, via).await; @@ -1450,80 +1410,12 @@ async fn matrix_worker_task( }); } - MatrixRequest::SetRoomBotBinding { - room_id, - bound, - bot_user_id, - } => { - let Some(client) = get_client() else { continue }; - let _bot_binding_task = Handle::current().spawn(async move { - let Some(room) = client.get_room(&room_id) else { - let error_message = - format!("Room {room_id} was not found for the bot binding request."); - error!("{error_message}"); - enqueue_popup_notification(error_message, PopupKind::Error, None); - return; - }; - - let membership_result = if bound { - room.invite_user_by_id(&bot_user_id).await - } else { - room.kick_user(&bot_user_id, Some("Robrix app service unbind")).await - }; - - match membership_result { - Ok(()) => { - Cx::post_action(AppStateAction::BotRoomBindingUpdated { - room_id, - bound, - bot_user_id: Some(bot_user_id), - warning: None, - }); - } - Err(error) => { - let membership_exists = room - .get_member_no_sync(&bot_user_id) - .await - .ok() - .flatten() - .is_some(); - let should_mark_bound = if bound { membership_exists } else { false }; - - if should_mark_bound != bound { - error!( - "Failed to {} BotFather {bot_user_id} for room {room_id}: {error:?}", - if bound { "invite" } else { "remove" } - ); - enqueue_popup_notification( - format!( - "Failed to {} BotFather {bot_user_id}: {error}", - if bound { "invite" } else { "remove" } - ), - PopupKind::Error, - None, - ); - return; - } - - Cx::post_action(AppStateAction::BotRoomBindingUpdated { - room_id, - bound, - bot_user_id: Some(bot_user_id), - warning: Some(error.to_string()), - }); - } - } - }); - } - MatrixRequest::GetSuccessorRoomDetails { tombstoned_room_id } => { let Some(client) = get_client() else { continue }; let (sender, successor_room) = { let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&tombstoned_room_id) else { - error!( - "BUG: tombstoned room {tombstoned_room_id} info not found for get successor room details request" - ); + error!("BUG: tombstoned room {tombstoned_room_id} info not found for get successor room details request"); continue; }; ( @@ -1539,10 +1431,7 @@ async fn matrix_worker_task( ); } - MatrixRequest::OpenOrCreateDirectMessage { - user_profile, - allow_create, - } => { + MatrixRequest::OpenOrCreateDirectMessage { user_profile, allow_create } => { let Some(client) = get_client() else { continue }; let _create_dm_task = Handle::current().spawn(async move { if let Some(room) = client.get_dm_room(&user_profile.user_id) { @@ -1565,7 +1454,7 @@ async fn matrix_worker_task( user_profile, room_name_id: RoomNameId::from_room(&room).await, }); - } + }, Err(error) => { error!("Failed to create DM with {user_profile:?}: {error}"); Cx::post_action(DirectMessageRoomAction::FailedToCreate { @@ -1577,11 +1466,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetUserProfile { - user_id, - room_id, - local_only, - } => { + MatrixRequest::GetUserProfile { user_id, room_id, local_only } => { let Some(client) = get_client() else { continue }; let _fetch_task = Handle::current().spawn(async move { // log!("Sending get user profile request: user: {user_id}, \ @@ -1675,10 +1560,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::SetUnreadFlag { - room_id, - mark_as_unread, - } => { + MatrixRequest::SetUnreadFlag { room_id, mark_as_unread } => { let Some(main_timeline) = get_room_timeline(&room_id) else { log!("BUG: skipping set unread flag request for not-yet-known room {room_id}"); continue; @@ -1687,64 +1569,35 @@ async fn matrix_worker_task( let result = main_timeline.room().set_unread_flag(mark_as_unread).await; match result { Ok(_) => log!("Set unread flag to {} for room {}", mark_as_unread, room_id), - Err(e) => error!( - "Failed to set unread flag to {} for room {}: {:?}", - mark_as_unread, room_id, e - ), + Err(e) => error!("Failed to set unread flag to {} for room {}: {:?}", mark_as_unread, room_id, e), } }); } - MatrixRequest::SetIsFavorite { - room_id, - is_favorite, - } => { + MatrixRequest::SetIsFavorite { room_id, is_favorite } => { let Some(main_timeline) = get_room_timeline(&room_id) else { - log!( - "BUG: skipping set favorite flag request for not-yet-known room {room_id}" - ); + log!("BUG: skipping set favorite flag request for not-yet-known room {room_id}"); continue; }; let _set_favorite_task = Handle::current().spawn(async move { - let result = main_timeline - .room() - .set_is_favourite(is_favorite, None) - .await; + let result = main_timeline.room().set_is_favourite(is_favorite, None).await; match result { Ok(_) => log!("Set favorite to {} for room {}", is_favorite, room_id), - Err(e) => error!( - "Failed to set favorite to {} for room {}: {:?}", - is_favorite, room_id, e - ), + Err(e) => error!("Failed to set favorite to {} for room {}: {:?}", is_favorite, room_id, e), } }); } - MatrixRequest::SetIsLowPriority { - room_id, - is_low_priority, - } => { + MatrixRequest::SetIsLowPriority { room_id, is_low_priority } => { let Some(main_timeline) = get_room_timeline(&room_id) else { - log!( - "BUG: skipping set low priority flag request for not-yet-known room {room_id}" - ); + log!("BUG: skipping set low priority flag request for not-yet-known room {room_id}"); continue; }; let _set_lp_task = Handle::current().spawn(async move { - let result = main_timeline - .room() - .set_is_low_priority(is_low_priority, None) - .await; + let result = main_timeline.room().set_is_low_priority(is_low_priority, None).await; match result { - Ok(_) => log!( - "Set low priority to {} for room {}", - is_low_priority, - room_id - ), - Err(e) => error!( - "Failed to set low priority to {} for room {}: {:?}", - is_low_priority, room_id, e - ), + Ok(_) => log!("Set low priority to {} for room {}", is_low_priority, room_id), + Err(e) => error!("Failed to set low priority to {} for room {}: {:?}", is_low_priority, room_id, e), } }); } @@ -1753,24 +1606,15 @@ async fn matrix_worker_task( let Some(client) = get_client() else { continue }; let _set_avatar_task = Handle::current().spawn(async move { let is_removing = avatar_url.is_none(); - log!( - "Sending request to {} avatar...", - if is_removing { "remove" } else { "set" } - ); + log!("Sending request to {} avatar...", if is_removing { "remove" } else { "set" }); let result = client.account().set_avatar_url(avatar_url.as_deref()).await; match result { Ok(_) => { - log!( - "Successfully {} avatar.", - if is_removing { "removed" } else { "set" } - ); + log!("Successfully {} avatar.", if is_removing { "removed" } else { "set" }); Cx::post_action(AccountDataAction::AvatarChanged(avatar_url)); } Err(e) => { - let err_msg = format!( - "Failed to {} avatar: {e}", - if is_removing { "remove" } else { "set" } - ); + let err_msg = format!("Failed to {} avatar: {e}", if is_removing { "remove" } else { "set" }); Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); } } @@ -1781,87 +1625,57 @@ async fn matrix_worker_task( let Some(client) = get_client() else { continue }; let _set_display_name_task = Handle::current().spawn(async move { let is_removing = new_display_name.is_none(); - log!( - "Sending request to {} display name{}...", + log!("Sending request to {} display name{}...", if is_removing { "remove" } else { "set" }, - new_display_name - .as_ref() - .map(|n| format!(" to '{n}'")) - .unwrap_or_default() + new_display_name.as_ref().map(|n| format!(" to '{n}'")).unwrap_or_default() ); - let result = client - .account() - .set_display_name(new_display_name.as_deref()) - .await; + let result = client.account().set_display_name(new_display_name.as_deref()).await; match result { Ok(_) => { - log!( - "Successfully {} display name.", - if is_removing { "removed" } else { "set" } - ); - Cx::post_action(AccountDataAction::DisplayNameChanged( - new_display_name, - )); + log!("Successfully {} display name.", if is_removing { "removed" } else { "set" }); + Cx::post_action(AccountDataAction::DisplayNameChanged(new_display_name)); } Err(e) => { - let err_msg = format!( - "Failed to {} display name: {e}", - if is_removing { "remove" } else { "set" } - ); + let err_msg = format!("Failed to {} display name: {e}", if is_removing { "remove" } else { "set" }); Cx::post_action(AccountDataAction::DisplayNameChangeFailed(err_msg)); } } }); } - MatrixRequest::GenerateMatrixLink { - room_id, - event_id, - use_matrix_scheme, - join_on_click, - } => { + MatrixRequest::GenerateMatrixLink { room_id, event_id, use_matrix_scheme, join_on_click } => { let Some(client) = get_client() else { continue }; let _gen_link_task = Handle::current().spawn(async move { if let Some(room) = client.get_room(&room_id) { let result = if use_matrix_scheme { if let Some(event_id) = event_id { - room.matrix_event_permalink(event_id) - .await + room.matrix_event_permalink(event_id).await .map(MatrixLinkAction::MatrixUri) } else { - room.matrix_permalink(join_on_click) - .await + room.matrix_permalink(join_on_click).await .map(MatrixLinkAction::MatrixUri) } } else { if let Some(event_id) = event_id { - room.matrix_to_event_permalink(event_id) - .await + room.matrix_to_event_permalink(event_id).await .map(MatrixLinkAction::MatrixToUri) } else { - room.matrix_to_permalink() - .await + room.matrix_to_permalink().await .map(MatrixLinkAction::MatrixToUri) } }; - + match result { Ok(action) => Cx::post_action(action), Err(e) => Cx::post_action(MatrixLinkAction::Error(e.to_string())), } } else { - Cx::post_action(MatrixLinkAction::Error(format!( - "Room {room_id} not found" - ))); + Cx::post_action(MatrixLinkAction::Error(format!("Room {room_id} not found"))); } }); } - MatrixRequest::IgnoreUser { - ignore, - room_member, - room_id, - } => { + MatrixRequest::IgnoreUser { ignore, room_member, room_id } => { let Some(client) = get_client() else { continue }; let _ignore_task = Handle::current().spawn(async move { let user_id = room_member.user_id(); @@ -1916,9 +1730,7 @@ async fn matrix_worker_task( MatrixRequest::SendTypingNotice { room_id, typing } => { let Some(main_room_timeline) = get_room_timeline(&room_id) else { - log!( - "BUG: skipping send typing notice request for not-yet-known room {room_id}" - ); + log!("BUG: skipping send typing notice request for not-yet-known room {room_id}"); continue; }; let _typing_task = Handle::current().spawn(async move { @@ -1932,21 +1744,16 @@ async fn matrix_worker_task( let (main_timeline, timeline_update_sender, mut typing_notice_receiver) = { let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(jrd) = all_joined_rooms.get_mut(&room_id) else { - log!( - "BUG: room info not found for subscribe to typing notices request, room {room_id}" - ); + log!("BUG: room info not found for subscribe to typing notices request, room {room_id}"); continue; }; let (main_timeline, receiver) = if subscribe { if jrd.typing_notice_subscriber.is_some() { - warning!( - "Note: room {room_id} is already subscribed to typing notices." - ); + warning!("Note: room {room_id} is already subscribed to typing notices."); continue; } else { let main_timeline = jrd.main_timeline.timeline.clone(); - let (drop_guard, receiver) = - main_timeline.room().subscribe_to_typing_notifications(); + let (drop_guard, receiver) = main_timeline.room().subscribe_to_typing_notifications(); jrd.typing_notice_subscriber = Some(drop_guard); (main_timeline, receiver) } @@ -1955,11 +1762,7 @@ async fn matrix_worker_task( continue; }; // Here: we don't have an existing subscriber running, so we fall through and start one. - ( - main_timeline, - jrd.main_timeline.timeline_update_sender.clone(), - receiver, - ) + (main_timeline, jrd.main_timeline.timeline_update_sender.clone(), receiver) }; let _typing_notices_task = Handle::current().spawn(async move { @@ -1986,22 +1789,15 @@ async fn matrix_worker_task( }); } - MatrixRequest::SubscribeToOwnUserReadReceiptsChanged { - timeline_kind, - subscribe, - } => { + MatrixRequest::SubscribeToOwnUserReadReceiptsChanged { timeline_kind, subscribe } => { if !subscribe { - if let Some(task_handler) = - subscribers_own_user_read_receipts.remove(&timeline_kind) - { + if let Some(task_handler) = subscribers_own_user_read_receipts.remove(&timeline_kind) { task_handler.abort(); } continue; } let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { - log!( - "BUG: skipping subscribe to own user read receipts changed request for {timeline_kind}" - ); + log!("BUG: skipping subscribe to own user read receipts changed request for {timeline_kind}"); continue; }; @@ -2043,8 +1839,7 @@ async fn matrix_worker_task( } } }); - subscribers_own_user_read_receipts - .insert(timeline_kind_clone, subscribe_own_read_receipt_task); + subscribers_own_user_read_receipts.insert(timeline_kind_clone, subscribe_own_read_receipt_task); } MatrixRequest::SubscribeToPinnedEvents { room_id, subscribe } => { @@ -2054,13 +1849,9 @@ async fn matrix_worker_task( } continue; } - let kind = TimelineKind::MainRoom { - room_id: room_id.clone(), - }; + let kind = TimelineKind::MainRoom { room_id: room_id.clone() }; let Some((main_timeline, sender)) = get_timeline_and_sender(&kind) else { - log!( - "BUG: skipping subscribe to pinned events request for unknown room {room_id}" - ); + log!("BUG: skipping subscribe to pinned events request for unknown room {room_id}"); continue; }; let subscribe_pinned_events_task = Handle::current().spawn(async move { @@ -2082,18 +1873,8 @@ async fn matrix_worker_task( subscribers_pinned_events.insert(room_id, subscribe_pinned_events_task); } - MatrixRequest::SpawnSSOServer { - brand, - homeserver_url, - identity_provider_id, - } => { - spawn_sso_server( - brand, - homeserver_url, - identity_provider_id, - login_sender.clone(), - ) - .await; + MatrixRequest::SpawnSSOServer { brand, homeserver_url, identity_provider_id} => { + spawn_sso_server(brand, homeserver_url, identity_provider_id, login_sender.clone()).await; } MatrixRequest::ResolveRoomAlias(room_alias) => { @@ -2106,10 +1887,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::FetchAvatar { - mxc_uri, - on_fetched, - } => { + MatrixRequest::FetchAvatar { mxc_uri, on_fetched } => { let Some(client) = get_client() else { continue }; Handle::current().spawn(async move { // log!("Sending fetch avatar request for {mxc_uri:?}..."); @@ -2119,21 +1897,13 @@ async fn matrix_worker_task( }; let res = client.media().get_media_content(&media_request, true).await; // log!("Fetched avatar for {mxc_uri:?}, succeeded? {}", res.is_ok()); - on_fetched(AvatarUpdate { - mxc_uri, - avatar_data: res.map(|v| v.into()), - }); + on_fetched(AvatarUpdate { mxc_uri, avatar_data: res.map(|v| v.into()) }); }); } - MatrixRequest::FetchMedia { - media_request, - on_fetched, - destination, - update_sender, - } => { + MatrixRequest::FetchMedia { media_request, on_fetched, destination, update_sender } => { let Some(client) = get_client() else { continue }; - + let _fetch_task = Handle::current().spawn(async move { // log!("Sending fetch media request for {media_request:?}..."); let res = client.media().get_media_content(&media_request, true).await; @@ -2238,11 +2008,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::ReadReceipt { - timeline_kind, - event_id, - receipt_type, - } => { + MatrixRequest::ReadReceipt { timeline_kind, event_id, receipt_type } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found when sending read receipt, {event_id}"); continue; @@ -2263,7 +2029,7 @@ async fn matrix_worker_task( }); } }); - } + }, MatrixRequest::GetRoomPowerLevels { timeline_kind } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { @@ -2271,21 +2037,15 @@ async fn matrix_worker_task( continue; }; - let Some(user_id) = current_user_id() else { - continue; - }; + let Some(user_id) = current_user_id() else { continue }; let _power_levels_task = Handle::current().spawn(async move { match timeline.room().power_levels().await { Ok(power_levels) => { log!("Successfully fetched power levels for {timeline_kind}."); - if sender - .send(TimelineUpdate::UserPowerLevels(UserPowerLevels::from( - &power_levels, - &user_id, - ))) - .is_err() - { + if sender.send(TimelineUpdate::UserPowerLevels( + UserPowerLevels::from(&power_levels, &user_id), + )).is_err() { error!("Failed to send room power levels to UI.") } SignalToUI::set_ui_signal(); @@ -2295,13 +2055,9 @@ async fn matrix_worker_task( } } }); - } + }, - MatrixRequest::ToggleReaction { - timeline_kind, - timeline_event_id, - reaction, - } => { + MatrixRequest::ToggleReaction { timeline_kind, timeline_event_id, reaction } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found for toggle reaction request"); continue; @@ -2309,26 +2065,17 @@ async fn matrix_worker_task( let _toggle_reaction_task = Handle::current().spawn(async move { log!("Sending toggle reaction {reaction:?} to {timeline_kind}: ..."); - match timeline - .toggle_reaction(&timeline_event_id, &reaction) - .await - { + match timeline.toggle_reaction(&timeline_event_id, &reaction).await { Ok(_send_handle) => { log!("Sent toggle reaction {reaction:?} to {timeline_kind}."); SignalToUI::set_ui_signal(); - } - Err(_e) => error!( - "Failed to send toggle reaction to {timeline_kind}; error: {_e:?}" - ), + }, + Err(_e) => error!("Failed to send toggle reaction to {timeline_kind}; error: {_e:?}"), } }); - } + }, - MatrixRequest::RedactMessage { - timeline_kind, - timeline_event_id, - reason, - } => { + MatrixRequest::RedactMessage { timeline_kind, timeline_event_id, reason } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found for redact message request"); continue; @@ -2347,13 +2094,9 @@ async fn matrix_worker_task( } } }); - } + }, - MatrixRequest::PinEvent { - timeline_kind, - event_id, - pin, - } => { + MatrixRequest::PinEvent { timeline_kind, event_id, pin } => { let Some((timeline, sender)) = get_timeline_and_sender(&timeline_kind) else { log!("BUG: {timeline_kind} not found for pin event request"); continue; @@ -2365,11 +2108,7 @@ async fn matrix_worker_task( } else { timeline.unpin_event(&event_id).await }; - match sender.send(TimelineUpdate::PinResult { - event_id, - pin, - result, - }) { + match sender.send(TimelineUpdate::PinResult { event_id, pin, result }) { Ok(_) => SignalToUI::set_ui_signal(), Err(_) => log!("Failed to send UI update for pin event."), } @@ -2403,12 +2142,7 @@ async fn matrix_worker_task( }); } - MatrixRequest::GetUrlPreview { - url, - on_fetched, - destination, - update_sender, - } => { + MatrixRequest::GetUrlPreview { url, on_fetched, destination, update_sender } => { // const MAX_LOG_RESPONSE_BODY_LENGTH: usize = 1000; // log!("Starting URL preview fetch for: {}", url); let _fetch_url_preview_task = Handle::current().spawn(async move { @@ -2418,19 +2152,17 @@ async fn matrix_worker_task( // error!("Matrix client not available for URL preview: {}", url); UrlPreviewError::ClientNotAvailable })?; - + let token = client.access_token().ok_or_else(|| { // error!("Access token not available for URL preview: {}", url); UrlPreviewError::AccessTokenNotAvailable })?; // Official Doc: https://spec.matrix.org/v1.11/client-server-api/#get_matrixclientv1mediapreview_url // Element desktop is using /_matrix/media/v3/preview_url - let endpoint_url = client - .homeserver() - .join("/_matrix/client/v1/media/preview_url") + let endpoint_url = client.homeserver().join("/_matrix/client/v1/media/preview_url") .map_err(UrlPreviewError::UrlParse)?; // log!("Fetching URL preview from endpoint: {} for URL: {}", endpoint_url, url); - + let response = client .http_client() .get(endpoint_url.clone()) @@ -2443,20 +2175,20 @@ async fn matrix_worker_task( // error!("HTTP request failed for URL preview {}: {}", url, e); UrlPreviewError::Request(e) })?; - + let status = response.status(); // log!("URL preview response status for {}: {}", url, status); - + if !status.is_success() && status.as_u16() != 429 { // error!("URL preview request failed with status {} for URL: {}", status, url); return Err(UrlPreviewError::HttpStatus(status.as_u16())); } - + let text = response.text().await.map_err(|e| { // error!("Failed to read response text for URL preview {}: {}", url, e); UrlPreviewError::Request(e) })?; - + // log!("URL preview response body length for {}: {} bytes", url, text.len()); // if text.len() > MAX_LOG_RESPONSE_BODY_LENGTH { // log!("URL preview response body preview for {}: {}...", url, &text[..MAX_LOG_RESPONSE_BODY_LENGTH]); @@ -2465,25 +2197,22 @@ async fn matrix_worker_task( // } // This request is rate limited, retry after a duration we get from the server. if status.as_u16() == 429 { - let link_preview_429_res = - serde_json::from_str::(&text) - .map_err(|e| { - // error!("Failed to parse as LinkPreviewRateLimitResponse for URL preview {}: {}", url, e); - UrlPreviewError::Json(e) - }); + let link_preview_429_res = serde_json::from_str::(&text) + .map_err(|e| { + // error!("Failed to parse as LinkPreviewRateLimitResponse for URL preview {}: {}", url, e); + UrlPreviewError::Json(e) + }); match link_preview_429_res { Ok(link_preview_429_res) => { if let Some(retry_after) = link_preview_429_res.retry_after_ms { - tokio::time::sleep(Duration::from_millis( - retry_after.into(), - )) - .await; - submit_async_request(MatrixRequest::GetUrlPreview { + tokio::time::sleep(Duration::from_millis(retry_after.into())).await; + submit_async_request(MatrixRequest::GetUrlPreview{ url: url.clone(), on_fetched, destination: destination.clone(), update_sender: update_sender.clone(), }); + } } Err(_e) => { @@ -2503,12 +2232,11 @@ async fn matrix_worker_task( // error!("Response body that failed to parse: {}", text); UrlPreviewError::Json(e) }) - } - .await; + }.await; // match &result { // Ok(preview_data) => { - // log!("Successfully fetched URL preview for {}: title: {:?}, site_name: {:?}", + // log!("Successfully fetched URL preview for {}: title: {:?}, site_name: {:?}", // url, preview_data.title, preview_data.site_name); // } // Err(e) => { @@ -2527,6 +2255,7 @@ async fn matrix_worker_task( bail!("matrix_worker_task task ended unexpectedly") } + /// The single global Tokio runtime that is used by all async tasks. static TOKIO_RUNTIME: Mutex> = Mutex::new(None); @@ -2539,8 +2268,7 @@ static REQUEST_SENDER: Mutex>> = Mutex::ne static DEFAULT_SSO_CLIENT: Mutex> = Mutex::new(None); /// Used to notify the SSO login task that the async creation of the `DEFAULT_SSO_CLIENT` has finished. -static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = - LazyLock::new(|| Arc::new(Notify::new())); +static DEFAULT_SSO_CLIENT_NOTIFIER: LazyLock> = LazyLock::new(|| Arc::new(Notify::new())); /// Blocks the current thread until the given future completes. /// @@ -2551,45 +2279,36 @@ pub fn block_on_async_with_timeout( timeout: Option, async_future: impl Future, ) -> Result { - let rt = TOKIO_RUNTIME - .lock() - .unwrap() - .get_or_insert_with(|| { - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - }) - .handle() - .clone(); + let rt = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + ).handle().clone(); if let Some(timeout) = timeout { - rt.block_on(async { tokio::time::timeout(timeout, async_future).await }) + rt.block_on(async { + tokio::time::timeout(timeout, async_future).await + }) } else { Ok(rt.block_on(async_future)) } } + /// The primary initialization routine for starting the Matrix client sync /// and the async tokio runtime. /// /// Returns a handle to the Tokio runtime that is used to run async background tasks. pub fn start_matrix_tokio() -> Result { // Create a Tokio runtime, and save it in a static variable to ensure it isn't dropped. - let rt_handle = TOKIO_RUNTIME - .lock() - .unwrap() - .get_or_insert_with(|| { - tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") - }) - .handle() - .clone(); + let rt_handle = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| { + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + }).handle().clone(); // Proactively build a Matrix Client in the background so that the SSO Server // can have a quicker start if needed (as it's rather slow to build this client). rt_handle.spawn(async move { match build_client(&Cli::default(), app_data_dir()).await { Ok(client_and_session) => { - DEFAULT_SSO_CLIENT - .lock() - .unwrap() + DEFAULT_SSO_CLIENT.lock().unwrap() .get_or_insert(client_and_session); } Err(e) => error!("Error: could not create DEFAULT_SSO_CLIENT object: {e}"), @@ -2606,6 +2325,7 @@ pub fn start_matrix_tokio() -> Result { Ok(rt_handle) } + /// A tokio::watch channel sender for sending requests from the RoomScreen UI widget /// to the corresponding background async task for that room (its `timeline_subscriber_handler`). pub type TimelineRequestSender = watch::Sender>; @@ -2672,13 +2392,13 @@ impl Drop for JoinedRoomDetails { } } + /// A const-compatible hasher, used for `static` items containing `HashMap`s or `HashSet`s. type ConstHasher = BuildHasherDefault; /// Information about all joined rooms that our client currently know about. /// We use a `HashMap` for O(1) lookups, as this is accessed frequently (e.g. every timeline update). -static ALL_JOINED_ROOMS: Mutex> = - Mutex::new(HashMap::with_hasher(BuildHasherDefault::new())); +static ALL_JOINED_ROOMS: Mutex> = Mutex::new(HashMap::with_hasher(BuildHasherDefault::new())); /// Returns the timeline and timeline update sender for the given joined room/thread timeline. fn get_per_timeline_details<'a>( @@ -2688,10 +2408,7 @@ fn get_per_timeline_details<'a>( let room_info = all_joined_rooms.get_mut(kind.room_id())?; match kind { TimelineKind::MainRoom { .. } => Some(&mut room_info.main_timeline), - TimelineKind::Thread { - thread_root_event_id, - .. - } => room_info.thread_timelines.get_mut(thread_root_event_id), + TimelineKind::Thread { thread_root_event_id, .. } => room_info.thread_timelines.get_mut(thread_root_event_id), } } @@ -2702,22 +2419,14 @@ fn get_timeline(kind: &TimelineKind) -> Option> { } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the timeline and timeline update sender for the given timeline kind. -fn get_timeline_and_sender( - kind: &TimelineKind, -) -> Option<(Arc, crossbeam_channel::Sender)> { - get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind).map(|details| { - ( - details.timeline.clone(), - details.timeline_update_sender.clone(), - ) - }) +fn get_timeline_and_sender(kind: &TimelineKind) -> Option<(Arc, crossbeam_channel::Sender)> { + get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind) + .map(|details| (details.timeline.clone(), details.timeline_update_sender.clone())) } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the main timeline for the given room. fn get_room_timeline(room_id: &RoomId) -> Option> { - ALL_JOINED_ROOMS - .lock() - .unwrap() + ALL_JOINED_ROOMS.lock().unwrap() .get(room_id) .map(|jrd| jrd.main_timeline.timeline.clone()) } @@ -2731,16 +2440,15 @@ pub fn get_client() -> Option { /// Returns the user ID of the currently logged-in user, if any. pub fn current_user_id() -> Option { - CLIENT - .lock() - .unwrap() - .as_ref() - .and_then(|c| c.session_meta().map(|m| m.user_id.clone())) + CLIENT.lock().unwrap().as_ref().and_then(|c| + c.session_meta().map(|m| m.user_id.clone()) + ) } /// The singleton sync service. static SYNC_SERVICE: Mutex>> = Mutex::new(None); + /// Get a reference to the current sync service, if available. pub fn get_sync_service() -> Option> { SYNC_SERVICE.lock().ok()?.as_ref().cloned() @@ -2749,8 +2457,7 @@ pub fn get_sync_service() -> Option> { /// The list of users that the current user has chosen to ignore. /// Ideally we shouldn't have to maintain this list ourselves, /// but the Matrix SDK doesn't currently properly maintain the list of ignored users. -static IGNORED_USERS: Mutex> = - Mutex::new(HashSet::with_hasher(BuildHasherDefault::new())); +static IGNORED_USERS: Mutex> = Mutex::new(HashSet::with_hasher(BuildHasherDefault::new())); /// Returns a deep clone of the current list of ignored users. pub fn get_ignored_users() -> HashSet { @@ -2762,6 +2469,7 @@ pub fn is_user_ignored(user_id: &UserId) -> bool { IGNORED_USERS.lock().unwrap().contains(user_id) } + /// Returns three channel endpoints related to the timeline for the given joined room or thread. /// /// 1. A timeline update sender. @@ -2775,10 +2483,7 @@ pub fn take_timeline_endpoints(kind: &TimelineKind) -> Option let jrd = all_joined_rooms.get_mut(kind.room_id())?; let details = match kind { TimelineKind::MainRoom { .. } => &mut jrd.main_timeline, - TimelineKind::Thread { - thread_root_event_id, - .. - } => jrd.thread_timelines.get_mut(thread_root_event_id)?, + TimelineKind::Thread { thread_root_event_id, .. } => jrd.thread_timelines.get_mut(thread_root_event_id)?, }; let (update_receiver, request_sender) = details.timeline_singleton_endpoints.take()?; Some(TimelineEndpoints { @@ -2791,18 +2496,25 @@ pub fn take_timeline_endpoints(kind: &TimelineKind) -> Option const DEFAULT_HOMESERVER: &str = "matrix.org"; -fn username_to_full_user_id(username: &str, homeserver: Option<&str>) -> Option { - username.try_into().ok().or_else(|| { - let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); - let user_id_str = if username.starts_with("@") { - format!("{}:{}", username, homeserver_url) - } else { - format!("@{}:{}", username, homeserver_url) - }; - user_id_str.as_str().try_into().ok() - }) +fn username_to_full_user_id( + username: &str, + homeserver: Option<&str>, +) -> Option { + username + .try_into() + .ok() + .or_else(|| { + let homeserver_url = homeserver.unwrap_or(DEFAULT_HOMESERVER); + let user_id_str = if username.starts_with("@") { + format!("{}:{}", username, homeserver_url) + } else { + format!("@{}:{}", username, homeserver_url) + }; + user_id_str.as_str().try_into().ok() + }) } + /// Info we store about a room received by the room list service. /// /// This struct is necessary in order for us to track the previous state @@ -2830,14 +2542,18 @@ struct RoomListServiceRoomInfo { impl RoomListServiceRoomInfo { async fn from_room(room: matrix_sdk::Room, current_user_id: &Option) -> Self { // Parallelize fetching of independent room data. - let (is_direct, tags, display_name, user_power_levels) = - tokio::join!(room.is_direct(), room.tags(), room.display_name(), async { + let (is_direct, tags, display_name, user_power_levels) = tokio::join!( + room.is_direct(), + room.tags(), + room.display_name(), + async { if let Some(user_id) = current_user_id { UserPowerLevels::from_room(&room, user_id.deref()).await } else { None } - }); + } + ); Self { room_id: room.room_id().to_owned(), @@ -2879,26 +2595,26 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let most_recent_user_id = persistence::most_recent_user_id().await; log!("Most recent user ID: {most_recent_user_id:?}"); let cli_parse_result = Cli::try_parse(); - let cli_has_valid_username_password = cli_parse_result - .as_ref() + let cli_has_valid_username_password = cli_parse_result.as_ref() .is_ok_and(|cli| !cli.user_id.is_empty() && !cli.password.is_empty()); - log!( - "CLI parsing succeeded? {}. CLI has valid UN+PW? {}", + log!("CLI parsing succeeded? {}. CLI has valid UN+PW? {}", cli_parse_result.as_ref().is_ok(), cli_has_valid_username_password, ); - let wait_for_login = !cli_has_valid_username_password - && (most_recent_user_id.is_none() - || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login")); + let wait_for_login = !cli_has_valid_username_password && ( + most_recent_user_id.is_none() + || std::env::args().any(|arg| arg == "--login-screen" || arg == "--force-login") + ); log!("Waiting for login? {}", wait_for_login); let new_login_opt: Option<(Client, Option, bool)> = if !wait_for_login { - let specified_username = cli_parse_result - .as_ref() - .ok() - .and_then(|cli| username_to_full_user_id(&cli.user_id, cli.homeserver.as_deref())); - log!( - "Trying to restore session for user: {:?}", + let specified_username = cli_parse_result.as_ref().ok().and_then(|cli| + username_to_full_user_id( + &cli.user_id, + cli.homeserver.as_deref(), + ) + ); + log!("Trying to restore session for user: {:?}", specified_username.as_ref().or(most_recent_user_id.as_ref()) ); match persistence::restore_session(specified_username.clone()).await { @@ -2915,10 +2631,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { Cx::post_action(LoginAction::LoginFailure(status_err.to_string())); if let Ok(cli) = &cli_parse_result { - log!( - "Attempting auto-login from CLI arguments as user '{}'...", - cli.user_id - ); + log!("Attempting auto-login from CLI arguments as user '{}'...", cli.user_id); Cx::post_action(LoginAction::CliAutoLogin { user_id: cli.user_id.clone(), homeserver: cli.homeserver.clone(), @@ -2927,9 +2640,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { Ok((client, sync_token)) => Some((client, sync_token, false)), Err(e) => { error!("CLI-based login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!( - "Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}" - ))); + Cx::post_action(LoginAction::LoginFailure( + format!("Could not login with CLI-provided arguments.\n\nPlease login manually.\n\nError: {e}") + )); enqueue_rooms_list_update(RoomsListUpdate::Status { status: format!("Login failed: {e:?}"), }); @@ -2954,30 +2667,34 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let (client, sync_service, logged_in_user_id) = 'login_loop: loop { let (client, _sync_token, validate_session) = match initial_client_opt.take() { Some(login) => login, - None => loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => match login(&cli, login_request).await { - Ok((client, sync_token)) => break (client, sync_token, false), - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + None => { + loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => { + match login(&cli, login_request).await { + Ok((client, sync_token)) => break (client, sync_token, false), + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: format!("Login failed: {e}"), + }); + } + } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); + Cx::post_action(LoginAction::LoginFailure(err.clone())); enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), + status: err, }); + return; } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - let err = String::from( - "Please restart Robrix.\n\nUnable to listen for login requests.", - ); - Cx::post_action(LoginAction::LoginFailure(err.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err }); - return; } } - }, + } }; if validate_session { @@ -2985,8 +2702,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { Ok(_) => {} Err(e) if is_invalid_token_http_error(&e) => { clear_persisted_session(client.user_id()).await; - let err_msg = - "Your login token is no longer valid.\n\nPlease log in again."; + let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.to_string(), @@ -2994,9 +2710,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { continue 'login_loop; } Err(e) => { - warning!( - "Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}" - ); + warning!("Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}"); } } } @@ -3006,8 +2720,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let _ = client_opt.take(); } - let logged_in_user_id: OwnedUserId = client - .user_id() + let logged_in_user_id: OwnedUserId = client.user_id() .expect("BUG: Client::user_id() returned None after successful login!") .to_owned(); let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); @@ -3015,9 +2728,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // Store this active client in our global Client state so that other tasks can access it. if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { - error!( - "BUG: unexpectedly replaced an existing client when initializing the matrix client." - ); + error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); } // Listen for changes to our verification status and incoming verification requests. @@ -3041,9 +2752,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let err_msg = if is_invalid_token_error(&e) { "Your login token is no longer valid.\n\nPlease log in again.".to_string() } else { - format!( - "Please restart Robrix.\n\nFailed to create Matrix sync service: {e}." - ) + format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") }; if is_invalid_token_error(&e) { clear_persisted_session(client.user_id()).await; @@ -3081,9 +2790,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let room_list_service = sync_service.room_list_service(); if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { - error!( - "BUG: unexpectedly replaced an existing sync service when initializing the matrix client." - ); + error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); } let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); @@ -3200,6 +2907,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { } } + /// The main async task that listens for changes to all rooms. async fn room_list_service_loop(room_list_service: Arc) -> Result<()> { let all_rooms_list = room_list_service.all_rooms().await?; @@ -3213,13 +2921,13 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu // 1. not spaces (those are handled by the SpaceService), // 2. not left (clients don't typically show rooms that the user has already left), // 3. not outdated (don't show tombstoned rooms whose successor is already joined). - room_list_dynamic_entries_controller.set_filter(Box::new(filters::new_filter_all(vec![ - Box::new(filters::new_filter_not(Box::new( - filters::new_filter_space(), - ))), - Box::new(filters::new_filter_non_left()), - Box::new(filters::new_filter_deduplicate_versions()), - ]))); + room_list_dynamic_entries_controller.set_filter(Box::new( + filters::new_filter_all(vec![ + Box::new(filters::new_filter_not(Box::new(filters::new_filter_space()))), + Box::new(filters::new_filter_non_left()), + Box::new(filters::new_filter_deduplicate_versions()), + ]) + )); let mut all_known_rooms: Vector = Vector::new(); let current_user_id = current_user_id(); @@ -3235,13 +2943,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu // Append and Reset are identical, except for Reset first clears all rooms. let _num_new_rooms = new_rooms.len(); if is_reset { - if LOG_ROOM_LIST_DIFFS { - log!( - "room_list: diff Reset, old length {}, new length {}", - all_known_rooms.len(), - new_rooms.len() - ); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Reset, old length {}, new length {}", all_known_rooms.len(), new_rooms.len()); } // Iterate manually so we can know which rooms are being removed. while let Some(room) = all_known_rooms.pop_back() { remove_room(&room); @@ -3252,35 +2954,20 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); } else { - if LOG_ROOM_LIST_DIFFS { - log!( - "room_list: diff Append, old length {}, adding {} new items", - all_known_rooms.len(), - _num_new_rooms - ); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Append, old length {}, adding {} new items", all_known_rooms.len(), _num_new_rooms); } } // Parallelize creating each room's RoomListServiceRoomInfo and adding that new room. // We combine `from_room` and `add_new_room` into a single async task per room. - let new_room_infos: Vec = - join_all(new_rooms.into_iter().map(|room| async { - let room_info = RoomListServiceRoomInfo::from_room( - room.into_inner(), - ¤t_user_id, - ) - .await; - if let Err(e) = - add_new_room(&room_info, &room_list_service, false).await - { - error!( - "Failed to add new room: {:?} ({}); error: {:?}", - room_info.display_name, room_info.room_id, e - ); + let new_room_infos: Vec = join_all( + new_rooms.into_iter().map(|room| async { + let room_info = RoomListServiceRoomInfo::from_room(room.into_inner(), ¤t_user_id).await; + if let Err(e) = add_new_room(&room_info, &room_list_service, false).await { + error!("Failed to add new room: {:?} ({}); error: {:?}", room_info.display_name, room_info.room_id, e); } room_info - })) - .await; + }) + ).await; // Send room order update with the new room IDs let (room_id_refs, room_ids) = { @@ -3294,57 +2981,43 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu }; if !room_ids.is_empty() { enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Append { values: room_ids }, + VecDiff::Append { values: room_ids } )); room_list_service.subscribe_to_rooms(&room_id_refs).await; all_known_rooms.extend(new_room_infos); } } VectorDiff::Clear => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Clear"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Clear"); } all_known_rooms.clear(); ALL_JOINED_ROOMS.lock().unwrap().clear(); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); } VectorDiff::PushFront { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PushFront"); - } - let new_room = - RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) - .await; + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushFront"); } + let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushFront { value: room_id }, + VecDiff::PushFront { value: room_id } )); all_known_rooms.push_front(new_room); } VectorDiff::PushBack { value: new_room } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PushBack"); - } - let new_room = - RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) - .await; + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PushBack"); } + let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PushBack { value: room_id }, + VecDiff::PushBack { value: room_id } )); all_known_rooms.push_back(new_room); } remove_diff @ VectorDiff::PopFront => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PopFront"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopFront"); } if let Some(room) = all_known_rooms.pop_front() { - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PopFront, - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PopFront)); optimize_remove_then_add_into_update( remove_diff, &room, @@ -3352,18 +3025,13 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ) - .await?; + ).await?; } } remove_diff @ VectorDiff::PopBack => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff PopBack"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff PopBack"); } if let Some(room) = all_known_rooms.pop_back() { - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::PopBack, - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PopBack)); optimize_remove_then_add_into_update( remove_diff, &room, @@ -3371,61 +3039,38 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ) - .await?; + ).await?; } } - VectorDiff::Insert { - index, - value: new_room, - } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Insert at {index}"); - } - let new_room = - RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id) - .await; + VectorDiff::Insert { index, value: new_room } => { + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Insert at {index}"); } + let new_room = RoomListServiceRoomInfo::from_room(new_room.into_inner(), ¤t_user_id).await; let room_id = new_room.room_id.clone(); add_new_room(&new_room, &room_list_service, true).await?; - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Insert { - index, - value: room_id, - })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::Insert { index, value: room_id } + )); all_known_rooms.insert(index, new_room); } - VectorDiff::Set { - index, - value: changed_room, - } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Set at {index}"); - } - let changed_room = RoomListServiceRoomInfo::from_room( - changed_room.into_inner(), - ¤t_user_id, - ) - .await; + VectorDiff::Set { index, value: changed_room } => { + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Set at {index}"); } + let changed_room = RoomListServiceRoomInfo::from_room(changed_room.into_inner(), ¤t_user_id).await; if let Some(old_room) = all_known_rooms.get(index) { update_room(old_room, &changed_room, &room_list_service).await?; } else { error!("BUG: room list diff: Set index {index} was out of bounds."); } // Send order update (room ID at this index may have changed) - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Set { - index, - value: changed_room.room_id.clone(), - })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::Set { index, value: changed_room.room_id.clone() } + )); all_known_rooms.set(index, changed_room); } remove_diff @ VectorDiff::Remove { index } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Remove at {index}"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Remove at {index}"); } if index < all_known_rooms.len() { let room = all_known_rooms.remove(index); - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Remove { index }, - )); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Remove { index })); optimize_remove_then_add_into_update( remove_diff, &room, @@ -3433,19 +3078,13 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu &mut all_known_rooms, &room_list_service, ¤t_user_id, - ) - .await?; + ).await?; } else { - error!( - "BUG: room_list: diff Remove index {index} out of bounds, len {}", - all_known_rooms.len() - ); + error!("BUG: room_list: diff Remove index {index} out of bounds, len {}", all_known_rooms.len()); } } VectorDiff::Truncate { length } => { - if LOG_ROOM_LIST_DIFFS { - log!("room_list: diff Truncate to {length}"); - } + if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Truncate to {length}"); } // Iterate manually so we can know which rooms are being removed. while all_known_rooms.len() > length { if let Some(room) = all_known_rooms.pop_back() { @@ -3454,7 +3093,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu } all_known_rooms.truncate(length); // sanity check enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( - VecDiff::Truncate { length }, + VecDiff::Truncate { length } )); } } @@ -3464,6 +3103,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu bail!("room list service sync loop ended unexpectedly") } + /// Attempts to optimize a common RoomListService operation of remove + add. /// /// If a `Remove` diff (or `PopBack` or `PopFront`) is immediately followed by @@ -3483,58 +3123,48 @@ async fn optimize_remove_then_add_into_update( ) -> Result<()> { let next_diff_was_handled: bool; match peekable_diffs.peek() { - Some(VectorDiff::Insert { - index: insert_index, - value: new_room, - }) if room.room_id == new_room.room_id() => { + Some(VectorDiff::Insert { index: insert_index, value: new_room }) + if room.room_id == new_room.room_id() => + { if LOG_ROOM_LIST_DIFFS { - log!( - "Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", - room.room_id - ); + log!("Optimizing {remove_diff:?} + Insert({insert_index}) into Update for room {}", room.room_id); } - let new_room = - RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the insert - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Insert { - index: *insert_index, - value: new_room.room_id.clone(), - })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::Insert { index: *insert_index, value: new_room.room_id.clone() } + )); all_known_rooms.insert(*insert_index, new_room); next_diff_was_handled = true; } - Some(VectorDiff::PushFront { value: new_room }) if room.room_id == new_room.room_id() => { + Some(VectorDiff::PushFront { value: new_room }) + if room.room_id == new_room.room_id() => + { if LOG_ROOM_LIST_DIFFS { - log!( - "Optimizing {remove_diff:?} + PushFront into Update for room {}", - room.room_id - ); + log!("Optimizing {remove_diff:?} + PushFront into Update for room {}", room.room_id); } - let new_room = - RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the push front - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PushFront { - value: new_room.room_id.clone(), - })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::PushFront { value: new_room.room_id.clone() } + )); all_known_rooms.push_front(new_room); next_diff_was_handled = true; } - Some(VectorDiff::PushBack { value: new_room }) if room.room_id == new_room.room_id() => { + Some(VectorDiff::PushBack { value: new_room }) + if room.room_id == new_room.room_id() => + { if LOG_ROOM_LIST_DIFFS { - log!( - "Optimizing {remove_diff:?} + PushBack into Update for room {}", - room.room_id - ); + log!("Optimizing {remove_diff:?} + PushBack into Update for room {}", room.room_id); } - let new_room = - RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; + let new_room = RoomListServiceRoomInfo::from_room_ref(new_room.deref(), current_user_id).await; update_room(room, &new_room, room_list_service).await?; // Send order update for the push back - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::PushBack { - value: new_room.room_id.clone(), - })); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate( + VecDiff::PushBack { value: new_room.room_id.clone() } + )); all_known_rooms.push_back(new_room); next_diff_was_handled = true; } @@ -3548,6 +3178,7 @@ async fn optimize_remove_then_add_into_update( Ok(()) } + /// Invoked when the room list service has received an update that changes an existing room. async fn update_room( old_room: &RoomListServiceRoomInfo, @@ -3558,29 +3189,18 @@ async fn update_room( if old_room.room_id == new_room_id { // Handle state transitions for a room. if LOG_ROOM_LIST_DIFFS { - log!( - "Room {:?} ({new_room_id}) state went from {:?} --> {:?}", - new_room.display_name, - old_room.state, - new_room.state - ); + log!("Room {:?} ({new_room_id}) state went from {:?} --> {:?}", new_room.display_name, old_room.state, new_room.state); } if old_room.state != new_room.state { match new_room.state { RoomState::Banned => { // TODO: handle rooms that this user has been banned from. - log!( - "Removing Banned room: {:?} ({new_room_id})", - new_room.display_name - ); + log!("Removing Banned room: {:?} ({new_room_id})", new_room.display_name); remove_room(new_room); return Ok(()); } RoomState::Left => { - log!( - "Removing Left room: {:?} ({new_room_id})", - new_room.display_name - ); + log!("Removing Left room: {:?} ({new_room_id})", new_room.display_name); // TODO: instead of removing this, we could optionally add it to // a separate list of left rooms, which would be collapsed by default. // Upon clicking a left room, we could show a splash page @@ -3590,17 +3210,11 @@ async fn update_room( return Ok(()); } RoomState::Joined => { - log!( - "update_room(): adding new Joined room: {:?} ({new_room_id})", - new_room.display_name - ); + log!("update_room(): adding new Joined room: {:?} ({new_room_id})", new_room.display_name); return add_new_room(new_room, room_list_service, true).await; } RoomState::Invited => { - log!( - "update_room(): adding new Invited room: {:?} ({new_room_id})", - new_room.display_name - ); + log!("update_room(): adding new Invited room: {:?} ({new_room_id})", new_room.display_name); return add_new_room(new_room, room_list_service, true).await; } RoomState::Knocked => { @@ -3618,12 +3232,7 @@ async fn update_room( spawn_fetch_room_avatar(new_room); } if old_room.display_name != new_room.display_name { - log!( - "Updating room {} name: {:?} --> {:?}", - new_room_id, - old_room.display_name, - new_room.display_name - ); + log!("Updating room {} name: {:?} --> {:?}", new_room_id, old_room.display_name, new_room.display_name); enqueue_rooms_list_update(RoomsListUpdate::UpdateRoomName { new_room_name: (new_room.display_name.clone(), new_room_id.clone()).into(), @@ -3633,15 +3242,12 @@ async fn update_room( // Then, we check for changes to room data that is only relevant to joined rooms: // including the latest event, tags, unread counts, is_direct, tombstoned state, power levels, etc. // Invited or left rooms don't care about these details. - if matches!(new_room.state, RoomState::Joined) { + if matches!(new_room.state, RoomState::Joined) { // For some reason, the latest event API does not reliably catch *all* changes // to the latest event in a given room, such as redactions. // Thus, we have to re-obtain the latest event on *every* update, regardless of timestamp. // - let update_latest = match ( - old_room.latest_event_timestamp, - new_room.room.latest_event_timestamp(), - ) { + let update_latest = match (old_room.latest_event_timestamp, new_room.room.latest_event_timestamp()) { (Some(old_ts), Some(new_ts)) => new_ts >= old_ts, (None, Some(_)) => true, _ => false, @@ -3650,13 +3256,9 @@ async fn update_room( update_latest_event(&new_room.room).await; } + if old_room.tags != new_room.tags { - log!( - "Updating room {} tags from {:?} to {:?}", - new_room_id, - old_room.tags, - new_room.tags - ); + log!("Updating room {} tags from {:?} to {:?}", new_room_id, old_room.tags, new_room.tags); enqueue_rooms_list_update(RoomsListUpdate::Tags { room_id: new_room_id.clone(), new_tags: new_room.tags.clone().unwrap_or_default(), @@ -3667,15 +3269,11 @@ async fn update_room( || old_room.num_unread_messages != new_room.num_unread_messages || old_room.num_unread_mentions != new_room.num_unread_mentions { - log!( - "Updating room {}, marked unread {} --> {}, unread messages {} --> {}, unread mentions {} --> {}", + log!("Updating room {}, marked unread {} --> {}, unread messages {} --> {}, unread mentions {} --> {}", new_room_id, - old_room.is_marked_unread, - new_room.is_marked_unread, - old_room.num_unread_messages, - new_room.num_unread_messages, - old_room.num_unread_mentions, - new_room.num_unread_mentions, + old_room.is_marked_unread, new_room.is_marked_unread, + old_room.num_unread_messages, new_room.num_unread_messages, + old_room.num_unread_mentions, new_room.num_unread_mentions, ); enqueue_rooms_list_update(RoomsListUpdate::UpdateNumUnreadMessages { room_id: new_room_id.clone(), @@ -3686,8 +3284,7 @@ async fn update_room( } if old_room.is_direct != new_room.is_direct { - log!( - "Updating room {} is_direct from {} to {}", + log!("Updating room {} is_direct from {} to {}", new_room_id, old_room.is_direct, new_room.is_direct, @@ -3702,8 +3299,7 @@ async fn update_room( let mut get_timeline_update_sender = |room_id| { if __timeline_update_sender_opt.is_none() { if let Some(jrd) = ALL_JOINED_ROOMS.lock().unwrap().get(room_id) { - __timeline_update_sender_opt = - Some(jrd.main_timeline.timeline_update_sender.clone()); + __timeline_update_sender_opt = Some(jrd.main_timeline.timeline_update_sender.clone()); } } __timeline_update_sender_opt.clone() @@ -3712,9 +3308,7 @@ async fn update_room( if !old_room.is_tombstoned && new_room.is_tombstoned { let successor_room = new_room.room.successor_room(); log!("Updating room {new_room_id} to be tombstoned, {successor_room:?}"); - enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { - room_id: new_room_id.clone(), - }); + enqueue_rooms_list_update(RoomsListUpdate::TombstonedRoom { room_id: new_room_id.clone() }); if let Some(timeline_update_sender) = get_timeline_update_sender(&new_room_id) { spawn_fetch_successor_room_preview( room_list_service.client().clone(), @@ -3723,9 +3317,7 @@ async fn update_room( timeline_update_sender, ); } else { - error!( - "BUG: could not find JoinedRoomDetails for newly-tombstoned room {new_room_id}" - ); + error!("BUG: could not find JoinedRoomDetails for newly-tombstoned room {new_room_id}"); } } @@ -3736,38 +3328,37 @@ async fn update_room( log!("Updating room {new_room_id} user power levels."); match timeline_update_sender.send(TimelineUpdate::UserPowerLevels(nupl)) { Ok(_) => SignalToUI::set_ui_signal(), - Err(_) => error!( - "Failed to send the UserPowerLevels update to room {new_room_id}" - ), + Err(_) => error!("Failed to send the UserPowerLevels update to room {new_room_id}"), } } else { - error!( - "BUG: could not find JoinedRoomDetails for room {new_room_id} where power levels changed." - ); + error!("BUG: could not find JoinedRoomDetails for room {new_room_id} where power levels changed."); } } } Ok(()) - } else { - warning!( - "UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", - old_room.room_id, - new_room_id, + } + else { + warning!("UNTESTED SCENARIO: update_room(): removing old room {}, replacing with new room {}", + old_room.room_id, new_room_id, ); remove_room(old_room); add_new_room(new_room, room_list_service, true).await } } + /// Invoked when the room list service has received an update to remove an existing room. fn remove_room(room: &RoomListServiceRoomInfo) { ALL_JOINED_ROOMS.lock().unwrap().remove(&room.room_id); - enqueue_rooms_list_update(RoomsListUpdate::RemoveRoom { - room_id: room.room_id.clone(), - new_state: room.state, - }); + enqueue_rooms_list_update( + RoomsListUpdate::RemoveRoom { + room_id: room.room_id.clone(), + new_state: room.state, + } + ); } + /// Invoked when the room list service has received an update with a brand new room. async fn add_new_room( new_room: &RoomListServiceRoomInfo, @@ -3776,39 +3367,26 @@ async fn add_new_room( ) -> Result<()> { match new_room.state { RoomState::Knocked => { - log!( - "Got new Knocked room: {:?} ({})", - new_room.display_name, - new_room.room_id - ); + log!("Got new Knocked room: {:?} ({})", new_room.display_name, new_room.room_id); // Note: here we could optionally display Knocked rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Banned => { - log!( - "Got new Banned room: {:?} ({})", - new_room.display_name, - new_room.room_id - ); + log!("Got new Banned room: {:?} ({})", new_room.display_name, new_room.room_id); // Note: here we could optionally display Banned rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Left => { - log!( - "Got new Left room: {:?} ({:?})", - new_room.display_name, - new_room.room_id - ); + log!("Got new Left room: {:?} ({:?})", new_room.display_name, new_room.room_id); // Note: here we could optionally display Left rooms as a separate type of room // in the rooms list, but it's not really necessary at this point. return Ok(()); } RoomState::Invited => { let invite_details = new_room.room.invite_details().await.ok(); - let room_name_id = - RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); + let room_name_id = RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); // Start with a basic text avatar; the avatar image will be fetched asynchronously below. let room_avatar = avatar_from_room_name(room_name_id.name_for_avatar()); let inviter_info = if let Some(inviter) = invite_details.and_then(|d| d.inviter) { @@ -3825,20 +3403,18 @@ async fn add_new_room( } else { None }; - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom( - InvitedRoomInfo { - room_name_id: room_name_id.clone(), - inviter_info, - room_avatar, - canonical_alias: new_room.room.canonical_alias(), - alt_aliases: new_room.room.alt_aliases(), - // we don't actually display the latest event for Invited rooms, so don't bother. - latest: None, - invite_state: Default::default(), - is_selected: false, - is_direct: new_room.is_direct, - }, - )); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom(InvitedRoomInfo { + room_name_id: room_name_id.clone(), + inviter_info, + room_avatar, + canonical_alias: new_room.room.canonical_alias(), + alt_aliases: new_room.room.alt_aliases(), + // we don't actually display the latest event for Invited rooms, so don't bother. + latest: None, + invite_state: Default::default(), + is_selected: false, + is_direct: new_room.is_direct, + })); Cx::post_action(AppStateAction::RoomLoadedSuccessfully { room_name_id, is_invite: true, @@ -3846,21 +3422,17 @@ async fn add_new_room( spawn_fetch_room_avatar(new_room); return Ok(()); } - RoomState::Joined => {} // Fall through to adding the joined room below. + RoomState::Joined => { } // Fall through to adding the joined room below. } // If we didn't already subscribe to this room, do so now. // This ensures we will properly receive all of its states and latest event. if subscribe { - room_list_service - .subscribe_to_rooms(&[&new_room.room_id]) - .await; + room_list_service.subscribe_to_rooms(&[&new_room.room_id]).await; } let timeline = Arc::new( - new_room - .room - .timeline_builder() + new_room.room.timeline_builder() .with_focus(TimelineFocus::Live { // we show threads as separate timelines in their own RoomScreen hide_threaded_events: true, @@ -3868,12 +3440,7 @@ async fn add_new_room( .track_read_marker_and_receipts(TimelineReadReceiptTracking::AllEvents) .build() .await - .map_err(|e| { - anyhow::anyhow!( - "BUG: Failed to build timeline for room {}: {e}", - new_room.room_id - ) - })?, + .map_err(|e| anyhow::anyhow!("BUG: Failed to build timeline for room {}: {e}", new_room.room_id))?, ); let (timeline_update_sender, timeline_update_receiver) = crossbeam_channel::unbounded(); @@ -3889,11 +3456,7 @@ async fn add_new_room( // We need to add the room to the `ALL_JOINED_ROOMS` list before we can send // an `AddJoinedRoom` update to the RoomsList widget, because that widget might // immediately issue a `MatrixRequest` that relies on that room being in `ALL_JOINED_ROOMS`. - log!( - "Adding new joined room {}, name: {:?}", - new_room.room_id, - new_room.display_name - ); + log!("Adding new joined room {}, name: {:?}", new_room.room_id, new_room.display_name); ALL_JOINED_ROOMS.lock().unwrap().insert( new_room.room_id.clone(), JoinedRoomDetails { @@ -3914,8 +3477,7 @@ async fn add_new_room( let latest = get_latest_event_details( &new_room.room.latest_event().await, room_list_service.client(), - ) - .await; + ).await; let room_name_id = RoomNameId::from((new_room.display_name.clone(), new_room.room_id.clone())); // Start with a basic text avatar; the avatar image will be fetched asynchronously below. let room_avatar = avatar_from_room_name(room_name_id.name_for_avatar()); @@ -3946,8 +3508,7 @@ async fn add_new_room( #[allow(unused)] async fn current_ignore_user_list(client: &Client) -> Option> { use matrix_sdk::ruma::events::ignored_user_list::IgnoredUserListEventContent; - let ignored_users = client - .account() + let ignored_users = client.account() .account_data::() .await .ok()?? @@ -4011,9 +3572,7 @@ fn handle_load_app_state(user_id: OwnedUserId) { && !app_state.saved_dock_state_home.dock_items.is_empty() { log!("Loaded room panel state from app data directory. Restoring now..."); - Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState( - app_state, - )); + Cx::post_action(AppStateAction::RestoreAppStateFromPersistentState(app_state)); } } Err(_e) => { @@ -4032,12 +3591,12 @@ fn handle_load_app_state(user_id: OwnedUserId) { fn is_invalid_token_error(e: &sync_service::Error) -> bool { use matrix_sdk::ruma::api::client::error::ErrorKind; let sdk_error = match e { - sync_service::Error::RoomList(matrix_sdk_ui::room_list_service::Error::SlidingSync( - err, - )) => err, - sync_service::Error::EncryptionSync(encryption_sync_service::Error::SlidingSync(err)) => { - err - } + sync_service::Error::RoomList( + matrix_sdk_ui::room_list_service::Error::SlidingSync(err) + ) => err, + sync_service::Error::EncryptionSync( + encryption_sync_service::Error::SlidingSync(err) + ) => err, _ => return false, }; matches!( @@ -4119,12 +3678,14 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { const SYNC_INDICATOR_DELAY: Duration = Duration::from_millis(100); /// Duration for sync indicator delay before hiding const SYNC_INDICATOR_HIDE_DELAY: Duration = Duration::from_millis(200); - let sync_indicator_stream = sync_service - .room_list_service() - .sync_indicator(SYNC_INDICATOR_DELAY, SYNC_INDICATOR_HIDE_DELAY); - + let sync_indicator_stream = sync_service.room_list_service() + .sync_indicator( + SYNC_INDICATOR_DELAY, + SYNC_INDICATOR_HIDE_DELAY + ); + Handle::current().spawn(async move { - let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); + let mut sync_indicator_stream = std::pin::pin!(sync_indicator_stream); while let Some(indicator) = sync_indicator_stream.next().await { let is_syncing = match indicator { @@ -4137,10 +3698,7 @@ fn handle_sync_indicator_subscriber(sync_service: &SyncService) { } fn handle_room_list_service_loading_state(mut loading_state: Subscriber) { - log!( - "Initial room list loading state is {:?}", - loading_state.get() - ); + log!("Initial room list loading state is {:?}", loading_state.get()); Handle::current().spawn(async move { while let Some(state) = loading_state.next().await { log!("Received a room list loading state update: {state:?}"); @@ -4148,12 +3706,8 @@ fn handle_room_list_service_loading_state(mut loading_state: Subscriber { enqueue_rooms_list_update(RoomsListUpdate::NotLoaded); } - RoomListLoadingState::Loaded { - maximum_number_of_rooms, - } => { - enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { - max_rooms: maximum_number_of_rooms, - }); + RoomListLoadingState::Loaded { maximum_number_of_rooms } => { + enqueue_rooms_list_update(RoomsListUpdate::LoadedRooms { max_rooms: maximum_number_of_rooms }); // The SDK docs state that we cannot move from the `Loaded` state // back to the `NotLoaded` state, so we can safely exit this task here. return; @@ -4176,12 +3730,12 @@ fn spawn_fetch_successor_room_preview( Handle::current().spawn(async move { log!("Updating room {tombstoned_room_id} to be tombstoned, {successor_room:?}"); let srd = if let Some(SuccessorRoom { room_id, reason }) = successor_room { - match fetch_room_preview_with_avatar(&client, room_id.deref().into(), Vec::new()).await - { - Ok(room_preview) => SuccessorRoomDetails::Full { - room_preview, - reason, - }, + match fetch_room_preview_with_avatar( + &client, + room_id.deref().into(), + Vec::new(), + ).await { + Ok(room_preview) => SuccessorRoomDetails::Full { room_preview, reason }, Err(e) => { log!("Failed to fetch preview of successor room {room_id}, error: {e:?}"); SuccessorRoomDetails::Basic(SuccessorRoom { room_id, reason }) @@ -4215,18 +3769,12 @@ async fn fetch_room_preview_with_avatar( }; match client.media().get_media_content(&media_request, true).await { Ok(avatar_content) => { - log!( - "Fetched avatar for room preview {:?} ({})", - room_preview.name, - room_preview.room_id - ); + log!("Fetched avatar for room preview {:?} ({})", room_preview.name, room_preview.room_id); FetchedRoomAvatar::Image(avatar_content.into()) } Err(e) => { - log!( - "Failed to fetch avatar for room preview {:?} ({}), error: {e:?}", - room_preview.name, - room_preview.room_id + log!("Failed to fetch avatar for room preview {:?} ({}), error: {e:?}", + room_preview.name, room_preview.room_id ); avatar_from_room_name(room_preview.name.as_deref()) } @@ -4246,10 +3794,7 @@ async fn fetch_room_preview_with_avatar( async fn fetch_thread_summary_details( room: &Room, thread_root_event_id: &EventId, -) -> ( - u32, - Option, -) { +) -> (u32, Option) { let mut num_replies = 0; let mut latest_reply_event = None; @@ -4307,7 +3852,10 @@ async fn fetch_latest_thread_reply_event( } /// Counts all replies in the given thread by paginating `/relations` in batches. -async fn count_thread_replies(room: &Room, thread_root_event_id: &EventId) -> Option { +async fn count_thread_replies( + room: &Room, + thread_root_event_id: &EventId, +) -> Option { let mut total_replies: u32 = 0; let mut next_batch_token = None; @@ -4320,10 +3868,7 @@ async fn count_thread_replies(room: &Room, thread_root_event_id: &EventId) -> Op ..Default::default() }; - let relations = room - .relations(thread_root_event_id.to_owned(), options) - .await - .ok()?; + let relations = room.relations(thread_root_event_id.to_owned(), options).await.ok()?; if relations.chunk.is_empty() { break; } @@ -4349,8 +3894,7 @@ async fn text_preview_of_latest_thread_reply( Ok(Some(rm)) => Some(rm), _ => room.get_member(&sender_id).await.ok().flatten(), }; - let sender_name = sender_room_member - .as_ref() + let sender_name = sender_room_member.as_ref() .and_then(|rm| rm.display_name()) .unwrap_or(sender_id.as_str()); let text_preview = text_preview_of_raw_timeline_event(raw, sender_name).unwrap_or_else(|| { @@ -4367,6 +3911,7 @@ async fn text_preview_of_latest_thread_reply( } } + /// Returns the timestamp and an HTML-formatted text preview of the given `latest_event`. /// /// If the sender profile of the event is not yet available, this function will @@ -4390,37 +3935,29 @@ async fn get_latest_event_details( match latest_event_value { LatestEventValue::None => None, - LatestEventValue::Remote { - timestamp, - sender, - is_own, - profile, - content, - } => { + LatestEventValue::Remote { timestamp, sender, is_own, profile, content } => { let sender_username = get_sender_username!(profile, sender, *is_own); - let latest_message_text = - text_preview_of_timeline_item(content, sender, &sender_username) - .format_with(&sender_username, true); + let latest_message_text = text_preview_of_timeline_item( + content, + sender, + &sender_username, + ).format_with(&sender_username, true); Some((*timestamp, latest_message_text)) } - LatestEventValue::Local { - timestamp, - sender, - profile, - content, - state: _, - } => { + LatestEventValue::Local { timestamp, sender, profile, content, state: _ } => { // TODO: use the `state` enum to augment the preview text with more details. // Example: "Sending... {msg}" or // "Failed to send {msg}" let is_own = current_user_id().is_some_and(|id| &id == sender); let sender_username = get_sender_username!(profile, sender, is_own); - let latest_message_text = - text_preview_of_timeline_item(content, sender, &sender_username) - .format_with(&sender_username, true); + let latest_message_text = text_preview_of_timeline_item( + content, + sender, + &sender_username, + ).format_with(&sender_username, true); Some((*timestamp, latest_message_text)) } - } + } } /// Handles the given updated latest event for the given room. @@ -4428,9 +3965,10 @@ async fn get_latest_event_details( /// This function sends a `RoomsListUpdate::UpdateLatestEvent` /// to update the latest event in the RoomsListEntry for the given room. async fn update_latest_event(room: &Room) { - if let Some((timestamp, latest_message_text)) = - get_latest_event_details(&room.latest_event().await, &room.client()).await - { + if let Some((timestamp, latest_message_text)) = get_latest_event_details( + &room.latest_event().await, + &room.client(), + ).await { enqueue_rooms_list_update(RoomsListUpdate::UpdateLatestEvent { room_id: room.room_id().to_owned(), timestamp, @@ -4467,6 +4005,7 @@ async fn timeline_subscriber_handler( mut request_receiver: watch::Receiver>, thread_root_event_id: Option, ) { + /// An inner function that searches the given new timeline items for a target event. /// /// If the target event is found, it is removed from the `target_event_id_opt` and returned, @@ -4475,13 +4014,14 @@ async fn timeline_subscriber_handler( target_event_id_opt: &mut Option, mut new_items_iter: impl Iterator>, ) -> Option<(usize, OwnedEventId)> { - let found_index = target_event_id_opt.as_ref().and_then(|target_event_id| { - new_items_iter.position(|new_item| { - new_item + let found_index = target_event_id_opt + .as_ref() + .and_then(|target_event_id| new_items_iter + .position(|new_item| new_item .as_event() .is_some_and(|new_ev| new_ev.event_id() == Some(target_event_id)) - }) - }); + ) + ); if let Some(index) = found_index { target_event_id_opt.take().map(|ev| (index, ev)) @@ -4490,13 +4030,11 @@ async fn timeline_subscriber_handler( } } + let room_id = room.room_id().to_owned(); log!("Starting timeline subscriber for room {room_id}, thread {thread_root_event_id:?}..."); let (mut timeline_items, mut subscriber) = timeline.subscribe().await; - log!( - "Received initial timeline update of {} items for room {room_id}, thread {thread_root_event_id:?}.", - timeline_items.len() - ); + log!("Received initial timeline update of {} items for room {room_id}, thread {thread_root_event_id:?}.", timeline_items.len()); timeline_update_sender.send(TimelineUpdate::FirstUpdate { initial_items: timeline_items.clone(), @@ -4509,266 +4047,262 @@ async fn timeline_subscriber_handler( // the timeline index and event ID of the target event, if it has been found. let mut found_target_event_id: Option<(usize, OwnedEventId)> = None; - loop { - tokio::select! { - // we should check for new requests before handling new timeline updates, - // because the request might influence how we handle a timeline update. - biased; - - // Handle updates to the current backwards pagination requests. - Ok(()) = request_receiver.changed() => { - let prev_target_event_id = target_event_id.clone(); - let new_request_details = request_receiver - .borrow_and_update() - .iter() - .find_map(|req| req.room_id - .eq(&room_id) - .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) - ); + loop { tokio::select! { + // we should check for new requests before handling new timeline updates, + // because the request might influence how we handle a timeline update. + biased; + + // Handle updates to the current backwards pagination requests. + Ok(()) = request_receiver.changed() => { + let prev_target_event_id = target_event_id.clone(); + let new_request_details = request_receiver + .borrow_and_update() + .iter() + .find_map(|req| req.room_id + .eq(&room_id) + .then(|| (req.target_event_id.clone(), req.starting_index, req.current_tl_len)) + ); - target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); + target_event_id = new_request_details.as_ref().map(|(ev, ..)| ev.clone()); - // If we received a new request, start searching backwards for the target event. - if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { - if prev_target_event_id.as_ref() != Some(&new_target_event_id) { - let starting_index = if current_tl_len == timeline_items.len() { - starting_index - } else { - // The timeline has changed since the request was made, so we can't rely on the `starting_index`. - // Instead, we have no choice but to start from the end of the timeline. - timeline_items.len() - }; - // log!("Received new request to search for event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} starting from index {starting_index} (tl len {}).", timeline_items.len()); - // Search backwards for the target event in the timeline, starting from the given index. - if let Some(target_event_tl_index) = timeline_items - .focus() - .narrow(..starting_index) - .into_iter() - .rev() - .position(|i| i.as_event() - .and_then(|e| e.event_id()) - .is_some_and(|ev_id| ev_id == new_target_event_id) - ) - .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) - { - // log!("Found existing target event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} at index {target_event_tl_index}."); - - // Nice! We found the target event in the current timeline items, - // so there's no need to actually proceed with backwards pagination; - // thus, we can clear the locally-tracked target event ID. - target_event_id = None; - found_target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: new_target_event_id.clone(), - index: target_event_tl_index, + // If we received a new request, start searching backwards for the target event. + if let Some((new_target_event_id, starting_index, current_tl_len)) = new_request_details { + if prev_target_event_id.as_ref() != Some(&new_target_event_id) { + let starting_index = if current_tl_len == timeline_items.len() { + starting_index + } else { + // The timeline has changed since the request was made, so we can't rely on the `starting_index`. + // Instead, we have no choice but to start from the end of the timeline. + timeline_items.len() + }; + // log!("Received new request to search for event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} starting from index {starting_index} (tl len {}).", timeline_items.len()); + // Search backwards for the target event in the timeline, starting from the given index. + if let Some(target_event_tl_index) = timeline_items + .focus() + .narrow(..starting_index) + .into_iter() + .rev() + .position(|i| i.as_event() + .and_then(|e| e.event_id()) + .is_some_and(|ev_id| ev_id == new_target_event_id) + ) + .map(|i| starting_index.saturating_sub(i).saturating_sub(1)) + { + // log!("Found existing target event {new_target_event_id} in room {room_id}, thread {thread_root_event_id:?} at index {target_event_tl_index}."); + + // Nice! We found the target event in the current timeline items, + // so there's no need to actually proceed with backwards pagination; + // thus, we can clear the locally-tracked target event ID. + target_event_id = None; + found_target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: new_target_event_id.clone(), + index: target_event_tl_index, + } + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}, thread {thread_root_event_id:?}!") + ); + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); + } + else { + log!("Target event not in timeline. Starting backwards pagination \ + in room {room_id}, thread {thread_root_event_id:?} to find target event \ + {new_target_event_id} starting from index {starting_index}.", + ); + // If we didn't find the target event in the current timeline items, + // we need to start loading previous items into the timeline. + submit_async_request(MatrixRequest::PaginateTimeline { + timeline_kind: if let Some(thread_root_event_id) = thread_root_event_id.clone() { + TimelineKind::Thread { + room_id: room_id.clone(), + thread_root_event_id, } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({new_target_event_id}, {target_event_tl_index}) to room {room_id}, thread {thread_root_event_id:?}!") - ); - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); - } - else { - log!("Target event not in timeline. Starting backwards pagination \ - in room {room_id}, thread {thread_root_event_id:?} to find target event \ - {new_target_event_id} starting from index {starting_index}.", - ); - // If we didn't find the target event in the current timeline items, - // we need to start loading previous items into the timeline. - submit_async_request(MatrixRequest::PaginateTimeline { - timeline_kind: if let Some(thread_root_event_id) = thread_root_event_id.clone() { - TimelineKind::Thread { - room_id: room_id.clone(), - thread_root_event_id, - } - } else { - TimelineKind::MainRoom { - room_id: room_id.clone(), - } - }, - num_events: 50, - direction: PaginationDirection::Backwards, - }); - } + } else { + TimelineKind::MainRoom { + room_id: room_id.clone(), + } + }, + num_events: 50, + direction: PaginationDirection::Backwards, + }); } } } + } - // Handle updates to the actual timeline content. - batch_opt = subscriber.next() => { - let Some(batch) = batch_opt else { break }; - let mut num_updates = 0; - let mut index_of_first_change = usize::MAX; - let mut index_of_last_change = usize::MIN; - // whether to clear the entire cache of drawn items - let mut clear_cache = false; - // whether the changes include items being appended to the end of the timeline - let mut is_append = false; - for diff in batch { - num_updates += 1; - match diff { - VectorDiff::Append { values } => { - let _values_len = values.len(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.extend(values); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } - is_append = true; - } - VectorDiff::Clear => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Clear"); } - clear_cache = true; - timeline_items.clear(); + // Handle updates to the actual timeline content. + batch_opt = subscriber.next() => { + let Some(batch) = batch_opt else { break }; + let mut num_updates = 0; + let mut index_of_first_change = usize::MAX; + let mut index_of_last_change = usize::MIN; + // whether to clear the entire cache of drawn items + let mut clear_cache = false; + // whether the changes include items being appended to the end of the timeline + let mut is_append = false; + for diff in batch { + num_updates += 1; + match diff { + VectorDiff::Append { values } => { + let _values_len = values.len(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.extend(values); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Append {_values_len}. Changes: {index_of_first_change}..{index_of_last_change}"); } + is_append = true; + } + VectorDiff::Clear => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Clear"); } + clear_cache = true; + timeline_items.clear(); + } + VectorDiff::PushFront { value } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushFront"); } + if let Some((index, _ev)) = found_target_event_id.as_mut() { + *index += 1; // account for this new `value` being prepended. + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); } - VectorDiff::PushFront { value } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushFront"); } - if let Some((index, _ev)) = found_target_event_id.as_mut() { - *index += 1; // account for this new `value` being prepended. - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)); - } - clear_cache = true; - timeline_items.push_front(value); - } - VectorDiff::PushBack { value } => { - index_of_first_change = min(index_of_first_change, timeline_items.len()); - timeline_items.push_back(value); - index_of_last_change = max(index_of_last_change, timeline_items.len()); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } - is_append = true; + clear_cache = true; + timeline_items.push_front(value); + } + VectorDiff::PushBack { value } => { + index_of_first_change = min(index_of_first_change, timeline_items.len()); + timeline_items.push_back(value); + index_of_last_change = max(index_of_last_change, timeline_items.len()); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PushBack. Changes: {index_of_first_change}..{index_of_last_change}"); } + is_append = true; + } + VectorDiff::PopFront => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopFront"); } + clear_cache = true; + timeline_items.pop_front(); + if let Some((i, _ev)) = found_target_event_id.as_mut() { + *i = i.saturating_sub(1); // account for the first item being removed. } - VectorDiff::PopFront => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopFront"); } + // This doesn't affect whether we should reobtain the latest event. + } + VectorDiff::PopBack => { + timeline_items.pop_back(); + index_of_first_change = min(index_of_first_change, timeline_items.len()); + index_of_last_change = usize::MAX; + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Insert { index, value } => { + if index == 0 { clear_cache = true; - timeline_items.pop_front(); - if let Some((i, _ev)) = found_target_event_id.as_mut() { - *i = i.saturating_sub(1); // account for the first item being removed. - } - // This doesn't affect whether we should reobtain the latest event. - } - VectorDiff::PopBack => { - timeline_items.pop_back(); - index_of_first_change = min(index_of_first_change, timeline_items.len()); + } else { + index_of_first_change = min(index_of_first_change, index); index_of_last_change = usize::MAX; - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff PopBack. Changes: {index_of_first_change}..{index_of_last_change}"); } } - VectorDiff::Insert { index, value } => { - if index == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = usize::MAX; - } - if index >= timeline_items.len() { - is_append = true; - } + if index >= timeline_items.len() { + is_append = true; + } - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for this new `value` being inserted before the previously-found target event's index. - if index <= *i { - *i += 1; - } - } else { - found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) - .map(|(i, ev)| (i + index, ev)); + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for this new `value` being inserted before the previously-found target event's index. + if index <= *i { + *i += 1; } - - timeline_items.insert(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } - } - VectorDiff::Set { index, value } => { - index_of_first_change = min(index_of_first_change, index); - index_of_last_change = max(index_of_last_change, index.saturating_add(1)); - timeline_items.set(index, value); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } else { + found_target_event_id = find_target_event(&mut target_event_id, std::iter::once(&value)) + .map(|(i, ev)| (i + index, ev)); } - VectorDiff::Remove { index } => { - if index == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); - index_of_last_change = usize::MAX; - } - if let Some((i, _ev)) = found_target_event_id.as_mut() { - // account for an item being removed before the previously-found target event's index. - if index <= *i { - *i = i.saturating_sub(1); - } - } - timeline_items.remove(index); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + + timeline_items.insert(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Insert at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Set { index, value } => { + index_of_first_change = min(index_of_first_change, index); + index_of_last_change = max(index_of_last_change, index.saturating_add(1)); + timeline_items.set(index, value); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Set at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Remove { index } => { + if index == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, index.saturating_sub(1)); + index_of_last_change = usize::MAX; } - VectorDiff::Truncate { length } => { - if length == 0 { - clear_cache = true; - } else { - index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); - index_of_last_change = usize::MAX; + if let Some((i, _ev)) = found_target_event_id.as_mut() { + // account for an item being removed before the previously-found target event's index. + if index <= *i { + *i = i.saturating_sub(1); } - timeline_items.truncate(length); - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } } - VectorDiff::Reset { values } => { - if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Reset, new length {}", values.len()); } - clear_cache = true; // we must assume all items have changed. - timeline_items = values; + timeline_items.remove(index); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Remove at {index}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Truncate { length } => { + if length == 0 { + clear_cache = true; + } else { + index_of_first_change = min(index_of_first_change, length.saturating_sub(1)); + index_of_last_change = usize::MAX; } + timeline_items.truncate(length); + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Truncate to length {length}. Changes: {index_of_first_change}..{index_of_last_change}"); } + } + VectorDiff::Reset { values } => { + if LOG_TIMELINE_DIFFS { log!("timeline_subscriber: room {room_id}, thread {thread_root_event_id:?} diff Reset, new length {}", values.len()); } + clear_cache = true; // we must assume all items have changed. + timeline_items = values; } } + } - if num_updates > 0 { - // Handle the case where back pagination inserts items at the beginning of the timeline - // (meaning the entire timeline needs to be re-drawn), - // but there is a virtual event at index 0 (e.g., a day divider). - // When that happens, we want the RoomScreen to treat this as if *all* events changed. - if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { - index_of_first_change = 0; - clear_cache = true; - } + if num_updates > 0 { + // Handle the case where back pagination inserts items at the beginning of the timeline + // (meaning the entire timeline needs to be re-drawn), + // but there is a virtual event at index 0 (e.g., a day divider). + // When that happens, we want the RoomScreen to treat this as if *all* events changed. + if index_of_first_change == 1 && timeline_items.front().and_then(|item| item.as_virtual()).is_some() { + index_of_first_change = 0; + clear_cache = true; + } - let changed_indices = index_of_first_change..index_of_last_change; + let changed_indices = index_of_first_change..index_of_last_change; - if LOG_TIMELINE_DIFFS { - log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, thread {thread_root_event_id:?}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); - } - timeline_update_sender.send(TimelineUpdate::NewItems { - new_items: timeline_items.clone(), - changed_indices, - clear_cache, - is_append, - }).expect("Error: timeline update sender couldn't send update with new items!"); - - // We must send this update *after* the actual NewItems update, - // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. - if let Some((index, found_event_id)) = found_target_event_id.take() { - target_event_id = None; - timeline_update_sender.send( - TimelineUpdate::TargetEventFound { - target_event_id: found_event_id.clone(), - index, - } - ).unwrap_or_else( - |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}, thread {thread_root_event_id:?}!") - ); - } - - // Send a Makepad-level signal to update this room's timeline UI view. - SignalToUI::set_ui_signal(); + if LOG_TIMELINE_DIFFS { + log!("timeline_subscriber: applied {num_updates} updates for room {room_id}, thread {thread_root_event_id:?}, timeline now has {} items. is_append? {is_append}, clear_cache? {clear_cache}. Changes: {changed_indices:?}.", timeline_items.len()); + } + timeline_update_sender.send(TimelineUpdate::NewItems { + new_items: timeline_items.clone(), + changed_indices, + clear_cache, + is_append, + }).expect("Error: timeline update sender couldn't send update with new items!"); + + // We must send this update *after* the actual NewItems update, + // otherwise the UI thread (RoomScreen) won't be able to correctly locate the target event. + if let Some((index, found_event_id)) = found_target_event_id.take() { + target_event_id = None; + timeline_update_sender.send( + TimelineUpdate::TargetEventFound { + target_event_id: found_event_id.clone(), + index, + } + ).unwrap_or_else( + |_e| panic!("Error: timeline update sender couldn't send TargetEventFound({found_event_id}, {index}) to room {room_id}, thread {thread_root_event_id:?}!") + ); } - } - else => { - break; + // Send a Makepad-level signal to update this room's timeline UI view. + SignalToUI::set_ui_signal(); } } - } - error!( - "Error: unexpectedly ended timeline subscriber for room {room_id}, thread {thread_root_event_id:?}." - ); + else => { + break; + } + } } + + error!("Error: unexpectedly ended timeline subscriber for room {room_id}, thread {thread_root_event_id:?}."); } /// Spawn a new async task to fetch the room's new avatar. @@ -4793,13 +4327,8 @@ async fn room_avatar(room: &Room, room_name_id: &RoomNameId) -> FetchedRoomAvata _ => { if let Ok(room_members) = room.members(RoomMemberships::ACTIVE).await { if room_members.len() == 2 { - if let Some(non_account_member) = - room_members.iter().find(|m| !m.is_account_user()) - { - if let Ok(Some(avatar)) = non_account_member - .avatar(AVATAR_THUMBNAIL_FORMAT.into()) - .await - { + if let Some(non_account_member) = room_members.iter().find(|m| !m.is_account_user()) { + if let Ok(Some(avatar)) = non_account_member.avatar(AVATAR_THUMBNAIL_FORMAT.into()).await { return FetchedRoomAvatar::Image(avatar.into()); } } @@ -4828,8 +4357,7 @@ async fn spawn_sso_server( // Post a status update to inform the user that we're waiting for the client to be built. Cx::post_action(LoginAction::Status { title: "Initializing client...".into(), - status: "Please wait while Matrix builds and configures the client object for login." - .into(), + status: "Please wait while Matrix builds and configures the client object for login.".into(), }); // Wait for the notification that the client has been built @@ -4850,21 +4378,19 @@ async fn spawn_sso_server( // or if the homeserver_url is *not* empty and isn't the default, // we cannot use the DEFAULT_SSO_CLIENT, so we must build a new one. let mut build_client_error = None; - if client_and_session.is_none() - || (!homeserver_url.is_empty() + if client_and_session.is_none() || ( + !homeserver_url.is_empty() && homeserver_url != "matrix.org" && Url::parse(&homeserver_url) != Url::parse("https://matrix-client.matrix.org/") - && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/")) - { + && Url::parse(&homeserver_url) != Url::parse("https://matrix.org/") + ) { match build_client( &Cli { homeserver: homeserver_url.is_empty().not().then_some(homeserver_url), ..Default::default() }, app_data_dir(), - ) - .await - { + ).await { Ok(success) => client_and_session = Some(success), Err(e) => build_client_error = Some(e), } @@ -4873,12 +4399,10 @@ async fn spawn_sso_server( let Some((client, client_session)) = client_and_session else { Cx::post_action(LoginAction::LoginFailure( if let Some(err) = build_client_error { - format!( - "Could not create client object. Please try to login again.\n\nError: {err}" - ) + format!("Could not create client object. Please try to login again.\n\nError: {err}") } else { String::from("Could not create client object. Please try to login again.") - }, + } )); // This ensures that the called to `DEFAULT_SSO_CLIENT_NOTIFIER.notified()` // at the top of this function will not block upon the next login attempt. @@ -4890,8 +4414,7 @@ async fn spawn_sso_server( let mut is_logged_in = false; Cx::post_action(LoginAction::Status { title: "Opening your browser...".into(), - status: "Please finish logging in using your browser, and then come back to Robrix." - .into(), + status: "Please finish logging in using your browser, and then come back to Robrix.".into(), }); match client .matrix_auth() @@ -4901,15 +4424,12 @@ async fn spawn_sso_server( if key == "redirectUrl" { let redirect_url = Url::parse(&value)?; Cx::post_action(LoginAction::SsoSetRedirectUrl(redirect_url)); - break; + break } } - Uri::new(&sso_url).open().map_err(|err| { - Error::Io(io::Error::other(format!( - "Unable to open SSO login url. Error: {:?}", - err - ))) - }) + Uri::new(&sso_url).open().map_err(|err| + Error::Io(io::Error::other(format!("Unable to open SSO login url. Error: {:?}", err))) + ) }) .identity_provider_id(&identity_provider_id) .initial_device_display_name(&format!("robrix-sso-{brand}")) @@ -4924,13 +4444,10 @@ async fn spawn_sso_server( }) { Ok(identity_provider_res) => { if !is_logged_in { - if let Err(e) = login_sender - .send(LoginRequest::LoginBySSOSuccess(client, client_session)) - .await - { + if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session)).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to matrix worker thread.", + "BUG: failed to send login request to matrix worker thread." ))); } enqueue_rooms_list_update(RoomsListUpdate::Status { @@ -4956,6 +4473,7 @@ async fn spawn_sso_server( }); } + bitflags! { /// The powers that a user has in a given room. #[derive(Copy, Clone, PartialEq, Eq)] @@ -5033,38 +4551,14 @@ impl UserPowerLevels { retval.set(UserPowerLevels::Invite, user_power >= power_levels.invite); retval.set(UserPowerLevels::Kick, user_power >= power_levels.kick); retval.set(UserPowerLevels::Redact, user_power >= power_levels.redact); - retval.set( - UserPowerLevels::NotifyRoom, - user_power >= power_levels.notifications.room, - ); - retval.set( - UserPowerLevels::Location, - user_power >= power_levels.for_message(MessageLikeEventType::Location), - ); - retval.set( - UserPowerLevels::Message, - user_power >= power_levels.for_message(MessageLikeEventType::Message), - ); - retval.set( - UserPowerLevels::Reaction, - user_power >= power_levels.for_message(MessageLikeEventType::Reaction), - ); - retval.set( - UserPowerLevels::RoomMessage, - user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage), - ); - retval.set( - UserPowerLevels::RoomRedaction, - user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction), - ); - retval.set( - UserPowerLevels::Sticker, - user_power >= power_levels.for_message(MessageLikeEventType::Sticker), - ); - retval.set( - UserPowerLevels::RoomPinnedEvents, - user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents), - ); + retval.set(UserPowerLevels::NotifyRoom, user_power >= power_levels.notifications.room); + retval.set(UserPowerLevels::Location, user_power >= power_levels.for_message(MessageLikeEventType::Location)); + retval.set(UserPowerLevels::Message, user_power >= power_levels.for_message(MessageLikeEventType::Message)); + retval.set(UserPowerLevels::Reaction, user_power >= power_levels.for_message(MessageLikeEventType::Reaction)); + retval.set(UserPowerLevels::RoomMessage, user_power >= power_levels.for_message(MessageLikeEventType::RoomMessage)); + retval.set(UserPowerLevels::RoomRedaction, user_power >= power_levels.for_message(MessageLikeEventType::RoomRedaction)); + retval.set(UserPowerLevels::Sticker, user_power >= power_levels.for_message(MessageLikeEventType::Sticker)); + retval.set(UserPowerLevels::RoomPinnedEvents, user_power >= power_levels.for_state(StateEventType::RoomPinnedEvents)); retval } @@ -5110,7 +4604,8 @@ impl UserPowerLevels { } pub fn can_send_message(self) -> bool { - self.contains(UserPowerLevels::RoomMessage) || self.contains(UserPowerLevels::Message) + self.contains(UserPowerLevels::RoomMessage) + || self.contains(UserPowerLevels::Message) } pub fn can_send_reaction(self) -> bool { @@ -5127,6 +4622,7 @@ impl UserPowerLevels { } } + /// Shuts down the current Tokio runtime completely and takes ownership to ensure proper cleanup. pub fn shutdown_background_tasks() { if let Some(runtime) = TOKIO_RUNTIME.lock().unwrap().take() { @@ -5144,16 +4640,9 @@ pub async fn clear_app_state(config: &LogoutConfig) -> Result<()> { ALL_JOINED_ROOMS.lock().unwrap().clear(); let on_clear_appstate = Arc::new(Notify::new()); - Cx::post_action(LogoutAction::ClearAppState { - on_clear_appstate: on_clear_appstate.clone(), - }); - - match tokio::time::timeout( - config.app_state_cleanup_timeout, - on_clear_appstate.notified(), - ) - .await - { + Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); + + match tokio::time::timeout(config.app_state_cleanup_timeout, on_clear_appstate.notified()).await { Ok(_) => { log!("Received signal that UI-side app state was cleaned successfully"); Ok(()) From 9f1f5fd1e7185a1f127f1f12d60c0f7e91f37306 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: Thu, 26 Mar 2026 09:30:43 +0800 Subject: [PATCH 29/66] chore: remove diff noise from bot management changes --- src/home/home_screen.rs | 1 + src/home/room_context_menu.rs | 8 +------- src/settings/settings_screen.rs | 21 +++------------------ 3 files changed, 5 insertions(+), 25 deletions(-) diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index 418c5214c..c4d34d2aa 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -512,3 +512,4 @@ impl HomeScreen { ) } } + diff --git a/src/home/room_context_menu.rs b/src/home/room_context_menu.rs index b2aaf90aa..9a73e08b8 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -3,13 +3,7 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; -use crate::{ - app::AppState, - home::invite_modal::InviteModalAction, - shared::popup_list::{PopupKind, enqueue_popup_notification}, - sliding_sync::{MatrixRequest, current_user_id, submit_async_request}, - utils::RoomNameId, -}; +use crate::{app::AppState, home::invite_modal::InviteModalAction, shared::popup_list::{PopupKind, enqueue_popup_notification}, sliding_sync::{MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId}; const BUTTON_HEIGHT: f64 = 35.0; const MENU_WIDTH: f64 = 215.0; diff --git a/src/settings/settings_screen.rs b/src/settings/settings_screen.rs index 79c690997..5d24945bc 100644 --- a/src/settings/settings_screen.rs +++ b/src/settings/settings_screen.rs @@ -1,12 +1,7 @@ use makepad_widgets::*; -use crate::{ - app::BotSettingsState, - home::navigation_tab_bar::{NavigationBarAction, get_own_profile}, - profile::user_profile::UserProfile, - settings::{account_settings::AccountSettingsWidgetExt, bot_settings::BotSettingsWidgetExt}, -}; +use crate::{app::BotSettingsState, home::navigation_tab_bar::{NavigationBarAction, get_own_profile}, profile::user_profile::UserProfile, settings::{account_settings::AccountSettingsWidgetExt, bot_settings::BotSettingsWidgetExt}}; script_mod! { use mod.prelude.widgets.* @@ -173,12 +168,7 @@ impl Widget for SettingsScreen { impl SettingsScreen { /// Fetches the current user's profile and uses it to populate the settings screen. - pub fn populate( - &mut self, - cx: &mut Cx, - own_profile: Option, - bot_settings: &BotSettingsState, - ) { + pub fn populate(&mut self, cx: &mut Cx, own_profile: Option, bot_settings: &BotSettingsState) { let Some(profile) = own_profile.or_else(|| get_own_profile(cx)) else { error!("Failed to get own profile for settings screen."); return; @@ -193,12 +183,7 @@ impl SettingsScreen { impl SettingsScreenRef { /// See [`SettingsScreen::populate()`]. - pub fn populate( - &self, - cx: &mut Cx, - own_profile: Option, - bot_settings: &BotSettingsState, - ) { + pub fn populate(&self, cx: &mut Cx, own_profile: Option, bot_settings: &BotSettingsState) { let Some(mut inner) = self.borrow_mut() else { return; }; inner.populate(cx, own_profile, bot_settings); } From 71b28853db84deae5a8b230a40d5218c079395a5 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: Thu, 26 Mar 2026 09:39:22 +0800 Subject: [PATCH 30/66] refactor: drop login and persistence changes from bot management --- src/app.rs | 2 +- src/login/login_screen.rs | 211 ++++------ src/persistence/matrix_state.rs | 74 +--- src/sliding_sync.rs | 667 +++++++++----------------------- 4 files changed, 246 insertions(+), 708 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2897cffc8..d20772c5d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -993,7 +993,7 @@ pub struct AppState { /// Whether a user is currently logged in to Robrix or not. pub logged_in: bool, /// Local configuration and UI state for bot-assisted room binding. - #[serde(default)] + #[serde(skip)] pub bot_settings: BotSettingsState, } diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index bf4ec59c2..3b3c322a1 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -3,7 +3,7 @@ use std::ops::Not; use makepad_widgets::*; use url::Url; -use crate::sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest, RegisterAccount}; +use crate::sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest}; use super::login_status_modal::{LoginStatusModalAction, LoginStatusModalWidgetExt}; @@ -69,13 +69,19 @@ script_mod! { } } - View { + RoundedView { margin: Inset{top: 40, bottom: 40} width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit align: Align{x: 0.5, y: 0.5} flow: Overlay, + show_bg: true, + draw_bg +: { + color: (COLOR_SECONDARY) + border_radius: 6.0 + } + View { width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit @@ -117,19 +123,6 @@ script_mod! { is_password: true, } - confirm_password_wrapper := View { - width: 275, height: Fit, - visible: false, - - confirm_password_input := RobrixTextInput { - width: 275, height: Fit - flow: Right, // do not wrap - padding: 10, - empty_text: "Confirm password" - is_password: true, - } - } - View { width: 275, height: Fit, flow: Down, @@ -178,61 +171,54 @@ script_mod! { text: "Login" } - login_only_view := View { - width: Fit, height: Fit, - flow: Down, - align: Align{x: 0.5, y: 0.5} - spacing: 15.0 + LineH { + width: 275 + margin: Inset{bottom: -5} + draw_bg.color: #C8C8C8 + } - LineH { - width: 275 - margin: Inset{bottom: -5} - draw_bg.color: #C8C8C8 + Label { + width: Fit, height: Fit + padding: 0, + draw_text +: { + color: (COLOR_TEXT) + text_style: TITLE_TEXT {font_size: 11.0} } + text: "Or, login with an SSO provider:" + } - Label { - width: Fit, height: Fit - padding: 0, - draw_text +: { - color: (COLOR_TEXT) - text_style: TITLE_TEXT {font_size: 11.0} + sso_view := View { + width: 275, height: Fit, + margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide + flow: Flow.Right{wrap: true}, + apple_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/apple.png") } - text: "Or, login with an SSO provider:" } - - sso_view := View { - width: 275, height: Fit, - margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide - flow: Flow.Right{wrap: true}, - apple_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/apple.png") - } - } - facebook_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/facebook.png") - } + facebook_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/facebook.png") } - github_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/github.png") - } + } + github_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/github.png") } - gitlab_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/gitlab.png") - } + } + gitlab_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/gitlab.png") } - google_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/google.png") - } + } + google_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/google.png") } - twitter_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/x.png") - } + } + twitter_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/x.png") } } } @@ -247,7 +233,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } - account_prompt_label := Label { + Label { width: Fit, height: Fit padding: Inset{left: 1, right: 1, top: 0, bottom: 0} draw_text +: { @@ -260,7 +246,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } } - mode_toggle_button := RobrixIconButton { + signup_button := RobrixIconButton { width: Fit, height: Fit padding: Inset{left: 15, right: 15, top: 10, bottom: 10} margin: Inset{bottom: 5} @@ -284,44 +270,16 @@ script_mod! { } } +static MATRIX_SIGN_UP_URL: &str = "https://matrix.org/docs/chat_basics/matrix-for-im/#creating-a-matrix-account"; + #[derive(Script, ScriptHook, Widget)] pub struct LoginScreen { #[source] source: ScriptObjectRef, #[deref] view: View, - /// Whether the screen is showing the in-app sign-up flow. - #[rust] signup_mode: bool, /// Boolean to indicate if the SSO login process is still in flight #[rust] sso_pending: bool, /// The URL to redirect to after logging in with SSO. #[rust] sso_redirect_url: Option, - /// The most recent login failure message shown to the user. - #[rust] last_failure_message_shown: Option, -} - -impl LoginScreen { - fn set_signup_mode(&mut self, cx: &mut Cx, signup_mode: bool) { - self.signup_mode = signup_mode; - self.view.view(cx, ids!(confirm_password_wrapper)).set_visible(cx, signup_mode); - self.view.view(cx, ids!(login_only_view)).set_visible(cx, !signup_mode); - self.view.label(cx, ids!(title)).set_text(cx, - if signup_mode { "Create your Robrix account" } else { "Login to Robrix" } - ); - self.view.button(cx, ids!(login_button)).set_text(cx, - if signup_mode { "Create account" } else { "Login" } - ); - self.view.label(cx, ids!(account_prompt_label)).set_text(cx, - if signup_mode { "Already have an account?" } else { "Don't have an account?" } - ); - self.view.button(cx, ids!(mode_toggle_button)).set_text(cx, - if signup_mode { "Back to login" } else { "Sign up here" } - ); - - if !signup_mode { - self.view.text_input(cx, ids!(confirm_password_input)).set_text(cx, ""); - } - - self.redraw(cx); - } } @@ -339,29 +297,27 @@ impl Widget for LoginScreen { impl MatchEvent for LoginScreen { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { let login_button = self.view.button(cx, ids!(login_button)); - let mode_toggle_button = self.view.button(cx, ids!(mode_toggle_button)); + let signup_button = self.view.button(cx, ids!(signup_button)); let user_id_input = self.view.text_input(cx, ids!(user_id_input)); let password_input = self.view.text_input(cx, ids!(password_input)); - let confirm_password_input = self.view.text_input(cx, ids!(confirm_password_input)); let homeserver_input = self.view.text_input(cx, ids!(homeserver_input)); let login_status_modal = self.view.modal(cx, ids!(login_status_modal)); let login_status_modal_inner = self.view.login_status_modal(cx, ids!(login_status_modal_inner)); - if mode_toggle_button.clicked(actions) { - self.set_signup_mode(cx, !self.signup_mode); + if signup_button.clicked(actions) { + log!("Opening URL \"{}\"", MATRIX_SIGN_UP_URL); + let _ = robius_open::Uri::new(MATRIX_SIGN_UP_URL).open(); } if login_button.clicked(actions) || user_id_input.returned(actions).is_some() || password_input.returned(actions).is_some() - || (self.signup_mode && confirm_password_input.returned(actions).is_some()) || homeserver_input.returned(actions).is_some() { - let user_id = user_id_input.text().trim().to_owned(); + let user_id = user_id_input.text(); let password = password_input.text(); - let confirm_password = confirm_password_input.text(); - let homeserver = homeserver_input.text().trim().to_owned(); + let homeserver = homeserver_input.text(); if user_id.is_empty() { login_status_modal_inner.set_title(cx, "Missing User ID"); login_status_modal_inner.set_status(cx, "Please enter a valid User ID."); @@ -370,39 +326,15 @@ impl MatchEvent for LoginScreen { login_status_modal_inner.set_title(cx, "Missing Password"); login_status_modal_inner.set_status(cx, "Please enter a valid password."); login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); - } else if self.signup_mode && password != confirm_password { - login_status_modal_inner.set_title(cx, "Passwords do not match"); - login_status_modal_inner.set_status(cx, "Please enter the same password in both password fields."); - login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); } else { - self.last_failure_message_shown = None; - login_status_modal_inner.set_title(cx, if self.signup_mode { - "Creating account..." - } else { - "Logging in..." - }); - login_status_modal_inner.set_status( - cx, - if self.signup_mode { - "Waiting for the homeserver to create your account..." - } else { - "Waiting for a login response..." - }, - ); + login_status_modal_inner.set_title(cx, "Logging in..."); + login_status_modal_inner.set_status(cx, "Waiting for a login response..."); login_status_modal_inner.button_ref(cx).set_text(cx, "Cancel"); - submit_async_request(MatrixRequest::Login(if self.signup_mode { - LoginRequest::Register(RegisterAccount { - user_id, - password, - homeserver: homeserver.is_empty().not().then_some(homeserver), - }) - } else { - LoginRequest::LoginByPassword(LoginByPassword { - user_id, - password, - homeserver: homeserver.is_empty().not().then_some(homeserver), - }) - })); + submit_async_request(MatrixRequest::Login(LoginRequest::LoginByPassword(LoginByPassword { + user_id, + password, + homeserver: homeserver.is_empty().not().then_some(homeserver), + }))); } login_status_modal.open(cx); self.redraw(cx); @@ -425,7 +357,6 @@ impl MatchEvent for LoginScreen { // Handle login-related actions received from background async tasks. match action.downcast_ref() { Some(LoginAction::CliAutoLogin { user_id, homeserver }) => { - self.last_failure_message_shown = None; user_id_input.set_text(cx, user_id); password_input.set_text(cx, ""); homeserver_input.set_text(cx, homeserver.as_deref().unwrap_or_default()); @@ -440,7 +371,6 @@ impl MatchEvent for LoginScreen { login_status_modal.open(cx); } Some(LoginAction::Status { title, status }) => { - self.last_failure_message_shown = None; login_status_modal_inner.set_title(cx, title); login_status_modal_inner.set_status(cx, status); let login_status_modal_button = login_status_modal_inner.button_ref(cx); @@ -452,25 +382,14 @@ impl MatchEvent for LoginScreen { Some(LoginAction::LoginSuccess) => { // The main `App` component handles showing the main screen // and hiding the login screen & login status modal. - self.last_failure_message_shown = None; - self.set_signup_mode(cx, false); user_id_input.set_text(cx, ""); password_input.set_text(cx, ""); - confirm_password_input.set_text(cx, ""); homeserver_input.set_text(cx, ""); login_status_modal.close(cx); self.redraw(cx); } Some(LoginAction::LoginFailure(error)) => { - if self.last_failure_message_shown.as_deref() == Some(error.as_str()) { - continue; - } - self.last_failure_message_shown = Some(error.clone()); - login_status_modal_inner.set_title(cx, if self.signup_mode { - "Account Creation Failed." - } else { - "Login Failed." - }); + login_status_modal_inner.set_title(cx, "Login Failed."); login_status_modal_inner.set_status(cx, error); let login_status_modal_button = login_status_modal_inner.button_ref(cx); login_status_modal_button.set_text(cx, "Okay"); diff --git a/src/persistence/matrix_state.rs b/src/persistence/matrix_state.rs index f7d09bdf8..d99855b7c 100644 --- a/src/persistence/matrix_state.rs +++ b/src/persistence/matrix_state.rs @@ -1,8 +1,8 @@ //! Handles app persistence by saving and restoring client session data to/from the filesystem. -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::{anyhow, bail}; -use makepad_widgets::{log, warning, Cx}; +use makepad_widgets::{log, Cx}; use matrix_sdk::{ authentication::matrix::MatrixSession, ruma::{OwnedUserId, UserId}, @@ -254,73 +254,3 @@ pub async fn delete_latest_user_id() -> anyhow::Result { Ok(false) } } - -async fn delete_path_if_exists(path: &Path) -> anyhow::Result { - let metadata = match tokio::fs::metadata(path).await { - Ok(metadata) => metadata, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(e) => return Err(anyhow!("Failed to inspect path {}: {e}", path.display())), - }; - - if metadata.is_dir() { - tokio::fs::remove_dir_all(path) - .await - .map_err(|e| anyhow!("Failed to remove directory {}: {e}", path.display()))?; - } else { - tokio::fs::remove_file(path) - .await - .map_err(|e| anyhow!("Failed to remove file {}: {e}", path.display()))?; - } - - Ok(true) -} - -/// Remove the persisted Matrix session file for the given user if it exists. -/// -/// Returns: -/// - Ok(true) if the session file was found and deleted -/// - Ok(false) if the session file didn't exist -/// - Err if deletion failed -pub async fn delete_session(user_id: &UserId) -> anyhow::Result { - let session_file = session_file_path(user_id); - - if session_file.exists() { - let persisted_db_path = match tokio::fs::read_to_string(&session_file).await { - Ok(serialized_session) => { - match serde_json::from_str::(&serialized_session) { - Ok(session) => Some(session.client_session.db_path), - Err(e) => { - warning!( - "Failed to parse session file {} before cleanup: {e}", - session_file.display() - ); - None - } - } - } - Err(e) => { - warning!( - "Failed to read session file {} before cleanup: {e}", - session_file.display() - ); - None - } - }; - - if let Some(db_path) = persisted_db_path { - if let Err(e) = delete_path_if_exists(&db_path).await { - warning!( - "Failed to remove persisted Matrix store {} for {user_id}: {e}", - db_path.display() - ); - } - } - - tokio::fs::remove_file(&session_file) - .await - .map_err(|e| anyhow::anyhow!("Failed to remove session file {session_file:?}: {e}")) - .map(|_| true) - } else { - Ok(false) - } -} diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 255332260..489efba51 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -9,13 +9,7 @@ use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ - api::{Direction, client::{ - account::register::v3::Request as RegistrationRequest, - error::ErrorKind, - profile::{AvatarUrl, DisplayName}, - receipt::create_receipt::v3::ReceiptType, - uiaa::{AuthData, AuthType, Dummy}, - }}, events::{ + api::{Direction, client::{profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType}}, events::{ relation::RelationType, room::{ message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource @@ -90,11 +84,9 @@ impl std::fmt::Debug for Cli { impl From for Cli { fn from(login: LoginByPassword) -> Self { Self { - user_id: login.user_id.trim().to_owned(), + user_id: login.user_id, password: login.password, - homeserver: login.homeserver - .map(|homeserver| homeserver.trim().to_owned()) - .filter(|homeserver| !homeserver.is_empty()), + homeserver: login.homeserver, proxy: None, login_screen: false, verbose: false, @@ -102,186 +94,6 @@ impl From for Cli { } } -impl From for Cli { - fn from(registration: RegisterAccount) -> Self { - Self { - user_id: registration.user_id.trim().to_owned(), - password: registration.password, - homeserver: registration.homeserver - .map(|homeserver| homeserver.trim().to_owned()) - .filter(|homeserver| !homeserver.is_empty()), - proxy: None, - login_screen: false, - verbose: false, - } - } -} - -fn infer_homeserver_from_user_id(user_id: &str) -> Option { - let user_id: OwnedUserId = user_id.trim().try_into().ok()?; - Some(user_id.server_name().to_string()) -} - -async fn finalize_authenticated_client( - client: Client, - client_session: ClientSessionPersisted, - fallback_user_id: &str, -) -> Result<(Client, Option)> { - if client.matrix_auth().logged_in() { - let logged_in_user_id = client.user_id() - .map(ToString::to_string) - .unwrap_or_else(|| fallback_user_id.to_owned()); - log!("Logged in successfully."); - let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); - enqueue_rooms_list_update(RoomsListUpdate::Status { status }); - if let Err(e) = persistence::save_session(&client, client_session).await { - let err_msg = format!("Failed to save session state to storage: {e}"); - error!("{err_msg}"); - enqueue_popup_notification(err_msg, PopupKind::Error, None); - } - Ok((client, None)) - } else { - let err_msg = format!( - "Authentication succeeded for {fallback_user_id}, but the homeserver did not return a login session." - ); - enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); - bail!(err_msg); - } -} - -fn registration_localpart(user_id: &str) -> Result { - let trimmed = user_id.trim(); - if trimmed.is_empty() { - bail!("Please enter a valid username or Matrix user ID."); - } - - if let Ok(full_user_id) = >::try_from(trimmed) { - return Ok(full_user_id.localpart().to_owned()); - } - - let localpart = trimmed.trim_start_matches('@'); - if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) { - bail!("Please enter a valid username or full Matrix user ID."); - } - - Ok(localpart.to_owned()) -} - -fn registration_request( - username: &str, - password: &str, - session: Option, -) -> RegistrationRequest { - let mut request = RegistrationRequest::new(); - request.username = Some(username.to_owned()); - request.password = Some(password.to_owned()); - request.initial_device_display_name = Some("robrix-un-pw".to_owned()); - request.refresh_token = true; - if let Some(session) = session { - let mut dummy = Dummy::new(); - dummy.session = Some(session); - request.auth = Some(AuthData::Dummy(dummy)); - } - request -} - -fn registration_uiaa_error_message(error: &matrix_sdk::Error) -> String { - if let matrix_sdk::Error::Http(http_error) = error { - match http_error.client_api_error_kind() { - Some(ErrorKind::UserInUse) => { - return "That user ID is already taken. Please choose another one.".to_owned(); - } - Some(ErrorKind::InvalidUsername) => { - return "That user ID is invalid. Use a username like `alice` or a full Matrix ID like `@alice:matrix.org`.".to_owned(); - } - Some(ErrorKind::WeakPassword) => { - return "That password is too weak. Please choose a stronger password.".to_owned(); - } - Some(ErrorKind::Forbidden { .. }) => { - return "This homeserver does not allow open registration.".to_owned(); - } - Some(ErrorKind::LimitExceeded { .. }) => { - return "The homeserver is rate limiting account creation right now. Please try again shortly.".to_owned(); - } - _ => {} - } - } - - format!("Could not create account: {error}") -} - -fn unsupported_registration_flow_message( - flows: &[matrix_sdk::ruma::api::client::uiaa::AuthFlow], -) -> String { - let supports_registration_token = flows.iter().any(|flow| { - flow.stages - .iter() - .any(|stage| matches!(stage, AuthType::RegistrationToken)) - }); - if supports_registration_token { - return "This homeserver requires a registration token. Robrix does not support token-based registration yet.".to_owned(); - } - - let supports_terms = flows.iter().any(|flow| { - flow.stages - .iter() - .any(|stage| matches!(stage, AuthType::Terms)) - }); - if supports_terms { - return "This homeserver requires an interactive terms-of-service step. Robrix does not support that registration flow yet.".to_owned(); - } - - "This homeserver requires an unsupported registration flow. Please try another homeserver or register with a different client.".to_owned() -} - -async fn clear_persisted_session(user_id: Option<&UserId>) { - let Some(user_id) = user_id else { - return; - }; - - if let Err(e) = persistence::delete_session(user_id).await { - warning!("Failed to delete persisted session for {user_id}: {e}"); - } - - let latest_user_id = persistence::most_recent_user_id().await; - if latest_user_id.as_deref() == Some(user_id) { - if let Err(e) = persistence::delete_latest_user_id().await { - warning!("Failed to delete latest user id for {user_id}: {e}"); - } - } -} - -enum SessionResetAction { - Reauthenticate { message: String }, -} - -async fn reset_runtime_state_for_relogin() { - let sync_service = { SYNC_SERVICE.lock().unwrap().take() }; - if let Some(sync_service) = sync_service { - sync_service.stop().await; - } - - CLIENT.lock().unwrap().take(); - DEFAULT_SSO_CLIENT.lock().unwrap().take(); - IGNORED_USERS.lock().unwrap().clear(); - ALL_JOINED_ROOMS.lock().unwrap().clear(); - - let on_clear_appstate = Arc::new(Notify::new()); - Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); - - if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()).await.is_err() { - warning!("Timed out waiting for UI-side app state cleanup during re-login reset"); - } -} - -fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { - matches!( - error.client_api_error_kind(), - Some(ErrorKind::UnknownToken { .. } | ErrorKind::MissingToken) - ) -} - /// Build a new client. async fn build_client( @@ -304,10 +116,7 @@ async fn build_client( .collect() }; - let inferred_homeserver = infer_homeserver_from_user_id(&cli.user_id); let homeserver_url = cli.homeserver.as_deref() - .filter(|homeserver| !homeserver.trim().is_empty()) - .or(inferred_homeserver.as_deref()) .unwrap_or("https://matrix-client.matrix.org/"); // .unwrap_or("https://matrix.org/"); @@ -382,71 +191,23 @@ async fn login( .initial_device_display_name("robrix-un-pw") .send() .await?; - if !client.matrix_auth().logged_in() { - let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); - enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); - bail!(err_msg); - } - finalize_authenticated_client(client, client_session, &cli.user_id).await - } - - LoginRequest::Register(registration) => { - let cli = Cli::from(RegisterAccount { - user_id: registration.user_id.clone(), - password: registration.password.clone(), - homeserver: registration.homeserver.clone(), - }); - let localpart = registration_localpart(®istration.user_id)?; - let (client, client_session) = build_client(&cli, app_data_dir()).await?; - Cx::post_action(LoginAction::Status { - title: "Creating account".into(), - status: format!("Creating account {localpart}..."), - }); - - let auth = client.matrix_auth(); - let initial_request = registration_request(&localpart, ®istration.password, None); - let register_result = match auth.register(initial_request).await { - Ok(response) => Ok(response), - Err(error) => { - if let Some(uiaa_info) = error.as_uiaa_response() { - let supports_dummy = uiaa_info.flows.iter().any(|flow| { - flow.stages - .iter() - .any(|stage| matches!(stage, AuthType::Dummy)) - }); - if supports_dummy { - Cx::post_action(LoginAction::Status { - title: "Completing sign up".into(), - status: "Confirming registration with the homeserver...".into(), - }); - auth.register(registration_request( - &localpart, - ®istration.password, - uiaa_info.session.clone(), - )) - .await - } else { - bail!(unsupported_registration_flow_message(&uiaa_info.flows)); - } - } else { - bail!(registration_uiaa_error_message(&error)); - } + if client.matrix_auth().logged_in() { + log!("Logged in successfully."); + let status = format!("Logged in as {}.\n → Loading rooms...", cli.user_id); + // enqueue_popup_notification(status.clone()); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + if let Err(e) = persistence::save_session(&client, client_session).await { + let err_msg = format!("Failed to save session state to storage: {e}"); + error!("{err_msg}"); + enqueue_popup_notification(err_msg, PopupKind::Error, None); } - }?; - - if !client.matrix_auth().logged_in() { - let err_msg = format!( - "Account {} was created, but the homeserver did not return a login session. Please log in manually.", - register_result.user_id, - ); + Ok((client, None)) + } else { + let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } - - finalize_authenticated_client(client, client_session, register_result.user_id.as_str()) - .await } LoginRequest::LoginBySSOSuccess(client, client_session) => { @@ -926,7 +687,6 @@ pub fn submit_async_request(req: MatrixRequest) { /// Details of a login request that get submitted within [`MatrixRequest::Login`]. pub enum LoginRequest{ LoginByPassword(LoginByPassword), - Register(RegisterAccount), LoginBySSOSuccess(Client, ClientSessionPersisted), LoginByCli, HomeserverLoginTypesQuery(String), @@ -939,14 +699,6 @@ pub struct LoginByPassword { pub homeserver: Option, } -/// Information needed to register a new account on a Matrix homeserver. -#[derive(Clone)] -pub struct RegisterAccount { - pub user_id: String, - pub password: String, - pub homeserver: Option, -} - /// The entry point for the worker task that runs Matrix-related operations. /// @@ -2607,7 +2359,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { ); log!("Waiting for login? {}", wait_for_login); - let new_login_opt: Option<(Client, Option, bool)> = if !wait_for_login { + let new_login_opt = if !wait_for_login { let specified_username = cli_parse_result.as_ref().ok().and_then(|cli| username_to_full_user_id( &cli.user_id, @@ -2617,17 +2369,11 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { log!("Trying to restore session for user: {:?}", specified_username.as_ref().or(most_recent_user_id.as_ref()) ); - match persistence::restore_session(specified_username.clone()).await { - Ok((client, sync_token)) => Some((client, sync_token, true)), + match persistence::restore_session(specified_username).await { + Ok(session) => Some(session), Err(e) => { let status_err = "Could not restore previous user session.\n\nPlease login again."; log!("{status_err} Error: {e:?}"); - clear_persisted_session( - specified_username - .as_deref() - .or(most_recent_user_id.as_deref()), - ) - .await; Cx::post_action(LoginAction::LoginFailure(status_err.to_string())); if let Ok(cli) = &cli_parse_result { @@ -2637,7 +2383,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { homeserver: cli.homeserver.clone(), }); match login(cli, LoginRequest::LoginByCli).await { - Ok((client, sync_token)) => Some((client, sync_token, false)), + Ok(new_login) => Some(new_login), Err(e) => { error!("CLI-based login failed: {e:?}"); Cx::post_action(LoginAction::LoginFailure( @@ -2663,247 +2409,197 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // which causes the loop to wait for the user to submit a new manual login request. let mut initial_client_opt = new_login_opt; - loop { - let (client, sync_service, logged_in_user_id) = 'login_loop: loop { - let (client, _sync_token, validate_session) = match initial_client_opt.take() { - Some(login) => login, - None => { - loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => { - match login(&cli, login_request).await { - Ok((client, sync_token)) => break (client, sync_token, false), - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), - }); - } + let (client, sync_service, logged_in_user_id) = 'login_loop: loop { + let (client, _sync_token) = match initial_client_opt.take() { + Some(login) => login, + None => { + loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => { + match login(&cli, login_request).await { + Ok((client, sync_token)) => break (client, sync_token), + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: format!("Login failed: {e}"), + }); } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); - Cx::post_action(LoginAction::LoginFailure(err.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err, - }); - return; } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); + Cx::post_action(LoginAction::LoginFailure(err.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err, + }); + return; } } } - }; - - if validate_session { - match client.whoami().await { - Ok(_) => {} - Err(e) if is_invalid_token_http_error(&e) => { - clear_persisted_session(client.user_id()).await; - let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; - Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err_msg.to_string(), - }); - continue 'login_loop; - } - Err(e) => { - warning!("Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}"); - } - } } + }; - // Deallocate the default SSO client after a successful login. - if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { - let _ = client_opt.take(); - } + // Deallocate the default SSO client after a successful login. + if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { + let _ = client_opt.take(); + } - let logged_in_user_id: OwnedUserId = client.user_id() - .expect("BUG: Client::user_id() returned None after successful login!") - .to_owned(); - let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); - enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + let logged_in_user_id: OwnedUserId = client.user_id() + .expect("BUG: Client::user_id() returned None after successful login!") + .to_owned(); + let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); - // Store this active client in our global Client state so that other tasks can access it. - if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { - error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); - } + // Store this active client in our global Client state so that other tasks can access it. + if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { + error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); + } - // Listen for changes to our verification status and incoming verification requests. - add_verification_event_handlers_and_sync_client(client.clone()); + // Listen for changes to our verification status and incoming verification requests. + add_verification_event_handlers_and_sync_client(client.clone()); - // Listen for updates to the ignored user list. - handle_ignore_user_list_subscriber(client.clone()); + // Listen for updates to the ignored user list. + handle_ignore_user_list_subscriber(client.clone()); - Cx::post_action(LoginAction::Status { - title: "Connecting".into(), - status: "Setting up sync service...".into(), - }); - let sync_service = match SyncService::builder(client.clone()) - .with_offline_mode() - .build() - .await - { - Ok(ss) => ss, - Err(e) => { - error!("Failed to create SyncService: {e:?}"); - let err_msg = if is_invalid_token_error(&e) { - "Your login token is no longer valid.\n\nPlease log in again.".to_string() - } else { - format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") - }; - if is_invalid_token_error(&e) { - clear_persisted_session(client.user_id()).await; - } - Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); - enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); - // Clear the stored client so the next login attempt doesn't trigger the - // "unexpectedly replaced an existing client" warning. - let _ = CLIENT.lock().unwrap().take(); - continue 'login_loop; - } - }; + // Listen for session changes, e.g., when the access token becomes invalid. + handle_session_changes(client.clone()); - break 'login_loop (client, sync_service, logged_in_user_id); + Cx::post_action(LoginAction::Status { + title: "Connecting".into(), + status: "Setting up sync service...".into(), + }); + let sync_service = match SyncService::builder(client.clone()) + .with_offline_mode() + .build() + .await + { + Ok(ss) => ss, + Err(e) => { + error!("Failed to create SyncService: {e:?}"); + let err_msg = if is_invalid_token_error(&e) { + "Your login token is no longer valid.\n\nPlease log in again.".to_string() + } else { + format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") + }; + Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); + // Clear the stored client so the next login attempt doesn't trigger the + // "unexpectedly replaced an existing client" warning. + let _ = CLIENT.lock().unwrap().take(); + continue 'login_loop; + } }; - let (session_reset_sender, mut session_reset_receiver) = - tokio::sync::mpsc::unbounded_channel::(); - let session_change_handler_task = - handle_session_changes(client.clone(), session_reset_sender); + break 'login_loop (client, sync_service, logged_in_user_id); + }; - // Signal login success now that SyncService::build() has already succeeded (inside - // 'login_loop), which is the only step that can fail with an invalid/expired token. - // Doing this before sync_service.start() lets the UI transition to the home screen - // without waiting for the sync loop to begin. - Cx::post_action(LoginAction::LoginSuccess); + // Signal login success now that SyncService::build() has already succeeded (inside + // 'login_loop), which is the only step that can fail with an invalid/expired token. + // Doing this before sync_service.start() lets the UI transition to the home screen + // without waiting for the sync loop to begin. + Cx::post_action(LoginAction::LoginSuccess); - // Attempt to load the previously-saved app state. - handle_load_app_state(logged_in_user_id.to_owned()); - handle_sync_indicator_subscriber(&sync_service); - handle_sync_service_state_subscriber(sync_service.state()); - sync_service.start().await; + // Attempt to load the previously-saved app state. + handle_load_app_state(logged_in_user_id.to_owned()); + handle_sync_indicator_subscriber(&sync_service); + handle_sync_service_state_subscriber(sync_service.state()); + sync_service.start().await; - let room_list_service = sync_service.room_list_service(); + let room_list_service = sync_service.room_list_service(); - if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { - error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); - } + if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { + error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); + } - let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); - let mut space_service_task = rt.spawn(space_service_loop(client)); - - // Now, this task becomes an infinite loop that monitors the - // matrix/background tasks for the currently-authenticated session. - #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. - let reauth_message = loop { - tokio::select! { - session_reset = session_reset_receiver.recv() => { - match session_reset { - Some(SessionResetAction::Reauthenticate { message }) => { - break message; - } - None => { - warning!("Session reset receiver closed unexpectedly."); - continue; - } - } - } - result = &mut matrix_worker_task_handle => { - session_change_handler_task.abort(); - match result { - Ok(Ok(())) => { - // Check if this is due to logout - if is_logout_in_progress() { - log!("matrix worker task ended due to logout"); - } else { - error!("BUG: matrix worker task ended unexpectedly!"); - } - } - Ok(Err(e)) => { - // Check if this is due to logout - if is_logout_in_progress() { - log!("matrix worker task ended with error due to logout: {e:?}"); - } else { - error!("Error: matrix worker task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Rooms list update error: {e}"), - PopupKind::Error, - None, - ); - } - }, - Err(e) => { - error!("BUG: failed to join matrix worker task: {e:?}"); + let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); + let mut space_service_task = rt.spawn(space_service_loop(client)); + + // Now, this task becomes an infinite loop that monitors the state of the + // three core matrix-related background tasks that we just spawned above. + #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. + loop { + tokio::select! { + result = &mut matrix_worker_task_handle => { + match result { + Ok(Ok(())) => { + // Check if this is due to logout + if is_logout_in_progress() { + log!("matrix worker task ended due to logout"); + } else { + error!("BUG: matrix worker task ended unexpectedly!"); } } - return; - } - result = &mut room_list_service_task => { - session_change_handler_task.abort(); - match result { - Ok(Ok(())) => { - error!("BUG: room list service loop task ended unexpectedly!"); - } - Ok(Err(e)) => { - error!("Error: room list service loop task ended:\n\t{e:?}"); + Ok(Err(e)) => { + // Check if this is due to logout + if is_logout_in_progress() { + log!("matrix worker task ended with error due to logout: {e:?}"); + } else { + error!("Error: matrix worker task ended:\n\t{e:?}"); rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { status: e.to_string(), }); enqueue_popup_notification( - format!("Room list service error: {e}"), + format!("Rooms list update error: {e}"), PopupKind::Error, None, ); - }, - Err(e) => { - error!("BUG: failed to join room list service loop task: {e:?}"); } + }, + Err(e) => { + error!("BUG: failed to join matrix worker task: {e:?}"); } - return; } - result = &mut space_service_task => { - session_change_handler_task.abort(); - match result { - Ok(Ok(())) => { - error!("BUG: space service loop task ended unexpectedly!"); - } - Ok(Err(e)) => { - error!("Error: space service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Space service error: {e}"), - PopupKind::Error, - None, - ); - }, - Err(e) => { - error!("BUG: failed to join space service loop task: {e:?}"); - } + break; + } + result = &mut room_list_service_task => { + match result { + Ok(Ok(())) => { + error!("BUG: room list service loop task ended unexpectedly!"); + } + Ok(Err(e)) => { + error!("Error: room list service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Room list service error: {e}"), + PopupKind::Error, + None, + ); + }, + Err(e) => { + error!("BUG: failed to join room list service loop task: {e:?}"); } - return; } + break; } - }; - - session_change_handler_task.abort(); - room_list_service_task.abort(); - space_service_task.abort(); - - reset_runtime_state_for_relogin().await; - Cx::post_action(LoginAction::LoginFailure(reauth_message.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: reauth_message, - }); - initial_client_opt = None; + result = &mut space_service_task => { + match result { + Ok(Ok(())) => { + error!("BUG: space service loop task ended unexpectedly!"); + } + Ok(Err(e)) => { + error!("Error: space service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Space service error: {e}"), + PopupKind::Error, + None, + ); + }, + Err(e) => { + error!("BUG: failed to join space service loop task: {e:?}"); + } + } + break; + } + } } } @@ -3610,10 +3306,7 @@ fn is_invalid_token_error(e: &sync_service::Error) -> bool { /// When the homeserver rejects the access token with a 401 `M_UNKNOWN_TOKEN` error /// (e.g., the token was revoked or expired), this emits a [`LoginAction::LoginFailure`] /// so the user is prompted to log in again. -fn handle_session_changes( - client: Client, - session_reset_sender: UnboundedSender, -) -> JoinHandle<()> { +fn handle_session_changes(client: Client) { let mut receiver = client.subscribe_to_session_changes(); Handle::current().spawn(async move { loop { @@ -3625,11 +3318,7 @@ fn handle_session_changes( "Your login token is no longer valid.\n\nPlease log in again." }; error!("Session token is no longer valid (soft_logout: {soft_logout}). Prompting re-login."); - clear_persisted_session(client.user_id()).await; - let _ = session_reset_sender.send(SessionResetAction::Reauthenticate { - message: msg.to_string(), - }); - break; + Cx::post_action(LoginAction::LoginFailure(msg.to_string())); } Ok(SessionChange::TokensRefreshed) => {} Err(broadcast::error::RecvError::Lagged(n)) => { @@ -3640,7 +3329,7 @@ fn handle_session_changes( } } } - }) + }); } fn handle_sync_service_state_subscriber(mut subscriber: Subscriber) { From d417e92d5f08d26ac115fcc3e664808c8c7ad6b4 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: Thu, 26 Mar 2026 09:49:13 +0800 Subject: [PATCH 31/66] refactor: align bot management branch with robrix2 main --- src/login/login_screen.rs | 211 ++++++---- src/persistence/matrix_state.rs | 74 +++- src/sliding_sync.rs | 667 +++++++++++++++++++++++--------- 3 files changed, 707 insertions(+), 245 deletions(-) diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index 3b3c322a1..bf4ec59c2 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -3,7 +3,7 @@ use std::ops::Not; use makepad_widgets::*; use url::Url; -use crate::sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest}; +use crate::sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest, RegisterAccount}; use super::login_status_modal::{LoginStatusModalAction, LoginStatusModalWidgetExt}; @@ -69,19 +69,13 @@ script_mod! { } } - RoundedView { + View { margin: Inset{top: 40, bottom: 40} width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit align: Align{x: 0.5, y: 0.5} flow: Overlay, - show_bg: true, - draw_bg +: { - color: (COLOR_SECONDARY) - border_radius: 6.0 - } - View { width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit @@ -123,6 +117,19 @@ script_mod! { is_password: true, } + confirm_password_wrapper := View { + width: 275, height: Fit, + visible: false, + + confirm_password_input := RobrixTextInput { + width: 275, height: Fit + flow: Right, // do not wrap + padding: 10, + empty_text: "Confirm password" + is_password: true, + } + } + View { width: 275, height: Fit, flow: Down, @@ -171,54 +178,61 @@ script_mod! { text: "Login" } - LineH { - width: 275 - margin: Inset{bottom: -5} - draw_bg.color: #C8C8C8 - } + login_only_view := View { + width: Fit, height: Fit, + flow: Down, + align: Align{x: 0.5, y: 0.5} + spacing: 15.0 - Label { - width: Fit, height: Fit - padding: 0, - draw_text +: { - color: (COLOR_TEXT) - text_style: TITLE_TEXT {font_size: 11.0} + LineH { + width: 275 + margin: Inset{bottom: -5} + draw_bg.color: #C8C8C8 } - text: "Or, login with an SSO provider:" - } - sso_view := View { - width: 275, height: Fit, - margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide - flow: Flow.Right{wrap: true}, - apple_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/apple.png") + Label { + width: Fit, height: Fit + padding: 0, + draw_text +: { + color: (COLOR_TEXT) + text_style: TITLE_TEXT {font_size: 11.0} } + text: "Or, login with an SSO provider:" } - facebook_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/facebook.png") + + sso_view := View { + width: 275, height: Fit, + margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide + flow: Flow.Right{wrap: true}, + apple_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/apple.png") + } } - } - github_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/github.png") + facebook_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/facebook.png") + } } - } - gitlab_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/gitlab.png") + github_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/github.png") + } } - } - google_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/google.png") + gitlab_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/gitlab.png") + } } - } - twitter_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/x.png") + google_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/google.png") + } + } + twitter_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/x.png") + } } } } @@ -233,7 +247,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } - Label { + account_prompt_label := Label { width: Fit, height: Fit padding: Inset{left: 1, right: 1, top: 0, bottom: 0} draw_text +: { @@ -246,7 +260,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } } - signup_button := RobrixIconButton { + mode_toggle_button := RobrixIconButton { width: Fit, height: Fit padding: Inset{left: 15, right: 15, top: 10, bottom: 10} margin: Inset{bottom: 5} @@ -270,16 +284,44 @@ script_mod! { } } -static MATRIX_SIGN_UP_URL: &str = "https://matrix.org/docs/chat_basics/matrix-for-im/#creating-a-matrix-account"; - #[derive(Script, ScriptHook, Widget)] pub struct LoginScreen { #[source] source: ScriptObjectRef, #[deref] view: View, + /// Whether the screen is showing the in-app sign-up flow. + #[rust] signup_mode: bool, /// Boolean to indicate if the SSO login process is still in flight #[rust] sso_pending: bool, /// The URL to redirect to after logging in with SSO. #[rust] sso_redirect_url: Option, + /// The most recent login failure message shown to the user. + #[rust] last_failure_message_shown: Option, +} + +impl LoginScreen { + fn set_signup_mode(&mut self, cx: &mut Cx, signup_mode: bool) { + self.signup_mode = signup_mode; + self.view.view(cx, ids!(confirm_password_wrapper)).set_visible(cx, signup_mode); + self.view.view(cx, ids!(login_only_view)).set_visible(cx, !signup_mode); + self.view.label(cx, ids!(title)).set_text(cx, + if signup_mode { "Create your Robrix account" } else { "Login to Robrix" } + ); + self.view.button(cx, ids!(login_button)).set_text(cx, + if signup_mode { "Create account" } else { "Login" } + ); + self.view.label(cx, ids!(account_prompt_label)).set_text(cx, + if signup_mode { "Already have an account?" } else { "Don't have an account?" } + ); + self.view.button(cx, ids!(mode_toggle_button)).set_text(cx, + if signup_mode { "Back to login" } else { "Sign up here" } + ); + + if !signup_mode { + self.view.text_input(cx, ids!(confirm_password_input)).set_text(cx, ""); + } + + self.redraw(cx); + } } @@ -297,27 +339,29 @@ impl Widget for LoginScreen { impl MatchEvent for LoginScreen { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { let login_button = self.view.button(cx, ids!(login_button)); - let signup_button = self.view.button(cx, ids!(signup_button)); + let mode_toggle_button = self.view.button(cx, ids!(mode_toggle_button)); let user_id_input = self.view.text_input(cx, ids!(user_id_input)); let password_input = self.view.text_input(cx, ids!(password_input)); + let confirm_password_input = self.view.text_input(cx, ids!(confirm_password_input)); let homeserver_input = self.view.text_input(cx, ids!(homeserver_input)); let login_status_modal = self.view.modal(cx, ids!(login_status_modal)); let login_status_modal_inner = self.view.login_status_modal(cx, ids!(login_status_modal_inner)); - if signup_button.clicked(actions) { - log!("Opening URL \"{}\"", MATRIX_SIGN_UP_URL); - let _ = robius_open::Uri::new(MATRIX_SIGN_UP_URL).open(); + if mode_toggle_button.clicked(actions) { + self.set_signup_mode(cx, !self.signup_mode); } if login_button.clicked(actions) || user_id_input.returned(actions).is_some() || password_input.returned(actions).is_some() + || (self.signup_mode && confirm_password_input.returned(actions).is_some()) || homeserver_input.returned(actions).is_some() { - let user_id = user_id_input.text(); + let user_id = user_id_input.text().trim().to_owned(); let password = password_input.text(); - let homeserver = homeserver_input.text(); + let confirm_password = confirm_password_input.text(); + let homeserver = homeserver_input.text().trim().to_owned(); if user_id.is_empty() { login_status_modal_inner.set_title(cx, "Missing User ID"); login_status_modal_inner.set_status(cx, "Please enter a valid User ID."); @@ -326,15 +370,39 @@ impl MatchEvent for LoginScreen { login_status_modal_inner.set_title(cx, "Missing Password"); login_status_modal_inner.set_status(cx, "Please enter a valid password."); login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); + } else if self.signup_mode && password != confirm_password { + login_status_modal_inner.set_title(cx, "Passwords do not match"); + login_status_modal_inner.set_status(cx, "Please enter the same password in both password fields."); + login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); } else { - login_status_modal_inner.set_title(cx, "Logging in..."); - login_status_modal_inner.set_status(cx, "Waiting for a login response..."); + self.last_failure_message_shown = None; + login_status_modal_inner.set_title(cx, if self.signup_mode { + "Creating account..." + } else { + "Logging in..." + }); + login_status_modal_inner.set_status( + cx, + if self.signup_mode { + "Waiting for the homeserver to create your account..." + } else { + "Waiting for a login response..." + }, + ); login_status_modal_inner.button_ref(cx).set_text(cx, "Cancel"); - submit_async_request(MatrixRequest::Login(LoginRequest::LoginByPassword(LoginByPassword { - user_id, - password, - homeserver: homeserver.is_empty().not().then_some(homeserver), - }))); + submit_async_request(MatrixRequest::Login(if self.signup_mode { + LoginRequest::Register(RegisterAccount { + user_id, + password, + homeserver: homeserver.is_empty().not().then_some(homeserver), + }) + } else { + LoginRequest::LoginByPassword(LoginByPassword { + user_id, + password, + homeserver: homeserver.is_empty().not().then_some(homeserver), + }) + })); } login_status_modal.open(cx); self.redraw(cx); @@ -357,6 +425,7 @@ impl MatchEvent for LoginScreen { // Handle login-related actions received from background async tasks. match action.downcast_ref() { Some(LoginAction::CliAutoLogin { user_id, homeserver }) => { + self.last_failure_message_shown = None; user_id_input.set_text(cx, user_id); password_input.set_text(cx, ""); homeserver_input.set_text(cx, homeserver.as_deref().unwrap_or_default()); @@ -371,6 +440,7 @@ impl MatchEvent for LoginScreen { login_status_modal.open(cx); } Some(LoginAction::Status { title, status }) => { + self.last_failure_message_shown = None; login_status_modal_inner.set_title(cx, title); login_status_modal_inner.set_status(cx, status); let login_status_modal_button = login_status_modal_inner.button_ref(cx); @@ -382,14 +452,25 @@ impl MatchEvent for LoginScreen { Some(LoginAction::LoginSuccess) => { // The main `App` component handles showing the main screen // and hiding the login screen & login status modal. + self.last_failure_message_shown = None; + self.set_signup_mode(cx, false); user_id_input.set_text(cx, ""); password_input.set_text(cx, ""); + confirm_password_input.set_text(cx, ""); homeserver_input.set_text(cx, ""); login_status_modal.close(cx); self.redraw(cx); } Some(LoginAction::LoginFailure(error)) => { - login_status_modal_inner.set_title(cx, "Login Failed."); + if self.last_failure_message_shown.as_deref() == Some(error.as_str()) { + continue; + } + self.last_failure_message_shown = Some(error.clone()); + login_status_modal_inner.set_title(cx, if self.signup_mode { + "Account Creation Failed." + } else { + "Login Failed." + }); login_status_modal_inner.set_status(cx, error); let login_status_modal_button = login_status_modal_inner.button_ref(cx); login_status_modal_button.set_text(cx, "Okay"); diff --git a/src/persistence/matrix_state.rs b/src/persistence/matrix_state.rs index d99855b7c..f7d09bdf8 100644 --- a/src/persistence/matrix_state.rs +++ b/src/persistence/matrix_state.rs @@ -1,8 +1,8 @@ //! Handles app persistence by saving and restoring client session data to/from the filesystem. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, bail}; -use makepad_widgets::{log, Cx}; +use makepad_widgets::{log, warning, Cx}; use matrix_sdk::{ authentication::matrix::MatrixSession, ruma::{OwnedUserId, UserId}, @@ -254,3 +254,73 @@ pub async fn delete_latest_user_id() -> anyhow::Result { Ok(false) } } + +async fn delete_path_if_exists(path: &Path) -> anyhow::Result { + let metadata = match tokio::fs::metadata(path).await { + Ok(metadata) => metadata, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(anyhow!("Failed to inspect path {}: {e}", path.display())), + }; + + if metadata.is_dir() { + tokio::fs::remove_dir_all(path) + .await + .map_err(|e| anyhow!("Failed to remove directory {}: {e}", path.display()))?; + } else { + tokio::fs::remove_file(path) + .await + .map_err(|e| anyhow!("Failed to remove file {}: {e}", path.display()))?; + } + + Ok(true) +} + +/// Remove the persisted Matrix session file for the given user if it exists. +/// +/// Returns: +/// - Ok(true) if the session file was found and deleted +/// - Ok(false) if the session file didn't exist +/// - Err if deletion failed +pub async fn delete_session(user_id: &UserId) -> anyhow::Result { + let session_file = session_file_path(user_id); + + if session_file.exists() { + let persisted_db_path = match tokio::fs::read_to_string(&session_file).await { + Ok(serialized_session) => { + match serde_json::from_str::(&serialized_session) { + Ok(session) => Some(session.client_session.db_path), + Err(e) => { + warning!( + "Failed to parse session file {} before cleanup: {e}", + session_file.display() + ); + None + } + } + } + Err(e) => { + warning!( + "Failed to read session file {} before cleanup: {e}", + session_file.display() + ); + None + } + }; + + if let Some(db_path) = persisted_db_path { + if let Err(e) = delete_path_if_exists(&db_path).await { + warning!( + "Failed to remove persisted Matrix store {} for {user_id}: {e}", + db_path.display() + ); + } + } + + tokio::fs::remove_file(&session_file) + .await + .map_err(|e| anyhow::anyhow!("Failed to remove session file {session_file:?}: {e}")) + .map(|_| true) + } else { + Ok(false) + } +} diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 489efba51..255332260 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -9,7 +9,13 @@ use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ - api::{Direction, client::{profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType}}, events::{ + api::{Direction, client::{ + account::register::v3::Request as RegistrationRequest, + error::ErrorKind, + profile::{AvatarUrl, DisplayName}, + receipt::create_receipt::v3::ReceiptType, + uiaa::{AuthData, AuthType, Dummy}, + }}, events::{ relation::RelationType, room::{ message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource @@ -84,9 +90,11 @@ impl std::fmt::Debug for Cli { impl From for Cli { fn from(login: LoginByPassword) -> Self { Self { - user_id: login.user_id, + user_id: login.user_id.trim().to_owned(), password: login.password, - homeserver: login.homeserver, + homeserver: login.homeserver + .map(|homeserver| homeserver.trim().to_owned()) + .filter(|homeserver| !homeserver.is_empty()), proxy: None, login_screen: false, verbose: false, @@ -94,6 +102,186 @@ impl From for Cli { } } +impl From for Cli { + fn from(registration: RegisterAccount) -> Self { + Self { + user_id: registration.user_id.trim().to_owned(), + password: registration.password, + homeserver: registration.homeserver + .map(|homeserver| homeserver.trim().to_owned()) + .filter(|homeserver| !homeserver.is_empty()), + proxy: None, + login_screen: false, + verbose: false, + } + } +} + +fn infer_homeserver_from_user_id(user_id: &str) -> Option { + let user_id: OwnedUserId = user_id.trim().try_into().ok()?; + Some(user_id.server_name().to_string()) +} + +async fn finalize_authenticated_client( + client: Client, + client_session: ClientSessionPersisted, + fallback_user_id: &str, +) -> Result<(Client, Option)> { + if client.matrix_auth().logged_in() { + let logged_in_user_id = client.user_id() + .map(ToString::to_string) + .unwrap_or_else(|| fallback_user_id.to_owned()); + log!("Logged in successfully."); + let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + if let Err(e) = persistence::save_session(&client, client_session).await { + let err_msg = format!("Failed to save session state to storage: {e}"); + error!("{err_msg}"); + enqueue_popup_notification(err_msg, PopupKind::Error, None); + } + Ok((client, None)) + } else { + let err_msg = format!( + "Authentication succeeded for {fallback_user_id}, but the homeserver did not return a login session." + ); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + bail!(err_msg); + } +} + +fn registration_localpart(user_id: &str) -> Result { + let trimmed = user_id.trim(); + if trimmed.is_empty() { + bail!("Please enter a valid username or Matrix user ID."); + } + + if let Ok(full_user_id) = >::try_from(trimmed) { + return Ok(full_user_id.localpart().to_owned()); + } + + let localpart = trimmed.trim_start_matches('@'); + if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) { + bail!("Please enter a valid username or full Matrix user ID."); + } + + Ok(localpart.to_owned()) +} + +fn registration_request( + username: &str, + password: &str, + session: Option, +) -> RegistrationRequest { + let mut request = RegistrationRequest::new(); + request.username = Some(username.to_owned()); + request.password = Some(password.to_owned()); + request.initial_device_display_name = Some("robrix-un-pw".to_owned()); + request.refresh_token = true; + if let Some(session) = session { + let mut dummy = Dummy::new(); + dummy.session = Some(session); + request.auth = Some(AuthData::Dummy(dummy)); + } + request +} + +fn registration_uiaa_error_message(error: &matrix_sdk::Error) -> String { + if let matrix_sdk::Error::Http(http_error) = error { + match http_error.client_api_error_kind() { + Some(ErrorKind::UserInUse) => { + return "That user ID is already taken. Please choose another one.".to_owned(); + } + Some(ErrorKind::InvalidUsername) => { + return "That user ID is invalid. Use a username like `alice` or a full Matrix ID like `@alice:matrix.org`.".to_owned(); + } + Some(ErrorKind::WeakPassword) => { + return "That password is too weak. Please choose a stronger password.".to_owned(); + } + Some(ErrorKind::Forbidden { .. }) => { + return "This homeserver does not allow open registration.".to_owned(); + } + Some(ErrorKind::LimitExceeded { .. }) => { + return "The homeserver is rate limiting account creation right now. Please try again shortly.".to_owned(); + } + _ => {} + } + } + + format!("Could not create account: {error}") +} + +fn unsupported_registration_flow_message( + flows: &[matrix_sdk::ruma::api::client::uiaa::AuthFlow], +) -> String { + let supports_registration_token = flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::RegistrationToken)) + }); + if supports_registration_token { + return "This homeserver requires a registration token. Robrix does not support token-based registration yet.".to_owned(); + } + + let supports_terms = flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::Terms)) + }); + if supports_terms { + return "This homeserver requires an interactive terms-of-service step. Robrix does not support that registration flow yet.".to_owned(); + } + + "This homeserver requires an unsupported registration flow. Please try another homeserver or register with a different client.".to_owned() +} + +async fn clear_persisted_session(user_id: Option<&UserId>) { + let Some(user_id) = user_id else { + return; + }; + + if let Err(e) = persistence::delete_session(user_id).await { + warning!("Failed to delete persisted session for {user_id}: {e}"); + } + + let latest_user_id = persistence::most_recent_user_id().await; + if latest_user_id.as_deref() == Some(user_id) { + if let Err(e) = persistence::delete_latest_user_id().await { + warning!("Failed to delete latest user id for {user_id}: {e}"); + } + } +} + +enum SessionResetAction { + Reauthenticate { message: String }, +} + +async fn reset_runtime_state_for_relogin() { + let sync_service = { SYNC_SERVICE.lock().unwrap().take() }; + if let Some(sync_service) = sync_service { + sync_service.stop().await; + } + + CLIENT.lock().unwrap().take(); + DEFAULT_SSO_CLIENT.lock().unwrap().take(); + IGNORED_USERS.lock().unwrap().clear(); + ALL_JOINED_ROOMS.lock().unwrap().clear(); + + let on_clear_appstate = Arc::new(Notify::new()); + Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); + + if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()).await.is_err() { + warning!("Timed out waiting for UI-side app state cleanup during re-login reset"); + } +} + +fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { + matches!( + error.client_api_error_kind(), + Some(ErrorKind::UnknownToken { .. } | ErrorKind::MissingToken) + ) +} + /// Build a new client. async fn build_client( @@ -116,7 +304,10 @@ async fn build_client( .collect() }; + let inferred_homeserver = infer_homeserver_from_user_id(&cli.user_id); let homeserver_url = cli.homeserver.as_deref() + .filter(|homeserver| !homeserver.trim().is_empty()) + .or(inferred_homeserver.as_deref()) .unwrap_or("https://matrix-client.matrix.org/"); // .unwrap_or("https://matrix.org/"); @@ -191,23 +382,71 @@ async fn login( .initial_device_display_name("robrix-un-pw") .send() .await?; - if client.matrix_auth().logged_in() { - log!("Logged in successfully."); - let status = format!("Logged in as {}.\n → Loading rooms...", cli.user_id); - // enqueue_popup_notification(status.clone()); - enqueue_rooms_list_update(RoomsListUpdate::Status { status }); - if let Err(e) = persistence::save_session(&client, client_session).await { - let err_msg = format!("Failed to save session state to storage: {e}"); - error!("{err_msg}"); - enqueue_popup_notification(err_msg, PopupKind::Error, None); - } - Ok((client, None)) - } else { + if !client.matrix_auth().logged_in() { let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } + finalize_authenticated_client(client, client_session, &cli.user_id).await + } + + LoginRequest::Register(registration) => { + let cli = Cli::from(RegisterAccount { + user_id: registration.user_id.clone(), + password: registration.password.clone(), + homeserver: registration.homeserver.clone(), + }); + let localpart = registration_localpart(®istration.user_id)?; + let (client, client_session) = build_client(&cli, app_data_dir()).await?; + Cx::post_action(LoginAction::Status { + title: "Creating account".into(), + status: format!("Creating account {localpart}..."), + }); + + let auth = client.matrix_auth(); + let initial_request = registration_request(&localpart, ®istration.password, None); + let register_result = match auth.register(initial_request).await { + Ok(response) => Ok(response), + Err(error) => { + if let Some(uiaa_info) = error.as_uiaa_response() { + let supports_dummy = uiaa_info.flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::Dummy)) + }); + if supports_dummy { + Cx::post_action(LoginAction::Status { + title: "Completing sign up".into(), + status: "Confirming registration with the homeserver...".into(), + }); + auth.register(registration_request( + &localpart, + ®istration.password, + uiaa_info.session.clone(), + )) + .await + } else { + bail!(unsupported_registration_flow_message(&uiaa_info.flows)); + } + } else { + bail!(registration_uiaa_error_message(&error)); + } + } + }?; + + if !client.matrix_auth().logged_in() { + let err_msg = format!( + "Account {} was created, but the homeserver did not return a login session. Please log in manually.", + register_result.user_id, + ); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + bail!(err_msg); + } + + finalize_authenticated_client(client, client_session, register_result.user_id.as_str()) + .await } LoginRequest::LoginBySSOSuccess(client, client_session) => { @@ -687,6 +926,7 @@ pub fn submit_async_request(req: MatrixRequest) { /// Details of a login request that get submitted within [`MatrixRequest::Login`]. pub enum LoginRequest{ LoginByPassword(LoginByPassword), + Register(RegisterAccount), LoginBySSOSuccess(Client, ClientSessionPersisted), LoginByCli, HomeserverLoginTypesQuery(String), @@ -699,6 +939,14 @@ pub struct LoginByPassword { pub homeserver: Option, } +/// Information needed to register a new account on a Matrix homeserver. +#[derive(Clone)] +pub struct RegisterAccount { + pub user_id: String, + pub password: String, + pub homeserver: Option, +} + /// The entry point for the worker task that runs Matrix-related operations. /// @@ -2359,7 +2607,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { ); log!("Waiting for login? {}", wait_for_login); - let new_login_opt = if !wait_for_login { + let new_login_opt: Option<(Client, Option, bool)> = if !wait_for_login { let specified_username = cli_parse_result.as_ref().ok().and_then(|cli| username_to_full_user_id( &cli.user_id, @@ -2369,11 +2617,17 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { log!("Trying to restore session for user: {:?}", specified_username.as_ref().or(most_recent_user_id.as_ref()) ); - match persistence::restore_session(specified_username).await { - Ok(session) => Some(session), + match persistence::restore_session(specified_username.clone()).await { + Ok((client, sync_token)) => Some((client, sync_token, true)), Err(e) => { let status_err = "Could not restore previous user session.\n\nPlease login again."; log!("{status_err} Error: {e:?}"); + clear_persisted_session( + specified_username + .as_deref() + .or(most_recent_user_id.as_deref()), + ) + .await; Cx::post_action(LoginAction::LoginFailure(status_err.to_string())); if let Ok(cli) = &cli_parse_result { @@ -2383,7 +2637,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { homeserver: cli.homeserver.clone(), }); match login(cli, LoginRequest::LoginByCli).await { - Ok(new_login) => Some(new_login), + Ok((client, sync_token)) => Some((client, sync_token, false)), Err(e) => { error!("CLI-based login failed: {e:?}"); Cx::post_action(LoginAction::LoginFailure( @@ -2409,197 +2663,247 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // which causes the loop to wait for the user to submit a new manual login request. let mut initial_client_opt = new_login_opt; - let (client, sync_service, logged_in_user_id) = 'login_loop: loop { - let (client, _sync_token) = match initial_client_opt.take() { - Some(login) => login, - None => { - loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => { - match login(&cli, login_request).await { - Ok((client, sync_token)) => break (client, sync_token), - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), - }); + loop { + let (client, sync_service, logged_in_user_id) = 'login_loop: loop { + let (client, _sync_token, validate_session) = match initial_client_opt.take() { + Some(login) => login, + None => { + loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => { + match login(&cli, login_request).await { + Ok((client, sync_token)) => break (client, sync_token, false), + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: format!("Login failed: {e}"), + }); + } } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); + Cx::post_action(LoginAction::LoginFailure(err.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err, + }); + return; } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); - Cx::post_action(LoginAction::LoginFailure(err.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err, - }); - return; } } } + }; + + if validate_session { + match client.whoami().await { + Ok(_) => {} + Err(e) if is_invalid_token_http_error(&e) => { + clear_persisted_session(client.user_id()).await; + let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; + Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.to_string(), + }); + continue 'login_loop; + } + Err(e) => { + warning!("Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}"); + } + } } - }; - // Deallocate the default SSO client after a successful login. - if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { - let _ = client_opt.take(); - } + // Deallocate the default SSO client after a successful login. + if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { + let _ = client_opt.take(); + } - let logged_in_user_id: OwnedUserId = client.user_id() - .expect("BUG: Client::user_id() returned None after successful login!") - .to_owned(); - let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); - enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + let logged_in_user_id: OwnedUserId = client.user_id() + .expect("BUG: Client::user_id() returned None after successful login!") + .to_owned(); + let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); - // Store this active client in our global Client state so that other tasks can access it. - if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { - error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); - } + // Store this active client in our global Client state so that other tasks can access it. + if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { + error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); + } - // Listen for changes to our verification status and incoming verification requests. - add_verification_event_handlers_and_sync_client(client.clone()); + // Listen for changes to our verification status and incoming verification requests. + add_verification_event_handlers_and_sync_client(client.clone()); - // Listen for updates to the ignored user list. - handle_ignore_user_list_subscriber(client.clone()); + // Listen for updates to the ignored user list. + handle_ignore_user_list_subscriber(client.clone()); - // Listen for session changes, e.g., when the access token becomes invalid. - handle_session_changes(client.clone()); + Cx::post_action(LoginAction::Status { + title: "Connecting".into(), + status: "Setting up sync service...".into(), + }); + let sync_service = match SyncService::builder(client.clone()) + .with_offline_mode() + .build() + .await + { + Ok(ss) => ss, + Err(e) => { + error!("Failed to create SyncService: {e:?}"); + let err_msg = if is_invalid_token_error(&e) { + "Your login token is no longer valid.\n\nPlease log in again.".to_string() + } else { + format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") + }; + if is_invalid_token_error(&e) { + clear_persisted_session(client.user_id()).await; + } + Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); + // Clear the stored client so the next login attempt doesn't trigger the + // "unexpectedly replaced an existing client" warning. + let _ = CLIENT.lock().unwrap().take(); + continue 'login_loop; + } + }; - Cx::post_action(LoginAction::Status { - title: "Connecting".into(), - status: "Setting up sync service...".into(), - }); - let sync_service = match SyncService::builder(client.clone()) - .with_offline_mode() - .build() - .await - { - Ok(ss) => ss, - Err(e) => { - error!("Failed to create SyncService: {e:?}"); - let err_msg = if is_invalid_token_error(&e) { - "Your login token is no longer valid.\n\nPlease log in again.".to_string() - } else { - format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") - }; - Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); - enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); - // Clear the stored client so the next login attempt doesn't trigger the - // "unexpectedly replaced an existing client" warning. - let _ = CLIENT.lock().unwrap().take(); - continue 'login_loop; - } + break 'login_loop (client, sync_service, logged_in_user_id); }; - break 'login_loop (client, sync_service, logged_in_user_id); - }; - - // Signal login success now that SyncService::build() has already succeeded (inside - // 'login_loop), which is the only step that can fail with an invalid/expired token. - // Doing this before sync_service.start() lets the UI transition to the home screen - // without waiting for the sync loop to begin. - Cx::post_action(LoginAction::LoginSuccess); + let (session_reset_sender, mut session_reset_receiver) = + tokio::sync::mpsc::unbounded_channel::(); + let session_change_handler_task = + handle_session_changes(client.clone(), session_reset_sender); - // Attempt to load the previously-saved app state. - handle_load_app_state(logged_in_user_id.to_owned()); - handle_sync_indicator_subscriber(&sync_service); - handle_sync_service_state_subscriber(sync_service.state()); - sync_service.start().await; + // Signal login success now that SyncService::build() has already succeeded (inside + // 'login_loop), which is the only step that can fail with an invalid/expired token. + // Doing this before sync_service.start() lets the UI transition to the home screen + // without waiting for the sync loop to begin. + Cx::post_action(LoginAction::LoginSuccess); - let room_list_service = sync_service.room_list_service(); + // Attempt to load the previously-saved app state. + handle_load_app_state(logged_in_user_id.to_owned()); + handle_sync_indicator_subscriber(&sync_service); + handle_sync_service_state_subscriber(sync_service.state()); + sync_service.start().await; - if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { - error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); - } + let room_list_service = sync_service.room_list_service(); - let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); - let mut space_service_task = rt.spawn(space_service_loop(client)); + if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { + error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); + } - // Now, this task becomes an infinite loop that monitors the state of the - // three core matrix-related background tasks that we just spawned above. - #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. - loop { - tokio::select! { - result = &mut matrix_worker_task_handle => { - match result { - Ok(Ok(())) => { - // Check if this is due to logout - if is_logout_in_progress() { - log!("matrix worker task ended due to logout"); - } else { - error!("BUG: matrix worker task ended unexpectedly!"); + let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); + let mut space_service_task = rt.spawn(space_service_loop(client)); + + // Now, this task becomes an infinite loop that monitors the + // matrix/background tasks for the currently-authenticated session. + #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. + let reauth_message = loop { + tokio::select! { + session_reset = session_reset_receiver.recv() => { + match session_reset { + Some(SessionResetAction::Reauthenticate { message }) => { + break message; + } + None => { + warning!("Session reset receiver closed unexpectedly."); + continue; } } - Ok(Err(e)) => { - // Check if this is due to logout - if is_logout_in_progress() { - log!("matrix worker task ended with error due to logout: {e:?}"); - } else { - error!("Error: matrix worker task ended:\n\t{e:?}"); + } + result = &mut matrix_worker_task_handle => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + // Check if this is due to logout + if is_logout_in_progress() { + log!("matrix worker task ended due to logout"); + } else { + error!("BUG: matrix worker task ended unexpectedly!"); + } + } + Ok(Err(e)) => { + // Check if this is due to logout + if is_logout_in_progress() { + log!("matrix worker task ended with error due to logout: {e:?}"); + } else { + error!("Error: matrix worker task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Rooms list update error: {e}"), + PopupKind::Error, + None, + ); + } + }, + Err(e) => { + error!("BUG: failed to join matrix worker task: {e:?}"); + } + } + return; + } + result = &mut room_list_service_task => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + error!("BUG: room list service loop task ended unexpectedly!"); + } + Ok(Err(e)) => { + error!("Error: room list service loop task ended:\n\t{e:?}"); rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { status: e.to_string(), }); enqueue_popup_notification( - format!("Rooms list update error: {e}"), + format!("Room list service error: {e}"), PopupKind::Error, None, ); + }, + Err(e) => { + error!("BUG: failed to join room list service loop task: {e:?}"); } - }, - Err(e) => { - error!("BUG: failed to join matrix worker task: {e:?}"); - } - } - break; - } - result = &mut room_list_service_task => { - match result { - Ok(Ok(())) => { - error!("BUG: room list service loop task ended unexpectedly!"); - } - Ok(Err(e)) => { - error!("Error: room list service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Room list service error: {e}"), - PopupKind::Error, - None, - ); - }, - Err(e) => { - error!("BUG: failed to join room list service loop task: {e:?}"); } + return; } - break; - } - result = &mut space_service_task => { - match result { - Ok(Ok(())) => { - error!("BUG: space service loop task ended unexpectedly!"); - } - Ok(Err(e)) => { - error!("Error: space service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Space service error: {e}"), - PopupKind::Error, - None, - ); - }, - Err(e) => { - error!("BUG: failed to join space service loop task: {e:?}"); + result = &mut space_service_task => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + error!("BUG: space service loop task ended unexpectedly!"); + } + Ok(Err(e)) => { + error!("Error: space service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Space service error: {e}"), + PopupKind::Error, + None, + ); + }, + Err(e) => { + error!("BUG: failed to join space service loop task: {e:?}"); + } } + return; } - break; } - } + }; + + session_change_handler_task.abort(); + room_list_service_task.abort(); + space_service_task.abort(); + + reset_runtime_state_for_relogin().await; + Cx::post_action(LoginAction::LoginFailure(reauth_message.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: reauth_message, + }); + initial_client_opt = None; } } @@ -3306,7 +3610,10 @@ fn is_invalid_token_error(e: &sync_service::Error) -> bool { /// When the homeserver rejects the access token with a 401 `M_UNKNOWN_TOKEN` error /// (e.g., the token was revoked or expired), this emits a [`LoginAction::LoginFailure`] /// so the user is prompted to log in again. -fn handle_session_changes(client: Client) { +fn handle_session_changes( + client: Client, + session_reset_sender: UnboundedSender, +) -> JoinHandle<()> { let mut receiver = client.subscribe_to_session_changes(); Handle::current().spawn(async move { loop { @@ -3318,7 +3625,11 @@ fn handle_session_changes(client: Client) { "Your login token is no longer valid.\n\nPlease log in again." }; error!("Session token is no longer valid (soft_logout: {soft_logout}). Prompting re-login."); - Cx::post_action(LoginAction::LoginFailure(msg.to_string())); + clear_persisted_session(client.user_id()).await; + let _ = session_reset_sender.send(SessionResetAction::Reauthenticate { + message: msg.to_string(), + }); + break; } Ok(SessionChange::TokensRefreshed) => {} Err(broadcast::error::RecvError::Lagged(n)) => { @@ -3329,7 +3640,7 @@ fn handle_session_changes(client: Client) { } } } - }); + }) } fn handle_sync_service_state_subscriber(mut subscriber: Subscriber) { From bc9b49f6c0d2d23cf3b58c0e178990f00ccbdb71 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: Thu, 26 Mar 2026 10:16:14 +0800 Subject: [PATCH 32/66] fix: persist botfather bindings and recover room state --- src/app.rs | 81 +++++++++++++++++++++++++++++------ src/home/room_context_menu.rs | 5 ++- src/home/room_screen.rs | 33 ++++++++++++-- src/settings/bot_settings.rs | 12 ++++++ src/sliding_sync.rs | 42 +++++++++++++++++- 5 files changed, 154 insertions(+), 19 deletions(-) diff --git a/src/app.rs b/src/app.rs index d20772c5d..bf7ea02ff 100644 --- a/src/app.rs +++ b/src/app.rs @@ -419,7 +419,16 @@ impl MatchEvent for App { bot_user_id, warning, }) => { - self.app_state.bot_settings.set_room_bound(room_id.clone(), *bound); + self.app_state.bot_settings.set_room_bound( + room_id.clone(), + bot_user_id.clone(), + *bound, + ); + if let Some(user_id) = current_user_id() { + if let Err(e) = persistence::save_app_state(self.app_state.clone(), user_id) { + error!("Failed to persist app state after updating BotFather room binding. Error: {e}"); + } + } let kind = if warning.is_some() { PopupKind::Warning } else { @@ -993,7 +1002,6 @@ pub struct AppState { /// Whether a user is currently logged in to Robrix or not. pub logged_in: bool, /// Local configuration and UI state for bot-assisted room binding. - #[serde(skip)] pub bot_settings: BotSettingsState, } @@ -1005,8 +1013,16 @@ pub struct BotSettingsState { pub enabled: bool, /// The configured botfather user, either as a full MXID or localpart. pub botfather_user_id: String, - /// Rooms that Robrix currently considers bound to BotFather. - pub bound_rooms: Vec, + /// Rooms that Robrix currently considers bound to BotFather, + /// paired with the exact BotFather MXID used for that room. + pub room_bindings: Vec, +} + +/// A persisted room-level BotFather binding. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct RoomBotBindingState { + pub room_id: OwnedRoomId, + pub bot_user_id: OwnedUserId, } impl Default for BotSettingsState { @@ -1014,7 +1030,7 @@ impl Default for BotSettingsState { Self { enabled: false, botfather_user_id: Self::DEFAULT_BOTFATHER_LOCALPART.to_string(), - bound_rooms: Vec::new(), + room_bindings: Vec::new(), } } } @@ -1024,21 +1040,44 @@ impl BotSettingsState { /// Returns `true` if the given room is currently marked as bound locally. pub fn is_room_bound(&self, room_id: &RoomId) -> bool { - self.bound_rooms + self.room_bindings + .iter() + .any(|binding| binding.room_id == room_id) + } + + /// Returns the persisted BotFather MXID for the given room, if any. + pub fn bound_bot_user_id(&self, room_id: &RoomId) -> Option<&UserId> { + self.room_bindings .iter() - .any(|bound_room_id| bound_room_id == room_id) + .find(|binding| binding.room_id == room_id) + .map(|binding| binding.bot_user_id.as_ref()) } /// Updates the local bound/unbound state for the given room. - pub fn set_room_bound(&mut self, room_id: OwnedRoomId, bound: bool) { + pub fn set_room_bound( + &mut self, + room_id: OwnedRoomId, + bot_user_id: Option, + bound: bool, + ) { if bound { - if !self.is_room_bound(&room_id) { - self.bound_rooms.push(room_id); - self.bound_rooms.sort_by(|lhs, rhs| lhs.as_str().cmp(rhs.as_str())); + let Some(bot_user_id) = bot_user_id else { return }; + if let Some(existing_binding) = self + .room_bindings + .iter_mut() + .find(|binding| binding.room_id == room_id) + { + existing_binding.bot_user_id = bot_user_id; + } else { + self.room_bindings.push(RoomBotBindingState { + room_id, + bot_user_id, + }); + self.room_bindings.sort_by(|lhs, rhs| lhs.room_id.as_str().cmp(rhs.room_id.as_str())); } } else { - self.bound_rooms - .retain(|existing_room_id| existing_room_id != &room_id); + self.room_bindings + .retain(|existing_binding| existing_binding.room_id != room_id); } } @@ -1073,6 +1112,22 @@ impl BotSettingsState { .map(|user_id| user_id.to_owned()) .map_err(|_| format!("Invalid bot user ID: {full_user_id}")) } + + /// Returns the BotFather MXID that should be used for a room action. + /// + /// If the room already has a persisted binding, that exact MXID wins. + /// Otherwise, the current global configuration is resolved. + pub fn resolved_bot_user_id_for_room( + &self, + room_id: &RoomId, + current_user_id: Option<&UserId>, + ) -> Result { + if let Some(bot_user_id) = self.bound_bot_user_id(room_id) { + return Ok(bot_user_id.to_owned()); + } + + self.resolved_bot_user_id(current_user_id) + } } /// A snapshot of the main dock: all state needed to restore the dock tabs/layout. diff --git a/src/home/room_context_menu.rs b/src/home/room_context_menu.rs index 9a73e08b8..796a43a86 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -244,7 +244,10 @@ impl WidgetMatchEvent for RoomContextMenu { else if self.button(cx, ids!(bot_binding_button)).clicked(actions) { if let Some(app_state) = scope.data.get::() { let room_id = details.room_name_id.room_id().clone(); - match app_state.bot_settings.resolved_bot_user_id(current_user_id().as_deref()) { + match app_state.bot_settings.resolved_bot_user_id_for_room( + &room_id, + current_user_id().as_deref(), + ) { Ok(bot_user_id) => { if details.is_bot_bound { submit_async_request(MatrixRequest::SetRoomBotBinding { diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index e3e9d7ab0..4df209a19 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1177,7 +1177,7 @@ impl Widget for RoomScreen { .map(|app_state| { ( app_state.bot_settings.enabled, - app_state.bot_settings.is_room_bound(&room_id), + self.is_app_service_room_bound(app_state, &room_id), ) }) .unwrap_or((false, false)); @@ -1346,7 +1346,10 @@ impl Widget for RoomScreen { } else { match app_state .bot_settings - .resolved_bot_user_id(current_user_id().as_deref()) + .resolved_bot_user_id_for_room( + room_props.room_name_id.room_id(), + current_user_id().as_deref(), + ) { Ok(bot_user_id) => { submit_async_request(MatrixRequest::SetRoomBotBinding { @@ -1776,6 +1779,28 @@ impl RoomScreen { self.close_delete_bot_modal(cx); } + fn is_app_service_room_bound(&self, app_state: &AppState, room_id: &OwnedRoomId) -> bool { + if app_state.bot_settings.is_room_bound(room_id) { + return true; + } + + let Ok(bot_user_id) = app_state + .bot_settings + .resolved_bot_user_id_for_room(room_id, current_user_id().as_deref()) + else { + return false; + }; + + self.tl_state + .as_ref() + .and_then(|tl| tl.room_members.as_ref()) + .is_some_and(|room_members| { + room_members + .iter() + .any(|room_member| room_member.user_id() == bot_user_id) + }) + } + fn send_botfather_command( &mut self, cx: &mut Cx, @@ -1806,7 +1831,7 @@ impl RoomScreen { ); return; } - if !app_state.bot_settings.is_room_bound(&room_id) { + if !self.is_app_service_room_bound(app_state, &room_id) { enqueue_popup_notification( "Bind BotFather to this room before using BotFather commands.", PopupKind::Warning, @@ -1858,7 +1883,7 @@ impl RoomScreen { ); return; } - if !app_state.bot_settings.is_room_bound(&room_id) { + if !self.is_app_service_room_bound(app_state, &room_id) { enqueue_popup_notification( "Bind BotFather to this room before creating a bot.", PopupKind::Warning, diff --git a/src/settings/bot_settings.rs b/src/settings/bot_settings.rs index c1fc6a837..bc23b9c14 100644 --- a/src/settings/bot_settings.rs +++ b/src/settings/bot_settings.rs @@ -2,7 +2,9 @@ use makepad_widgets::*; use crate::{ app::{AppState, BotSettingsState}, + persistence, shared::popup_list::{PopupKind, enqueue_popup_notification}, + sliding_sync::current_user_id, }; script_mod! { @@ -129,6 +131,7 @@ impl WidgetMatchEvent for BotSettings { if toggle_button.clicked(actions) { let enabled = !app_state.bot_settings.enabled; app_state.bot_settings.enabled = enabled; + persist_bot_settings(app_state); self.sync_ui(cx, &app_state.bot_settings); bot_details.set_visible(cx, enabled); self.view.redraw(cx); @@ -136,6 +139,7 @@ impl WidgetMatchEvent for BotSettings { if save_button.clicked(actions) || bot_user_id_input.returned(actions).is_some() { app_state.bot_settings.botfather_user_id = bot_user_id_input.text().trim().to_string(); + persist_bot_settings(app_state); enqueue_popup_notification( "Saved Matrix app service settings.", PopupKind::Success, @@ -185,3 +189,11 @@ impl BotSettingsRef { inner.populate(cx, bot_settings); } } + +fn persist_bot_settings(app_state: &AppState) { + if let Some(user_id) = current_user_id() { + if let Err(e) = persistence::save_app_state(app_state.clone(), user_id) { + error!("Failed to persist bot settings. Error: {e}"); + } + } +} diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 255332260..131e6610f 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -282,6 +282,12 @@ fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { ) } +fn is_invalid_batch_token_timeline_error(error: &matrix_sdk_ui::timeline::Error) -> bool { + let error_text = error.to_string().to_ascii_lowercase(); + error_text.contains("invalid batch token") + || error_text.contains("must start with 's' or 't'") +} + /// Build a new client. async fn build_client( @@ -994,6 +1000,7 @@ async fn matrix_worker_task( log!("Skipping pagination request for unknown {timeline_kind}"); continue; }; + let client = get_client(); // Spawn a new async task that will make the actual pagination request. let _paginate_task = Handle::current().spawn(async move { @@ -1001,12 +1008,45 @@ async fn matrix_worker_task( sender.send(TimelineUpdate::PaginationRunning(direction)).unwrap(); SignalToUI::set_ui_signal(); - let res = if direction == PaginationDirection::Forwards { + let mut res = if direction == PaginationDirection::Forwards { timeline.paginate_forwards(num_events).await } else { timeline.paginate_backwards(num_events).await }; + if direction == PaginationDirection::Backwards + && res + .as_ref() + .err() + .is_some_and(is_invalid_batch_token_timeline_error) + { + warning!( + "Detected an invalid cached batch token for {timeline_kind}; clearing the room event cache and retrying once." + ); + let room_id = timeline_kind.room_id().clone(); + if let Some(room) = client.and_then(|client| client.get_room(&room_id)) { + match room.event_cache().await { + Ok((room_event_cache, _drop_handles)) => { + match room_event_cache.clear().await { + Ok(()) => { + res = timeline.paginate_backwards(num_events).await; + } + Err(clear_error) => { + warning!( + "Failed to clear event cache for room {room_id} after invalid batch token: {clear_error}" + ); + } + } + } + Err(event_cache_error) => { + warning!( + "Failed to access room event cache for room {room_id} after invalid batch token: {event_cache_error}" + ); + } + } + } + } + match res { Ok(fully_paginated) => { log!("Completed {direction} pagination request for {timeline_kind}, hit {} of timeline? {}", From 6dbe704bd57ebc2c33d135cad77f91d3ed8ae44c 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: Thu, 26 Mar 2026 10:29:52 +0800 Subject: [PATCH 33/66] feat: add thread entry points to the message context menu --- src/home/new_message_context_menu.rs | 28 +++++++++++++++++++++++++++- src/home/room_screen.rs | 1 + 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/home/new_message_context_menu.rs b/src/home/new_message_context_menu.rs index 06c963fb3..b7552b733 100644 --- a/src/home/new_message_context_menu.rs +++ b/src/home/new_message_context_menu.rs @@ -116,6 +116,11 @@ script_mod! { text: "Reply" } + thread_button := mod.widgets.NewMessageContextMenuButton { + draw_icon +: { svg: crate_resource("self://resources/icons/double_chat.svg") } + text: "" + } + divider_after_react_reply := LineH { margin: Inset{top: 3, bottom: 3} width: Fill, @@ -272,6 +277,8 @@ pub struct MessageDetails { 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 is currently being shown in a thread-focused timeline. + pub is_thread_timeline: bool, /// Whether this message should be highlighted, i.e., /// if it mentions the room/current user or is a reply to the current user. pub should_be_highlighted: bool, @@ -382,6 +389,15 @@ impl WidgetMatchEvent for NewMessageContextMenu { ); close_menu = true; } + else if self.button(cx, ids!(thread_button)).clicked(actions) { + if let Some(thread_root_event_id) = details.thread_root_event_id.as_ref().or_else(|| details.event_id()) { + cx.widget_action( + details.room_screen_widget_uid, + MessageAction::OpenThread(thread_root_event_id.clone()), + ); + } + close_menu = true; + } else if self.button(cx, ids!(edit_message_button)).clicked(actions) { cx.widget_action( details.room_screen_widget_uid, @@ -497,6 +513,7 @@ impl NewMessageContextMenu { let react_button = self.view.button(cx, ids!(react_button)); let reply_button = self.view.button(cx, ids!(reply_button)); + let thread_button = self.view.button(cx, ids!(thread_button)); let edit_button = self.view.button(cx, ids!(edit_message_button)); let pin_button = self.view.button(cx, ids!(pin_button)); let copy_text_button = self.view.button(cx, ids!(copy_text_button)); @@ -512,7 +529,8 @@ impl NewMessageContextMenu { // `copy_text_button`, `copy_link_to_message_button`, and `view_source_button` let show_react = details.abilities.contains(MessageAbilities::CanReact); let show_reply_to = details.abilities.contains(MessageAbilities::CanReplyTo); - let show_divider_after_react_reply = show_react || show_reply_to; + let show_thread = !details.is_thread_timeline && details.event_id().is_some(); + let show_divider_after_react_reply = show_react || show_reply_to || show_thread; let show_edit = details.abilities.contains(MessageAbilities::CanEdit); let show_pin: bool; let show_copy_text = true; @@ -528,8 +546,14 @@ impl NewMessageContextMenu { self.view.view(cx, ids!(react_view)).set_visible(cx, show_react); react_button.set_visible(cx, show_react); reply_button.set_visible(cx, show_reply_to); + thread_button.set_visible(cx, show_thread); self.view.view(cx, ids!(divider_after_react_reply)).set_visible(cx, show_divider_after_react_reply); edit_button.set_visible(cx, show_edit); + if details.thread_root_event_id.is_some() { + thread_button.set_text(cx, "Open Thread"); + } else { + thread_button.set_text(cx, "Reply in Thread"); + } if details.abilities.contains(MessageAbilities::CanPin) { pin_button.set_text(cx, "Pin Message"); show_pin = true; @@ -549,6 +573,7 @@ impl NewMessageContextMenu { // Reset the hover state of each button. react_button.reset_hover(cx); reply_button.reset_hover(cx); + thread_button.reset_hover(cx); edit_button.reset_hover(cx); pin_button.reset_hover(cx); copy_text_button.reset_hover(cx); @@ -568,6 +593,7 @@ impl NewMessageContextMenu { let num_visible_buttons = show_react as u8 + show_reply_to as u8 + + show_thread as u8 + show_edit as u8 + show_pin as u8 + show_copy_text as u8 diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 61f20ced9..65ed41cbd 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -3477,6 +3477,7 @@ fn populate_message_view( item_id, related_event_id: msg_like_content.in_reply_to.as_ref().map(|r| r.event_id.clone()), room_screen_widget_uid, + is_thread_timeline: timeline_kind.thread_root_event_id().is_some(), abilities: MessageAbilities::from_user_power_and_event( user_power_levels, event_tl_item, From fd2aaca16757779454745523c8626f098ffecb2c Mon Sep 17 00:00:00 2001 From: Alvin Date: Thu, 26 Mar 2026 11:48:52 +0800 Subject: [PATCH 34/66] fix: recalculate byte offset on every target update to prevent UTF-8 panic update_target() previously only recalculated displayed_byte_offset when the new text was shorter. If the streaming backend reformatted the message body (e.g. adding markdown backticks), the stale byte offset could land inside a multi-byte character, panicking on string slice. --- src/home/streaming_animation.rs | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs index 886f0e1ed..8456cec88 100644 --- a/src/home/streaming_animation.rs +++ b/src/home/streaming_animation.rs @@ -63,15 +63,19 @@ impl StreamingAnimState { self.target_char_count = new_text.chars().count(); self.is_live = is_live; - // Clamp display pointers if the new text is shorter than what was already displayed. + // Clamp char count if the new text is shorter than what was already displayed. if self.displayed_char_count > self.target_char_count { self.displayed_char_count = self.target_char_count; - self.displayed_byte_offset = self.target_text - .char_indices() - .nth(self.target_char_count) - .map_or(self.target_text.len(), |(i, _)| i); } + // Always recalculate byte offset: the new text may have different + // byte widths at already-displayed positions (e.g. markdown formatting + // changes between streaming updates). + self.displayed_byte_offset = self.target_text + .char_indices() + .nth(self.displayed_char_count) + .map_or(self.target_text.len(), |(i, _)| i); + let now = Instant::now(); self.chars_at_last_update = self.displayed_char_count; self.last_update_time = now; @@ -243,6 +247,25 @@ mod tests { assert!(s.display_buffer.starts_with("Hi")); } + #[test] + fn test_update_target_recalculates_byte_offset_for_different_prefix() { + // Simulate: displayed 5 ASCII chars, then text replaced with CJK characters. + // Old byte offset (5) would be inside a multi-byte char in the new text. + let mut s = make_state("hello world"); + s.advance_displayed(5); + assert_eq!(s.displayed_byte_offset, 5); + + // New text has 5+ chars but first 5 chars are 3-byte CJK. + // Without the fix, displayed_byte_offset stays 5, crashing on slice. + s.update_target("你好世界测试数据", true); + assert_eq!(s.displayed_char_count, 5); + // 5 CJK chars × 3 bytes = 15 + assert_eq!(s.displayed_byte_offset, 15); + // Must not panic: + s.fill_display_buffer(); + assert!(s.display_buffer.starts_with("你好世界测")); + } + #[test] fn test_tick_advances() { let mut s = make_state("Hello, world!"); From df0e7741140d05e249df08270dc69208a5c8af5d 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: Thu, 26 Mar 2026 12:33:25 +0800 Subject: [PATCH 35/66] perf: reduce bot binding scans in app state --- src/app.rs | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/app.rs b/src/app.rs index bf7ea02ff..01083e199 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1038,19 +1038,21 @@ impl Default for BotSettingsState { impl BotSettingsState { pub const DEFAULT_BOTFATHER_LOCALPART: &'static str = "bot"; + fn room_binding_index(&self, room_id: &RoomId) -> Result { + self.room_bindings + .binary_search_by(|binding| binding.room_id.as_str().cmp(room_id.as_str())) + } + /// Returns `true` if the given room is currently marked as bound locally. pub fn is_room_bound(&self, room_id: &RoomId) -> bool { - self.room_bindings - .iter() - .any(|binding| binding.room_id == room_id) + self.room_binding_index(room_id).is_ok() } /// Returns the persisted BotFather MXID for the given room, if any. pub fn bound_bot_user_id(&self, room_id: &RoomId) -> Option<&UserId> { - self.room_bindings - .iter() - .find(|binding| binding.room_id == room_id) - .map(|binding| binding.bot_user_id.as_ref()) + self.room_binding_index(room_id) + .ok() + .map(|index| self.room_bindings[index].bot_user_id.as_ref()) } /// Updates the local bound/unbound state for the given room. @@ -1062,22 +1064,21 @@ impl BotSettingsState { ) { if bound { let Some(bot_user_id) = bot_user_id else { return }; - if let Some(existing_binding) = self - .room_bindings - .iter_mut() - .find(|binding| binding.room_id == room_id) - { - existing_binding.bot_user_id = bot_user_id; - } else { - self.room_bindings.push(RoomBotBindingState { - room_id, - bot_user_id, - }); - self.room_bindings.sort_by(|lhs, rhs| lhs.room_id.as_str().cmp(rhs.room_id.as_str())); + match self.room_binding_index(room_id.as_ref()) { + Ok(existing_index) => { + self.room_bindings[existing_index].bot_user_id = bot_user_id; + } + Err(insert_index) => { + self.room_bindings.insert(insert_index, RoomBotBindingState { + room_id, + bot_user_id, + }); + } } } else { - self.room_bindings - .retain(|existing_binding| existing_binding.room_id != room_id); + if let Ok(existing_index) = self.room_binding_index(room_id.as_ref()) { + self.room_bindings.remove(existing_index); + } } } From 8ebcf9b358c275158631b8cbddbac145dc6a8ecf 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: Thu, 26 Mar 2026 12:53:40 +0800 Subject: [PATCH 36/66] refactor: cache bot binding detection in room ui --- src/app.rs | 30 ++++++ src/home/create_bot_modal.rs | 3 - src/home/delete_bot_modal.rs | 3 - src/home/room_screen.rs | 183 +++++++++++++++++++---------------- 4 files changed, 129 insertions(+), 90 deletions(-) diff --git a/src/app.rs b/src/app.rs index 01083e199..b9a75f6ee 100644 --- a/src/app.rs +++ b/src/app.rs @@ -464,6 +464,31 @@ impl MatchEvent for App { self.ui.redraw(cx); continue; } + Some(AppStateAction::BotRoomBindingDetected { + room_id, + bot_user_id, + }) => { + if self + .app_state + .bot_settings + .bound_bot_user_id(room_id.as_ref()) + .is_some_and(|existing_bot_user_id| existing_bot_user_id.as_str() == bot_user_id.as_str()) + { + continue; + } + self.app_state.bot_settings.set_room_bound( + room_id.clone(), + Some(bot_user_id.clone()), + true, + ); + if let Some(user_id) = current_user_id() { + if let Err(e) = persistence::save_app_state(self.app_state.clone(), user_id) { + error!("Failed to persist detected BotFather room binding. Error: {e}"); + } + } + self.ui.redraw(cx); + continue; + } Some(AppStateAction::NavigateToRoom { room_to_close, destination_room }) => { self.navigate_to_room(cx, room_to_close.as_ref(), destination_room); continue; @@ -1279,6 +1304,11 @@ pub enum AppStateAction { bot_user_id: Option, warning: Option, }, + /// A room's member list indicates that the configured BotFather is already present. + BotRoomBindingDetected { + room_id: OwnedRoomId, + bot_user_id: OwnedUserId, + }, /// The given room was successfully loaded from the homeserver /// and is now known to our client. /// diff --git a/src/home/create_bot_modal.rs b/src/home/create_bot_modal.rs index bafb822e6..384510f48 100644 --- a/src/home/create_bot_modal.rs +++ b/src/home/create_bot_modal.rs @@ -184,8 +184,6 @@ pub struct CreateBotModal { #[deref] view: View, #[rust] - room_name_id: Option, - #[rust] is_showing_error: bool, } @@ -260,7 +258,6 @@ impl WidgetMatchEvent for CreateBotModal { impl CreateBotModal { pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId) { - self.room_name_id = Some(room_name_id.clone()); self.is_showing_error = false; self.view diff --git a/src/home/delete_bot_modal.rs b/src/home/delete_bot_modal.rs index caab2bd49..634171936 100644 --- a/src/home/delete_bot_modal.rs +++ b/src/home/delete_bot_modal.rs @@ -145,8 +145,6 @@ pub struct DeleteBotModal { #[deref] view: View, #[rust] - room_name_id: Option, - #[rust] is_showing_error: bool, } @@ -206,7 +204,6 @@ impl WidgetMatchEvent for DeleteBotModal { impl DeleteBotModal { pub fn show(&mut self, cx: &mut Cx, room_name_id: RoomNameId) { - self.room_name_id = Some(room_name_id.clone()); self.is_showing_error = false; self.view diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 4df209a19..ce2dfd115 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -8,7 +8,7 @@ use hashbrown::{HashMap, HashSet}; use imbl::Vector; use makepad_widgets::{image_cache::ImageBuffer, *}; use matrix_sdk::{ - OwnedServerName, RoomDisplayName, media::{MediaFormat, MediaRequestParameters}, room::RoomMember, ruma::{ + OwnedServerName, media::{MediaFormat, MediaRequestParameters}, room::RoomMember, ruma::{ EventId, MatrixToUri, MatrixUri, OwnedEventId, OwnedMxcUri, OwnedRoomId, UserId, events::{ receipt::Receipt, room::{ @@ -119,6 +119,28 @@ fn resolve_delete_bot_user_id( .map_err(|_| format!("Invalid Matrix user ID: {full_user_id}")) } +fn detected_bot_binding_for_members( + app_state: &AppState, + room_id: &OwnedRoomId, + members: &[RoomMember], +) -> Option { + if app_state.bot_settings.is_room_bound(room_id) { + return None; + } + + let Ok(bot_user_id) = app_state + .bot_settings + .resolved_bot_user_id_for_room(room_id, current_user_id().as_deref()) + else { + return None; + }; + + members + .iter() + .any(|room_member| room_member.user_id() == bot_user_id) + .then_some(bot_user_id) +} + script_mod! { use mod.prelude.widgets.* @@ -868,6 +890,8 @@ pub struct RoomScreen { /// The name and ID of the currently-shown room, if any. #[rust] room_name_id: Option, + /// The avatar URL of the currently-shown room, if any. + #[rust] room_avatar_url: Option, /// The timeline currently displayed by this RoomScreen, if any. #[rust] timeline_kind: Option, /// The persistent UI-relevant states for the room that this widget is currently displaying. @@ -1126,7 +1150,7 @@ impl Widget for RoomScreen { self.show_timeline(cx); } - self.process_timeline_updates(cx, &portal_list); + self.process_timeline_updates(cx, &portal_list, scope.data.get::()); // Ideally we would do this elsewhere on the main thread, because it's not room-specific, // but it doesn't hurt to do it here. @@ -1182,21 +1206,12 @@ impl Widget for RoomScreen { }) .unwrap_or((false, false)); - // Fetch room data once to avoid duplicate expensive lookups - let (room_display_name, room_avatar_url) = get_client() - .and_then(|client| client.get_room(&room_id)) - .map(|room| ( - room.cached_display_name().unwrap_or(RoomDisplayName::Empty), - room.avatar_url() - )) - .unwrap_or((RoomDisplayName::Empty, None)); - RoomScreenProps { room_screen_widget_uid, - room_name_id: RoomNameId::new(room_display_name, room_id), + room_name_id: self.room_name_id.clone().unwrap_or_else(|| RoomNameId::empty(room_id)), timeline_kind: tl.kind.clone(), room_members, - room_avatar_url, + room_avatar_url: self.room_avatar_url.clone(), app_service_enabled, app_service_room_bound, } @@ -1435,36 +1450,33 @@ impl Widget for RoomScreen { None => {} } - match action + if let MessageAction::ToggleAppServiceActions = action .as_widget_action() .widget_uid_eq(room_screen_widget_uid) .cast() { - MessageAction::ToggleAppServiceActions => { - if room_props.timeline_kind.thread_root_event_id().is_some() { - enqueue_popup_notification( - "Bot commands are only supported in the main room timeline.", - PopupKind::Warning, - Some(4.0), - ); - } else if !room_props.app_service_enabled { - enqueue_popup_notification( - "Enable App Service in Settings before using /bot.", - PopupKind::Warning, - Some(4.0), - ); - } else if !room_props.app_service_room_bound { - enqueue_popup_notification( - "Bind BotFather to this room before using /bot.", - PopupKind::Warning, - Some(4.0), - ); - } else { - self.toggle_app_service_actions(cx); - } - return false; + if room_props.timeline_kind.thread_root_event_id().is_some() { + enqueue_popup_notification( + "Bot commands are only supported in the main room timeline.", + PopupKind::Warning, + Some(4.0), + ); + } else if !room_props.app_service_enabled { + enqueue_popup_notification( + "Enable App Service in Settings before using /bot.", + PopupKind::Warning, + Some(4.0), + ); + } else if !room_props.app_service_room_bound { + enqueue_popup_notification( + "Bind BotFather to this room before using /bot.", + PopupKind::Warning, + Some(4.0), + ); + } else { + self.toggle_app_service_actions(cx); } - _ => {} + return false; } // Handle the action that requests to show the user profile sliding pane. @@ -1780,25 +1792,7 @@ impl RoomScreen { } fn is_app_service_room_bound(&self, app_state: &AppState, room_id: &OwnedRoomId) -> bool { - if app_state.bot_settings.is_room_bound(room_id) { - return true; - } - - let Ok(bot_user_id) = app_state - .bot_settings - .resolved_bot_user_id_for_room(room_id, current_user_id().as_deref()) - else { - return false; - }; - - self.tl_state - .as_ref() - .and_then(|tl| tl.room_members.as_ref()) - .is_some_and(|room_members| { - room_members - .iter() - .any(|room_member| room_member.user_id() == bot_user_id) - }) + app_state.bot_settings.is_room_bound(room_id) } fn send_botfather_command( @@ -1807,9 +1801,9 @@ impl RoomScreen { app_state: &AppState, command: &str, success_message: &str, - ) { + ) -> bool { let Some(timeline_kind) = self.timeline_kind.clone() else { - return; + return false; }; if timeline_kind.thread_root_event_id().is_some() { enqueue_popup_notification( @@ -1817,11 +1811,11 @@ impl RoomScreen { PopupKind::Warning, Some(4.0), ); - return; + return false; } let Some(room_id) = self.room_id().cloned() else { - return; + return false; }; if !app_state.bot_settings.enabled { enqueue_popup_notification( @@ -1829,7 +1823,7 @@ impl RoomScreen { PopupKind::Warning, Some(4.0), ); - return; + return false; } if !self.is_app_service_room_bound(app_state, &room_id) { enqueue_popup_notification( @@ -1837,7 +1831,7 @@ impl RoomScreen { PopupKind::Warning, Some(4.0), ); - return; + return false; } submit_async_request(MatrixRequest::SendMessage { @@ -1850,6 +1844,7 @@ impl RoomScreen { enqueue_popup_notification(success_message.to_string(), PopupKind::Info, Some(4.0)); self.set_app_service_actions_visible(cx, false); + true } fn send_create_bot_command( @@ -1893,20 +1888,14 @@ impl RoomScreen { } let command = format_create_bot_command(username, display_name, system_prompt); - submit_async_request(MatrixRequest::SendMessage { - timeline_kind, - message: RoomMessageEventContent::text_plain(command), - replied_to: None, - #[cfg(feature = "tsp")] - sign_with_tsp: false, - }); - - enqueue_popup_notification( - format!("Sent `/createbot` for `{username}` to BotFather."), - PopupKind::Info, - Some(4.0), - ); - self.close_create_bot_modal(cx); + if self.send_botfather_command( + cx, + app_state, + &command, + &format!("Sent `/createbot` for `{username}` to BotFather."), + ) { + self.close_create_bot_modal(cx); + } } fn send_delete_bot_command( @@ -1925,19 +1914,25 @@ impl RoomScreen { }; let command = format_delete_bot_command(matrix_user_id.as_ref()); - self.send_botfather_command( + if self.send_botfather_command( cx, app_state, &command, &format!("Sent `/deletebot` for {matrix_user_id} to BotFather."), - ); - self.close_delete_bot_modal(cx); + ) { + self.close_delete_bot_modal(cx); + } } /// Processes all pending background updates to the currently-shown timeline. /// /// Redraws this RoomScreen view if any updates were applied. - fn process_timeline_updates(&mut self, cx: &mut Cx, portal_list: &PortalListRef) { + fn process_timeline_updates( + &mut self, + cx: &mut Cx, + portal_list: &PortalListRef, + app_state: Option<&AppState>, + ) { let top_space = self.view(cx, ids!(top_space)); let jump_to_bottom_button = self.jump_to_bottom_button(cx, ids!(jump_to_bottom_button)); let curr_first_id = portal_list.first_id(); @@ -2209,8 +2204,21 @@ impl RoomScreen { // but for now we just fall through and let the final `redraw()` call re-draw the whole timeline view. } TimelineUpdate::RoomMembersListFetched { members } => { - // Store room members directly in TimelineUiState - tl.room_members = Some(Arc::new(members)); + let members = Arc::new(members); + if let Some(app_state) = app_state { + let room_id = tl.kind.room_id().clone(); + if let Some(bot_user_id) = detected_bot_binding_for_members( + app_state, + &room_id, + members.as_ref(), + ) { + Cx::post_action(AppStateAction::BotRoomBindingDetected { + room_id, + bot_user_id, + }); + } + } + tl.room_members = Some(members); }, TimelineUpdate::MediaFetched(request) => { log!("process_timeline_updates(): media fetched for room {}", tl.kind.room_id()); @@ -3047,7 +3055,7 @@ impl RoomScreen { // Now that we have restored the TimelineUiState into this RoomScreen widget, // we can proceed to processing pending background updates. - self.process_timeline_updates(cx, &self.portal_list(cx, ids!(list))); + self.process_timeline_updates(cx, &self.portal_list(cx, ids!(list)), None); self.redraw(cx); } @@ -3078,6 +3086,7 @@ impl RoomScreen { timeline_kind, subscribe: false, }); + self.room_avatar_url = None; } /// Removes the current room's visual UI state from this widget @@ -3165,6 +3174,9 @@ impl RoomScreen { // but we do need update the `room_name_id` in case it has changed, or it has been cleared. if self.timeline_kind.as_ref().is_some_and(|kind| kind == &timeline_kind) { self.room_name_id = Some(room_name_id.clone()); + self.room_avatar_url = get_client() + .and_then(|client| client.get_room(room_name_id.room_id())) + .and_then(|room| room.avatar_url()); return; } @@ -3174,6 +3186,9 @@ impl RoomScreen { self.loading_pane(cx, ids!(loading_pane)).take_state(); self.room_name_id = Some(room_name_id.clone()); + self.room_avatar_url = get_client() + .and_then(|client| client.get_room(room_name_id.room_id())) + .and_then(|room| room.avatar_url()); self.timeline_kind = Some(timeline_kind.clone()); // We initially tell every MentionableTextInput widget that the current user From ded8ff24f779103f6f919ed386221b0f0512d160 Mon Sep 17 00:00:00 2001 From: Alvin Date: Thu, 26 Mar 2026 14:21:06 +0800 Subject: [PATCH 37/66] refactor: switch to fixed-cadence Moly-style reveal for smoother streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the dynamic speed strategy (speed = remaining, with hard jumps at gap > 200/500) with a fixed-cadence chunked reveal: 2 chars every 55ms. - Arrival burst only when display had fully caught up (not on every update) - Preserve tick clock when backlog exists to maintain smooth cadence - Finish snap when stream ends with ≤20 chars remaining - Remove chars_per_second/chars_at_last_update/update_speed() complexity --- src/home/streaming_animation.rs | 164 ++++++++++++++++++++++---------- 1 file changed, 114 insertions(+), 50 deletions(-) diff --git a/src/home/streaming_animation.rs b/src/home/streaming_animation.rs index 8456cec88..2846885d6 100644 --- a/src/home/streaming_animation.rs +++ b/src/home/streaming_animation.rs @@ -3,6 +3,15 @@ use std::time::{Duration, Instant}; const FINISHED_STREAM_TIMEOUT: Duration = Duration::from_secs(30); const LIVE_STREAM_STALL_TIMEOUT: Duration = Duration::from_secs(5 * 60); +/// Characters to reveal per amortized chunk, closer to Moly's small-block growth. +const REVEAL_CHUNK_SIZE: usize = 2; +/// Fixed cadence for releasing each chunk. +const REVEAL_INTERVAL: Duration = Duration::from_millis(55); +/// Characters to reveal immediately when new content arrives after the UI had caught up. +const ARRIVAL_BURST: usize = 1; +/// When the stream is finished and this few chars remain, snap to the end. +const FINISH_SNAP_THRESHOLD: usize = 20; + /// Animation state for a single streaming message. /// Tracks an MSC4357 live message and drives character-by-character reveal. pub struct StreamingAnimState { @@ -10,12 +19,10 @@ pub struct StreamingAnimState { pub target_char_count: usize, pub displayed_char_count: usize, pub displayed_byte_offset: usize, - pub chars_per_second: f64, - pub fractional_chars: f64, + pub fractional_chunks: f64, pub last_update_time: Instant, pub last_tick_time: Instant, pub animation_start_time: Instant, - pub chars_at_last_update: usize, pub display_buffer: String, /// Whether the message currently carries the MSC4357 `live` field. pub is_live: bool, @@ -31,12 +38,10 @@ impl StreamingAnimState { target_char_count: char_count, displayed_char_count: 0, displayed_byte_offset: 0, - chars_per_second: 1.0, - fractional_chars: 0.0, + fractional_chunks: 0.0, last_update_time: now, last_tick_time: now, animation_start_time: now, - chars_at_last_update: 0, display_buffer: String::with_capacity(initial_text.len() + 4), is_live, timeline_index: None, @@ -50,14 +55,15 @@ impl StreamingAnimState { restored.displayed_char_count = common_chars; restored.displayed_byte_offset = common_bytes; - restored.chars_at_last_update = common_chars; restored.animation_start_time = previous.animation_start_time; restored.timeline_index = previous.timeline_index; - restored.update_speed(); restored } pub fn update_target(&mut self, new_text: &str, is_live: bool) { + let prev_char_count = self.target_char_count; + let had_backlog = self.displayed_char_count < prev_char_count; + self.target_text.clear(); self.target_text.push_str(new_text); self.target_char_count = new_text.chars().count(); @@ -76,11 +82,21 @@ impl StreamingAnimState { .nth(self.displayed_char_count) .map_or(self.target_text.len(), |(i, _)| i); + // Arrival burst: only when we had fully caught up and were waiting + // for more text. If backlog already exists, stay on the amortized cadence. + let added_chars = self.target_char_count.saturating_sub(prev_char_count); + if added_chars > 0 && !had_backlog { + self.advance_displayed(added_chars.min(ARRIVAL_BURST)); + } + let now = Instant::now(); - self.chars_at_last_update = self.displayed_char_count; self.last_update_time = now; - self.last_tick_time = now; - self.update_speed(); + // If the animation had already caught up and was waiting for more text, + // restart the frame clock so idle time doesn't count as reveal time. + // If backlog already existed, keep the clock to preserve smooth cadence. + if !had_backlog { + self.last_tick_time = now; + } // Reserve only the deficit (reserve(n) guarantees capacity >= len + n). let needed = new_text.len() + 4; if self.display_buffer.capacity() < needed { @@ -88,16 +104,6 @@ impl StreamingAnimState { } } - fn update_speed(&mut self) { - let remaining = self.target_char_count.saturating_sub(self.displayed_char_count); - if remaining > 0 { - self.chars_per_second = remaining as f64; - if self.chars_per_second < 30.0 { - self.chars_per_second = 30.0; - } - } - } - pub fn advance_displayed(&mut self, chars_to_add: usize) { if chars_to_add == 0 || self.displayed_char_count >= self.target_char_count { return; } let remaining = &self.target_text[self.displayed_byte_offset..]; @@ -123,28 +129,24 @@ impl StreamingAnimState { pub fn tick_with_elapsed(&mut self, elapsed: Duration) -> bool { if self.displayed_char_count >= self.target_char_count { return false; } - let gap = self.target_char_count - self.displayed_char_count; - let mut changed = false; - - let speed = if gap > 500 { - let jump = gap - 50; - self.advance_displayed(jump); - changed = true; - self.chars_per_second - } else if gap > 200 { - self.chars_per_second * 3.0 - } else { - self.chars_per_second - }; + let remaining = self.target_char_count - self.displayed_char_count; + + // Finish snap: when the stream is done and only a few chars remain, show them all. + if !self.is_live && remaining <= FINISH_SNAP_THRESHOLD { + self.advance_displayed(remaining); + return true; + } - self.fractional_chars += speed * elapsed.as_secs_f64(); - let advance = self.fractional_chars.floor() as usize; - self.fractional_chars -= advance as f64; - if advance > 0 { - self.advance_displayed(advance); - changed = true; + // Moly-style amortization: reveal fixed-size chunks at a fixed cadence + // instead of accelerating as backlog grows. + self.fractional_chunks += elapsed.as_secs_f64() / REVEAL_INTERVAL.as_secs_f64(); + let advance_chunks = self.fractional_chunks.floor() as usize; + self.fractional_chunks -= advance_chunks as f64; + if advance_chunks > 0 { + self.advance_displayed(advance_chunks * REVEAL_CHUNK_SIZE); + return true; } - changed + false } pub fn fill_display_buffer(&mut self) { @@ -232,8 +234,26 @@ mod tests { s.advance_displayed(5); s.update_target("Hello, world!", true); assert_eq!(s.target_char_count, 13); - assert_eq!(s.displayed_char_count, 5); - assert!(s.chars_per_second > 0.0); + // Arrival burst reveals only the newly added chars, capped by ARRIVAL_BURST. + assert_eq!(s.displayed_char_count, 5 + ARRIVAL_BURST.min(8)); + } + + #[test] + fn test_update_target_uses_single_char_burst_when_waiting_for_new_text() { + let mut s = make_state("Hello"); + s.advance_displayed(5); + s.update_target("Hello, world!", true); + assert_eq!(s.displayed_char_count, 6); + } + + #[test] + fn test_update_target_does_not_burst_while_backlog_exists() { + let mut s = make_state("Hello"); + s.advance_displayed(2); + s.update_target("Hello!", true); + // When backlog already exists, keep the amortized cadence instead of + // applying a fresh burst on every incoming update. + assert_eq!(s.displayed_char_count, 2); } #[test] @@ -269,18 +289,25 @@ mod tests { #[test] fn test_tick_advances() { let mut s = make_state("Hello, world!"); - s.chars_per_second = 4.0; - let changed = s.tick_with_elapsed(Duration::from_millis(500)); + let changed = s.tick_with_elapsed(REVEAL_INTERVAL); assert!(changed); - assert_eq!(s.displayed_char_count, 2); + assert_eq!(s.displayed_char_count, REVEAL_CHUNK_SIZE); + } + + #[test] + fn test_tick_waits_for_full_chunk_interval() { + let mut s = make_state("Hello, world!"); + assert!(!s.tick_with_elapsed(REVEAL_INTERVAL / 2)); + assert_eq!(s.displayed_char_count, 0); } #[test] - fn test_tick_large_gap() { + fn test_tick_large_gap_smooth() { let mut s = make_state(&"a".repeat(1000)); - s.chars_per_second = 0.1; + // Even after a large elapsed gap, keep a steady amortized pace. assert!(s.tick_with_elapsed(Duration::from_secs(1))); - assert!(s.displayed_char_count > 900); + assert!(s.displayed_char_count >= 30); + assert!(s.displayed_char_count <= 40); } #[test] @@ -344,9 +371,46 @@ mod tests { #[test] fn test_tick_zero_elapsed() { let mut s = make_state("Hello"); - s.chars_per_second = 20.0; assert!(!s.tick_with_elapsed(Duration::ZERO)); assert_eq!(s.displayed_char_count, 0); } + #[test] + fn test_update_target_preserves_tick_clock_when_backlog_already_exists() { + let mut s = make_state("Hello, world!"); + s.advance_displayed(3); + let before = Instant::now() - Duration::from_millis(120); + s.last_tick_time = before; + + s.update_target("Hello, world!!!", true); + + assert_eq!(s.last_tick_time, before); + } + + #[test] + fn test_update_target_resets_tick_clock_when_waiting_for_new_text() { + let mut s = make_state("Hello"); + s.advance_displayed(5); + let before = Instant::now() - Duration::from_secs(5); + s.last_tick_time = before; + + s.update_target("Hello!", true); + + assert!(s.last_tick_time > before); + } + + #[test] + fn test_finish_snap() { + let mut s = make_state(&"a".repeat(30)); + s.advance_displayed(20); + // 10 remaining but is_live=true → normal tick, no snap. + s.tick_with_elapsed(Duration::from_millis(16)); + assert!(s.displayed_char_count < 30); + + // Mark as finished → remaining <= FINISH_SNAP_THRESHOLD → snaps to end. + s.is_live = false; + assert!(s.tick_with_elapsed(Duration::from_millis(1))); + assert_eq!(s.displayed_char_count, 30); + } + } From f0efba2e87f00536c6f9fbc02ae0aac6760752e9 Mon Sep 17 00:00:00 2001 From: alanpoon Date: Fri, 27 Mar 2026 10:02:12 +0800 Subject: [PATCH 38/66] logging in release mode --- src/app.rs | 212 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) diff --git a/src/app.rs b/src/app.rs index b9a75f6ee..011d9ce7a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,6 +2,8 @@ //! //! See `handle_startup()` for the first code that runs on app startup. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +use std::{fs::{File, OpenOptions}, io::Write, sync::Mutex}; use std::{cell::RefCell, collections::HashMap}; use makepad_widgets::*; use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId}}; @@ -192,11 +194,221 @@ impl ScriptHook for App { } } +// ============================================================================= +// File Logging for Packaged Builds (non-mobile platforms) +// ============================================================================= + +/// Global log file handle for packaged builds. +/// Only used on desktop platforms when running as a packaged application. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +static LOG_FILE: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +/// Detects if the application is running as a packaged build (not via `cargo run`). +/// +/// Detection methods per platform: +/// - macOS: Check if executable is inside a `.app/Contents/MacOS/` bundle +/// - Windows: Check if executable is in `Program Files` or similar installation directory +/// - Linux: Check if executable is in `/usr`, `/opt`, or is an AppImage +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn is_packaged_build() -> bool { + let Ok(exe_path) = std::env::current_exe() else { + return false; + }; + let exe_path_str = exe_path.to_string_lossy(); + + #[cfg(target_os = "macos")] + { + // Check if running from a .app bundle + exe_path_str.contains(".app/Contents/MacOS/") + } + + #[cfg(target_os = "windows")] + { + // Check if running from Program Files or a typical installation directory + let exe_lower = exe_path_str.to_lowercase(); + exe_lower.contains("program files") + || exe_lower.contains("programfiles") + || exe_lower.contains("appdata\\local\\programs") + } + + #[cfg(target_os = "linux")] + { + // Check if running from system directories or AppImage + exe_path_str.starts_with("/usr/") + || exe_path_str.starts_with("/opt/") + || exe_path_str.contains(".AppImage") + || std::env::var("APPIMAGE").is_ok() + } + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + false + } +} + +/// Initializes file logging for packaged builds. +/// Creates a log file in the app data directory with timestamp. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn init_file_logging() -> Option<()> { + if !is_packaged_build() { + LOG_FILE.get_or_init(|| None); + return None; + } + + // Get platform-specific logs directory + let logs_dir = logs_dir(); + std::fs::create_dir_all(&logs_dir).ok()?; + + // Create log file with timestamp + let now = chrono::Local::now(); + let log_filename = format!("robrix_{}.log", now.format("%Y-%m-%d_%H-%M-%S")); + let log_path = logs_dir.join(&log_filename); + + // Also create/update a symlink to the latest log file for convenience + let latest_log_path = logs_dir.join("robrix_latest.log"); + + // Remove old symlink if it exists (ignore errors) + #[cfg(unix)] + { + let _ = std::fs::remove_file(&latest_log_path); + let _ = std::os::unix::fs::symlink(&log_filename, &latest_log_path); + } + + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .ok()?; + + LOG_FILE.get_or_init(|| Some(Mutex::new(file))); + + // Print to stderr so user knows where logs are going + eprintln!("[Robrix] Logging to file: {}", log_path.display()); + + Some(()) +} + +/// Writes a log message to the log file (if file logging is enabled). +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn write_to_log_file(message: &str) { + if let Some(Some(file_mutex)) = LOG_FILE.get() { + if let Ok(mut file) = file_mutex.lock() { + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); + let _ = writeln!(file, "[{}] {}", timestamp, message); + let _ = file.flush(); + } + } +} + +/// Returns the path to the logs directory using platform-standard locations. +/// +/// Platform-specific paths: +/// - macOS: `~/Library/Logs/Robrix/` +/// - Windows: `%APPDATA%/Robrix/logs/` +/// - Linux: `~/.local/share/robrix/logs/` (or `$XDG_DATA_HOME/robrix/logs/`) +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn logs_dir() -> std::path::PathBuf { + use std::path::PathBuf; + + #[cfg(target_os = "macos")] + { + // macOS standard log location: ~/Library/Logs/Robrix/ + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join("Library") + .join("Logs") + .join("Robrix"); + } + } + + #[cfg(target_os = "windows")] + { + // Windows: %APPDATA%/Robrix/logs/ + if let Ok(appdata) = std::env::var("APPDATA") { + return PathBuf::from(appdata).join("Robrix").join("logs"); + } + } + + #[cfg(target_os = "linux")] + { + // Linux: Use XDG_DATA_HOME if set, otherwise ~/.local/share/ + if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") { + return PathBuf::from(xdg_data).join("robrix").join("logs"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".local") + .join("share") + .join("robrix") + .join("logs"); + } + } + + // Fallback to app data directory + crate::app_data_dir().join("logs") +} + +/// Cleans up old log files, keeping only the most recent N log files. +/// This should be called periodically to prevent disk space issues. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn cleanup_old_logs(max_logs_to_keep: usize) { + let logs_dir = logs_dir(); + if !logs_dir.exists() { + return; + } + + // Collect all log files (excluding the symlink) + let mut log_files: Vec<_> = match std::fs::read_dir(&logs_dir) { + Ok(entries) => entries + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let name_str = name.to_string_lossy(); + name_str.starts_with("robrix_") + && name_str.ends_with(".log") + && name_str != "robrix_latest.log" + }) + .collect(), + Err(_) => return, + }; + + // Sort by modification time (oldest first) + log_files.sort_by(|a, b| { + let a_time = a.metadata().and_then(|m| m.modified()).ok(); + let b_time = b.metadata().and_then(|m| m.modified()).ok(); + a_time.cmp(&b_time) + }); + + // Remove old log files + if log_files.len() > max_logs_to_keep { + let files_to_remove = log_files.len() - max_logs_to_keep; + for entry in log_files.into_iter().take(files_to_remove) { + let _ = std::fs::remove_file(entry.path()); + } + } +} + +/// Maximum number of log files to keep +#[cfg(not(any(target_os = "android", target_os = "ios")))] +const MAX_LOG_FILES_TO_KEEP: usize = 10; + impl MatchEvent for App { fn handle_startup(&mut self, cx: &mut Cx) { // only init logging/tracing once let _ = tracing_subscriber::fmt::try_init(); + // Initialize the project directory here from the main UI thread + // such that background threads/tasks will be able to access it. + // This must be done before initializing file logging. + let _app_data_dir = crate::app_data_dir(); + // Initialize file logging for packaged builds (non-mobile platforms). + // This must be done before setting up the log handler. + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + init_file_logging(); + // Clean up old log files to prevent disk space issues + cleanup_old_logs(MAX_LOG_FILES_TO_KEEP); + } // Override Makepad's new default-JSON logger. We just want regular formatting. fn regular_log(file_name: &str, line_start: u32, column_start: u32, _line_end: u32, _column_end: u32, message: String, level: LogLevel) { let l = match level { From 4983cc25023ad5ed39ac443c805422ffee17034b Mon Sep 17 00:00:00 2001 From: alanpoon Date: Fri, 27 Mar 2026 10:18:02 +0800 Subject: [PATCH 39/66] Add multi-account management support (issue #374) Introduces AccountManager for handling multiple Matrix accounts: - Account struct for storing client, user_id, session, profile info - AccountManager for add/remove/switch account operations - Global singleton with thread-safe access functions Co-Authored-By: Claude Opus 4.5 --- src/account_manager.rs | 250 +++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + 2 files changed, 252 insertions(+) create mode 100644 src/account_manager.rs diff --git a/src/account_manager.rs b/src/account_manager.rs new file mode 100644 index 000000000..e23732e67 --- /dev/null +++ b/src/account_manager.rs @@ -0,0 +1,250 @@ +//! Multi-account management for Robrix. +//! +//! This module provides the infrastructure for managing multiple Matrix accounts +//! simultaneously, including: +//! - Storing and switching between multiple logged-in accounts +//! - Tracking the active (currently selected) account +//! - Managing account-specific state and sync connections + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; +use matrix_sdk::{Client, ruma::OwnedUserId}; +use crate::persistence::ClientSessionPersisted; + +/// Represents a logged-in Matrix account with its associated client and session info. +#[derive(Clone)] +pub struct Account { + /// The Matrix client for this account + pub client: Client, + /// The user ID for this account + pub user_id: OwnedUserId, + /// The persisted session data for rebuilding the client + pub session: ClientSessionPersisted, + /// Display name for the account (cached from profile) + pub display_name: Option, + /// Avatar URL for the account (cached from profile) + pub avatar_url: Option, +} + +impl std::fmt::Debug for Account { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Account") + .field("user_id", &self.user_id) + .field("display_name", &self.display_name) + .field("avatar_url", &self.avatar_url) + .finish_non_exhaustive() + } +} + +/// Manager for multiple Matrix accounts. +/// +/// This struct handles: +/// - Storing multiple logged-in accounts +/// - Tracking which account is currently active +/// - Providing access to account-specific clients +#[derive(Default, Debug)] +pub struct AccountManager { + /// Map of user_id to Account for all logged-in accounts + accounts: HashMap, + /// The currently active (selected) account's user_id + active_account_id: Option, +} + +impl AccountManager { + /// Creates a new empty AccountManager. + pub fn new() -> Self { + Self { + accounts: HashMap::new(), + active_account_id: None, + } + } + + /// Adds a new account to the manager. + /// + /// If this is the first account, it becomes the active account automatically. + /// Returns true if the account was newly added, false if it replaced an existing one. + pub fn add_account(&mut self, account: Account) -> bool { + let user_id = account.user_id.clone(); + let is_new = !self.accounts.contains_key(&user_id); + + // If this is the first account, make it active + if self.accounts.is_empty() { + self.active_account_id = Some(user_id.clone()); + } + + self.accounts.insert(user_id, account); + is_new + } + + /// Removes an account from the manager. + /// + /// If the removed account was active, switches to another available account. + /// Returns the removed account if it existed. + pub fn remove_account(&mut self, user_id: &OwnedUserId) -> Option { + let removed = self.accounts.remove(user_id); + + // If we removed the active account, switch to another one + if self.active_account_id.as_ref() == Some(user_id) { + self.active_account_id = self.accounts.keys().next().cloned(); + } + + removed + } + + /// Sets the active account by user_id. + /// + /// Returns true if the account exists and was made active, false otherwise. + pub fn set_active_account(&mut self, user_id: &OwnedUserId) -> bool { + if self.accounts.contains_key(user_id) { + self.active_account_id = Some(user_id.clone()); + true + } else { + false + } + } + + /// Gets the currently active account. + pub fn active_account(&self) -> Option<&Account> { + self.active_account_id + .as_ref() + .and_then(|id| self.accounts.get(id)) + } + + /// Gets the currently active account mutably. + pub fn active_account_mut(&mut self) -> Option<&mut Account> { + let id = self.active_account_id.clone()?; + self.accounts.get_mut(&id) + } + + /// Gets the client for the currently active account. + pub fn active_client(&self) -> Option { + self.active_account().map(|a| a.client.clone()) + } + + /// Gets the user_id of the currently active account. + pub fn active_user_id(&self) -> Option<&OwnedUserId> { + self.active_account_id.as_ref() + } + + /// Gets an account by user_id. + pub fn get_account(&self, user_id: &OwnedUserId) -> Option<&Account> { + self.accounts.get(user_id) + } + + /// Gets a client by user_id. + pub fn get_client(&self, user_id: &OwnedUserId) -> Option { + self.accounts.get(user_id).map(|a| a.client.clone()) + } + + /// Returns an iterator over all accounts. + pub fn accounts(&self) -> impl Iterator { + self.accounts.values() + } + + /// Returns the number of logged-in accounts. + pub fn account_count(&self) -> usize { + self.accounts.len() + } + + /// Returns true if there are no logged-in accounts. + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + + /// Returns all user IDs of logged-in accounts. + pub fn user_ids(&self) -> Vec { + self.accounts.keys().cloned().collect() + } + + /// Updates the display name for an account. + pub fn update_display_name(&mut self, user_id: &OwnedUserId, display_name: Option) { + if let Some(account) = self.accounts.get_mut(user_id) { + account.display_name = display_name; + } + } + + /// Updates the avatar URL for an account. + pub fn update_avatar_url(&mut self, user_id: &OwnedUserId, avatar_url: Option) { + if let Some(account) = self.accounts.get_mut(user_id) { + account.avatar_url = avatar_url; + } + } +} + +// ============================================================================= +// Global Account Manager Singleton +// ============================================================================= + +/// Global singleton for the account manager. +static ACCOUNT_MANAGER: OnceLock> = OnceLock::new(); + +/// Gets the global account manager. +fn account_manager() -> &'static Mutex { + ACCOUNT_MANAGER.get_or_init(|| Mutex::new(AccountManager::new())) +} + +/// Adds an account to the global account manager. +pub fn add_account(account: Account) -> bool { + account_manager().lock().unwrap().add_account(account) +} + +/// Removes an account from the global account manager. +pub fn remove_account(user_id: &OwnedUserId) -> Option { + account_manager().lock().unwrap().remove_account(user_id) +} + +/// Sets the active account in the global account manager. +pub fn set_active_account(user_id: &OwnedUserId) -> bool { + account_manager().lock().unwrap().set_active_account(user_id) +} + +/// Gets the client for the currently active account. +pub fn get_active_client() -> Option { + account_manager().lock().unwrap().active_client() +} + +/// Gets the user_id of the currently active account. +pub fn get_active_user_id() -> Option { + account_manager().lock().unwrap().active_user_id().cloned() +} + +/// Gets a client by user_id. +pub fn get_client_for_user(user_id: &OwnedUserId) -> Option { + account_manager().lock().unwrap().get_client(user_id) +} + +/// Returns the number of logged-in accounts. +pub fn account_count() -> usize { + account_manager().lock().unwrap().account_count() +} + +/// Returns all user IDs of logged-in accounts. +pub fn get_all_user_ids() -> Vec { + account_manager().lock().unwrap().user_ids() +} + +/// Executes a closure with access to the account manager. +pub fn with_account_manager(f: F) -> R +where + F: FnOnce(&AccountManager) -> R, +{ + let manager = account_manager().lock().unwrap(); + f(&manager) +} + +/// Executes a closure with mutable access to the account manager. +pub fn with_account_manager_mut(f: F) -> R +where + F: FnOnce(&mut AccountManager) -> R, +{ + let mut manager = account_manager().lock().unwrap(); + f(&mut manager) +} + +/// Clears all accounts from the global account manager. +/// This should only be used during logout of all accounts. +pub fn clear_all_accounts() { + let mut manager = account_manager().lock().unwrap(); + manager.accounts.clear(); + manager.active_account_id = None; +} diff --git a/src/lib.rs b/src/lib.rs index 346c0314b..164d00802 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,6 +78,8 @@ pub mod media_cache; pub mod verification; pub mod utils; +/// Multi-account management for supporting multiple Matrix accounts simultaneously. +pub mod account_manager; pub mod temp_storage; pub mod location; From cacb3ebd4e95aebd3554979871bbe9caba18bb1a Mon Sep 17 00:00:00 2001 From: alanpoon Date: Fri, 27 Mar 2026 10:23:45 +0800 Subject: [PATCH 40/66] Add multi-account UI and sync support (issue #374) - app.rs: Handle account switching actions and state, add adding_account field - sliding_sync.rs: Add AccountSwitchAction, is_add_account flag for logins, support running multiple sync connections - login_screen.rs: Support add-account login flow - account_settings.rs: Account switcher UI integration - navigation_tab_bar.rs: Account indicator updates Note: File logging changes (issue #345) intentionally excluded. Co-Authored-By: Claude Opus 4.5 --- src/app.rs | 64 +- src/home/navigation_tab_bar.rs | 9 +- src/login/login_screen.rs | 276 +++-- src/settings/account_settings.rs | 282 ++++- src/sliding_sync.rs | 1812 +++++++++++++++++++----------- 5 files changed, 1644 insertions(+), 799 deletions(-) diff --git a/src/app.rs b/src/app.rs index b9a75f6ee..1faa187b5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,7 @@ use crate::{ event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, invite_screen::InviteScreenWidgetRefExt, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, space_lobby::SpaceLobbyScreenWidgetRefExt }, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt - }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ + }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{AccountSwitchAction, DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, VerificationModalWidgetRefExt, } @@ -293,11 +293,69 @@ impl MatchEvent for App { if let Some(LoginAction::LoginSuccess) = action.downcast_ref() { log!("Received LoginAction::LoginSuccess, hiding login view."); self.app_state.logged_in = true; + self.app_state.adding_account = false; self.update_login_visibility(cx); self.ui.redraw(cx); continue; } + // Handle request to show login screen for adding another account + if let Some(LoginAction::ShowAddAccountScreen) = action.downcast_ref() { + log!("Received LoginAction::ShowAddAccountScreen, showing login view for adding account."); + self.app_state.adding_account = true; + self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, true); + self.ui.redraw(cx); + continue; + } + + // Handle successful addition of a new account + if let Some(LoginAction::AddAccountSuccess) = action.downcast_ref() { + log!("Received LoginAction::AddAccountSuccess, hiding login view."); + self.app_state.adding_account = false; + self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, false); + self.ui.redraw(cx); + continue; + } + + // Handle account switch actions + match action.downcast_ref() { + Some(AccountSwitchAction::Starting(user_id)) => { + log!("Account switch starting to: {}", user_id); + // Clear UI state during account switch + clear_all_app_state(cx); + self.app_state.selected_room = None; + // Clear saved dock state so tabs will be closed + self.app_state.saved_dock_state_home = Default::default(); + enqueue_popup_notification( + format!("Switching to account {}...", user_id), + PopupKind::Info, + Some(3.0), + ); + self.ui.redraw(cx); + continue; + } + Some(AccountSwitchAction::Switched(user_id)) => { + log!("Account switch completed to: {}", user_id); + enqueue_popup_notification( + format!("Switched to account {}", user_id), + PopupKind::Info, + Some(5.0), + ); + self.ui.redraw(cx); + continue; + } + Some(AccountSwitchAction::Failed(error)) => { + log!("Account switch failed: {}", error); + enqueue_popup_notification( + format!("Failed to switch account: {}", error), + PopupKind::Error, + None, + ); + continue; + } + _ => {} + } + // If a login failure occurs mid-session (e.g., an expired/revoked token detected // by `handle_session_changes`), navigate back to the login screen. // When not yet logged in, the login_screen widget handles displaying the failure modal. @@ -1026,6 +1084,10 @@ pub struct AppState { pub saved_dock_state_per_space: HashMap, /// Whether a user is currently logged in to Robrix or not. pub logged_in: bool, + /// Whether the app is currently showing the login screen for adding another account. + /// This is transient state and not persisted. + #[serde(skip)] + pub adding_account: bool, /// Local configuration and UI state for bot-assisted room binding. pub bot_settings: BotSettingsState, } diff --git a/src/home/navigation_tab_bar.rs b/src/home/navigation_tab_bar.rs index 95cec1317..1f52e9b6c 100644 --- a/src/home/navigation_tab_bar.rs +++ b/src/home/navigation_tab_bar.rs @@ -36,7 +36,7 @@ use crate::{ user_profile_cache::{self, UserProfileUpdate}, }, shared::{ avatar::{AvatarState, AvatarWidgetExt}, styles::*, verification_badge::VerificationBadgeWidgetExt - }, sliding_sync::{current_user_id, AccountDataAction}, utils::{self, RoomNameId} + }, sliding_sync::{current_user_id, AccountDataAction, AccountSwitchAction}, utils::{self, RoomNameId} }; script_mod! { @@ -289,6 +289,13 @@ impl Widget for ProfileIcon { continue; } + // Handle account switch - refresh profile with new account's data + if let Some(AccountSwitchAction::Switched(_new_user_id)) = action.downcast_ref() { + self.own_profile = get_own_profile(cx); + self.view.redraw(cx); + continue; + } + // Handle account data changes (e.g., avatar updated/removed) match action.downcast_ref() { Some(AccountDataAction::AvatarChanged(None)) => { diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index bf4ec59c2..5fdedfa1d 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -3,7 +3,7 @@ use std::ops::Not; use makepad_widgets::*; use url::Url; -use crate::sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest, RegisterAccount}; +use crate::sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest}; use super::login_status_modal::{LoginStatusModalAction, LoginStatusModalWidgetExt}; @@ -69,13 +69,19 @@ script_mod! { } } - View { + RoundedView { margin: Inset{top: 40, bottom: 40} width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit align: Align{x: 0.5, y: 0.5} flow: Overlay, + show_bg: true, + draw_bg +: { + color: (COLOR_SECONDARY) + border_radius: 6.0 + } + View { width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit @@ -117,19 +123,6 @@ script_mod! { is_password: true, } - confirm_password_wrapper := View { - width: 275, height: Fit, - visible: false, - - confirm_password_input := RobrixTextInput { - width: 275, height: Fit - flow: Right, // do not wrap - padding: 10, - empty_text: "Confirm password" - is_password: true, - } - } - View { width: 275, height: Fit, flow: Down, @@ -178,61 +171,54 @@ script_mod! { text: "Login" } - login_only_view := View { - width: Fit, height: Fit, - flow: Down, - align: Align{x: 0.5, y: 0.5} - spacing: 15.0 + LineH { + width: 275 + margin: Inset{bottom: -5} + draw_bg.color: #C8C8C8 + } - LineH { - width: 275 - margin: Inset{bottom: -5} - draw_bg.color: #C8C8C8 + Label { + width: Fit, height: Fit + padding: 0, + draw_text +: { + color: (COLOR_TEXT) + text_style: TITLE_TEXT {font_size: 11.0} } + text: "Or, login with an SSO provider:" + } - Label { - width: Fit, height: Fit - padding: 0, - draw_text +: { - color: (COLOR_TEXT) - text_style: TITLE_TEXT {font_size: 11.0} + sso_view := View { + width: 275, height: Fit, + margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide + flow: Flow.Right{wrap: true}, + apple_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/apple.png") } - text: "Or, login with an SSO provider:" } - - sso_view := View { - width: 275, height: Fit, - margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide - flow: Flow.Right{wrap: true}, - apple_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/apple.png") - } - } - facebook_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/facebook.png") - } + facebook_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/facebook.png") } - github_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/github.png") - } + } + github_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/github.png") } - gitlab_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/gitlab.png") - } + } + gitlab_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/gitlab.png") } - google_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/google.png") - } + } + google_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/google.png") } - twitter_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/x.png") - } + } + twitter_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/x.png") } } } @@ -247,7 +233,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } - account_prompt_label := Label { + Label { width: Fit, height: Fit padding: Inset{left: 1, right: 1, top: 0, bottom: 0} draw_text +: { @@ -260,13 +246,23 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } } - mode_toggle_button := RobrixIconButton { + signup_button := RobrixIconButton { width: Fit, height: Fit padding: Inset{left: 15, right: 15, top: 10, bottom: 10} margin: Inset{bottom: 5} align: Align{x: 0.5, y: 0.5} text: "Sign up here" } + + // Cancel button for add-account mode (hidden by default) + cancel_button := RobrixIconButton { + width: Fit, height: Fit + padding: Inset{left: 15, right: 15, top: 10, bottom: 10} + margin: Inset{top: 10, bottom: 5} + align: Align{x: 0.5, y: 0.5} + text: "Cancel" + visible: false + } } // The modal that pops up to display login status messages, @@ -284,44 +280,18 @@ script_mod! { } } +static MATRIX_SIGN_UP_URL: &str = "https://matrix.org/docs/chat_basics/matrix-for-im/#creating-a-matrix-account"; + #[derive(Script, ScriptHook, Widget)] pub struct LoginScreen { #[source] source: ScriptObjectRef, #[deref] view: View, - /// Whether the screen is showing the in-app sign-up flow. - #[rust] signup_mode: bool, /// Boolean to indicate if the SSO login process is still in flight #[rust] sso_pending: bool, /// The URL to redirect to after logging in with SSO. #[rust] sso_redirect_url: Option, - /// The most recent login failure message shown to the user. - #[rust] last_failure_message_shown: Option, -} - -impl LoginScreen { - fn set_signup_mode(&mut self, cx: &mut Cx, signup_mode: bool) { - self.signup_mode = signup_mode; - self.view.view(cx, ids!(confirm_password_wrapper)).set_visible(cx, signup_mode); - self.view.view(cx, ids!(login_only_view)).set_visible(cx, !signup_mode); - self.view.label(cx, ids!(title)).set_text(cx, - if signup_mode { "Create your Robrix account" } else { "Login to Robrix" } - ); - self.view.button(cx, ids!(login_button)).set_text(cx, - if signup_mode { "Create account" } else { "Login" } - ); - self.view.label(cx, ids!(account_prompt_label)).set_text(cx, - if signup_mode { "Already have an account?" } else { "Don't have an account?" } - ); - self.view.button(cx, ids!(mode_toggle_button)).set_text(cx, - if signup_mode { "Back to login" } else { "Sign up here" } - ); - - if !signup_mode { - self.view.text_input(cx, ids!(confirm_password_input)).set_text(cx, ""); - } - - self.redraw(cx); - } + /// Boolean to indicate if we're in "add account" mode (adding another Matrix account). + #[rust] adding_account: bool, } @@ -339,29 +309,40 @@ impl Widget for LoginScreen { impl MatchEvent for LoginScreen { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { let login_button = self.view.button(cx, ids!(login_button)); - let mode_toggle_button = self.view.button(cx, ids!(mode_toggle_button)); + let signup_button = self.view.button(cx, ids!(signup_button)); + let cancel_button = self.view.button(cx, ids!(cancel_button)); let user_id_input = self.view.text_input(cx, ids!(user_id_input)); let password_input = self.view.text_input(cx, ids!(password_input)); - let confirm_password_input = self.view.text_input(cx, ids!(confirm_password_input)); let homeserver_input = self.view.text_input(cx, ids!(homeserver_input)); let login_status_modal = self.view.modal(cx, ids!(login_status_modal)); let login_status_modal_inner = self.view.login_status_modal(cx, ids!(login_status_modal_inner)); - if mode_toggle_button.clicked(actions) { - self.set_signup_mode(cx, !self.signup_mode); + // Handle cancel button for add-account mode + if cancel_button.clicked(actions) { + self.adding_account = false; + // Reset the UI back to normal login mode + self.view.label(cx, ids!(title)).set_text(cx, "Login to Robrix"); + cancel_button.set_visible(cx, false); + self.view.view(cx, ids!(sso_view)).set_visible(cx, true); + signup_button.set_visible(cx, true); + cx.action(LoginAction::CancelAddAccount); + self.redraw(cx); + } + + if signup_button.clicked(actions) { + log!("Opening URL \"{}\"", MATRIX_SIGN_UP_URL); + let _ = robius_open::Uri::new(MATRIX_SIGN_UP_URL).open(); } if login_button.clicked(actions) || user_id_input.returned(actions).is_some() || password_input.returned(actions).is_some() - || (self.signup_mode && confirm_password_input.returned(actions).is_some()) || homeserver_input.returned(actions).is_some() { - let user_id = user_id_input.text().trim().to_owned(); + let user_id = user_id_input.text(); let password = password_input.text(); - let confirm_password = confirm_password_input.text(); - let homeserver = homeserver_input.text().trim().to_owned(); + let homeserver = homeserver_input.text(); if user_id.is_empty() { login_status_modal_inner.set_title(cx, "Missing User ID"); login_status_modal_inner.set_status(cx, "Please enter a valid User ID."); @@ -370,39 +351,16 @@ impl MatchEvent for LoginScreen { login_status_modal_inner.set_title(cx, "Missing Password"); login_status_modal_inner.set_status(cx, "Please enter a valid password."); login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); - } else if self.signup_mode && password != confirm_password { - login_status_modal_inner.set_title(cx, "Passwords do not match"); - login_status_modal_inner.set_status(cx, "Please enter the same password in both password fields."); - login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); } else { - self.last_failure_message_shown = None; - login_status_modal_inner.set_title(cx, if self.signup_mode { - "Creating account..." - } else { - "Logging in..." - }); - login_status_modal_inner.set_status( - cx, - if self.signup_mode { - "Waiting for the homeserver to create your account..." - } else { - "Waiting for a login response..." - }, - ); + login_status_modal_inner.set_title(cx, "Logging in..."); + login_status_modal_inner.set_status(cx, "Waiting for a login response..."); login_status_modal_inner.button_ref(cx).set_text(cx, "Cancel"); - submit_async_request(MatrixRequest::Login(if self.signup_mode { - LoginRequest::Register(RegisterAccount { - user_id, - password, - homeserver: homeserver.is_empty().not().then_some(homeserver), - }) - } else { - LoginRequest::LoginByPassword(LoginByPassword { - user_id, - password, - homeserver: homeserver.is_empty().not().then_some(homeserver), - }) - })); + submit_async_request(MatrixRequest::Login(LoginRequest::LoginByPassword(LoginByPassword { + user_id, + password, + homeserver: homeserver.is_empty().not().then_some(homeserver), + is_add_account: self.adding_account, + }))); } login_status_modal.open(cx); self.redraw(cx); @@ -425,7 +383,6 @@ impl MatchEvent for LoginScreen { // Handle login-related actions received from background async tasks. match action.downcast_ref() { Some(LoginAction::CliAutoLogin { user_id, homeserver }) => { - self.last_failure_message_shown = None; user_id_input.set_text(cx, user_id); password_input.set_text(cx, ""); homeserver_input.set_text(cx, homeserver.as_deref().unwrap_or_default()); @@ -440,7 +397,6 @@ impl MatchEvent for LoginScreen { login_status_modal.open(cx); } Some(LoginAction::Status { title, status }) => { - self.last_failure_message_shown = None; login_status_modal_inner.set_title(cx, title); login_status_modal_inner.set_status(cx, status); let login_status_modal_button = login_status_modal_inner.button_ref(cx); @@ -452,25 +408,19 @@ impl MatchEvent for LoginScreen { Some(LoginAction::LoginSuccess) => { // The main `App` component handles showing the main screen // and hiding the login screen & login status modal. - self.last_failure_message_shown = None; - self.set_signup_mode(cx, false); + self.adding_account = false; user_id_input.set_text(cx, ""); password_input.set_text(cx, ""); - confirm_password_input.set_text(cx, ""); homeserver_input.set_text(cx, ""); + // Reset title and buttons in case we were in add-account mode + self.view.label(cx, ids!(title)).set_text(cx, "Login to Robrix"); + cancel_button.set_visible(cx, false); + signup_button.set_visible(cx, true); login_status_modal.close(cx); self.redraw(cx); } Some(LoginAction::LoginFailure(error)) => { - if self.last_failure_message_shown.as_deref() == Some(error.as_str()) { - continue; - } - self.last_failure_message_shown = Some(error.clone()); - login_status_modal_inner.set_title(cx, if self.signup_mode { - "Account Creation Failed." - } else { - "Login Failed." - }); + login_status_modal_inner.set_title(cx, "Login Failed."); login_status_modal_inner.set_status(cx, error); let login_status_modal_button = login_status_modal_inner.button_ref(cx); login_status_modal_button.set_text(cx, "Okay"); @@ -495,6 +445,28 @@ impl MatchEvent for LoginScreen { Some(LoginAction::SsoSetRedirectUrl(url)) => { self.sso_redirect_url = Some(url.to_string()); } + Some(LoginAction::ShowAddAccountScreen) => { + self.adding_account = true; + // Update UI to "add account" mode + self.view.label(cx, ids!(title)).set_text(cx, "Add Another Account"); + cancel_button.set_visible(cx, true); + // Hide signup button in add-account mode (user already has an account) + signup_button.set_visible(cx, false); + self.redraw(cx); + } + Some(LoginAction::AddAccountSuccess) => { + // Reset the login screen state + self.adding_account = false; + user_id_input.set_text(cx, ""); + password_input.set_text(cx, ""); + homeserver_input.set_text(cx, ""); + // Reset title and buttons + self.view.label(cx, ids!(title)).set_text(cx, "Login to Robrix"); + cancel_button.set_visible(cx, false); + signup_button.set_visible(cx, true); + login_status_modal.close(cx); + self.redraw(cx); + } _ => { } } } @@ -529,6 +501,9 @@ impl MatchEvent for LoginScreen { pub enum LoginAction { /// A positive response from the backend Matrix task to the login screen. LoginSuccess, + /// A positive response when adding an additional account (multi-account mode). + /// The login was successful but we should add this as a new account, not replace the existing one. + AddAccountSuccess, /// A negative response from the backend Matrix task to the login screen. LoginFailure(String), /// A login-related status message to display to the user. @@ -546,15 +521,20 @@ pub enum LoginAction { /// informing it that the SSO login process is either still in flight (`true`) or has finished (`false`). /// /// Note that an inner value of `false` does *not* imply that the login request has - /// successfully finished. + /// successfully finished. /// The login screen can use this to prevent the user from submitting - /// additional SSO login requests while a previous request is in flight. + /// additional SSO login requests while a previous request is in flight. SsoPending(bool), /// Set the SSO redirect URL in the LoginScreen. /// /// When an SSO-based login is pendng, pressing the cancel button will send /// an HTTP request to this SSO server URL to gracefully shut it down. SsoSetRedirectUrl(Url), + /// Request to show the login screen in "add account" mode. + /// This is used when the user wants to add another Matrix account. + ShowAddAccountScreen, + /// Request to cancel adding an account and return to the previous screen. + CancelAddAccount, #[default] None, } diff --git a/src/settings/account_settings.rs b/src/settings/account_settings.rs index 877f66bfc..25b2d43fc 100644 --- a/src/settings/account_settings.rs +++ b/src/settings/account_settings.rs @@ -2,7 +2,8 @@ use std::cell::RefCell; use makepad_widgets::{text::selection::Cursor, *}; -use crate::{app::ConfirmDeleteAction, avatar_cache::{self}, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction}, profile::user_profile::UserProfile, shared::{avatar::{AvatarState, AvatarWidgetExt}, confirmation_modal::ConfirmationModalContent, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{AccountDataAction, MatrixRequest, submit_async_request}, utils}; +use matrix_sdk::ruma::OwnedUserId; +use crate::{account_manager, app::ConfirmDeleteAction, avatar_cache::{self}, home::navigation_tab_bar::get_own_profile, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction}, profile::user_profile::UserProfile, shared::{avatar::{AvatarState, AvatarWidgetExt}, confirmation_modal::ConfirmationModalContent, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{AccountDataAction, AccountSwitchAction, MatrixRequest, submit_async_request}, utils}; script_mod! { use mod.prelude.widgets.* @@ -174,6 +175,127 @@ script_mod! { } } + SubsectionLabel { + text: "Multiple Accounts:" + } + + View { + width: Fill, height: Fit + flow: Down, + spacing: 8, + margin: Inset{left: 5, right: 5, bottom: 10} + + // Account entries will be shown here + // Active account (current) + active_account_view := RoundedView { + width: Fill, height: Fit + flow: Right, + align: Align{y: 0.5} + padding: Inset{left: 10, right: 10, top: 8, bottom: 8} + spacing: 10 + show_bg: true + draw_bg +: { + color: (COLOR_ACTIVE_PRIMARY) + border_radius: 4.0 + } + + View { + width: Fill, height: Fit + flow: Down, + spacing: 2 + + active_account_label := Label { + width: Fill, height: Fit + draw_text +: { + color: (COLOR_TEXT), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + text: "@user:server" + } + + Label { + width: Fit, height: Fit + draw_text +: { + color: (COLOR_FG_ACCEPT_GREEN), + text_style: MESSAGE_TEXT_STYLE { font_size: 9 }, + } + text: "Active" + } + } + } + + // Other accounts section (populated dynamically) + other_accounts_label := Label { + width: Fill, height: Fit + margin: Inset{top: 5, left: 2} + visible: false + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 10 }, + } + text: "Other accounts:" + } + + // Container for other account entries (simplified: show one other account) + other_account_entry := RoundedView { + width: Fill, height: Fit + flow: Right, + align: Align{y: 0.5} + padding: Inset{left: 10, right: 10, top: 8, bottom: 8} + spacing: 10 + visible: false + show_bg: true + draw_bg +: { + color: (COLOR_SECONDARY) + border_radius: 4.0 + border_size: 1.0 + border_color: #555 + } + + View { + width: Fill, height: Fit + flow: Down, + spacing: 2 + + other_account_label := Label { + width: Fill, height: Fit + draw_text +: { + color: (COLOR_TEXT), + text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, + } + text: "@other:server" + } + } + + switch_account_button := RobrixIconButton { + width: Fit, height: Fit + padding: Inset{top: 6, bottom: 6, left: 10, right: 10} + draw_icon.svg: (ICON_JUMP) + icon_walk: Walk{width: 14, height: 14} + text: "Switch" + } + } + + account_count_label := Label { + width: Fill, height: Fit + margin: Inset{top: 5, bottom: 5, left: 5} + draw_text +: { + color: (MESSAGE_TEXT_COLOR), + text_style: MESSAGE_TEXT_STYLE { font_size: 10 }, + } + text: "1 account logged in" + } + + add_account_button := RobrixIconButton { + width: Fit, + padding: Inset{top: 10, bottom: 10, left: 12, right: 15} + margin: Inset{top: 5} + draw_icon.svg: (ICON_ADD) + icon_walk: Walk{width: 16, height: 16} + text: "Add Another Account" + } + } + SubsectionLabel { text: "Other actions:" } @@ -210,6 +332,8 @@ pub struct AccountSettings { #[deref] view: View, #[rust] own_profile: Option, + /// List of other account user IDs (not the currently active one) + #[rust] other_accounts: Vec, } impl Widget for AccountSettings { @@ -221,7 +345,7 @@ impl Widget for AccountSettings { match event.hits(cx, copy_user_id_button_area) { Hit::FingerHoverIn(_) | Hit::FingerLongPress(_) => { cx.widget_action( - copy_user_id_button.widget_uid(), + copy_user_id_button.widget_uid(), TooltipAction::HoverIn { text: "Copy User ID".to_string(), widget_rect: copy_user_id_button_area.rect(cx), @@ -234,7 +358,7 @@ impl Widget for AccountSettings { } Hit::FingerHoverOut(_) => { cx.widget_action( - copy_user_id_button.widget_uid(), + copy_user_id_button.widget_uid(), TooltipAction::HoverOut, ); } @@ -371,14 +495,70 @@ impl MatchEvent for AccountSettings { let Some(own_profile) = &self.own_profile else { return }; if upload_avatar_button.clicked(actions) { - // TODO: uncomment the below once avatar uploading is implemented - // Self::enable_upload_avatar_button(cx, false, &upload_avatar_button); - // Self::enable_delete_avatar_button(cx, false, &delete_avatar_button); - enqueue_popup_notification( - "Avatar uploading is not yet implemented.", - PopupKind::Warning, - Some(4.0), - ); + Self::enable_upload_avatar_button(cx, false, &upload_avatar_button); + Self::enable_delete_avatar_button(cx, false, &delete_avatar_button); + + // Use rfd directly on the main thread (modal dialog blocks until selection) + let file_dialog = rfd::FileDialog::new() + .add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp"]) + .set_title("Select Avatar Image"); + + if let Some(path) = file_dialog.pick_file() { + // Read the file data + match std::fs::read(&path) { + Ok(data) => { + if data.is_empty() { + enqueue_popup_notification( + "Cannot upload empty file.", + PopupKind::Error, + None, + ); + Self::enable_upload_avatar_button(cx, true, &upload_avatar_button); + Self::enable_delete_avatar_button(cx, true, &delete_avatar_button); + } else { + let file_name = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("avatar") + .to_string(); + + // Determine MIME type from extension + let mime_type = mime_guess::from_path(&path) + .first_or(mime_guess::mime::IMAGE_PNG) + .to_string(); + + log!("Avatar file selected: {} ({}, {} bytes)", file_name, mime_type, data.len()); + + // Submit the avatar upload request + submit_async_request(MatrixRequest::UploadAvatar { + file_name, + mime_type, + data, + }); + + enqueue_popup_notification( + "Uploading avatar...", + PopupKind::Info, + Some(3.0), + ); + Cx::post_action(AccountSettingsAction::AvatarUploadStarted); + } + } + Err(e) => { + error!("Failed to read avatar file: {:?}", e); + enqueue_popup_notification( + format!("Failed to read file: {}", e), + PopupKind::Error, + None, + ); + Self::enable_upload_avatar_button(cx, true, &upload_avatar_button); + Self::enable_delete_avatar_button(cx, true, &delete_avatar_button); + } + } + } else { + // User cancelled - re-enable buttons + Self::enable_upload_avatar_button(cx, true, &upload_avatar_button); + Self::enable_delete_avatar_button(cx, true, &delete_avatar_button); + } } if delete_avatar_button.clicked(actions) { @@ -459,6 +639,47 @@ impl MatchEvent for AccountSettings { if self.view.button(cx, ids!(logout_button)).clicked(actions) { cx.action(LogoutConfirmModalAction::Open); } + + // Handle "Switch Account" button click + if self.view.button(cx, ids!(switch_account_button)).clicked(actions) { + // Switch to the first other account + if let Some(other_id) = self.other_accounts.first().cloned() { + log!("Switching to account: {}", other_id); + submit_async_request(MatrixRequest::SwitchAccount { user_id: other_id }); + } + } + + // Handle "Add Account" button click + if self.view.button(cx, ids!(add_account_button)).clicked(actions) { + // Navigate to login screen in "add account" mode + cx.action(LoginAction::ShowAddAccountScreen); + } + + // Handle account switch result and new account added + for action in actions { + if let Some(AccountSwitchAction::Switched(new_user_id)) = action.downcast_ref() { + log!("Account switched to: {}, refreshing profile and account list", new_user_id); + // Refresh the profile with new account's data + if let Some(new_profile) = get_own_profile(cx) { + self.own_profile = Some(new_profile.clone()); + // Update the UI with new profile + self.view.label(cx, ids!(user_id)) + .set_text(cx, new_profile.user_id.as_str()); + self.view.text_input(cx, ids!(display_name_input)) + .set_text(cx, new_profile.username.as_deref().unwrap_or_default()); + self.populate_avatar_views(cx); + } + // Refresh the account list to show new active account + self.populate_account_list(cx); + self.view.redraw(cx); + } + // Refresh account list when a new account is added + if let Some(LoginAction::AddAccountSuccess) = action.downcast_ref() { + log!("New account added, refreshing account list"); + self.populate_account_list(cx); + self.view.redraw(cx); + } + } } } @@ -517,6 +738,7 @@ impl AccountSettings { self.own_profile = Some(own_profile); self.populate_avatar_views(cx); + self.populate_account_list(cx); self.view.button(cx, ids!(upload_avatar_button)).reset_hover(cx); self.view.button(cx, ids!(delete_avatar_button)).reset_hover(cx); @@ -528,6 +750,44 @@ impl AccountSettings { self.view.redraw(cx); } + /// Populate the account list with logged-in accounts from the AccountManager. + fn populate_account_list(&mut self, cx: &mut Cx) { + let count = account_manager::account_count(); + let label_text = if count == 1 { + "1 account logged in".to_string() + } else { + format!("{} accounts logged in", count) + }; + self.view.label(cx, ids!(account_count_label)).set_text(cx, &label_text); + + // Get the active account + let active_user_id = account_manager::get_active_user_id(); + + // Show the active account + if let Some(ref active_id) = active_user_id { + self.view.label(cx, ids!(active_account_label)) + .set_text(cx, active_id.as_str()); + } + + // Get other accounts (excluding active) + let all_accounts = account_manager::get_all_user_ids(); + self.other_accounts = all_accounts + .into_iter() + .filter(|id| Some(id) != active_user_id.as_ref()) + .collect(); + + // Show "Other accounts" label and entry only if there are other accounts + let has_other_accounts = !self.other_accounts.is_empty(); + self.view.label(cx, ids!(other_accounts_label)).set_visible(cx, has_other_accounts); + self.view.view(cx, ids!(other_account_entry)).set_visible(cx, has_other_accounts); + + // If there's at least one other account, show it + if let Some(other_id) = self.other_accounts.first() { + self.view.label(cx, ids!(other_account_label)) + .set_text(cx, other_id.as_str()); + } + } + /// Enable or disable the delete avatar button. fn enable_delete_avatar_button( cx: &mut Cx, diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 131e6610f..4fa0fa6bc 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -8,14 +8,8 @@ use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ - config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ - api::{Direction, client::{ - account::register::v3::Request as RegistrationRequest, - error::ErrorKind, - profile::{AvatarUrl, DisplayName}, - receipt::create_receipt::v3::ReceiptType, - uiaa::{AuthData, AuthType, Dummy}, - }}, events::{ + config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::{MediaFormat, MediaRequestParameters}, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ + api::{Direction, client::{profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType}}, events::{ relation::RelationType, room::{ message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource @@ -37,8 +31,9 @@ use std::{borrow::Cow, cmp::{max, min}, future::Future, hash::{BuildHasherDefaul use std::io; use hashbrown::{HashMap, HashSet}; use crate::{ + account_manager::{self, Account}, app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ - add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails + add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate, TypingUser}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, @@ -47,7 +42,7 @@ use crate::{ }, space_service_sync::space_service_loop, utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, verification::add_verification_event_handlers_and_sync_client }; -#[derive(Parser, Default)] +#[derive(Parser, Default, Clone)] struct Cli { /// The user ID to login with. #[clap(value_parser)] @@ -90,11 +85,9 @@ impl std::fmt::Debug for Cli { impl From for Cli { fn from(login: LoginByPassword) -> Self { Self { - user_id: login.user_id.trim().to_owned(), + user_id: login.user_id, password: login.password, - homeserver: login.homeserver - .map(|homeserver| homeserver.trim().to_owned()) - .filter(|homeserver| !homeserver.is_empty()), + homeserver: login.homeserver, proxy: None, login_screen: false, verbose: false, @@ -102,192 +95,6 @@ impl From for Cli { } } -impl From for Cli { - fn from(registration: RegisterAccount) -> Self { - Self { - user_id: registration.user_id.trim().to_owned(), - password: registration.password, - homeserver: registration.homeserver - .map(|homeserver| homeserver.trim().to_owned()) - .filter(|homeserver| !homeserver.is_empty()), - proxy: None, - login_screen: false, - verbose: false, - } - } -} - -fn infer_homeserver_from_user_id(user_id: &str) -> Option { - let user_id: OwnedUserId = user_id.trim().try_into().ok()?; - Some(user_id.server_name().to_string()) -} - -async fn finalize_authenticated_client( - client: Client, - client_session: ClientSessionPersisted, - fallback_user_id: &str, -) -> Result<(Client, Option)> { - if client.matrix_auth().logged_in() { - let logged_in_user_id = client.user_id() - .map(ToString::to_string) - .unwrap_or_else(|| fallback_user_id.to_owned()); - log!("Logged in successfully."); - let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); - enqueue_rooms_list_update(RoomsListUpdate::Status { status }); - if let Err(e) = persistence::save_session(&client, client_session).await { - let err_msg = format!("Failed to save session state to storage: {e}"); - error!("{err_msg}"); - enqueue_popup_notification(err_msg, PopupKind::Error, None); - } - Ok((client, None)) - } else { - let err_msg = format!( - "Authentication succeeded for {fallback_user_id}, but the homeserver did not return a login session." - ); - enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); - bail!(err_msg); - } -} - -fn registration_localpart(user_id: &str) -> Result { - let trimmed = user_id.trim(); - if trimmed.is_empty() { - bail!("Please enter a valid username or Matrix user ID."); - } - - if let Ok(full_user_id) = >::try_from(trimmed) { - return Ok(full_user_id.localpart().to_owned()); - } - - let localpart = trimmed.trim_start_matches('@'); - if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) { - bail!("Please enter a valid username or full Matrix user ID."); - } - - Ok(localpart.to_owned()) -} - -fn registration_request( - username: &str, - password: &str, - session: Option, -) -> RegistrationRequest { - let mut request = RegistrationRequest::new(); - request.username = Some(username.to_owned()); - request.password = Some(password.to_owned()); - request.initial_device_display_name = Some("robrix-un-pw".to_owned()); - request.refresh_token = true; - if let Some(session) = session { - let mut dummy = Dummy::new(); - dummy.session = Some(session); - request.auth = Some(AuthData::Dummy(dummy)); - } - request -} - -fn registration_uiaa_error_message(error: &matrix_sdk::Error) -> String { - if let matrix_sdk::Error::Http(http_error) = error { - match http_error.client_api_error_kind() { - Some(ErrorKind::UserInUse) => { - return "That user ID is already taken. Please choose another one.".to_owned(); - } - Some(ErrorKind::InvalidUsername) => { - return "That user ID is invalid. Use a username like `alice` or a full Matrix ID like `@alice:matrix.org`.".to_owned(); - } - Some(ErrorKind::WeakPassword) => { - return "That password is too weak. Please choose a stronger password.".to_owned(); - } - Some(ErrorKind::Forbidden { .. }) => { - return "This homeserver does not allow open registration.".to_owned(); - } - Some(ErrorKind::LimitExceeded { .. }) => { - return "The homeserver is rate limiting account creation right now. Please try again shortly.".to_owned(); - } - _ => {} - } - } - - format!("Could not create account: {error}") -} - -fn unsupported_registration_flow_message( - flows: &[matrix_sdk::ruma::api::client::uiaa::AuthFlow], -) -> String { - let supports_registration_token = flows.iter().any(|flow| { - flow.stages - .iter() - .any(|stage| matches!(stage, AuthType::RegistrationToken)) - }); - if supports_registration_token { - return "This homeserver requires a registration token. Robrix does not support token-based registration yet.".to_owned(); - } - - let supports_terms = flows.iter().any(|flow| { - flow.stages - .iter() - .any(|stage| matches!(stage, AuthType::Terms)) - }); - if supports_terms { - return "This homeserver requires an interactive terms-of-service step. Robrix does not support that registration flow yet.".to_owned(); - } - - "This homeserver requires an unsupported registration flow. Please try another homeserver or register with a different client.".to_owned() -} - -async fn clear_persisted_session(user_id: Option<&UserId>) { - let Some(user_id) = user_id else { - return; - }; - - if let Err(e) = persistence::delete_session(user_id).await { - warning!("Failed to delete persisted session for {user_id}: {e}"); - } - - let latest_user_id = persistence::most_recent_user_id().await; - if latest_user_id.as_deref() == Some(user_id) { - if let Err(e) = persistence::delete_latest_user_id().await { - warning!("Failed to delete latest user id for {user_id}: {e}"); - } - } -} - -enum SessionResetAction { - Reauthenticate { message: String }, -} - -async fn reset_runtime_state_for_relogin() { - let sync_service = { SYNC_SERVICE.lock().unwrap().take() }; - if let Some(sync_service) = sync_service { - sync_service.stop().await; - } - - CLIENT.lock().unwrap().take(); - DEFAULT_SSO_CLIENT.lock().unwrap().take(); - IGNORED_USERS.lock().unwrap().clear(); - ALL_JOINED_ROOMS.lock().unwrap().clear(); - - let on_clear_appstate = Arc::new(Notify::new()); - Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); - - if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()).await.is_err() { - warning!("Timed out waiting for UI-side app state cleanup during re-login reset"); - } -} - -fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { - matches!( - error.client_api_error_kind(), - Some(ErrorKind::UnknownToken { .. } | ErrorKind::MissingToken) - ) -} - -fn is_invalid_batch_token_timeline_error(error: &matrix_sdk_ui::timeline::Error) -> bool { - let error_text = error.to_string().to_ascii_lowercase(); - error_text.contains("invalid batch token") - || error_text.contains("must start with 's' or 't'") -} - /// Build a new client. async fn build_client( @@ -310,10 +117,7 @@ async fn build_client( .collect() }; - let inferred_homeserver = infer_homeserver_from_user_id(&cli.user_id); let homeserver_url = cli.homeserver.as_deref() - .filter(|homeserver| !homeserver.trim().is_empty()) - .or(inferred_homeserver.as_deref()) .unwrap_or("https://matrix-client.matrix.org/"); // .unwrap_or("https://matrix.org/"); @@ -364,19 +168,22 @@ async fn build_client( /// /// This function is used by the login screen to log in to the Matrix server. /// -/// Upon success, this function returns the logged-in client and an optional sync token. +/// Upon success, this function returns the logged-in client, an optional sync token, +/// a boolean indicating if this is an add-account operation (multi-account mode), +/// and the client session for storing in the account manager. async fn login( cli: &Cli, login_request: LoginRequest, -) -> Result<(Client, Option)> { +) -> Result<(Client, Option, bool, ClientSessionPersisted)> { match login_request { LoginRequest::LoginByCli | LoginRequest::LoginByPassword(_) => { - let cli = if let LoginRequest::LoginByPassword(login_by_password) = login_request { - &Cli::from(login_by_password) + let (cli, is_add_account) = if let LoginRequest::LoginByPassword(login_by_password) = login_request { + let is_add_account = login_by_password.is_add_account; + (Cli::from(login_by_password), is_add_account) } else { - cli + ((*cli).clone(), false) }; - let (client, client_session) = build_client(cli, app_data_dir()).await?; + let (client, client_session) = build_client(&cli, app_data_dir()).await?; Cx::post_action(LoginAction::Status { title: "Authenticating".into(), status: format!("Logging in as {}...", cli.user_id), @@ -388,78 +195,30 @@ async fn login( .initial_device_display_name("robrix-un-pw") .send() .await?; - if !client.matrix_auth().logged_in() { - let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); - enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); - bail!(err_msg); - } - finalize_authenticated_client(client, client_session, &cli.user_id).await - } - - LoginRequest::Register(registration) => { - let cli = Cli::from(RegisterAccount { - user_id: registration.user_id.clone(), - password: registration.password.clone(), - homeserver: registration.homeserver.clone(), - }); - let localpart = registration_localpart(®istration.user_id)?; - let (client, client_session) = build_client(&cli, app_data_dir()).await?; - Cx::post_action(LoginAction::Status { - title: "Creating account".into(), - status: format!("Creating account {localpart}..."), - }); - - let auth = client.matrix_auth(); - let initial_request = registration_request(&localpart, ®istration.password, None); - let register_result = match auth.register(initial_request).await { - Ok(response) => Ok(response), - Err(error) => { - if let Some(uiaa_info) = error.as_uiaa_response() { - let supports_dummy = uiaa_info.flows.iter().any(|flow| { - flow.stages - .iter() - .any(|stage| matches!(stage, AuthType::Dummy)) - }); - if supports_dummy { - Cx::post_action(LoginAction::Status { - title: "Completing sign up".into(), - status: "Confirming registration with the homeserver...".into(), - }); - auth.register(registration_request( - &localpart, - ®istration.password, - uiaa_info.session.clone(), - )) - .await - } else { - bail!(unsupported_registration_flow_message(&uiaa_info.flows)); - } - } else { - bail!(registration_uiaa_error_message(&error)); - } + if client.matrix_auth().logged_in() { + log!("Logged in successfully."); + let status = format!("Logged in as {}.\n → Loading rooms...", cli.user_id); + // enqueue_popup_notification(status.clone()); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + if let Err(e) = persistence::save_session(&client, client_session.clone()).await { + let err_msg = format!("Failed to save session state to storage: {e}"); + error!("{err_msg}"); + enqueue_popup_notification(err_msg, PopupKind::Error, None); } - }?; - - if !client.matrix_auth().logged_in() { - let err_msg = format!( - "Account {} was created, but the homeserver did not return a login session. Please log in manually.", - register_result.user_id, - ); + Ok((client, None, is_add_account, client_session)) + } else { + let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } - - finalize_authenticated_client(client, client_session, register_result.user_id.as_str()) - .await } - LoginRequest::LoginBySSOSuccess(client, client_session) => { - if let Err(e) = persistence::save_session(&client, client_session).await { + LoginRequest::LoginBySSOSuccess(client, client_session, is_add_account) => { + if let Err(e) = persistence::save_session(&client, client_session.clone()).await { error!("Failed to save session state to storage: {e:?}"); } - Ok((client, None)) + Ok((client, None, is_add_account, client_session)) } LoginRequest::HomeserverLoginTypesQuery(_) => { bail!("LoginRequest::HomeserverLoginTypesQuery not handled earlier"); @@ -558,6 +317,17 @@ pub enum AccountDataAction { DisplayNameChangeFailed(String), } +/// Actions emitted in response to account switching. +#[derive(Debug, Clone)] +pub enum AccountSwitchAction { + /// Account switch is starting - UI should show loading state. + Starting(OwnedUserId), + /// Successfully switched to a different account. + Switched(OwnedUserId), + /// Failed to switch accounts. + Failed(String), +} + /// Actions emitted in response to a [`MatrixRequest::OpenOrCreateDirectMessage`]. #[derive(Debug)] pub enum DirectMessageRoomAction { @@ -624,6 +394,10 @@ impl std::fmt::Display for TimelineKind { pub enum MatrixRequest { /// Request from the login screen to log in with the given credentials. Login(LoginRequest), + /// Request to switch to a different logged-in account. + SwitchAccount { + user_id: OwnedUserId, + }, /// Request to logout. Logout { is_desktop: bool, @@ -679,12 +453,6 @@ pub enum MatrixRequest { room_id: OwnedRoomId, user_id: OwnedUserId, }, - /// Request to bind or unbind the configured botfather for the given room. - SetRoomBotBinding { - room_id: OwnedRoomId, - bound: bool, - bot_user_id: OwnedUserId, - }, /// Request to join the given room. JoinRoom { room_id: OwnedRoomId, @@ -791,6 +559,15 @@ pub enum MatrixRequest { /// * If `None`, the avatar will be removed. avatar_url: Option, }, + /// Request to upload and set a new avatar for the current user's account. + UploadAvatar { + /// The file name of the avatar image. + file_name: String, + /// The MIME type of the avatar image (e.g., "image/png", "image/jpeg"). + mime_type: String, + /// The raw bytes of the avatar image. + data: Vec, + }, /// Request to set or remove the display name of the current user's account. SetDisplayName { /// * If `Some`, the display name will be set to the given value. @@ -816,6 +593,15 @@ pub enum MatrixRequest { destination: MediaCacheEntryRef, update_sender: Option>, }, + /// Request to download a file from Matrix and save it to disk. + DownloadFile { + /// The media source of the file to download. + media_source: ruma::events::room::MediaSource, + /// The suggested filename for the downloaded file. + filename: String, + /// The destination path to save the file to. + destination_path: std::path::PathBuf, + }, /// Request to send a message to the given room. SendMessage { timeline_kind: TimelineKind, @@ -824,6 +610,16 @@ pub enum MatrixRequest { #[cfg(feature = "tsp")] sign_with_tsp: bool, }, + /// Request to send a file attachment to the given room. + SendAttachment { + room_id: OwnedRoomId, + file_name: String, + mime_type: String, + data: Vec, + /// Optional sender for progress updates. If provided, the upload will send + /// progress notifications through this channel. + timeline_update_sender: Option>, + }, /// Sends a notice to the given room that the current user is or is not typing. /// /// This request does not return a response or notify the UI thread, and @@ -919,6 +715,60 @@ pub enum MatrixRequest { destination: Arc>, update_sender: Option>, }, + + // ==================== Call-related requests ==================== + + /// Request to start a new call in a room. + StartCall { + room_id: OwnedRoomId, + /// Whether this is a video call (vs audio-only). + is_video_call: bool, + }, + /// Request to join an existing call in a room. + JoinCall { + room_id: OwnedRoomId, + }, + /// Request to leave an ongoing call. + LeaveCall { + room_id: OwnedRoomId, + }, + /// Request to send a MatrixRTC call membership state event. + SendCallMembershipEvent { + room_id: OwnedRoomId, + /// The serialized membership event content. + membership_content: String, + }, + /// Toggle audio mute for the current call. + ToggleCallAudio { + room_id: OwnedRoomId, + }, + /// Toggle video for the current call. + ToggleCallVideo { + room_id: OwnedRoomId, + }, + /// Fetch the TURN server configuration from the homeserver. + GetTurnServers, + /// Fetch the RTC foci configuration from the homeserver's well-known endpoint. + /// This retrieves the LiveKit service URL from `org.matrix.msc4143.rtc_foci`. + FetchRtcWellKnown, + /// Fetch a LiveKit SFU JWT token for joining a call. + FetchLiveKitSfuToken { + room_id: OwnedRoomId, + /// The device ID of the local user. + device_id: String, + }, + /// Request to search room members in the background. + /// Used to avoid blocking the UI thread for large rooms. + SearchRoomMembers { + /// Unique ID to identify this search and discard stale results. + search_id: u64, + /// The search query string. + query: String, + /// The room ID this search is for. + room_id: OwnedRoomId, + /// The list of members to search through. + members: std::sync::Arc>, + }, } /// Submits a request to the worker thread to be executed asynchronously. @@ -932,8 +782,7 @@ pub fn submit_async_request(req: MatrixRequest) { /// Details of a login request that get submitted within [`MatrixRequest::Login`]. pub enum LoginRequest{ LoginByPassword(LoginByPassword), - Register(RegisterAccount), - LoginBySSOSuccess(Client, ClientSessionPersisted), + LoginBySSOSuccess(Client, ClientSessionPersisted, bool), LoginByCli, HomeserverLoginTypesQuery(String), @@ -943,14 +792,8 @@ pub struct LoginByPassword { pub user_id: String, pub password: String, pub homeserver: Option, -} - -/// Information needed to register a new account on a Matrix homeserver. -#[derive(Clone)] -pub struct RegisterAccount { - pub user_id: String, - pub password: String, - pub homeserver: Option, + /// Whether this login is for adding another account (multi-account mode). + pub is_add_account: bool, } @@ -971,11 +814,92 @@ async fn matrix_worker_task( while let Some(request) = request_receiver.recv().await { match request { MatrixRequest::Login(login_request) => { - if let Err(e) = login_sender.send(login_request).await { - error!("Error sending login request to login_sender: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(String::from( - "BUG: failed to send login request to login worker task." - ))); + // Check if this is an add-account login (when already logged in) + let is_add_account = match &login_request { + LoginRequest::LoginByPassword(lpw) => lpw.is_add_account, + LoginRequest::LoginBySSOSuccess(_, _, is_add) => *is_add, + _ => false, + }; + + if is_add_account { + // Handle add-account login directly in the worker task + log!("Processing add-account login directly in worker task"); + let cli = Cli::default(); + match login(&cli, login_request).await { + Ok((client, _sync_token, _is_add, session)) => { + let user_id = client.user_id() + .expect("BUG: client.user_id() returned None after login!"); + + // Add to account manager + let account = Account { + client: client.clone(), + user_id: user_id.to_owned(), + session, + display_name: None, + avatar_url: None, + }; + let is_new = account_manager::add_account(account); + log!("Add-account login successful for {}. New account: {}", user_id, is_new); + + // Post success action + Cx::post_action(LoginAction::AddAccountSuccess); + enqueue_popup_notification( + format!("Added account: {}", user_id), + PopupKind::Success, + Some(3.0), + ); + } + Err(e) => { + error!("Add-account login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + } + } + } else { + // Forward to login_sender for initial login flow + if let Err(e) = login_sender.send(login_request).await { + error!("Error sending login request to login_sender: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(String::from( + "BUG: failed to send login request to login worker task." + ))); + } + } + } + + MatrixRequest::SwitchAccount { user_id } => { + log!("Received MatrixRequest::SwitchAccount for {}", user_id); + + // Check if the account exists in AccountManager + if account_manager::get_client_for_user(&user_id).is_some() { + // Set the target account for switch + set_account_switch_target(user_id.clone()); + + // Notify UI that switch is starting + Cx::post_action(AccountSwitchAction::Starting(user_id.clone())); + enqueue_popup_notification( + format!("Switching to {}...", user_id), + PopupKind::Info, + Some(2.0), + ); + + // Stop the sync service - this will cause the main loop to restart + if let Some(sync_service) = get_sync_service() { + log!("Stopping sync service for account switch"); + sync_service.stop().await; + } + + // The main loop will detect the account switch target and restart with the new account + // We return Ok(()) to signal the worker should end gracefully + return Ok(()); + } else { + error!("Account {} not found in AccountManager", user_id); + Cx::post_action(AccountSwitchAction::Failed( + format!("Account {} not found", user_id) + )); + enqueue_popup_notification( + format!("Account not found: {}", user_id), + PopupKind::Error, + Some(3.0), + ); } } @@ -1000,7 +924,6 @@ async fn matrix_worker_task( log!("Skipping pagination request for unknown {timeline_kind}"); continue; }; - let client = get_client(); // Spawn a new async task that will make the actual pagination request. let _paginate_task = Handle::current().spawn(async move { @@ -1008,45 +931,12 @@ async fn matrix_worker_task( sender.send(TimelineUpdate::PaginationRunning(direction)).unwrap(); SignalToUI::set_ui_signal(); - let mut res = if direction == PaginationDirection::Forwards { + let res = if direction == PaginationDirection::Forwards { timeline.paginate_forwards(num_events).await } else { timeline.paginate_backwards(num_events).await }; - if direction == PaginationDirection::Backwards - && res - .as_ref() - .err() - .is_some_and(is_invalid_batch_token_timeline_error) - { - warning!( - "Detected an invalid cached batch token for {timeline_kind}; clearing the room event cache and retrying once." - ); - let room_id = timeline_kind.room_id().clone(); - if let Some(room) = client.and_then(|client| client.get_room(&room_id)) { - match room.event_cache().await { - Ok((room_event_cache, _drop_handles)) => { - match room_event_cache.clear().await { - Ok(()) => { - res = timeline.paginate_backwards(num_events).await; - } - Err(clear_error) => { - warning!( - "Failed to clear event cache for room {room_id} after invalid batch token: {clear_error}" - ); - } - } - } - Err(event_cache_error) => { - warning!( - "Failed to access room event cache for room {room_id} after invalid batch token: {event_cache_error}" - ); - } - } - } - } - match res { Ok(fully_paginated) => { log!("Completed {direction} pagination request for {timeline_kind}, hit {} of timeline? {}", @@ -1296,68 +1186,6 @@ async fn matrix_worker_task( }); } - MatrixRequest::SetRoomBotBinding { - room_id, - bound, - bot_user_id, - } => { - let Some(client) = get_client() else { continue }; - let _bot_binding_task = Handle::current().spawn(async move { - let Some(room) = client.get_room(&room_id) else { - let error_message = - format!("Room {room_id} was not found for the bot binding request."); - error!("{error_message}"); - enqueue_popup_notification(error_message, PopupKind::Error, None); - return; - }; - - let membership_result = if bound { - room.invite_user_by_id(&bot_user_id).await - } else { - room.kick_user(&bot_user_id, Some("Robrix app service unbind")).await - }; - - match membership_result { - Ok(()) => { - Cx::post_action(AppStateAction::BotRoomBindingUpdated { - room_id, - bound, - bot_user_id: Some(bot_user_id), - warning: None, - }); - } - Err(error) => { - let membership_exists = - room.get_member_no_sync(&bot_user_id).await.ok().flatten().is_some(); - let should_mark_bound = if bound { membership_exists } else { false }; - - if should_mark_bound != bound { - error!( - "Failed to {} BotFather {bot_user_id} for room {room_id}: {error:?}", - if bound { "invite" } else { "remove" } - ); - enqueue_popup_notification( - format!( - "Failed to {} BotFather {bot_user_id}: {error}", - if bound { "invite" } else { "remove" } - ), - PopupKind::Error, - None, - ); - return; - } - - Cx::post_action(AppStateAction::BotRoomBindingUpdated { - room_id, - bound, - bot_user_id: Some(bot_user_id), - warning: Some(error.to_string()), - }); - } - } - }); - } - MatrixRequest::JoinRoom { room_id } => { let Some(client) = get_client() else { continue }; let _join_room_task = Handle::current().spawn(async move { @@ -1431,12 +1259,14 @@ async fn matrix_worker_task( let room = timeline.room(); if local_only { - if let Ok(members) = room.members_no_sync(memberships).await { - send_update(members, "Got"); + match room.members_no_sync(memberships).await { + Ok(members) => send_update(members, "Got"), + Err(e) => error!("Failed to get room members (local_only) for {timeline_kind}: {e:?}"), } } else { - if let Ok(members) = room.members(memberships).await { - send_update(members, "Successfully fetched"); + match room.members(memberships).await { + Ok(members) => send_update(members, "Successfully fetched"), + Err(e) => error!("Failed to fetch room members for {timeline_kind}: {e:?}"), } } }); @@ -1661,6 +1491,48 @@ async fn matrix_worker_task( }); } + MatrixRequest::UploadAvatar { file_name, mime_type, data } => { + let Some(client) = get_client() else { continue }; + let _upload_avatar_task = Handle::current().spawn(async move { + log!("Uploading avatar {} ({}, {} bytes)...", file_name, mime_type, data.len()); + + // Parse the MIME type + let content_type: mime::Mime = mime_type.parse().unwrap_or(mime::IMAGE_PNG); + + // Upload the media to the server + match client.media().upload(&content_type, data, None).await { + Ok(response) => { + let mxc_uri = response.content_uri; + log!("Successfully uploaded avatar, got MXC URI: {}", mxc_uri); + + // Now set the avatar URL + match client.account().set_avatar_url(Some(&mxc_uri)).await { + Ok(_) => { + log!("Successfully set avatar to {}", mxc_uri); + Cx::post_action(AccountDataAction::AvatarChanged(Some(mxc_uri))); + enqueue_popup_notification( + "Avatar updated successfully!", + PopupKind::Info, + Some(3.0), + ); + } + Err(e) => { + let err_msg = format!("Failed to set avatar URL: {e}"); + error!("{}", err_msg); + Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); + } + } + } + Err(e) => { + let err_msg = format!("Failed to upload avatar: {e}"); + error!("{}", err_msg); + Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); + } + } + SignalToUI::set_ui_signal(); + }); + } + MatrixRequest::SetDisplayName { new_display_name } => { let Some(client) = get_client() else { continue }; let _set_display_name_task = Handle::current().spawn(async move { @@ -1810,15 +1682,21 @@ async fn matrix_worker_task( // log!("Received typing notifications for room {room_id}: {user_ids:?}"); let mut users = Vec::with_capacity(user_ids.len()); for user_id in user_ids { - users.push( - main_timeline.room() - .get_member_no_sync(&user_id) - .await - .ok() - .flatten() - .and_then(|m| m.display_name().map(|d| d.to_owned())) - .unwrap_or_else(|| user_id.to_string()) - ); + let member = main_timeline.room() + .get_member_no_sync(&user_id) + .await + .ok() + .flatten(); + let display_name = member.as_ref() + .and_then(|m| m.display_name().map(|d| d.to_owned())) + .unwrap_or_else(|| user_id.to_string()); + let avatar_url = member.as_ref() + .and_then(|m| m.avatar_url().map(|u| u.to_owned())); + users.push(TypingUser { + user_id: user_id.clone(), + display_name, + avatar_url, + }); } if let Err(e) = timeline_update_sender.send(TimelineUpdate::TypingUsers { users }) { error!("Error: timeline update sender couldn't send the list of typing users: {e:?}"); @@ -1943,7 +1821,7 @@ async fn matrix_worker_task( MatrixRequest::FetchMedia { media_request, on_fetched, destination, update_sender } => { let Some(client) = get_client() else { continue }; - + let _fetch_task = Handle::current().spawn(async move { // log!("Sending fetch media request for {media_request:?}..."); let res = client.media().get_media_content(&media_request, true).await; @@ -1951,6 +1829,48 @@ async fn matrix_worker_task( }); } + MatrixRequest::DownloadFile { media_source, filename, destination_path } => { + let Some(client) = get_client() else { continue }; + + let _download_task = Handle::current().spawn(async move { + log!("Downloading file {filename} to {:?}...", destination_path); + let media_request = MediaRequestParameters { + source: media_source, + format: MediaFormat::File, + }; + match client.media().get_media_content(&media_request, true).await { + Ok(data) => { + match std::fs::write(&destination_path, &data) { + Ok(_) => { + log!("Successfully downloaded file to {:?}", destination_path); + enqueue_popup_notification( + format!("Downloaded: {filename}"), + PopupKind::Success, + None, + ); + } + Err(e) => { + error!("Failed to write file to {:?}: {e}", destination_path); + enqueue_popup_notification( + format!("Failed to save file: {e}"), + PopupKind::Error, + None, + ); + } + } + } + Err(e) => { + error!("Failed to download file {filename}: {e}"); + enqueue_popup_notification( + format!("Failed to download: {e}"), + PopupKind::Error, + None, + ); + } + } + }); + } + MatrixRequest::SendMessage { timeline_kind, message, @@ -2048,19 +1968,81 @@ async fn matrix_worker_task( }); } - MatrixRequest::ReadReceipt { timeline_kind, event_id, receipt_type } => { - let Some(timeline) = get_timeline(&timeline_kind) else { - log!("BUG: {timeline_kind} not found when sending read receipt, {event_id}"); + MatrixRequest::SendAttachment { room_id, file_name, mime_type, data, timeline_update_sender } => { + let Some(client) = get_client() else { continue }; + let Some(room) = client.get_room(&room_id) else { + error!("BUG: room {room_id} not found for send attachment request"); + enqueue_popup_notification( + "Failed to send attachment: room not found.", + PopupKind::Error, + None, + ); continue; }; - let _send_rr_task = Handle::current().spawn(async move { - match timeline.send_single_receipt(receipt_type.clone(), event_id.clone()).await { - Ok(sent) => log!("{} {receipt_type} read receipt to {timeline_kind} for event {event_id}", if sent { "Sent" } else { "Already sent" }), - Err(_e) => error!("Failed to send {receipt_type} read receipt to {timeline_kind} for event {event_id}; error: {_e:?}"), + let _send_attachment_task = Handle::current().spawn(async move { + use crate::home::room_screen::TimelineUpdate; + + let data_len = data.len() as u64; + log!("Sending attachment {} ({}, {} bytes) to room {}", file_name, mime_type, data_len, room_id); + + // Send initial progress update (0%) + if let Some(ref sender) = timeline_update_sender { + let _ = sender.send(TimelineUpdate::FileUploadProgress { current: 0, total: data_len }); + SignalToUI::set_ui_signal(); } - if let TimelineKind::MainRoom { room_id } = timeline_kind { - // Also update the number of unread messages in the room. + + // Parse the MIME type + let content_type: mime::Mime = mime_type.parse().unwrap_or(mime::APPLICATION_OCTET_STREAM); + + // Create attachment config + let config = matrix_sdk::attachment::AttachmentConfig::new(); + + // Send the attachment + match room.send_attachment(&file_name, &content_type, data, config).await { + Ok(_response) => { + log!("Successfully sent attachment {} to room {}", file_name, room_id); + // Send completion progress update (100%) + if let Some(ref sender) = timeline_update_sender { + let _ = sender.send(TimelineUpdate::FileUploadProgress { current: data_len, total: data_len }); + SignalToUI::set_ui_signal(); + } + enqueue_popup_notification( + format!("Sent: {}", file_name), + PopupKind::Info, + Some(3.0), + ); + } + Err(e) => { + error!("Failed to send attachment {} to room {}: {:?}", file_name, room_id, e); + if let Some(ref sender) = timeline_update_sender { + let _ = sender.send(TimelineUpdate::FileUploadError(format!("{}", e))); + SignalToUI::set_ui_signal(); + } + enqueue_popup_notification( + format!("Failed to send attachment: {}", e), + PopupKind::Error, + None, + ); + } + } + SignalToUI::set_ui_signal(); + }); + } + + MatrixRequest::ReadReceipt { timeline_kind, event_id, receipt_type } => { + let Some(timeline) = get_timeline(&timeline_kind) else { + log!("BUG: {timeline_kind} not found when sending read receipt, {event_id}"); + continue; + }; + + let _send_rr_task = Handle::current().spawn(async move { + match timeline.send_single_receipt(receipt_type.clone(), event_id.clone()).await { + Ok(sent) => log!("{} {receipt_type} read receipt to {timeline_kind} for event {event_id}", if sent { "Sent" } else { "Already sent" }), + Err(_e) => error!("Failed to send {receipt_type} read receipt to {timeline_kind} for event {event_id}; error: {_e:?}"), + } + if let TimelineKind::MainRoom { room_id } = timeline_kind { + // Also update the number of unread messages in the room. enqueue_rooms_list_update(RoomsListUpdate::UpdateNumUnreadMessages { room_id, is_marked_unread: timeline.room().is_marked_unread(), @@ -2288,6 +2270,430 @@ async fn matrix_worker_task( SignalToUI::set_ui_signal(); }); } + + MatrixRequest::SearchRoomMembers { search_id, query, room_id, members } => { + // Perform the search in a background task to avoid blocking the worker. + Handle::current().spawn(async move { + let query_lower = query.to_lowercase(); + let matched_indices: Vec = members + .iter() + .enumerate() + .filter(|(_, m)| { + m.displayable_name().to_lowercase().contains(&query_lower) + || m.user_id.as_str().to_lowercase().contains(&query_lower) + }) + .map(|(i, _)| i) + .collect(); + + crate::home::members_panel::enqueue_member_search_result( + crate::home::members_panel::MemberSearchResult { + search_id, + room_id, + query, + matched_indices, + } + ); + }); + } + + // ==================== Call-related request handlers ==================== + MatrixRequest::StartCall { room_id, is_video_call } => { + log!("StartCall request received for room {} (video: {})", room_id, is_video_call); + let Some(client) = get_client() else { continue }; + let manager = crate::call::webrtc_manager::webrtc_manager(); + + let _task = Handle::current().spawn(async move { + let Some(user_id) = client.user_id().map(|u| u.to_owned()) else { + error!("StartCall: user_id not available"); + return; + }; + let Some(device_id) = client.device_id().map(|d| d.to_owned()) else { + error!("StartCall: device_id not available"); + return; + }; + let config = crate::call::webrtc_session::WebRTCSessionConfig::default(); + + match manager.start_call(room_id.clone(), user_id.clone(), device_id.clone(), is_video_call, config).await { + Ok(membership) => { + // Serialize membership directly (not wrapped in MatrixRTCMemberEvent) + match serde_json::to_value(&membership) { + Ok(content) => { + if let Some(room) = client.get_room(&room_id) { + // Use correct state key format: _@user:server_deviceId_m.call + let state_key = crate::call::matrixrtc::MatrixRTCMembership::state_key( + user_id.as_str(), + device_id.as_str(), + ); + match room.send_state_event_raw( + crate::call::matrixrtc::MATRIXRTC_MEMBER_EVENT_TYPE, + &state_key, + content, + ).await { + Ok(_) => log!("Successfully sent call membership event for room {}", room_id), + Err(e) => error!("Failed to send call membership event: {}", e), + } + } else { + error!("StartCall: room {} not found", room_id); + } + } + Err(e) => error!("Failed to serialize membership event: {}", e), + } + } + Err(e) => error!("Failed to start call: {}", e), + } + }); + } + MatrixRequest::JoinCall { room_id } => { + log!("JoinCall request received for room {}", room_id); + let Some(client) = get_client() else { continue }; + + // Check if LiveKit service URL is available + let Some(livekit_service_url) = get_livekit_service_url() else { + error!("JoinCall: No LiveKit service URL available. Call joining requires LiveKit."); + Cx::post_action(crate::call::call_state::CallAction::MediaError { + error: "LiveKit service not configured for this homeserver".to_string(), + }); + continue; + }; + + let room_id_clone = room_id.clone(); + let livekit_url_clone = livekit_service_url.clone(); + let _task = Handle::current().spawn(async move { + let Some(user_id) = client.user_id().map(|u| u.to_owned()) else { + error!("JoinCall: user_id not available"); + return; + }; + let Some(device_id) = client.device_id().map(|d| d.to_owned()) else { + error!("JoinCall: device_id not available"); + return; + }; + + // Step 1: Get OpenID token for authentication with LiveKit service + log!("JoinCall: Step 1 - Getting OpenID token"); + let openid_token = match crate::call::matrixrtc::get_openid_token(&client).await { + Ok(token) => { + log!("JoinCall: OpenID token obtained successfully"); + token + } + Err(e) => { + error!("JoinCall: Failed to get OpenID token: {}", e); + Cx::post_action(crate::call::call_state::CallAction::MediaError { + error: format!("Failed to authenticate: {}", e), + }); + SignalToUI::set_ui_signal(); + return; + } + }; + + // Step 2: Send call membership state event to the room (BEFORE getting SFU token) + // This matches Element's flow where membership event is sent first + log!("JoinCall: Step 2 - Sending call membership state event"); + let start_time_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + // Create membership content matching Element's format + let mut membership = crate::call::matrixrtc::MatrixRTCMembership::new(device_id.clone()) + .with_focus_active(crate::call::matrixrtc::FocusActive::livekit()) + .with_call_intent("video"); + + // Add LiveKit focus info with room_id as alias (matching Element) + let livekit_alias = room_id_clone.to_string(); + membership.add_preferred_focus( + crate::call::matrixrtc::FocusInfo::livekit(livekit_url_clone.clone(), livekit_alias.clone()) + ); + // Add second focus entry (Element sends two identical entries) + membership.add_preferred_focus( + crate::call::matrixrtc::FocusInfo::livekit(livekit_url_clone.clone(), livekit_alias) + ); + + // Serialize membership directly (not wrapped in MatrixRTCMemberEvent) + if let Ok(content) = serde_json::to_value(&membership) { + if let Some(room) = client.get_room(&room_id_clone) { + // State key format: _@user:server_deviceId_m.call + let state_key = crate::call::matrixrtc::MatrixRTCMembership::state_key( + user_id.as_str(), + device_id.as_str(), + ); + log!("JoinCall: Sending membership with state_key: {}", state_key); + + match room.send_state_event_raw( + crate::call::matrixrtc::MATRIXRTC_MEMBER_EVENT_TYPE, + &state_key, + content, + ).await { + Ok(_) => log!("JoinCall: Membership state event sent successfully"), + Err(e) => { + error!("JoinCall: Failed to send membership event: {}", e); + Cx::post_action(crate::call::call_state::CallAction::MediaError { + error: format!("Failed to join room call: {}", e), + }); + SignalToUI::set_ui_signal(); + return; + } + } + } + } + + // Step 3: Fetch SFU token from LiveKit service (AFTER sending membership) + log!("JoinCall: Step 3 - Fetching SFU token from {}", livekit_url_clone); + let sfu_response = match crate::call::matrixrtc::fetch_livekit_sfu_token( + &livekit_url_clone, + room_id_clone.as_str(), + &openid_token, + device_id.as_str(), + ).await { + Ok(response) => { + log!("JoinCall: SFU token received successfully"); + response + } + Err(e) => { + error!("JoinCall: Failed to get SFU token: {}", e); + Cx::post_action(crate::call::call_state::CallAction::MediaError { + error: format!("Failed to get call token: {}", e), + }); + SignalToUI::set_ui_signal(); + return; + } + }; + + let (jwt, livekit_url) = match (sfu_response.jwt, sfu_response.url) { + (Some(jwt), Some(url)) => (jwt, url), + _ => { + error!("JoinCall: SFU response missing jwt or url"); + Cx::post_action(crate::call::call_state::CallAction::MediaError { + error: "Invalid response from call service".to_string(), + }); + SignalToUI::set_ui_signal(); + return; + } + }; + + // Step 4: Post action with LiveKit token to connect + log!("JoinCall: Step 4 - Posting LiveKitTokenReceived action (url: {})", livekit_url); + Cx::post_action(crate::call::call_state::CallAction::LiveKitTokenReceived { + room_id: room_id_clone.clone(), + jwt, + livekit_url, + }); + + // Update call state to connected + let local_participant = crate::call::call_state::CallParticipant::new( + user_id.clone(), + device_id.clone(), + ); + Cx::post_action(crate::call::call_state::CallAction::StateChanged { + room_id: room_id_clone.clone(), + new_state: crate::call::call_state::CallState::Connected { + room_id: room_id_clone, + participants: Vec::new(), + local_participant, + is_video_call: true, + start_time_ms, + }, + }); + SignalToUI::set_ui_signal(); + }); + } + MatrixRequest::LeaveCall { room_id } => { + log!("LeaveCall request received for room {}", room_id); + let Some(client) = get_client() else { continue }; + let manager = crate::call::webrtc_manager::webrtc_manager(); + + let _task = Handle::current().spawn(async move { + let _ = manager.leave_call(&room_id).await; + + // Send empty membership to signal leaving + if let Some(room) = client.get_room(&room_id) { + if let (Some(user_id), Some(device_id)) = (client.user_id(), client.device_id()) { + // Use correct state key format: _@user:server_deviceId_m.call + let state_key = crate::call::matrixrtc::MatrixRTCMembership::state_key( + user_id.as_str(), + device_id.as_str(), + ); + // Send empty object to clear membership + let empty = serde_json::json!({}); + let _ = room.send_state_event_raw( + crate::call::matrixrtc::MATRIXRTC_MEMBER_EVENT_TYPE, + &state_key, + empty, + ).await; + } + } + + Cx::post_action(crate::call::call_state::CallAction::StateChanged { + room_id: room_id.clone(), + new_state: crate::call::call_state::CallState::Idle, + }); + SignalToUI::set_ui_signal(); + }); + } + MatrixRequest::SendCallMembershipEvent { room_id, membership_content } => { + log!("SendCallMembershipEvent request for room {}", room_id); + let Some(client) = get_client() else { continue }; + + let _task = Handle::current().spawn(async move { + if let Some(room) = client.get_room(&room_id) { + if let Some(user_id) = client.user_id() { + match serde_json::from_str::(&membership_content) { + Ok(content) => { + let _ = room.send_state_event_raw( + crate::call::matrixrtc::MATRIXRTC_MEMBER_EVENT_TYPE, + user_id.as_str(), + content, + ).await; + } + Err(e) => error!("Failed to parse membership content: {}", e), + } + } + } + }); + } + MatrixRequest::ToggleCallAudio { room_id } => { + log!("ToggleCallAudio request for room {}", room_id); + let manager = crate::call::webrtc_manager::webrtc_manager(); + let _task = Handle::current().spawn(async move { + let _ = manager.toggle_audio(&room_id).await; + SignalToUI::set_ui_signal(); + }); + } + MatrixRequest::ToggleCallVideo { room_id } => { + log!("ToggleCallVideo request for room {}", room_id); + let manager = crate::call::webrtc_manager::webrtc_manager(); + let _task = Handle::current().spawn(async move { + let _ = manager.toggle_video(&room_id).await; + SignalToUI::set_ui_signal(); + }); + } + MatrixRequest::GetTurnServers => { + log!("GetTurnServers request received"); + // TURN server configuration is typically handled by the WebRTC session setup + // For now, we use the default STUN servers in WebRTCSessionConfig + } + MatrixRequest::FetchRtcWellKnown => { + log!("FetchRtcWellKnown request received"); + let Some(client) = get_client() else { + error!("FetchRtcWellKnown: No client available"); + continue; + }; + + // Use the server name from the user ID to construct the well-known URL. + // This is important because client.homeserver() returns the resolved + // homeserver API URL (e.g., https://matrix-client.matrix.org), but the + // well-known file is served from the original domain (e.g., https://matrix.org). + let well_known_url = if let Some(user_id) = client.user_id() { + let server_name = user_id.server_name().as_str(); + match Url::parse(&format!("https://{}/.well-known/matrix/client", server_name)) { + Ok(url) => url, + Err(e) => { + error!("FetchRtcWellKnown: Failed to build well-known URL from server name: {}", e); + // Fall back to homeserver URL + match client.homeserver().join("/.well-known/matrix/client") { + Ok(url) => url, + Err(e) => { + error!("FetchRtcWellKnown: Failed to build well-known URL: {}", e); + continue; + } + } + } + } + } else { + // No user ID available, fall back to homeserver URL + match client.homeserver().join("/.well-known/matrix/client") { + Ok(url) => url, + Err(e) => { + error!("FetchRtcWellKnown: Failed to build well-known URL: {}", e); + continue; + } + } + }; + log!("FetchRtcWellKnown: Fetching from {}", well_known_url); + + let _task = Handle::current().spawn(async move { + match fetch_rtc_well_known(well_known_url).await { + Ok(Some(livekit_url)) => { + log!("FetchRtcWellKnown: Found LiveKit service URL: {}", livekit_url); + set_livekit_service_url(Some(livekit_url)); + } + Ok(None) => { + log!("FetchRtcWellKnown: No LiveKit service URL found in well-known"); + set_livekit_service_url(None); + } + Err(e) => { + error!("FetchRtcWellKnown: Failed to fetch well-known: {}", e); + set_livekit_service_url(None); + } + } + SignalToUI::set_ui_signal(); + }); + } + MatrixRequest::FetchLiveKitSfuToken { room_id, device_id } => { + log!("FetchLiveKitSfuToken request for room {}", room_id); + + let Some(client) = get_client() else { + error!("FetchLiveKitSfuToken: No client available"); + Cx::post_action(crate::call::call_state::CallAction::MediaError { + error: "Not logged in".to_string(), + }); + continue; + }; + + let Some(livekit_service_url) = get_livekit_service_url() else { + error!("FetchLiveKitSfuToken: No LiveKit service URL available"); + Cx::post_action(crate::call::call_state::CallAction::MediaError { + error: "LiveKit service not configured for this homeserver".to_string(), + }); + continue; + }; + + let room_id_clone = room_id.clone(); + let _task = Handle::current().spawn(async move { + // First, get an OpenID token for authentication + let openid_token = match crate::call::matrixrtc::get_openid_token(&client).await { + Ok(token) => token, + Err(e) => { + error!("FetchLiveKitSfuToken: Failed to get OpenID token: {}", e); + Cx::post_action(crate::call::call_state::CallAction::MediaError { + error: format!("Failed to get authentication token: {}", e), + }); + SignalToUI::set_ui_signal(); + return; + } + }; + + // Fetch the SFU token + match crate::call::matrixrtc::fetch_livekit_sfu_token( + &livekit_service_url, + room_id_clone.as_str(), + &openid_token, + &device_id, + ).await { + Ok(sfu_response) => { + if let (Some(jwt), Some(url)) = (sfu_response.jwt, sfu_response.url) { + log!("FetchLiveKitSfuToken: Successfully obtained JWT for LiveKit"); + Cx::post_action(crate::call::call_state::CallAction::LiveKitTokenReceived { + room_id: room_id_clone, + jwt, + livekit_url: url, + }); + } else { + error!("FetchLiveKitSfuToken: SFU response missing jwt or url"); + Cx::post_action(crate::call::call_state::CallAction::MediaError { + error: "Invalid SFU response".to_string(), + }); + } + } + Err(e) => { + error!("FetchLiveKitSfuToken: Failed to fetch SFU token: {}", e); + Cx::post_action(crate::call::call_state::CallAction::MediaError { + error: format!("Failed to get call token: {}", e), + }); + } + } + SignalToUI::set_ui_signal(); + }); + } } } @@ -2295,6 +2701,43 @@ async fn matrix_worker_task( bail!("matrix_worker_task task ended unexpectedly") } +/// Fetches the RTC foci configuration from the homeserver's well-known endpoint. +/// Returns the LiveKit service URL if found. +async fn fetch_rtc_well_known(well_known_url: url::Url) -> Result, anyhow::Error> { + use serde_json::Value; + + let response = reqwest::get(well_known_url).await?; + + if !response.status().is_success() { + anyhow::bail!("Well-known request failed with status: {}", response.status()); + } + + let json: Value = response.json().await?; + + // Look for org.matrix.msc4143.rtc_foci array + let rtc_foci = match json.get("org.matrix.msc4143.rtc_foci") { + Some(Value::Array(arr)) => arr, + _ => { + log!("fetch_rtc_well_known: No org.matrix.msc4143.rtc_foci found"); + return Ok(None); + } + }; + + // Find the livekit entry + for focus in rtc_foci { + if let Some(focus_type) = focus.get("type").and_then(|t| t.as_str()) { + if focus_type == "livekit" { + if let Some(url) = focus.get("livekit_service_url").and_then(|u| u.as_str()) { + return Ok(Some(url.to_string())); + } + } + } + } + + log!("fetch_rtc_well_known: No livekit entry found in rtc_foci"); + Ok(None) +} + /// The single global Tokio runtime that is used by all async tasks. static TOKIO_RUNTIME: Mutex> = Mutex::new(None); @@ -2478,6 +2921,20 @@ pub fn get_client() -> Option { CLIENT.lock().unwrap().clone() } +/// The LiveKit service URL fetched from the homeserver's well-known configuration. +/// This is used for MatrixRTC calls via LiveKit SFU. +static LIVEKIT_SERVICE_URL: Mutex> = Mutex::new(None); + +/// Returns the LiveKit service URL if it has been fetched from the homeserver. +pub fn get_livekit_service_url() -> Option { + LIVEKIT_SERVICE_URL.lock().unwrap().clone() +} + +/// Sets the LiveKit service URL. +fn set_livekit_service_url(url: Option) { + *LIVEKIT_SERVICE_URL.lock().unwrap() = url; +} + /// Returns the user ID of the currently logged-in user, if any. pub fn current_user_id() -> Option { CLIENT.lock().unwrap().as_ref().and_then(|c| @@ -2488,6 +2945,22 @@ pub fn current_user_id() -> Option { /// The singleton sync service. static SYNC_SERVICE: Mutex>> = Mutex::new(None); +/// Flag to indicate an account switch is in progress. +/// Contains the user_id to switch to, if any. +static ACCOUNT_SWITCH_TARGET: Mutex> = Mutex::new(None); + +/// Check if an account switch is pending. +fn get_account_switch_target() -> Option { + ACCOUNT_SWITCH_TARGET.lock().ok()?.take() +} + +/// Set the target account to switch to. +fn set_account_switch_target(user_id: OwnedUserId) { + if let Ok(mut guard) = ACCOUNT_SWITCH_TARGET.lock() { + *guard = Some(user_id); + } +} + /// Get a reference to the current sync service, if available. pub fn get_sync_service() -> Option> { @@ -2647,7 +3120,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { ); log!("Waiting for login? {}", wait_for_login); - let new_login_opt: Option<(Client, Option, bool)> = if !wait_for_login { + let new_login_opt = if !wait_for_login { let specified_username = cli_parse_result.as_ref().ok().and_then(|cli| username_to_full_user_id( &cli.user_id, @@ -2657,17 +3130,11 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { log!("Trying to restore session for user: {:?}", specified_username.as_ref().or(most_recent_user_id.as_ref()) ); - match persistence::restore_session(specified_username.clone()).await { - Ok((client, sync_token)) => Some((client, sync_token, true)), + match persistence::restore_session(specified_username).await { + Ok(session) => Some(session), Err(e) => { let status_err = "Could not restore previous user session.\n\nPlease login again."; log!("{status_err} Error: {e:?}"); - clear_persisted_session( - specified_username - .as_deref() - .or(most_recent_user_id.as_deref()), - ) - .await; Cx::post_action(LoginAction::LoginFailure(status_err.to_string())); if let Ok(cli) = &cli_parse_result { @@ -2677,7 +3144,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { homeserver: cli.homeserver.clone(), }); match login(cli, LoginRequest::LoginByCli).await { - Ok((client, sync_token)) => Some((client, sync_token, false)), + Ok((client, sync_token, _is_add_account, _session)) => Some((client, sync_token)), Err(e) => { error!("CLI-based login failed: {e:?}"); Cx::post_action(LoginAction::LoginFailure( @@ -2703,247 +3170,320 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // which causes the loop to wait for the user to submit a new manual login request. let mut initial_client_opt = new_login_opt; - loop { - let (client, sync_service, logged_in_user_id) = 'login_loop: loop { - let (client, _sync_token, validate_session) = match initial_client_opt.take() { - Some(login) => login, - None => { - loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => { - match login(&cli, login_request).await { - Ok((client, sync_token)) => break (client, sync_token, false), - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), - }); - } + let (client, sync_service, logged_in_user_id) = 'login_loop: loop { + let (client, _sync_token) = match initial_client_opt.take() { + Some(login) => login, + None => { + loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => { + match login(&cli, login_request).await { + Ok((client, sync_token, ..)) => break (client, sync_token), + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: format!("Login failed: {e}"), + }); } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); - Cx::post_action(LoginAction::LoginFailure(err.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err, - }); - return; } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); + Cx::post_action(LoginAction::LoginFailure(err.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err, + }); + return; } } } - }; - - if validate_session { - match client.whoami().await { - Ok(_) => {} - Err(e) if is_invalid_token_http_error(&e) => { - clear_persisted_session(client.user_id()).await; - let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; - Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err_msg.to_string(), - }); - continue 'login_loop; - } - Err(e) => { - warning!("Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}"); - } - } } + }; - // Deallocate the default SSO client after a successful login. - if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { - let _ = client_opt.take(); - } + // Deallocate the default SSO client after a successful login. + if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { + let _ = client_opt.take(); + } - let logged_in_user_id: OwnedUserId = client.user_id() - .expect("BUG: Client::user_id() returned None after successful login!") - .to_owned(); - let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); - enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + let logged_in_user_id: OwnedUserId = client.user_id() + .expect("BUG: Client::user_id() returned None after successful login!") + .to_owned(); + let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); - // Store this active client in our global Client state so that other tasks can access it. - if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { - error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); - } + // Store this active client in our global Client state so that other tasks can access it. + if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { + error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); + } - // Listen for changes to our verification status and incoming verification requests. - add_verification_event_handlers_and_sync_client(client.clone()); + // Listen for changes to our verification status and incoming verification requests. + add_verification_event_handlers_and_sync_client(client.clone()); - // Listen for updates to the ignored user list. - handle_ignore_user_list_subscriber(client.clone()); + // Listen for updates to the ignored user list. + handle_ignore_user_list_subscriber(client.clone()); - Cx::post_action(LoginAction::Status { - title: "Connecting".into(), - status: "Setting up sync service...".into(), - }); - let sync_service = match SyncService::builder(client.clone()) - .with_offline_mode() - .build() - .await - { - Ok(ss) => ss, - Err(e) => { - error!("Failed to create SyncService: {e:?}"); - let err_msg = if is_invalid_token_error(&e) { - "Your login token is no longer valid.\n\nPlease log in again.".to_string() - } else { - format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") - }; - if is_invalid_token_error(&e) { - clear_persisted_session(client.user_id()).await; - } - Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); - enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); - // Clear the stored client so the next login attempt doesn't trigger the - // "unexpectedly replaced an existing client" warning. - let _ = CLIENT.lock().unwrap().take(); - continue 'login_loop; - } - }; + // Listen for session changes, e.g., when the access token becomes invalid. + handle_session_changes(client.clone()); - break 'login_loop (client, sync_service, logged_in_user_id); + Cx::post_action(LoginAction::Status { + title: "Connecting".into(), + status: "Setting up sync service...".into(), + }); + let sync_service = match SyncService::builder(client.clone()) + .with_offline_mode() + .build() + .await + { + Ok(ss) => ss, + Err(e) => { + error!("Failed to create SyncService: {e:?}"); + let err_msg = if is_invalid_token_error(&e) { + "Your login token is no longer valid.\n\nPlease log in again.".to_string() + } else { + format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") + }; + Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); + // Clear the stored client so the next login attempt doesn't trigger the + // "unexpectedly replaced an existing client" warning. + let _ = CLIENT.lock().unwrap().take(); + continue 'login_loop; + } }; - let (session_reset_sender, mut session_reset_receiver) = - tokio::sync::mpsc::unbounded_channel::(); - let session_change_handler_task = - handle_session_changes(client.clone(), session_reset_sender); + break 'login_loop (client, sync_service, logged_in_user_id); + }; - // Signal login success now that SyncService::build() has already succeeded (inside - // 'login_loop), which is the only step that can fail with an invalid/expired token. - // Doing this before sync_service.start() lets the UI transition to the home screen - // without waiting for the sync loop to begin. - Cx::post_action(LoginAction::LoginSuccess); + // Signal login success now that SyncService::build() has already succeeded (inside + // 'login_loop), which is the only step that can fail with an invalid/expired token. + // Doing this before sync_service.start() lets the UI transition to the home screen + // without waiting for the sync loop to begin. + Cx::post_action(LoginAction::LoginSuccess); - // Attempt to load the previously-saved app state. - handle_load_app_state(logged_in_user_id.to_owned()); - handle_sync_indicator_subscriber(&sync_service); - handle_sync_service_state_subscriber(sync_service.state()); - sync_service.start().await; + // Attempt to load the previously-saved app state. + handle_load_app_state(logged_in_user_id.to_owned()); + handle_sync_indicator_subscriber(&sync_service); + handle_sync_service_state_subscriber(sync_service.state()); + sync_service.start().await; - let room_list_service = sync_service.room_list_service(); + let room_list_service = sync_service.room_list_service(); - if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { - error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); - } + if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { + error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); + } - let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); - let mut space_service_task = rt.spawn(space_service_loop(client)); - - // Now, this task becomes an infinite loop that monitors the - // matrix/background tasks for the currently-authenticated session. - #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. - let reauth_message = loop { - tokio::select! { - session_reset = session_reset_receiver.recv() => { - match session_reset { - Some(SessionResetAction::Reauthenticate { message }) => { - break message; - } - None => { - warning!("Session reset receiver closed unexpectedly."); - continue; - } - } - } - result = &mut matrix_worker_task_handle => { - session_change_handler_task.abort(); - match result { - Ok(Ok(())) => { - // Check if this is due to logout - if is_logout_in_progress() { - log!("matrix worker task ended due to logout"); - } else { - error!("BUG: matrix worker task ended unexpectedly!"); - } - } - Ok(Err(e)) => { - // Check if this is due to logout - if is_logout_in_progress() { - log!("matrix worker task ended with error due to logout: {e:?}"); - } else { - error!("Error: matrix worker task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Rooms list update error: {e}"), - PopupKind::Error, - None, - ); - } - }, - Err(e) => { - error!("BUG: failed to join matrix worker task: {e:?}"); + let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); + let mut space_service_task = rt.spawn(space_service_loop(client)); + + // Now, this task becomes an infinite loop that monitors the state of the + // three core matrix-related background tasks that we just spawned above. + #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. + loop { + tokio::select! { + result = &mut matrix_worker_task_handle => { + match result { + Ok(Ok(())) => { + // Check if this is due to logout + if is_logout_in_progress() { + log!("matrix worker task ended due to logout"); + } else { + error!("BUG: matrix worker task ended unexpectedly!"); } } - return; - } - result = &mut room_list_service_task => { - session_change_handler_task.abort(); - match result { - Ok(Ok(())) => { - error!("BUG: room list service loop task ended unexpectedly!"); - } - Ok(Err(e)) => { - error!("Error: room list service loop task ended:\n\t{e:?}"); + Ok(Err(e)) => { + // Check if this is due to logout + if is_logout_in_progress() { + log!("matrix worker task ended with error due to logout: {e:?}"); + } else { + error!("Error: matrix worker task ended:\n\t{e:?}"); rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { status: e.to_string(), }); enqueue_popup_notification( - format!("Room list service error: {e}"), + format!("Rooms list update error: {e}"), PopupKind::Error, None, ); - }, - Err(e) => { - error!("BUG: failed to join room list service loop task: {e:?}"); } + }, + Err(e) => { + error!("BUG: failed to join matrix worker task: {e:?}"); } - return; } - result = &mut space_service_task => { - session_change_handler_task.abort(); - match result { - Ok(Ok(())) => { - error!("BUG: space service loop task ended unexpectedly!"); - } - Ok(Err(e)) => { - error!("Error: space service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Space service error: {e}"), - PopupKind::Error, - None, - ); - }, - Err(e) => { - error!("BUG: failed to join space service loop task: {e:?}"); - } + break; + } + result = &mut room_list_service_task => { + match result { + Ok(Ok(())) => { + error!("BUG: room list service loop task ended unexpectedly!"); + } + Ok(Err(e)) => { + error!("Error: room list service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Room list service error: {e}"), + PopupKind::Error, + None, + ); + }, + Err(e) => { + error!("BUG: failed to join room list service loop task: {e:?}"); } - return; } + break; } - }; + result = &mut space_service_task => { + match result { + Ok(Ok(())) => { + error!("BUG: space service loop task ended unexpectedly!"); + } + Ok(Err(e)) => { + error!("Error: space service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Space service error: {e}"), + PopupKind::Error, + None, + ); + }, + Err(e) => { + error!("BUG: failed to join space service loop task: {e:?}"); + } + } + break; + } + } + } - session_change_handler_task.abort(); - room_list_service_task.abort(); - space_service_task.abort(); + // Check if we need to restart for an account switch + if let Some(switch_user_id) = get_account_switch_target() { + log!("Account switch detected, restarting with user: {}", switch_user_id); - reset_runtime_state_for_relogin().await; - Cx::post_action(LoginAction::LoginFailure(reauth_message.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: reauth_message, - }); - initial_client_opt = None; + // Clear all backend state + CLIENT.lock().unwrap().take(); + SYNC_SERVICE.lock().unwrap().take(); + ALL_JOINED_ROOMS.lock().unwrap().clear(); + IGNORED_USERS.lock().unwrap().clear(); + + // Clear the rooms list UI + enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); + + // Post action to clear UI state + Cx::post_action(AccountSwitchAction::Starting(switch_user_id.clone())); + + // Update active account + account_manager::set_active_account(&switch_user_id); + + // Restore session for the switched account + match persistence::restore_session(Some(switch_user_id.clone())).await { + Ok((client, _sync_token)) => { + log!("Successfully restored session for {}", switch_user_id); + + // Store the client + CLIENT.lock().unwrap().replace(client.clone()); + + // Set up the new client + add_verification_event_handlers_and_sync_client(client.clone()); + crate::call::matrixrtc::add_matrixrtc_event_handlers(client.clone()); + handle_ignore_user_list_subscriber(client.clone()); + + // Create new sync service + let sync_service = match SyncService::builder(client.clone()) + .with_offline_mode() + .build() + .await + { + Ok(ss) => ss, + Err(e) => { + error!("Failed to create SyncService after account switch: {e:?}"); + Cx::post_action(AccountSwitchAction::Failed(format!("Failed to create sync service: {e}"))); + return; + } + }; + + // Load app state for the new user + handle_load_app_state(switch_user_id.clone()); + handle_sync_indicator_subscriber(&sync_service); + handle_sync_service_state_subscriber(sync_service.state()); + sync_service.start().await; + let room_list_service = sync_service.room_list_service(); + + SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)); + + // Recreate worker task and service loops + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); + REQUEST_SENDER.lock().unwrap().replace(sender); + let (login_sender, _login_receiver) = tokio::sync::mpsc::channel(1); + + let mut matrix_worker_task_handle = rt.spawn(matrix_worker_task(receiver, login_sender)); + let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); + let mut space_service_task = rt.spawn(space_service_loop(client.clone())); + + // Notify UI that switch is complete + Cx::post_action(AccountSwitchAction::Switched(switch_user_id.clone())); + enqueue_popup_notification( + format!("Switched to {}", switch_user_id), + PopupKind::Success, + Some(3.0), + ); + + // Re-enter the main monitoring loop + loop { + tokio::select! { + result = &mut matrix_worker_task_handle => { + match result { + Ok(Ok(())) => { + if is_logout_in_progress() { + log!("matrix worker task ended due to logout"); + } else if get_account_switch_target().is_some() { + // Another account switch requested, will handle after loop + } else { + error!("BUG: matrix worker task ended unexpectedly!"); + } + } + Ok(Err(e)) => { + error!("Error: matrix worker task ended:\n\t{e:?}"); + } + Err(e) => { + error!("BUG: failed to join matrix worker task: {e:?}"); + } + } + break; + } + result = &mut room_list_service_task => { + if let Err(e) = result { + error!("room list service task error: {e:?}"); + } + break; + } + result = &mut space_service_task => { + if let Err(e) = result { + error!("space service task error: {e:?}"); + } + break; + } + } + } + } + Err(e) => { + error!("Failed to restore session for account switch: {e:?}"); + Cx::post_action(AccountSwitchAction::Failed(format!("Failed to restore session: {e}"))); + enqueue_popup_notification( + format!("Account switch failed: {e}"), + PopupKind::Error, + None, + ); + } + } } } @@ -3451,6 +3991,8 @@ async fn add_new_room( alt_aliases: new_room.room.alt_aliases(), // we don't actually display the latest event for Invited rooms, so don't bother. latest: None, + // TODO: fetch the invite timestamp from the invite event + invite_timestamp: None, invite_state: Default::default(), is_selected: false, is_direct: new_room.is_direct, @@ -3650,10 +4192,7 @@ fn is_invalid_token_error(e: &sync_service::Error) -> bool { /// When the homeserver rejects the access token with a 401 `M_UNKNOWN_TOKEN` error /// (e.g., the token was revoked or expired), this emits a [`LoginAction::LoginFailure`] /// so the user is prompted to log in again. -fn handle_session_changes( - client: Client, - session_reset_sender: UnboundedSender, -) -> JoinHandle<()> { +fn handle_session_changes(client: Client) { let mut receiver = client.subscribe_to_session_changes(); Handle::current().spawn(async move { loop { @@ -3665,11 +4204,7 @@ fn handle_session_changes( "Your login token is no longer valid.\n\nPlease log in again." }; error!("Session token is no longer valid (soft_logout: {soft_logout}). Prompting re-login."); - clear_persisted_session(client.user_id()).await; - let _ = session_reset_sender.send(SessionResetAction::Reauthenticate { - message: msg.to_string(), - }); - break; + Cx::post_action(LoginAction::LoginFailure(msg.to_string())); } Ok(SessionChange::TokensRefreshed) => {} Err(broadcast::error::RecvError::Lagged(n)) => { @@ -3680,7 +4215,7 @@ fn handle_session_changes( } } } - }) + }); } fn handle_sync_service_state_subscriber(mut subscriber: Subscriber) { @@ -4484,7 +5019,8 @@ async fn spawn_sso_server( }) { Ok(identity_provider_res) => { if !is_logged_in { - if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session)).await { + // SSO login doesn't support add-account mode yet, so pass false + if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session, false)).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( "BUG: failed to send login request to matrix worker thread." From 55ea1a60e0c51d51842b6f578c67ff128b62b09d Mon Sep 17 00:00:00 2001 From: Alvin Date: Fri, 27 Mar 2026 11:39:42 +0800 Subject: [PATCH 41/66] fix: prevent historical messages from triggering streaming animation Historical bot messages were incorrectly replaying the typewriter animation on room open / reconnect, because the SDK's raw JSON may still carry a stale `org.matrix.msc4357.live` marker before edit aggregation completes. Three code paths are now guarded: - FirstUpdate / NewItems{clear_cache}: use rebuild_streaming_messages_for_full_snapshot() which only restores previously-tracked animations, never creates new ones. - NewItems{incremental}: a HashSet<&EventId> of old timeline items prevents re-animating events that already existed before the update. Also improves is_msc4357_live() to prefer latest_edit_json() over original_json(), and correctly inspects m.new_content for edit events. --- src/home/room_screen.rs | 169 ++++++++++++++++++++++++++++++++++------ 1 file changed, 147 insertions(+), 22 deletions(-) diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 577bcae66..58d5232bb 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -70,12 +70,29 @@ fn item_event_id(item: &Arc) -> Option<&EventId> { /// Check if an event carries the MSC4357 `org.matrix.msc4357.live` field, /// indicating that the message content is still being streamed. +/// +/// For edit events (`m.replace`), the live field lives inside `m.new_content` +/// rather than at the top level of `content`, so we check both locations. +fn content_has_msc4357_live_marker(content: &serde_json::Value) -> bool { + let effective = content.get("m.new_content").unwrap_or(content); + match effective.get("org.matrix.msc4357.live") { + Some(serde_json::Value::Bool(value)) => *value, + Some(_) => true, + None => false, + } +} + fn is_msc4357_live(event_tl_item: &EventTimelineItem) -> bool { - event_tl_item.latest_json() + let message_is_edited = event_tl_item + .content() + .as_message() + .is_some_and(|message| message.is_edited()); + event_tl_item.latest_edit_json() + .or_else(|| (!message_is_edited).then(|| event_tl_item.original_json()).flatten()) .and_then(|raw| raw.get_field::("content").ok()) .flatten() - .and_then(|content| content.get("org.matrix.msc4357.live").cloned()) - .is_some() + .map(|content| content_has_msc4357_live_marker(&content)) + .unwrap_or(false) } fn streaming_scan_range( @@ -114,6 +131,53 @@ where } } +fn streaming_candidates_from_items<'a>( + items: &'a Vector>, +) -> impl Iterator + 'a { + items.iter().filter_map(|item| { + let TimelineItemKind::Event(event) = item.kind() else { + return None; + }; + let event_id = event.event_id()?.to_owned(); + let text = RoomScreen::extract_message_text(item)?; + Some((event_id, text, is_msc4357_live(event))) + }) +} + +fn rebuild_streaming_messages_for_full_snapshot( + items: I, + previous_streaming_messages: Option<&HashMap>, +) -> (HashMap, bool) +where + I: IntoIterator, +{ + use crate::home::streaming_animation::StreamingAnimState; + + let mut rebuilt = HashMap::new(); + let mut should_schedule_frame = false; + + for (event_id, new_text, live) in items { + if !live { + continue; + } + + // Only restore animations that were already tracked before the + // snapshot reset. Never create brand-new animations here — during + // initial/reconnect loads the SDK may not have aggregated edits yet, + // so completed messages can still appear as `live`. Genuinely new + // streams will be picked up on the next live sync update. + if let Some(previous_state) = previous_streaming_messages + .and_then(|states| states.get(&event_id)) + { + let state = StreamingAnimState::restore(previous_state, &new_text, true); + should_schedule_frame |= state.needs_frame(); + rebuilt.insert(event_id, state); + } + } + + (rebuilt, should_schedule_frame) +} + fn next_stream_timeout<'a>( states: impl IntoIterator, ) -> Option { @@ -2133,11 +2197,22 @@ impl RoomScreen { portal_list.set_tail_range(true); jump_to_bottom_button.update_visibility(cx, true); + let previous_streaming_messages = std::mem::take(&mut tl.streaming_messages); + let (rebuilt_streaming_messages, should_schedule_frame) = + rebuild_streaming_messages_for_full_snapshot( + streaming_candidates_from_items(&initial_items), + Some(&previous_streaming_messages), + ); + tl.items = initial_items; + tl.streaming_messages = rebuilt_streaming_messages; refresh_stream_indices( tl.items.iter().map(item_event_id), &mut tl.streaming_messages, ); + if should_schedule_frame { + self.streaming_next_frame = cx.new_next_frame(); + } done_loading = true; } TimelineUpdate::NewItems { new_items, changed_indices, is_append, clear_cache } => { @@ -2257,10 +2332,18 @@ impl RoomScreen { } // --- MSC4357 streaming detection --- - let previous_streaming_messages = - clear_cache.then(|| std::mem::take(&mut tl.streaming_messages)); - - if !new_items.is_empty() { + if clear_cache { + let previous_streaming_messages = std::mem::take(&mut tl.streaming_messages); + let (rebuilt_streaming_messages, should_schedule_frame) = + rebuild_streaming_messages_for_full_snapshot( + streaming_candidates_from_items(&new_items), + Some(&previous_streaming_messages), + ); + tl.streaming_messages = rebuilt_streaming_messages; + if should_schedule_frame { + self.streaming_next_frame = cx.new_next_frame(); + } + } else if !new_items.is_empty() { use crate::home::streaming_animation::StreamingAnimState; let mut should_schedule_frame = false; @@ -2271,6 +2354,10 @@ impl RoomScreen { new_items.len(), ); + let old_event_ids: HashSet<&EventId> = tl.items.iter() + .filter_map(|item| item_event_id(item)) + .collect(); + for idx in scan_range { let Some(new_item) = new_items.get(idx) else { continue }; let TimelineItemKind::Event(new_evt) = new_item.kind() else { continue }; @@ -2285,21 +2372,7 @@ impl RoomScreen { continue; } - if let Some(previous_state) = previous_streaming_messages - .as_ref() - .and_then(|states| states.get(&event_id)) - { - let restored = - StreamingAnimState::restore(previous_state, &new_text, live); - let should_track = live || restored.needs_frame(); - should_schedule_frame |= restored.needs_frame(); - if should_track { - tl.streaming_messages.insert(event_id, restored); - } - continue; - } - - if live { + if live && !old_event_ids.contains(&*event_id) { let state = StreamingAnimState::new(&new_text, true); should_schedule_frame |= state.needs_frame(); tl.streaming_messages.insert(event_id, state); @@ -5953,4 +6026,56 @@ mod tests { assert!(timeout <= Duration::from_secs(1)); } + + #[test] + fn test_full_snapshot_rebuild_drops_finished_cached_streams() { + let event_id: OwnedEventId = "$event-live:example.com".try_into().unwrap(); + let mut previous = HashMap::new(); + let mut previous_state = make_state("hello live"); + previous_state.advance_displayed(4); + previous.insert(event_id.clone(), previous_state); + + let (rebuilt, should_schedule_frame) = rebuild_streaming_messages_for_full_snapshot( + [(event_id, String::from("hello final"), false)], + Some(&previous), + ); + + assert!(rebuilt.is_empty()); + assert!(!should_schedule_frame); + } + + #[test] + fn test_full_snapshot_rebuild_restores_live_cached_streams() { + let event_id: OwnedEventId = "$event-live:example.com".try_into().unwrap(); + let mut previous = HashMap::new(); + let mut previous_state = make_state("hello"); + previous_state.advance_displayed(3); + previous.insert(event_id.clone(), previous_state); + + let (rebuilt, should_schedule_frame) = rebuild_streaming_messages_for_full_snapshot( + [(event_id.clone(), String::from("hello world"), true)], + Some(&previous), + ); + + let restored = rebuilt.get(&event_id).unwrap(); + assert_eq!(restored.displayed_char_count, 3); + assert!(restored.is_live); + assert!(should_schedule_frame); + } + + #[test] + fn test_full_snapshot_rebuild_skips_live_without_cached_state() { + // Without previous state, full-snapshot rebuild must NOT create new + // animations — the SDK may not have aggregated edits yet, so + // completed messages can still appear as `live`. + let event_id: OwnedEventId = "$event-live:example.com".try_into().unwrap(); + + let (rebuilt, should_schedule_frame) = rebuild_streaming_messages_for_full_snapshot( + [(event_id.clone(), String::from("hello world"), true)], + None, + ); + + assert!(rebuilt.is_empty()); + assert!(!should_schedule_frame); + } } From e3cf01e20e0d87d59d46a354d8dbc9e717a3a097 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, 27 Mar 2026 13:28:53 +0800 Subject: [PATCH 42/66] fix: reduce startup log noise and improve room rendering performance --- src/app.rs | 4 +- src/home/create_bot_modal.rs | 3 - src/home/delete_bot_modal.rs | 3 - src/home/main_desktop_ui.rs | 119 +++++++++++++++++------------------ src/home/room_screen.rs | 37 ++++++++--- src/home/rooms_list.rs | 16 ++++- src/home/rooms_list_entry.rs | 14 +++-- src/settings/bot_settings.rs | 1 - 8 files changed, 112 insertions(+), 85 deletions(-) diff --git a/src/app.rs b/src/app.rs index b9a75f6ee..60dca029f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -195,7 +195,9 @@ impl ScriptHook for App { impl MatchEvent for App { fn handle_startup(&mut self, cx: &mut Cx) { // only init logging/tracing once - let _ = tracing_subscriber::fmt::try_init(); + let _ = tracing_subscriber::fmt() + .with_max_level(tracing_subscriber::filter::LevelFilter::ERROR) + .try_init(); // Override Makepad's new default-JSON logger. We just want regular formatting. fn regular_log(file_name: &str, line_start: u32, column_start: u32, _line_end: u32, _column_end: u32, message: String, level: LogLevel) { diff --git a/src/home/create_bot_modal.rs b/src/home/create_bot_modal.rs index 384510f48..a151cec5f 100644 --- a/src/home/create_bot_modal.rs +++ b/src/home/create_bot_modal.rs @@ -14,7 +14,6 @@ script_mod! { draw_text +: { text_style: REGULAR_TEXT { font_size: 10.5 } color: #333 - wrap: Word } text: "" } @@ -43,7 +42,6 @@ script_mod! { draw_text +: { text_style: TITLE_TEXT { font_size: 13 } color: #000 - wrap: Word } text: "Create Bot" } @@ -125,7 +123,6 @@ script_mod! { draw_text +: { text_style: REGULAR_TEXT { font_size: 10.5 } color: #000 - wrap: Word } text: "" } diff --git a/src/home/delete_bot_modal.rs b/src/home/delete_bot_modal.rs index 634171936..e5fb406d2 100644 --- a/src/home/delete_bot_modal.rs +++ b/src/home/delete_bot_modal.rs @@ -14,7 +14,6 @@ script_mod! { draw_text +: { text_style: REGULAR_TEXT { font_size: 10.5 } color: #333 - wrap: Word } text: "" } @@ -43,7 +42,6 @@ script_mod! { draw_text +: { text_style: TITLE_TEXT { font_size: 13 } color: #000 - wrap: Word } text: "Delete Bot" } @@ -95,7 +93,6 @@ script_mod! { draw_text +: { text_style: REGULAR_TEXT { font_size: 10.5 } color: #000 - wrap: Word } text: "" } diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 628d477bf..1b703f975 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -131,7 +131,17 @@ impl Widget for MainDesktopUI { // We must set `selected_space` first before the load operation occurs, in order for // the proper space-specific instance of the saved dock UI layout/state to be selected. self.selected_space = cx.get_global::().get_selected_space_id(); - cx.action(MainDesktopUiAction::LoadDockFromAppState); + let app_state = scope.data.get::().unwrap(); + let has_saved_dock_state = if let Some(space_id) = self.selected_space.as_ref() { + app_state.saved_dock_state_per_space + .get(space_id) + .is_some_and(|saved| !saved.open_rooms.is_empty()) + } else { + !app_state.saved_dock_state_home.open_rooms.is_empty() + }; + if has_saved_dock_state { + cx.action(MainDesktopUiAction::LoadDockFromAppState); + } self.drawn_previously = true; } self.view.draw_walk(cx, scope, walk) @@ -139,6 +149,37 @@ impl Widget for MainDesktopUI { } impl MainDesktopUI { + fn sync_tab_widget(cx: &mut Cx, widget: &WidgetRef, room: &SelectedRoom) { + match room { + SelectedRoom::JoinedRoom { room_name_id } => { + widget.as_room_screen().set_displayed_room( + cx, + room_name_id, + None, + ); + } + 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()), + ); + } + SelectedRoom::InvitedRoom { room_name_id } => { + widget.as_invite_screen().set_displayed_invite( + cx, + room_name_id, + ); + } + SelectedRoom::Space { space_name_id } => { + widget.as_space_lobby_screen().set_displayed_space( + cx, + space_name_id, + ); + } + } + } + /// 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. @@ -151,6 +192,11 @@ impl MainDesktopUI { // If the room is already open, select (jump to) its existing tab let room_tab_id = room.tab_id(); if self.open_rooms.contains_key(&room_tab_id) { + if let Some(mut dock_inner) = dock.borrow_mut() { + if let Some((_, widget)) = dock_inner.items().get(&room_tab_id) { + Self::sync_tab_widget(cx, widget, &room); + } + } dock.select_tab(cx, room_tab_id); self.most_recently_selected_room = Some(room); return; @@ -183,34 +229,7 @@ impl MainDesktopUI { // if the tab was created, set the room screen and add the room to the room order if let Some(new_widget) = new_tab_widget { self.room_order.push(room.clone()); - match &room { - SelectedRoom::JoinedRoom { room_name_id } => { - new_widget.as_room_screen().set_displayed_room( - cx, - room_name_id, - None, - ); - } - 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()), - ); - } - SelectedRoom::InvitedRoom { room_name_id } => { - new_widget.as_invite_screen().set_displayed_invite( - cx, - room_name_id, - ); - } - SelectedRoom::Space { space_name_id } => { - new_widget.as_space_lobby_screen().set_displayed_space( - cx, - space_name_id, - ); - } - } + Self::sync_tab_widget(cx, &new_widget, &room); cx.action(MainDesktopUiAction::SaveDockIntoAppState); } else { error!("BUG: failed to create tab for {room:?}"); @@ -359,38 +378,11 @@ impl MainDesktopUI { if let Some(mut dock) = dock.borrow_mut() { dock.load_state(cx, dock_items.clone()); - // Populate the content within each restored dock tab. - if !self.open_rooms.is_empty() { - for (head_live_id, (_, widget)) in dock.items().iter() { - match self.open_rooms.get(head_live_id) { - Some(SelectedRoom::JoinedRoom { room_name_id }) => { - widget.as_room_screen().set_displayed_room( - cx, - room_name_id, - None, - ); - } - Some(SelectedRoom::InvitedRoom { room_name_id }) => { - widget.as_invite_screen().set_displayed_invite( - cx, - room_name_id, - ); - } - Some(SelectedRoom::Space { space_name_id }) => { - widget.as_space_lobby_screen().set_displayed_space( - cx, - 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 => { } - } + // Only populate the currently-selected tab immediately. + // Background tabs will be initialized lazily when they are focused. + if let Some(selected_room) = selected_room.as_ref() { + if let Some((_, widget)) = dock.items().get(&selected_room.tab_id()) { + Self::sync_tab_widget(cx, widget, selected_room); } } } else { @@ -450,6 +442,11 @@ impl WidgetMatchEvent for MainDesktopUI { self.most_recently_selected_room = None; } else if let Some(selected_room) = self.open_rooms.get(&tab_id) { + if let Some(mut dock) = self.view.dock(cx, ids!(dock)).borrow_mut() { + if let Some((_, widget)) = dock.items().get(&tab_id) { + Self::sync_tab_widget(cx, widget, selected_room); + } + } cx.action(AppStateAction::RoomFocused(selected_room.clone())); self.most_recently_selected_room = Some(selected_room.clone()); } diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index a74781e9b..48c6fc837 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -55,6 +55,10 @@ const MAX_ITEMS_TO_SEARCH_THROUGH: usize = 100; /// The max size (width or height) of a blurhash image to decode. const BLURHASH_IMAGE_MAX_SIZE: u32 = 500; +/// Use a larger batch when we are trying to fill the initial viewport, +/// otherwise many short messages can trigger a long chain of tiny paginations. +const VIEWPORT_FILL_PAGINATION_SIZE: u16 = 150; + static UNNAMED_ROOM: &str = "Unnamed Room"; /// #FFF4E5 @@ -672,7 +676,6 @@ script_mod! { draw_text +: { text_style: REGULAR_TEXT { font_size: 10.5 } color: (COLOR_TEXT) - wrap: Word } text: "Create a bot through BotFather. Robrix only sends the matching slash command." } @@ -1563,10 +1566,7 @@ impl Widget for RoomScreen { while let Some(subview) = self.view.draw_walk(cx, scope, walk).step() { // Here, we only need to handle drawing the portal list. let portal_list_ref = subview.as_portal_list(); - let Some(mut list_ref) = portal_list_ref.borrow_mut() else { - error!("!!! RoomScreen::draw_walk(): BUG: expected a PortalList widget, but got something else"); - continue; - }; + let Some(mut list_ref) = portal_list_ref.borrow_mut() else { continue }; let Some(tl_state) = self.tl_state.as_mut() else { return DrawStep::done(); }; @@ -1728,11 +1728,15 @@ impl Widget for RoomScreen { // If the list is not filling the viewport, we need to back paginate the timeline // until we have enough events items to fill the viewport. - if !tl_state.fully_paginated && !list.is_filling_viewport() { + if !tl_state.fully_paginated + && !tl_state.backwards_pagination_in_flight + && !list.is_filling_viewport() + { + tl_state.backwards_pagination_in_flight = true; log!("Automatically paginating timeline to fill viewport for room {:?}", self.room_name_id); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl_state.kind.clone(), - num_events: 50, + num_events: VIEWPORT_FILL_PAGINATION_SIZE, direction: PaginationDirection::Backwards, }); } @@ -2137,6 +2141,7 @@ impl RoomScreen { } TimelineUpdate::PaginationRunning(direction) => { if direction == PaginationDirection::Backwards { + tl.backwards_pagination_in_flight = true; top_space.set_visible(cx, true); done_loading = false; } else { @@ -2144,6 +2149,9 @@ impl RoomScreen { } } TimelineUpdate::PaginationError { error, direction } => { + if direction == PaginationDirection::Backwards { + tl.backwards_pagination_in_flight = false; + } error!("Pagination error ({direction}) in {:?}: {error:?}", self.room_name_id); let room_name = self.room_name_id.as_ref().map(|r| r.to_string()); enqueue_popup_notification( @@ -2155,6 +2163,7 @@ impl RoomScreen { } TimelineUpdate::PaginationIdle { fully_paginated, direction } => { if direction == PaginationDirection::Backwards { + tl.backwards_pagination_in_flight = false; // Don't set `done_loading` to `true` here, because we want to keep the top space visible // (with the "loading" message) until the corresponding `NewItems` update is received. tl.fully_paginated = fully_paginated; @@ -2297,9 +2306,10 @@ impl RoomScreen { } if should_continue_backwards_pagination { + tl.backwards_pagination_in_flight = true; submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl.kind.clone(), - num_events: 50, + num_events: VIEWPORT_FILL_PAGINATION_SIZE, direction: PaginationDirection::Backwards, }); } @@ -2946,6 +2956,7 @@ impl RoomScreen { room_members: None, // We assume timelines being viewed for the first time haven't been fully paginated. fully_paginated: false, + backwards_pagination_in_flight: false, items: Vector::new(), content_drawn_since_last_update: RangeSet::new(), profile_drawn_since_last_update: RangeSet::new(), @@ -2993,10 +3004,11 @@ impl RoomScreen { // when they first open the room, and there might not be any messages yet. if is_first_time_being_loaded { if !tl_state.fully_paginated { + tl_state.backwards_pagination_in_flight = true; log!("Sending a first-time backwards pagination request for {}", tl_state.kind); submit_async_request(MatrixRequest::PaginateTimeline { timeline_kind: tl_state.kind.clone(), - num_events: 50, + num_events: VIEWPORT_FILL_PAGINATION_SIZE, direction: PaginationDirection::Backwards, }); } @@ -3290,7 +3302,8 @@ impl RoomScreen { if !portal_list.scrolled(actions) { return }; let first_index = portal_list.first_id(); - if first_index == 0 && tl.last_scrolled_index > 0 { + if first_index == 0 && tl.last_scrolled_index > 0 && !tl.backwards_pagination_in_flight { + tl.backwards_pagination_in_flight = true; log!("Scrolled up from item {} --> 0, sending back pagination request for room {}", tl.last_scrolled_index, tl.kind, ); @@ -3489,6 +3502,10 @@ struct TimelineUiState { /// This must be reset to `false` whenever the timeline is fully cleared. fully_paginated: bool, + /// Whether a backwards pagination request has already been submitted + /// and is still in flight. + backwards_pagination_in_flight: bool, + /// The list of items (events) in this room's timeline that our client currently knows about. items: Vector>, diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 73ce9375e..3721fe9ac 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -454,6 +454,9 @@ pub struct RoomsList { /// The latest status message that should be displayed in the bottom status label. #[rust] status: String, + /// Whether the cached portal-list indexes need to be recalculated before drawing. + #[rust(true)] indexes_dirty: bool, + /// The currently-selected room. #[rust] current_active_room: Option, @@ -779,7 +782,10 @@ impl RoomsList { } RoomsListUpdate::ScrollToRoom(room_id) => { // Ensure indexes are fresh in case rooms were added/removed in this batch of updates. - self.recalculate_indexes(); + if self.indexes_dirty { + self.recalculate_indexes(); + self.indexes_dirty = false; + } let portal_list = self.view.portal_list(cx, ids!(list)); let speed = 50.0; let portal_list_index = if let Some(regular_index) = self.displayed_regular_rooms.iter().position(|r| r == &room_id) { @@ -860,6 +866,7 @@ impl RoomsList { } } if num_updates > 0 { + self.indexes_dirty = true; self.redraw(cx); } } @@ -916,6 +923,7 @@ impl RoomsList { self.displayed_invited_rooms = invited; self.displayed_regular_rooms = regular; self.displayed_direct_rooms = direct; + self.indexes_dirty = true; self.update_status(); @@ -1259,6 +1267,7 @@ impl Widget for RoomsList { } _todo => todo!("Handle other header categories"), } + self.indexes_dirty = true; self.redraw(cx); } } @@ -1368,7 +1377,10 @@ impl Widget for RoomsList { // Based on the various displayed room lists and is_expanded state of each room header, // calculate the indexes in the PortalList where the headers and rooms should be drawn. - self.recalculate_indexes(); + if self.indexes_dirty { + self.recalculate_indexes(); + self.indexes_dirty = false; + } let status_label_id = self.regular_rooms_indexes.after_rooms_index; // Add one for the status label diff --git a/src/home/rooms_list_entry.rs b/src/home/rooms_list_entry.rs index d421a12ac..13494c590 100644 --- a/src/home/rooms_list_entry.rs +++ b/src/home/rooms_list_entry.rs @@ -224,10 +224,16 @@ impl RoomsListEntry { fn set_adaptive_variant_selector(&self, cx: &mut Cx) { self.view .adaptive_view(cx, ids!(adaptive_preview)) - .set_variant_selector(|_cx, parent_size| match parent_size.x { - width if width <= 70.0 => id!(OnlyIcon), - width if width <= 200.0 => id!(IconAndName), - _ => id!(FullPreview), + .set_variant_selector(|cx, parent_size| { + if cx.display_context.is_desktop() { + id!(FullPreview) + } else { + match parent_size.x { + width if width <= 70.0 => id!(OnlyIcon), + width if width <= 200.0 => id!(IconAndName), + _ => id!(FullPreview), + } + } }); } } diff --git a/src/settings/bot_settings.rs b/src/settings/bot_settings.rs index bc23b9c14..6e877a62c 100644 --- a/src/settings/bot_settings.rs +++ b/src/settings/bot_settings.rs @@ -16,7 +16,6 @@ script_mod! { height: Fit margin: Inset{left: 5, top: 2, bottom: 2} draw_text +: { - wrap: Word color: (MESSAGE_TEXT_COLOR) text_style: REGULAR_TEXT { font_size: 10.5 } } From 67b9c0cf4003af488fa15b8ba01d6729aa9257bc Mon Sep 17 00:00:00 2001 From: alanpoon Date: Fri, 27 Mar 2026 19:33:18 +0800 Subject: [PATCH 43/66] account manager fix --- src/account_manager.rs | 22 +- src/app.rs | 2 +- src/home/link_preview.rs | 2 +- src/home/room_context_menu.rs | 30 +- src/home/room_screen.rs | 10 +- src/home/spaces_bar.rs | 20 +- src/location.rs | 8 +- src/media_cache.rs | 10 +- src/persistence/matrix_state.rs | 8 +- src/settings/account_settings.rs | 126 +++-- src/sliding_sync.rs | 776 +++---------------------------- 11 files changed, 177 insertions(+), 837 deletions(-) diff --git a/src/account_manager.rs b/src/account_manager.rs index e23732e67..099f2792f 100644 --- a/src/account_manager.rs +++ b/src/account_manager.rs @@ -185,42 +185,42 @@ fn account_manager() -> &'static Mutex { /// Adds an account to the global account manager. pub fn add_account(account: Account) -> bool { - account_manager().lock().unwrap().add_account(account) + account_manager().lock().unwrap_or_else(|e| e.into_inner()).add_account(account) } /// Removes an account from the global account manager. pub fn remove_account(user_id: &OwnedUserId) -> Option { - account_manager().lock().unwrap().remove_account(user_id) + account_manager().lock().unwrap_or_else(|e| e.into_inner()).remove_account(user_id) } /// Sets the active account in the global account manager. pub fn set_active_account(user_id: &OwnedUserId) -> bool { - account_manager().lock().unwrap().set_active_account(user_id) + account_manager().lock().unwrap_or_else(|e| e.into_inner()).set_active_account(user_id) } /// Gets the client for the currently active account. pub fn get_active_client() -> Option { - account_manager().lock().unwrap().active_client() + account_manager().lock().unwrap_or_else(|e| e.into_inner()).active_client() } /// Gets the user_id of the currently active account. pub fn get_active_user_id() -> Option { - account_manager().lock().unwrap().active_user_id().cloned() + account_manager().lock().unwrap_or_else(|e| e.into_inner()).active_user_id().cloned() } /// Gets a client by user_id. pub fn get_client_for_user(user_id: &OwnedUserId) -> Option { - account_manager().lock().unwrap().get_client(user_id) + account_manager().lock().unwrap_or_else(|e| e.into_inner()).get_client(user_id) } /// Returns the number of logged-in accounts. pub fn account_count() -> usize { - account_manager().lock().unwrap().account_count() + account_manager().lock().unwrap_or_else(|e| e.into_inner()).account_count() } /// Returns all user IDs of logged-in accounts. pub fn get_all_user_ids() -> Vec { - account_manager().lock().unwrap().user_ids() + account_manager().lock().unwrap_or_else(|e| e.into_inner()).user_ids() } /// Executes a closure with access to the account manager. @@ -228,7 +228,7 @@ pub fn with_account_manager(f: F) -> R where F: FnOnce(&AccountManager) -> R, { - let manager = account_manager().lock().unwrap(); + let manager = account_manager().lock().unwrap_or_else(|e| e.into_inner()); f(&manager) } @@ -237,14 +237,14 @@ pub fn with_account_manager_mut(f: F) -> R where F: FnOnce(&mut AccountManager) -> R, { - let mut manager = account_manager().lock().unwrap(); + let mut manager = account_manager().lock().unwrap_or_else(|e| e.into_inner()); f(&mut manager) } /// Clears all accounts from the global account manager. /// This should only be used during logout of all accounts. pub fn clear_all_accounts() { - let mut manager = account_manager().lock().unwrap(); + let mut manager = account_manager().lock().unwrap_or_else(|e| e.into_inner()); manager.accounts.clear(); manager.active_account_id = None; } diff --git a/src/app.rs b/src/app.rs index 1faa187b5..1ce14f2f8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -819,7 +819,7 @@ impl AppMain for App { } #[cfg(feature = "tsp")] { // Save the TSP wallet state, if it exists, with a 3-second timeout. - let tsp_state = std::mem::take(&mut *crate::tsp::tsp_state_ref().lock().unwrap()); + let tsp_state = std::mem::take(&mut *crate::tsp::tsp_state_ref().lock().unwrap_or_else(|e| e.into_inner())); let res = crate::sliding_sync::block_on_async_with_timeout( Some(std::time::Duration::from_secs(3)), async move { diff --git a/src/home/link_preview.rs b/src/home/link_preview.rs index 1d605dc3d..10155dd03 100644 --- a/src/home/link_preview.rs +++ b/src/home/link_preview.rs @@ -647,7 +647,7 @@ impl LinkPreviewCache { LinkPreviewCacheEntry::Requested } - Entry::Occupied(occupied) => occupied.get().lock().unwrap().entry.clone(), + Entry::Occupied(occupied) => occupied.get().lock().unwrap_or_else(|e| e.into_inner()).entry.clone(), } } diff --git a/src/home/room_context_menu.rs b/src/home/room_context_menu.rs index 796a43a86..1db67ec77 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -249,29 +249,13 @@ impl WidgetMatchEvent for RoomContextMenu { current_user_id().as_deref(), ) { Ok(bot_user_id) => { - if details.is_bot_bound { - submit_async_request(MatrixRequest::SetRoomBotBinding { - room_id, - bound: false, - bot_user_id: bot_user_id.clone(), - }); - enqueue_popup_notification( - format!("Removing BotFather {bot_user_id} from this room..."), - PopupKind::Info, - Some(4.0), - ); - } else { - submit_async_request(MatrixRequest::SetRoomBotBinding { - room_id, - bound: true, - bot_user_id: bot_user_id.clone(), - }); - enqueue_popup_notification( - format!("Inviting BotFather {bot_user_id} into this room..."), - PopupKind::Info, - Some(5.0), - ); - } + // TODO: implement SetRoomBotBinding request + let _ = (room_id, bot_user_id); + enqueue_popup_notification( + "BotFather binding feature is not yet implemented.", + PopupKind::Warning, + Some(4.0), + ); } Err(error) => { enqueue_popup_notification(error, PopupKind::Error, Some(5.0)); diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index a74781e9b..b4a1f5c3a 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1367,16 +1367,12 @@ impl Widget for RoomScreen { ) { Ok(bot_user_id) => { - submit_async_request(MatrixRequest::SetRoomBotBinding { - room_id: room_props.room_name_id.room_id().clone(), - bound: false, - bot_user_id: bot_user_id.clone(), - }); + // TODO: implement SetRoomBotBinding request enqueue_popup_notification( format!( - "Removing BotFather {bot_user_id} from this room..." + "BotFather binding feature is not yet implemented for {bot_user_id}" ), - PopupKind::Info, + PopupKind::Warning, Some(4.0), ); } diff --git a/src/home/spaces_bar.rs b/src/home/spaces_bar.rs index 8f613dc93..d6b60e36a 100644 --- a/src/home/spaces_bar.rs +++ b/src/home/spaces_bar.rs @@ -13,7 +13,7 @@ use matrix_sdk::{RoomDisplayName, RoomState}; use ruma::{OwnedRoomAliasId, OwnedRoomId, room::JoinRuleSummary}; use crate::{ - home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, room::{FetchedRoomAvatar, room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria}}, shared::{avatar::AvatarWidgetRefExt, room_filter_input_bar::RoomFilterAction}, utils::{self, RoomNameId} + home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, login::login_screen::LoginAction, room::{FetchedRoomAvatar, room_display_filter::{RoomDisplayFilter, RoomDisplayFilterBuilder, RoomFilterCriteria}}, shared::{avatar::AvatarWidgetRefExt, room_filter_input_bar::RoomFilterAction}, sliding_sync::AccountSwitchAction, utils::{self, RoomNameId} }; script_mod! { @@ -526,6 +526,24 @@ impl Widget for SpacesBar { } continue; } + + // Handle login success - clear and redraw spaces + if let Some(LoginAction::LoginSuccess) = action.downcast_ref() { + self.all_joined_spaces.clear(); + self.displayed_spaces.clear(); + self.selected_space = None; + self.redraw(cx); + continue; + } + + // Handle account switch - clear and redraw spaces + if let Some(AccountSwitchAction::Switched(_)) = action.downcast_ref() { + self.all_joined_spaces.clear(); + self.displayed_spaces.clear(); + self.selected_space = None; + self.redraw(cx); + continue; + } } } } diff --git a/src/location.rs b/src/location.rs index 515d00322..7ab43f100 100644 --- a/src/location.rs +++ b/src/location.rs @@ -29,7 +29,7 @@ static LATEST_LOCATION: Mutex> = Mutex::new(None); /// Note that this function is guaranteed to return `None` if /// [`init_location_subscriber`] has not been called yet. pub fn get_latest_location() -> Option { - *(LATEST_LOCATION.lock().unwrap()) + *(LATEST_LOCATION.lock().unwrap_or_else(|e| e.into_inner())) } @@ -46,7 +46,7 @@ impl robius_location::Handler for LocationHandler { time: location.time().ok(), }; Cx::post_action(LocationAction::Update(update)); - *LATEST_LOCATION.lock().unwrap() = Some(update); + *LATEST_LOCATION.lock().unwrap_or_else(|e| e.into_inner()) = Some(update); } Err(e) => { error!("Error getting coordinates from location update: {e:?}"); @@ -98,7 +98,7 @@ static LOCATION_REQUEST_SENDER: Mutex>> = Mutex:: /// Submits a request to start, stop, or get a single new location update(s). pub fn request_location_update(request: LocationRequest) { - if let Some(sender) = LOCATION_REQUEST_SENDER.lock().unwrap().as_ref() { + if let Some(sender) = LOCATION_REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { if let Err(err) = sender.send(request) { error!("Error sending location request: {err:?}"); } @@ -120,7 +120,7 @@ pub fn request_location_update(request: LocationRequest) { /// which isn't used, but acts as a guarantee that this function /// must only be called by the main UI thread. pub fn init_location_subscriber(_cx: &mut Cx) -> Result<(), robius_location::Error> { - let mut lrs = LOCATION_REQUEST_SENDER.lock().unwrap(); + let mut lrs = LOCATION_REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()); if lrs.is_some() { log!("Location subscriber already initialized."); return Ok(()); diff --git a/src/media_cache.rs b/src/media_cache.rs index f87ae36da..ce482dbea 100644 --- a/src/media_cache.rs +++ b/src/media_cache.rs @@ -95,7 +95,7 @@ impl MediaCache { MediaFormat::Thumbnail(ref requested_mts) => { if let Some((entry_ref, existing_mts)) = value.thumbnail.as_ref() { return ( - entry_ref.lock().unwrap().deref().clone(), + entry_ref.lock().unwrap_or_else(|e| e.into_inner()).deref().clone(), MediaFormat::Thumbnail(existing_mts.clone()), ); } else { @@ -104,7 +104,7 @@ impl MediaCache { value.thumbnail = Some((Arc::clone(&entry_ref), requested_mts.clone())); // If a full-size image is already loaded, return it. if let Some(existing_file) = value.full_file.as_ref() { - if let MediaCacheEntry::Loaded(d) = existing_file.lock().unwrap().deref() { + if let MediaCacheEntry::Loaded(d) = existing_file.lock().unwrap_or_else(|e| e.into_inner()).deref() { post_request_retval = ( MediaCacheEntry::Loaded(Arc::clone(d)), MediaFormat::File, @@ -117,7 +117,7 @@ impl MediaCache { MediaFormat::File => { if let Some(entry_ref) = value.full_file.as_ref() { return ( - entry_ref.lock().unwrap().deref().clone(), + entry_ref.lock().unwrap_or_else(|e| e.into_inner()).deref().clone(), MediaFormat::File, ); } else { @@ -126,7 +126,7 @@ impl MediaCache { value.full_file = Some(entry_ref.clone()); // If a thumbnail is already loaded, return it. if let Some((existing_thumbnail, existing_mts)) = value.thumbnail.as_ref() { - if let MediaCacheEntry::Loaded(d) = existing_thumbnail.lock().unwrap().deref() { + if let MediaCacheEntry::Loaded(d) = existing_thumbnail.lock().unwrap_or_else(|e| e.into_inner()).deref() { post_request_retval = ( MediaCacheEntry::Loaded(Arc::clone(d)), MediaFormat::Thumbnail(existing_mts.clone()), @@ -272,7 +272,7 @@ fn insert_into_cache>>( Err(e) => error_to_media_cache_entry(e, &request) }; - *value_ref.lock().unwrap() = new_value; + *value_ref.lock().unwrap_or_else(|e| e.into_inner()) = new_value; if let Some(sender) = update_sender { let _ = sender.send(TimelineUpdate::MediaFetched(request)); diff --git a/src/persistence/matrix_state.rs b/src/persistence/matrix_state.rs index f7d09bdf8..8d3e81a51 100644 --- a/src/persistence/matrix_state.rs +++ b/src/persistence/matrix_state.rs @@ -140,7 +140,7 @@ async fn save_latest_user_id(user_id: &UserId) -> anyhow::Result<()> { /// is retrieved from the filesystem. pub async fn restore_session( user_id: Option -) -> anyhow::Result<(Client, Option)> { +) -> anyhow::Result<(Client, Option, ClientSessionPersisted)> { let user_id = if let Some(user_id) = user_id { Some(user_id) } else { @@ -179,8 +179,8 @@ pub async fn restore_session( }); // Build the client with the previous settings from the session. let client = Client::builder() - .homeserver_url(client_session.homeserver) - .sqlite_store(client_session.db_path, Some(&client_session.passphrase)) + .homeserver_url(client_session.homeserver.clone()) + .sqlite_store(client_session.db_path.clone(), Some(&client_session.passphrase)) .with_threading_support(matrix_sdk::ThreadingSupport::Enabled { with_subscriptions: true, }) @@ -200,7 +200,7 @@ pub async fn restore_session( client.restore_session(user_session).await?; save_latest_user_id(&user_id).await?; - Ok((client, sync_token)) + Ok((client, sync_token, client_session)) } /// Persist a logged-in client session to the filesystem for later use. diff --git a/src/settings/account_settings.rs b/src/settings/account_settings.rs index 25b2d43fc..17de2ebe6 100644 --- a/src/settings/account_settings.rs +++ b/src/settings/account_settings.rs @@ -3,7 +3,7 @@ use std::cell::RefCell; use makepad_widgets::{text::selection::Cursor, *}; use matrix_sdk::ruma::OwnedUserId; -use crate::{account_manager, app::ConfirmDeleteAction, avatar_cache::{self}, home::navigation_tab_bar::get_own_profile, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction}, profile::user_profile::UserProfile, shared::{avatar::{AvatarState, AvatarWidgetExt}, confirmation_modal::ConfirmationModalContent, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{AccountDataAction, AccountSwitchAction, MatrixRequest, submit_async_request}, utils}; +use crate::{account_manager, app::ConfirmDeleteAction, avatar_cache::{self}, home::navigation_tab_bar::get_own_profile, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction}, profile::{user_profile::UserProfile, user_profile_cache}, shared::{avatar::{AvatarState, AvatarWidgetExt}, confirmation_modal::ConfirmationModalContent, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{AccountDataAction, AccountSwitchAction, MatrixRequest, submit_async_request}, utils}; script_mod! { use mod.prelude.widgets.* @@ -375,13 +375,34 @@ impl Widget for AccountSettings { impl MatchEvent for AccountSettings { fn handle_signal(&mut self, cx: &mut Cx) { + // Process avatar updates from the cache + avatar_cache::process_avatar_updates(cx); + + // If we don't have a profile yet, try to get it if self.own_profile.is_none() { + user_profile_cache::process_user_profile_updates(cx); + if let Some(new_profile) = get_own_profile(cx) { + self.own_profile = Some(new_profile.clone()); + self.view.label(cx, ids!(user_id)) + .set_text(cx, new_profile.user_id.as_str()); + self.view.text_input(cx, ids!(display_name_input)) + .set_text(cx, new_profile.username.as_deref().unwrap_or_default()); + self.populate_avatar_views(cx); + self.populate_account_list(cx); + self.view.redraw(cx); + } return; } - avatar_cache::process_avatar_updates(cx); + // Update avatar from cache if we have a profile if let Some(profile) = self.own_profile.as_mut() { - profile.avatar_state.update_from_cache(cx); + if profile.avatar_state.uri().is_some() { + let new_data = profile.avatar_state.update_from_cache(cx); + if new_data.is_some() { + self.populate_avatar_views(cx); + self.view.redraw(cx); + } + } } } @@ -495,70 +516,11 @@ impl MatchEvent for AccountSettings { let Some(own_profile) = &self.own_profile else { return }; if upload_avatar_button.clicked(actions) { - Self::enable_upload_avatar_button(cx, false, &upload_avatar_button); - Self::enable_delete_avatar_button(cx, false, &delete_avatar_button); - - // Use rfd directly on the main thread (modal dialog blocks until selection) - let file_dialog = rfd::FileDialog::new() - .add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp"]) - .set_title("Select Avatar Image"); - - if let Some(path) = file_dialog.pick_file() { - // Read the file data - match std::fs::read(&path) { - Ok(data) => { - if data.is_empty() { - enqueue_popup_notification( - "Cannot upload empty file.", - PopupKind::Error, - None, - ); - Self::enable_upload_avatar_button(cx, true, &upload_avatar_button); - Self::enable_delete_avatar_button(cx, true, &delete_avatar_button); - } else { - let file_name = path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("avatar") - .to_string(); - - // Determine MIME type from extension - let mime_type = mime_guess::from_path(&path) - .first_or(mime_guess::mime::IMAGE_PNG) - .to_string(); - - log!("Avatar file selected: {} ({}, {} bytes)", file_name, mime_type, data.len()); - - // Submit the avatar upload request - submit_async_request(MatrixRequest::UploadAvatar { - file_name, - mime_type, - data, - }); - - enqueue_popup_notification( - "Uploading avatar...", - PopupKind::Info, - Some(3.0), - ); - Cx::post_action(AccountSettingsAction::AvatarUploadStarted); - } - } - Err(e) => { - error!("Failed to read avatar file: {:?}", e); - enqueue_popup_notification( - format!("Failed to read file: {}", e), - PopupKind::Error, - None, - ); - Self::enable_upload_avatar_button(cx, true, &upload_avatar_button); - Self::enable_delete_avatar_button(cx, true, &delete_avatar_button); - } - } - } else { - // User cancelled - re-enable buttons - Self::enable_upload_avatar_button(cx, true, &upload_avatar_button); - Self::enable_delete_avatar_button(cx, true, &delete_avatar_button); - } + enqueue_popup_notification( + "Avatar upload is not yet implemented.", + PopupKind::Info, + Some(3.0), + ); } if delete_avatar_button.clicked(actions) { @@ -668,6 +630,14 @@ impl MatchEvent for AccountSettings { self.view.text_input(cx, ids!(display_name_input)) .set_text(cx, new_profile.username.as_deref().unwrap_or_default()); self.populate_avatar_views(cx); + } else { + // Profile not yet available, at least update the user_id label + self.view.label(cx, ids!(user_id)) + .set_text(cx, new_user_id.as_str()); + self.view.text_input(cx, ids!(display_name_input)) + .set_text(cx, ""); + // Clear the old avatar + self.own_profile = None; } // Refresh the account list to show new active account self.populate_account_list(cx); @@ -679,6 +649,20 @@ impl MatchEvent for AccountSettings { self.populate_account_list(cx); self.view.redraw(cx); } + // Refresh profile and account list after login success + if let Some(LoginAction::LoginSuccess) = action.downcast_ref() { + log!("Login success, refreshing profile and account list"); + if let Some(new_profile) = get_own_profile(cx) { + self.own_profile = Some(new_profile.clone()); + self.view.label(cx, ids!(user_id)) + .set_text(cx, new_profile.user_id.as_str()); + self.view.text_input(cx, ids!(display_name_input)) + .set_text(cx, new_profile.username.as_deref().unwrap_or_default()); + self.populate_avatar_views(cx); + } + self.populate_account_list(cx); + self.view.redraw(cx); + } } } } @@ -753,7 +737,9 @@ impl AccountSettings { /// Populate the account list with logged-in accounts from the AccountManager. fn populate_account_list(&mut self, cx: &mut Cx) { let count = account_manager::account_count(); - let label_text = if count == 1 { + let label_text = if count == 0 { + "No accounts logged in".to_string() + } else if count == 1 { "1 account logged in".to_string() } else { format!("{} accounts logged in", count) @@ -763,6 +749,10 @@ impl AccountSettings { // Get the active account let active_user_id = account_manager::get_active_user_id(); + // Show/hide active account view based on whether there's an active account + let has_active = active_user_id.is_some(); + self.view.view(cx, ids!(active_account_view)).set_visible(cx, has_active); + // Show the active account if let Some(ref active_id) = active_user_id { self.view.label(cx, ids!(active_account_label)) diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 4fa0fa6bc..d06af7413 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -33,7 +33,7 @@ use hashbrown::{HashMap, HashSet}; use crate::{ account_manager::{self, Account}, app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ - add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate, TypingUser}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails + add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, @@ -559,15 +559,6 @@ pub enum MatrixRequest { /// * If `None`, the avatar will be removed. avatar_url: Option, }, - /// Request to upload and set a new avatar for the current user's account. - UploadAvatar { - /// The file name of the avatar image. - file_name: String, - /// The MIME type of the avatar image (e.g., "image/png", "image/jpeg"). - mime_type: String, - /// The raw bytes of the avatar image. - data: Vec, - }, /// Request to set or remove the display name of the current user's account. SetDisplayName { /// * If `Some`, the display name will be set to the given value. @@ -610,16 +601,6 @@ pub enum MatrixRequest { #[cfg(feature = "tsp")] sign_with_tsp: bool, }, - /// Request to send a file attachment to the given room. - SendAttachment { - room_id: OwnedRoomId, - file_name: String, - mime_type: String, - data: Vec, - /// Optional sender for progress updates. If provided, the upload will send - /// progress notifications through this channel. - timeline_update_sender: Option>, - }, /// Sends a notice to the given room that the current user is or is not typing. /// /// This request does not return a response or notify the UI thread, and @@ -715,67 +696,16 @@ pub enum MatrixRequest { destination: Arc>, update_sender: Option>, }, - - // ==================== Call-related requests ==================== - - /// Request to start a new call in a room. - StartCall { - room_id: OwnedRoomId, - /// Whether this is a video call (vs audio-only). - is_video_call: bool, - }, - /// Request to join an existing call in a room. - JoinCall { - room_id: OwnedRoomId, - }, - /// Request to leave an ongoing call. - LeaveCall { - room_id: OwnedRoomId, - }, - /// Request to send a MatrixRTC call membership state event. - SendCallMembershipEvent { - room_id: OwnedRoomId, - /// The serialized membership event content. - membership_content: String, - }, - /// Toggle audio mute for the current call. - ToggleCallAudio { - room_id: OwnedRoomId, - }, - /// Toggle video for the current call. - ToggleCallVideo { - room_id: OwnedRoomId, - }, - /// Fetch the TURN server configuration from the homeserver. - GetTurnServers, - /// Fetch the RTC foci configuration from the homeserver's well-known endpoint. - /// This retrieves the LiveKit service URL from `org.matrix.msc4143.rtc_foci`. - FetchRtcWellKnown, - /// Fetch a LiveKit SFU JWT token for joining a call. - FetchLiveKitSfuToken { - room_id: OwnedRoomId, - /// The device ID of the local user. - device_id: String, - }, - /// Request to search room members in the background. - /// Used to avoid blocking the UI thread for large rooms. - SearchRoomMembers { - /// Unique ID to identify this search and discard stale results. - search_id: u64, - /// The search query string. - query: String, - /// The room ID this search is for. - room_id: OwnedRoomId, - /// The list of members to search through. - members: std::sync::Arc>, - }, } /// Submits a request to the worker thread to be executed asynchronously. pub fn submit_async_request(req: MatrixRequest) { - if let Some(sender) = REQUEST_SENDER.lock().unwrap().as_ref() { - sender.send(req) - .expect("BUG: matrix worker task receiver has died!"); + if let Some(sender) = REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { + if let Err(_e) = sender.send(req) { + // The receiver has been dropped, likely due to account switching or logout. + // This is expected during transitions, so we silently ignore the error. + log!("Note: matrix worker task receiver unavailable, request dropped (likely during account switch)"); + } } } @@ -1056,7 +986,7 @@ async fn matrix_worker_task( MatrixRequest::CreateThreadTimeline { room_id, thread_root_event_id } => { let main_room_timeline = { - let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); + let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { error!("BUG: room info not found for create thread timeline request, room {room_id}"); continue; @@ -1084,7 +1014,7 @@ async fn matrix_worker_task( match build_result { Ok(thread_timeline) => { - let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); + let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { return; }; @@ -1120,7 +1050,7 @@ async fn matrix_worker_task( } Err(error) => { error!("Failed to create thread-focused timeline for room {room_id}, thread {thread_root_event_id}: {error}"); - let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); + let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); if let Some(room_info) = all_joined_rooms.get_mut(&room_id) { room_info .pending_thread_timelines @@ -1283,7 +1213,7 @@ async fn matrix_worker_task( MatrixRequest::GetSuccessorRoomDetails { tombstoned_room_id } => { let Some(client) = get_client() else { continue }; let (sender, successor_room) = { - let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); + let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); let Some(room_info) = all_joined_rooms.get(&tombstoned_room_id) else { error!("BUG: tombstoned room {tombstoned_room_id} info not found for get successor room details request"); continue; @@ -1491,48 +1421,6 @@ async fn matrix_worker_task( }); } - MatrixRequest::UploadAvatar { file_name, mime_type, data } => { - let Some(client) = get_client() else { continue }; - let _upload_avatar_task = Handle::current().spawn(async move { - log!("Uploading avatar {} ({}, {} bytes)...", file_name, mime_type, data.len()); - - // Parse the MIME type - let content_type: mime::Mime = mime_type.parse().unwrap_or(mime::IMAGE_PNG); - - // Upload the media to the server - match client.media().upload(&content_type, data, None).await { - Ok(response) => { - let mxc_uri = response.content_uri; - log!("Successfully uploaded avatar, got MXC URI: {}", mxc_uri); - - // Now set the avatar URL - match client.account().set_avatar_url(Some(&mxc_uri)).await { - Ok(_) => { - log!("Successfully set avatar to {}", mxc_uri); - Cx::post_action(AccountDataAction::AvatarChanged(Some(mxc_uri))); - enqueue_popup_notification( - "Avatar updated successfully!", - PopupKind::Info, - Some(3.0), - ); - } - Err(e) => { - let err_msg = format!("Failed to set avatar URL: {e}"); - error!("{}", err_msg); - Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); - } - } - } - Err(e) => { - let err_msg = format!("Failed to upload avatar: {e}"); - error!("{}", err_msg); - Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); - } - } - SignalToUI::set_ui_signal(); - }); - } - MatrixRequest::SetDisplayName { new_display_name } => { let Some(client) = get_client() else { continue }; let _set_display_name_task = Handle::current().spawn(async move { @@ -1654,7 +1542,7 @@ async fn matrix_worker_task( MatrixRequest::SubscribeToTypingNotices { room_id, subscribe } => { let (main_timeline, timeline_update_sender, mut typing_notice_receiver) = { - let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); + let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); let Some(jrd) = all_joined_rooms.get_mut(&room_id) else { log!("BUG: room info not found for subscribe to typing notices request, room {room_id}"); continue; @@ -1690,13 +1578,7 @@ async fn matrix_worker_task( let display_name = member.as_ref() .and_then(|m| m.display_name().map(|d| d.to_owned())) .unwrap_or_else(|| user_id.to_string()); - let avatar_url = member.as_ref() - .and_then(|m| m.avatar_url().map(|u| u.to_owned())); - users.push(TypingUser { - user_id: user_id.clone(), - display_name, - avatar_url, - }); + users.push(display_name); } if let Err(e) = timeline_update_sender.send(TimelineUpdate::TypingUsers { users }) { error!("Error: timeline update sender couldn't send the list of typing users: {e:?}"); @@ -1968,68 +1850,6 @@ async fn matrix_worker_task( }); } - MatrixRequest::SendAttachment { room_id, file_name, mime_type, data, timeline_update_sender } => { - let Some(client) = get_client() else { continue }; - let Some(room) = client.get_room(&room_id) else { - error!("BUG: room {room_id} not found for send attachment request"); - enqueue_popup_notification( - "Failed to send attachment: room not found.", - PopupKind::Error, - None, - ); - continue; - }; - - let _send_attachment_task = Handle::current().spawn(async move { - use crate::home::room_screen::TimelineUpdate; - - let data_len = data.len() as u64; - log!("Sending attachment {} ({}, {} bytes) to room {}", file_name, mime_type, data_len, room_id); - - // Send initial progress update (0%) - if let Some(ref sender) = timeline_update_sender { - let _ = sender.send(TimelineUpdate::FileUploadProgress { current: 0, total: data_len }); - SignalToUI::set_ui_signal(); - } - - // Parse the MIME type - let content_type: mime::Mime = mime_type.parse().unwrap_or(mime::APPLICATION_OCTET_STREAM); - - // Create attachment config - let config = matrix_sdk::attachment::AttachmentConfig::new(); - - // Send the attachment - match room.send_attachment(&file_name, &content_type, data, config).await { - Ok(_response) => { - log!("Successfully sent attachment {} to room {}", file_name, room_id); - // Send completion progress update (100%) - if let Some(ref sender) = timeline_update_sender { - let _ = sender.send(TimelineUpdate::FileUploadProgress { current: data_len, total: data_len }); - SignalToUI::set_ui_signal(); - } - enqueue_popup_notification( - format!("Sent: {}", file_name), - PopupKind::Info, - Some(3.0), - ); - } - Err(e) => { - error!("Failed to send attachment {} to room {}: {:?}", file_name, room_id, e); - if let Some(ref sender) = timeline_update_sender { - let _ = sender.send(TimelineUpdate::FileUploadError(format!("{}", e))); - SignalToUI::set_ui_signal(); - } - enqueue_popup_notification( - format!("Failed to send attachment: {}", e), - PopupKind::Error, - None, - ); - } - } - SignalToUI::set_ui_signal(); - }); - } - MatrixRequest::ReadReceipt { timeline_kind, event_id, receipt_type } => { let Some(timeline) = get_timeline(&timeline_kind) else { log!("BUG: {timeline_kind} not found when sending read receipt, {event_id}"); @@ -2270,430 +2090,6 @@ async fn matrix_worker_task( SignalToUI::set_ui_signal(); }); } - - MatrixRequest::SearchRoomMembers { search_id, query, room_id, members } => { - // Perform the search in a background task to avoid blocking the worker. - Handle::current().spawn(async move { - let query_lower = query.to_lowercase(); - let matched_indices: Vec = members - .iter() - .enumerate() - .filter(|(_, m)| { - m.displayable_name().to_lowercase().contains(&query_lower) - || m.user_id.as_str().to_lowercase().contains(&query_lower) - }) - .map(|(i, _)| i) - .collect(); - - crate::home::members_panel::enqueue_member_search_result( - crate::home::members_panel::MemberSearchResult { - search_id, - room_id, - query, - matched_indices, - } - ); - }); - } - - // ==================== Call-related request handlers ==================== - MatrixRequest::StartCall { room_id, is_video_call } => { - log!("StartCall request received for room {} (video: {})", room_id, is_video_call); - let Some(client) = get_client() else { continue }; - let manager = crate::call::webrtc_manager::webrtc_manager(); - - let _task = Handle::current().spawn(async move { - let Some(user_id) = client.user_id().map(|u| u.to_owned()) else { - error!("StartCall: user_id not available"); - return; - }; - let Some(device_id) = client.device_id().map(|d| d.to_owned()) else { - error!("StartCall: device_id not available"); - return; - }; - let config = crate::call::webrtc_session::WebRTCSessionConfig::default(); - - match manager.start_call(room_id.clone(), user_id.clone(), device_id.clone(), is_video_call, config).await { - Ok(membership) => { - // Serialize membership directly (not wrapped in MatrixRTCMemberEvent) - match serde_json::to_value(&membership) { - Ok(content) => { - if let Some(room) = client.get_room(&room_id) { - // Use correct state key format: _@user:server_deviceId_m.call - let state_key = crate::call::matrixrtc::MatrixRTCMembership::state_key( - user_id.as_str(), - device_id.as_str(), - ); - match room.send_state_event_raw( - crate::call::matrixrtc::MATRIXRTC_MEMBER_EVENT_TYPE, - &state_key, - content, - ).await { - Ok(_) => log!("Successfully sent call membership event for room {}", room_id), - Err(e) => error!("Failed to send call membership event: {}", e), - } - } else { - error!("StartCall: room {} not found", room_id); - } - } - Err(e) => error!("Failed to serialize membership event: {}", e), - } - } - Err(e) => error!("Failed to start call: {}", e), - } - }); - } - MatrixRequest::JoinCall { room_id } => { - log!("JoinCall request received for room {}", room_id); - let Some(client) = get_client() else { continue }; - - // Check if LiveKit service URL is available - let Some(livekit_service_url) = get_livekit_service_url() else { - error!("JoinCall: No LiveKit service URL available. Call joining requires LiveKit."); - Cx::post_action(crate::call::call_state::CallAction::MediaError { - error: "LiveKit service not configured for this homeserver".to_string(), - }); - continue; - }; - - let room_id_clone = room_id.clone(); - let livekit_url_clone = livekit_service_url.clone(); - let _task = Handle::current().spawn(async move { - let Some(user_id) = client.user_id().map(|u| u.to_owned()) else { - error!("JoinCall: user_id not available"); - return; - }; - let Some(device_id) = client.device_id().map(|d| d.to_owned()) else { - error!("JoinCall: device_id not available"); - return; - }; - - // Step 1: Get OpenID token for authentication with LiveKit service - log!("JoinCall: Step 1 - Getting OpenID token"); - let openid_token = match crate::call::matrixrtc::get_openid_token(&client).await { - Ok(token) => { - log!("JoinCall: OpenID token obtained successfully"); - token - } - Err(e) => { - error!("JoinCall: Failed to get OpenID token: {}", e); - Cx::post_action(crate::call::call_state::CallAction::MediaError { - error: format!("Failed to authenticate: {}", e), - }); - SignalToUI::set_ui_signal(); - return; - } - }; - - // Step 2: Send call membership state event to the room (BEFORE getting SFU token) - // This matches Element's flow where membership event is sent first - log!("JoinCall: Step 2 - Sending call membership state event"); - let start_time_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - - // Create membership content matching Element's format - let mut membership = crate::call::matrixrtc::MatrixRTCMembership::new(device_id.clone()) - .with_focus_active(crate::call::matrixrtc::FocusActive::livekit()) - .with_call_intent("video"); - - // Add LiveKit focus info with room_id as alias (matching Element) - let livekit_alias = room_id_clone.to_string(); - membership.add_preferred_focus( - crate::call::matrixrtc::FocusInfo::livekit(livekit_url_clone.clone(), livekit_alias.clone()) - ); - // Add second focus entry (Element sends two identical entries) - membership.add_preferred_focus( - crate::call::matrixrtc::FocusInfo::livekit(livekit_url_clone.clone(), livekit_alias) - ); - - // Serialize membership directly (not wrapped in MatrixRTCMemberEvent) - if let Ok(content) = serde_json::to_value(&membership) { - if let Some(room) = client.get_room(&room_id_clone) { - // State key format: _@user:server_deviceId_m.call - let state_key = crate::call::matrixrtc::MatrixRTCMembership::state_key( - user_id.as_str(), - device_id.as_str(), - ); - log!("JoinCall: Sending membership with state_key: {}", state_key); - - match room.send_state_event_raw( - crate::call::matrixrtc::MATRIXRTC_MEMBER_EVENT_TYPE, - &state_key, - content, - ).await { - Ok(_) => log!("JoinCall: Membership state event sent successfully"), - Err(e) => { - error!("JoinCall: Failed to send membership event: {}", e); - Cx::post_action(crate::call::call_state::CallAction::MediaError { - error: format!("Failed to join room call: {}", e), - }); - SignalToUI::set_ui_signal(); - return; - } - } - } - } - - // Step 3: Fetch SFU token from LiveKit service (AFTER sending membership) - log!("JoinCall: Step 3 - Fetching SFU token from {}", livekit_url_clone); - let sfu_response = match crate::call::matrixrtc::fetch_livekit_sfu_token( - &livekit_url_clone, - room_id_clone.as_str(), - &openid_token, - device_id.as_str(), - ).await { - Ok(response) => { - log!("JoinCall: SFU token received successfully"); - response - } - Err(e) => { - error!("JoinCall: Failed to get SFU token: {}", e); - Cx::post_action(crate::call::call_state::CallAction::MediaError { - error: format!("Failed to get call token: {}", e), - }); - SignalToUI::set_ui_signal(); - return; - } - }; - - let (jwt, livekit_url) = match (sfu_response.jwt, sfu_response.url) { - (Some(jwt), Some(url)) => (jwt, url), - _ => { - error!("JoinCall: SFU response missing jwt or url"); - Cx::post_action(crate::call::call_state::CallAction::MediaError { - error: "Invalid response from call service".to_string(), - }); - SignalToUI::set_ui_signal(); - return; - } - }; - - // Step 4: Post action with LiveKit token to connect - log!("JoinCall: Step 4 - Posting LiveKitTokenReceived action (url: {})", livekit_url); - Cx::post_action(crate::call::call_state::CallAction::LiveKitTokenReceived { - room_id: room_id_clone.clone(), - jwt, - livekit_url, - }); - - // Update call state to connected - let local_participant = crate::call::call_state::CallParticipant::new( - user_id.clone(), - device_id.clone(), - ); - Cx::post_action(crate::call::call_state::CallAction::StateChanged { - room_id: room_id_clone.clone(), - new_state: crate::call::call_state::CallState::Connected { - room_id: room_id_clone, - participants: Vec::new(), - local_participant, - is_video_call: true, - start_time_ms, - }, - }); - SignalToUI::set_ui_signal(); - }); - } - MatrixRequest::LeaveCall { room_id } => { - log!("LeaveCall request received for room {}", room_id); - let Some(client) = get_client() else { continue }; - let manager = crate::call::webrtc_manager::webrtc_manager(); - - let _task = Handle::current().spawn(async move { - let _ = manager.leave_call(&room_id).await; - - // Send empty membership to signal leaving - if let Some(room) = client.get_room(&room_id) { - if let (Some(user_id), Some(device_id)) = (client.user_id(), client.device_id()) { - // Use correct state key format: _@user:server_deviceId_m.call - let state_key = crate::call::matrixrtc::MatrixRTCMembership::state_key( - user_id.as_str(), - device_id.as_str(), - ); - // Send empty object to clear membership - let empty = serde_json::json!({}); - let _ = room.send_state_event_raw( - crate::call::matrixrtc::MATRIXRTC_MEMBER_EVENT_TYPE, - &state_key, - empty, - ).await; - } - } - - Cx::post_action(crate::call::call_state::CallAction::StateChanged { - room_id: room_id.clone(), - new_state: crate::call::call_state::CallState::Idle, - }); - SignalToUI::set_ui_signal(); - }); - } - MatrixRequest::SendCallMembershipEvent { room_id, membership_content } => { - log!("SendCallMembershipEvent request for room {}", room_id); - let Some(client) = get_client() else { continue }; - - let _task = Handle::current().spawn(async move { - if let Some(room) = client.get_room(&room_id) { - if let Some(user_id) = client.user_id() { - match serde_json::from_str::(&membership_content) { - Ok(content) => { - let _ = room.send_state_event_raw( - crate::call::matrixrtc::MATRIXRTC_MEMBER_EVENT_TYPE, - user_id.as_str(), - content, - ).await; - } - Err(e) => error!("Failed to parse membership content: {}", e), - } - } - } - }); - } - MatrixRequest::ToggleCallAudio { room_id } => { - log!("ToggleCallAudio request for room {}", room_id); - let manager = crate::call::webrtc_manager::webrtc_manager(); - let _task = Handle::current().spawn(async move { - let _ = manager.toggle_audio(&room_id).await; - SignalToUI::set_ui_signal(); - }); - } - MatrixRequest::ToggleCallVideo { room_id } => { - log!("ToggleCallVideo request for room {}", room_id); - let manager = crate::call::webrtc_manager::webrtc_manager(); - let _task = Handle::current().spawn(async move { - let _ = manager.toggle_video(&room_id).await; - SignalToUI::set_ui_signal(); - }); - } - MatrixRequest::GetTurnServers => { - log!("GetTurnServers request received"); - // TURN server configuration is typically handled by the WebRTC session setup - // For now, we use the default STUN servers in WebRTCSessionConfig - } - MatrixRequest::FetchRtcWellKnown => { - log!("FetchRtcWellKnown request received"); - let Some(client) = get_client() else { - error!("FetchRtcWellKnown: No client available"); - continue; - }; - - // Use the server name from the user ID to construct the well-known URL. - // This is important because client.homeserver() returns the resolved - // homeserver API URL (e.g., https://matrix-client.matrix.org), but the - // well-known file is served from the original domain (e.g., https://matrix.org). - let well_known_url = if let Some(user_id) = client.user_id() { - let server_name = user_id.server_name().as_str(); - match Url::parse(&format!("https://{}/.well-known/matrix/client", server_name)) { - Ok(url) => url, - Err(e) => { - error!("FetchRtcWellKnown: Failed to build well-known URL from server name: {}", e); - // Fall back to homeserver URL - match client.homeserver().join("/.well-known/matrix/client") { - Ok(url) => url, - Err(e) => { - error!("FetchRtcWellKnown: Failed to build well-known URL: {}", e); - continue; - } - } - } - } - } else { - // No user ID available, fall back to homeserver URL - match client.homeserver().join("/.well-known/matrix/client") { - Ok(url) => url, - Err(e) => { - error!("FetchRtcWellKnown: Failed to build well-known URL: {}", e); - continue; - } - } - }; - log!("FetchRtcWellKnown: Fetching from {}", well_known_url); - - let _task = Handle::current().spawn(async move { - match fetch_rtc_well_known(well_known_url).await { - Ok(Some(livekit_url)) => { - log!("FetchRtcWellKnown: Found LiveKit service URL: {}", livekit_url); - set_livekit_service_url(Some(livekit_url)); - } - Ok(None) => { - log!("FetchRtcWellKnown: No LiveKit service URL found in well-known"); - set_livekit_service_url(None); - } - Err(e) => { - error!("FetchRtcWellKnown: Failed to fetch well-known: {}", e); - set_livekit_service_url(None); - } - } - SignalToUI::set_ui_signal(); - }); - } - MatrixRequest::FetchLiveKitSfuToken { room_id, device_id } => { - log!("FetchLiveKitSfuToken request for room {}", room_id); - - let Some(client) = get_client() else { - error!("FetchLiveKitSfuToken: No client available"); - Cx::post_action(crate::call::call_state::CallAction::MediaError { - error: "Not logged in".to_string(), - }); - continue; - }; - - let Some(livekit_service_url) = get_livekit_service_url() else { - error!("FetchLiveKitSfuToken: No LiveKit service URL available"); - Cx::post_action(crate::call::call_state::CallAction::MediaError { - error: "LiveKit service not configured for this homeserver".to_string(), - }); - continue; - }; - - let room_id_clone = room_id.clone(); - let _task = Handle::current().spawn(async move { - // First, get an OpenID token for authentication - let openid_token = match crate::call::matrixrtc::get_openid_token(&client).await { - Ok(token) => token, - Err(e) => { - error!("FetchLiveKitSfuToken: Failed to get OpenID token: {}", e); - Cx::post_action(crate::call::call_state::CallAction::MediaError { - error: format!("Failed to get authentication token: {}", e), - }); - SignalToUI::set_ui_signal(); - return; - } - }; - - // Fetch the SFU token - match crate::call::matrixrtc::fetch_livekit_sfu_token( - &livekit_service_url, - room_id_clone.as_str(), - &openid_token, - &device_id, - ).await { - Ok(sfu_response) => { - if let (Some(jwt), Some(url)) = (sfu_response.jwt, sfu_response.url) { - log!("FetchLiveKitSfuToken: Successfully obtained JWT for LiveKit"); - Cx::post_action(crate::call::call_state::CallAction::LiveKitTokenReceived { - room_id: room_id_clone, - jwt, - livekit_url: url, - }); - } else { - error!("FetchLiveKitSfuToken: SFU response missing jwt or url"); - Cx::post_action(crate::call::call_state::CallAction::MediaError { - error: "Invalid SFU response".to_string(), - }); - } - } - Err(e) => { - error!("FetchLiveKitSfuToken: Failed to fetch SFU token: {}", e); - Cx::post_action(crate::call::call_state::CallAction::MediaError { - error: format!("Failed to get call token: {}", e), - }); - } - } - SignalToUI::set_ui_signal(); - }); - } } } @@ -2701,44 +2097,6 @@ async fn matrix_worker_task( bail!("matrix_worker_task task ended unexpectedly") } -/// Fetches the RTC foci configuration from the homeserver's well-known endpoint. -/// Returns the LiveKit service URL if found. -async fn fetch_rtc_well_known(well_known_url: url::Url) -> Result, anyhow::Error> { - use serde_json::Value; - - let response = reqwest::get(well_known_url).await?; - - if !response.status().is_success() { - anyhow::bail!("Well-known request failed with status: {}", response.status()); - } - - let json: Value = response.json().await?; - - // Look for org.matrix.msc4143.rtc_foci array - let rtc_foci = match json.get("org.matrix.msc4143.rtc_foci") { - Some(Value::Array(arr)) => arr, - _ => { - log!("fetch_rtc_well_known: No org.matrix.msc4143.rtc_foci found"); - return Ok(None); - } - }; - - // Find the livekit entry - for focus in rtc_foci { - if let Some(focus_type) = focus.get("type").and_then(|t| t.as_str()) { - if focus_type == "livekit" { - if let Some(url) = focus.get("livekit_service_url").and_then(|u| u.as_str()) { - return Ok(Some(url.to_string())); - } - } - } - } - - log!("fetch_rtc_well_known: No livekit entry found in rtc_foci"); - Ok(None) -} - - /// The single global Tokio runtime that is used by all async tasks. static TOKIO_RUNTIME: Mutex> = Mutex::new(None); @@ -2762,7 +2120,7 @@ pub fn block_on_async_with_timeout( timeout: Option, async_future: impl Future, ) -> Result { - let rt = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| + let rt = TOKIO_RUNTIME.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(|| tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") ).handle().clone(); @@ -2782,7 +2140,7 @@ pub fn block_on_async_with_timeout( /// Returns a handle to the Tokio runtime that is used to run async background tasks. pub fn start_matrix_tokio() -> Result { // Create a Tokio runtime, and save it in a static variable to ensure it isn't dropped. - let rt_handle = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| { + let rt_handle = TOKIO_RUNTIME.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(|| { tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") }).handle().clone(); @@ -2791,7 +2149,7 @@ pub fn start_matrix_tokio() -> Result { rt_handle.spawn(async move { match build_client(&Cli::default(), app_data_dir()).await { Ok(client_and_session) => { - DEFAULT_SSO_CLIENT.lock().unwrap() + DEFAULT_SSO_CLIENT.lock().unwrap_or_else(|e| e.into_inner()) .get_or_insert(client_and_session); } Err(e) => error!("Error: could not create DEFAULT_SSO_CLIENT object: {e}"), @@ -2897,19 +2255,19 @@ fn get_per_timeline_details<'a>( /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the timeline for the given timeline kind. fn get_timeline(kind: &TimelineKind) -> Option> { - get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind) + get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).deref_mut(), kind) .map(|details| details.timeline.clone()) } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the timeline and timeline update sender for the given timeline kind. fn get_timeline_and_sender(kind: &TimelineKind) -> Option<(Arc, crossbeam_channel::Sender)> { - get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind) + get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).deref_mut(), kind) .map(|details| (details.timeline.clone(), details.timeline_update_sender.clone())) } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the main timeline for the given room. fn get_room_timeline(room_id: &RoomId) -> Option> { - ALL_JOINED_ROOMS.lock().unwrap() + ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()) .get(room_id) .map(|jrd| jrd.main_timeline.timeline.clone()) } @@ -2918,26 +2276,12 @@ fn get_room_timeline(room_id: &RoomId) -> Option> { static CLIENT: Mutex> = Mutex::new(None); pub fn get_client() -> Option { - CLIENT.lock().unwrap().clone() -} - -/// The LiveKit service URL fetched from the homeserver's well-known configuration. -/// This is used for MatrixRTC calls via LiveKit SFU. -static LIVEKIT_SERVICE_URL: Mutex> = Mutex::new(None); - -/// Returns the LiveKit service URL if it has been fetched from the homeserver. -pub fn get_livekit_service_url() -> Option { - LIVEKIT_SERVICE_URL.lock().unwrap().clone() -} - -/// Sets the LiveKit service URL. -fn set_livekit_service_url(url: Option) { - *LIVEKIT_SERVICE_URL.lock().unwrap() = url; + CLIENT.lock().unwrap_or_else(|e| e.into_inner()).clone() } /// Returns the user ID of the currently logged-in user, if any. pub fn current_user_id() -> Option { - CLIENT.lock().unwrap().as_ref().and_then(|c| + CLIENT.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.session_meta().map(|m| m.user_id.clone()) ) } @@ -2974,12 +2318,12 @@ static IGNORED_USERS: Mutex> = Mutex::new(Hash /// Returns a deep clone of the current list of ignored users. pub fn get_ignored_users() -> HashSet { - IGNORED_USERS.lock().unwrap().clone() + IGNORED_USERS.lock().unwrap_or_else(|e| e.into_inner()).clone() } /// Returns whether the given user ID is currently being ignored. pub fn is_user_ignored(user_id: &UserId) -> bool { - IGNORED_USERS.lock().unwrap().contains(user_id) + IGNORED_USERS.lock().unwrap_or_else(|e| e.into_inner()).contains(user_id) } @@ -2992,7 +2336,7 @@ pub fn is_user_ignored(user_id: &UserId) -> bool { /// This will only succeed once per room (or once per room thread), /// as only a single channel receiver can exist. pub fn take_timeline_endpoints(kind: &TimelineKind) -> Option { - let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); + let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); let jrd = all_joined_rooms.get_mut(kind.room_id())?; let details = match kind { TimelineKind::MainRoom { .. } => &mut jrd.main_timeline, @@ -3096,7 +2440,7 @@ impl RoomListServiceRoomInfo { async fn start_matrix_client_login_and_sync(rt: Handle) { // Create a channel for sending requests from the main UI thread to a background worker task. let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); - REQUEST_SENDER.lock().unwrap().replace(sender); + REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()).replace(sender); let (login_sender, mut login_receiver) = tokio::sync::mpsc::channel(1); @@ -3131,7 +2475,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { specified_username.as_ref().or(most_recent_user_id.as_ref()) ); match persistence::restore_session(specified_username).await { - Ok(session) => Some(session), + Ok((client, sync_token, session)) => Some((client, sync_token, session)), Err(e) => { let status_err = "Could not restore previous user session.\n\nPlease login again."; log!("{status_err} Error: {e:?}"); @@ -3144,7 +2488,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { homeserver: cli.homeserver.clone(), }); match login(cli, LoginRequest::LoginByCli).await { - Ok((client, sync_token, _is_add_account, _session)) => Some((client, sync_token)), + Ok((client, sync_token, _is_add_account, session)) => Some((client, sync_token, session)), Err(e) => { error!("CLI-based login failed: {e:?}"); Cx::post_action(LoginAction::LoginFailure( @@ -3171,7 +2515,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let mut initial_client_opt = new_login_opt; let (client, sync_service, logged_in_user_id) = 'login_loop: loop { - let (client, _sync_token) = match initial_client_opt.take() { + let (client, _sync_token, session) = match initial_client_opt.take() { Some(login) => login, None => { loop { @@ -3179,7 +2523,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { match login_receiver.recv().await { Some(login_request) => { match login(&cli, login_request).await { - Ok((client, sync_token, ..)) => break (client, sync_token), + Ok((client, sync_token, _is_add_account, session)) => break (client, sync_token, session), Err(e) => { error!("Login failed: {e:?}"); Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); @@ -3214,8 +2558,19 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + // Add the account to the AccountManager + let account = account_manager::Account { + client: client.clone(), + user_id: logged_in_user_id.clone(), + session, + display_name: None, + avatar_url: None, + }; + let is_new = account_manager::add_account(account); + log!("Added account {} to AccountManager. New account: {}", logged_in_user_id, is_new); + // Store this active client in our global Client state so that other tasks can access it. - if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { + if let Some(_existing) = CLIENT.lock().unwrap_or_else(|e| e.into_inner()).replace(client.clone()) { error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); } @@ -3250,7 +2605,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); // Clear the stored client so the next login attempt doesn't trigger the // "unexpectedly replaced an existing client" warning. - let _ = CLIENT.lock().unwrap().take(); + let _ = CLIENT.lock().unwrap_or_else(|e| e.into_inner()).take(); continue 'login_loop; } }; @@ -3272,7 +2627,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let room_list_service = sync_service.room_list_service(); - if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { + if let Some(_existing) = SYNC_SERVICE.lock().unwrap_or_else(|e| e.into_inner()).replace(Arc::new(sync_service)) { error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); } @@ -3368,10 +2723,10 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { log!("Account switch detected, restarting with user: {}", switch_user_id); // Clear all backend state - CLIENT.lock().unwrap().take(); - SYNC_SERVICE.lock().unwrap().take(); - ALL_JOINED_ROOMS.lock().unwrap().clear(); - IGNORED_USERS.lock().unwrap().clear(); + CLIENT.lock().unwrap_or_else(|e| e.into_inner()).take(); + SYNC_SERVICE.lock().unwrap_or_else(|e| e.into_inner()).take(); + ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).clear(); + IGNORED_USERS.lock().unwrap_or_else(|e| e.into_inner()).clear(); // Clear the rooms list UI enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); @@ -3385,15 +2740,14 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // Restore session for the switched account match persistence::restore_session(Some(switch_user_id.clone())).await { - Ok((client, _sync_token)) => { + Ok((client, _sync_token, _session)) => { log!("Successfully restored session for {}", switch_user_id); // Store the client - CLIENT.lock().unwrap().replace(client.clone()); + CLIENT.lock().unwrap_or_else(|e| e.into_inner()).replace(client.clone()); // Set up the new client add_verification_event_handlers_and_sync_client(client.clone()); - crate::call::matrixrtc::add_matrixrtc_event_handlers(client.clone()); handle_ignore_user_list_subscriber(client.clone()); // Create new sync service @@ -3417,11 +2771,11 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { sync_service.start().await; let room_list_service = sync_service.room_list_service(); - SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)); + SYNC_SERVICE.lock().unwrap_or_else(|e| e.into_inner()).replace(Arc::new(sync_service)); // Recreate worker task and service loops let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); - REQUEST_SENDER.lock().unwrap().replace(sender); + REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()).replace(sender); let (login_sender, _login_receiver) = tokio::sync::mpsc::channel(1); let mut matrix_worker_task_handle = rt.spawn(matrix_worker_task(receiver, login_sender)); @@ -3530,7 +2884,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu } // ALL_JOINED_ROOMS should already be empty due to successive calls to `remove_room()`, // so this is just a sanity check. - ALL_JOINED_ROOMS.lock().unwrap().clear(); + ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).clear(); enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); } else { @@ -3570,7 +2924,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu VectorDiff::Clear => { if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Clear"); } all_known_rooms.clear(); - ALL_JOINED_ROOMS.lock().unwrap().clear(); + ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).clear(); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); } @@ -3878,7 +3232,7 @@ async fn update_room( let mut __timeline_update_sender_opt = None; let mut get_timeline_update_sender = |room_id| { if __timeline_update_sender_opt.is_none() { - if let Some(jrd) = ALL_JOINED_ROOMS.lock().unwrap().get(room_id) { + if let Some(jrd) = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).get(room_id) { __timeline_update_sender_opt = Some(jrd.main_timeline.timeline_update_sender.clone()); } } @@ -3929,7 +3283,7 @@ async fn update_room( /// Invoked when the room list service has received an update to remove an existing room. fn remove_room(room: &RoomListServiceRoomInfo) { - ALL_JOINED_ROOMS.lock().unwrap().remove(&room.room_id); + ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).remove(&room.room_id); enqueue_rooms_list_update( RoomsListUpdate::RemoveRoom { room_id: room.room_id.clone(), @@ -3991,8 +3345,6 @@ async fn add_new_room( alt_aliases: new_room.room.alt_aliases(), // we don't actually display the latest event for Invited rooms, so don't bother. latest: None, - // TODO: fetch the invite timestamp from the invite event - invite_timestamp: None, invite_state: Default::default(), is_selected: false, is_direct: new_room.is_direct, @@ -4039,7 +3391,7 @@ async fn add_new_room( // an `AddJoinedRoom` update to the RoomsList widget, because that widget might // immediately issue a `MatrixRequest` that relies on that room being in `ALL_JOINED_ROOMS`. log!("Adding new joined room {}, name: {:?}", new_room.room_id, new_room.display_name); - ALL_JOINED_ROOMS.lock().unwrap().insert( + ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).insert( new_room.room_id.clone(), JoinedRoomDetails { room_id: new_room.room_id.clone(), @@ -4116,7 +3468,7 @@ fn handle_ignore_user_list_subscriber(client: Client) { .collect::>(); // TODO: when we support persistent state, don't forget to update `IGNORED_USERS` upon app boot. - let mut ignored_users_old = IGNORED_USERS.lock().unwrap(); + let mut ignored_users_old = IGNORED_USERS.lock().unwrap_or_else(|e| e.into_inner()); let has_changed = *ignored_users_old != ignored_users_new; *ignored_users_old = ignored_users_new; @@ -4942,7 +4294,7 @@ async fn spawn_sso_server( // We do not clone it because a Client cannot be re-used again // once it has been used for a login attempt, so this forces us to create a new one // if that occurs. - let client_and_session_opt = DEFAULT_SSO_CLIENT.lock().unwrap().take(); + let client_and_session_opt = DEFAULT_SSO_CLIENT.lock().unwrap_or_else(|e| e.into_inner()).take(); Handle::current().spawn(async move { // Try to use the DEFAULT_SSO_CLIENT that we proactively created @@ -5201,7 +4553,7 @@ impl UserPowerLevels { /// Shuts down the current Tokio runtime completely and takes ownership to ensure proper cleanup. pub fn shutdown_background_tasks() { - if let Some(runtime) = TOKIO_RUNTIME.lock().unwrap().take() { + if let Some(runtime) = TOKIO_RUNTIME.lock().unwrap_or_else(|e| e.into_inner()).take() { runtime.shutdown_background(); } } @@ -5209,11 +4561,11 @@ pub fn shutdown_background_tasks() { pub async fn clear_app_state(config: &LogoutConfig) -> Result<()> { // Clear resources normally, allowing them to be properly dropped // This prevents memory leaks when users logout and login again without closing the app - CLIENT.lock().unwrap().take(); - SYNC_SERVICE.lock().unwrap().take(); - REQUEST_SENDER.lock().unwrap().take(); - IGNORED_USERS.lock().unwrap().clear(); - ALL_JOINED_ROOMS.lock().unwrap().clear(); + CLIENT.lock().unwrap_or_else(|e| e.into_inner()).take(); + SYNC_SERVICE.lock().unwrap_or_else(|e| e.into_inner()).take(); + REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()).take(); + IGNORED_USERS.lock().unwrap_or_else(|e| e.into_inner()).clear(); + ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).clear(); let on_clear_appstate = Arc::new(Notify::new()); Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); From ebfb2eb8e3a861acaf8cc011aceadebd41f53c29 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: Mon, 30 Mar 2026 08:54:06 +0800 Subject: [PATCH 44/66] Fix logout when homeserver is unreachable - Continue local logout cleanup when server logout fails due to connectivity/unavailability - Handle logout button click before own_profile guard so it always responds --- src/logout/logout_state_machine.rs | 41 ++++++++++++++++++++++++++++++ src/settings/account_settings.rs | 8 +++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/logout/logout_state_machine.rs b/src/logout/logout_state_machine.rs index 3ccb922ca..a8776377b 100644 --- a/src/logout/logout_state_machine.rs +++ b/src/logout/logout_state_machine.rs @@ -344,6 +344,20 @@ impl LogoutStateMachine { 50 ).await?; + // Same delete operation as in the success case above + if let Err(e) = delete_latest_user_id().await { + log!("Warning: Failed to delete latest user ID: {}", e); + } + } else if should_continue_local_logout_without_server(&e) { + log!("Homeserver appears unavailable, continuing with local logout: {}", e); + self.point_of_no_return.store(true, Ordering::Release); + set_logout_point_of_no_return(true); + self.transition_to( + LogoutState::PointOfNoReturn, + "Homeserver unavailable, continuing with local logout".to_string(), + 50 + ).await?; + // Same delete operation as in the success case above if let Err(e) = delete_latest_user_id().await { log!("Warning: Failed to delete latest user ID: {}", e); @@ -553,6 +567,33 @@ impl LogoutStateMachine { } } +fn should_continue_local_logout_without_server(error: &LogoutError) -> bool { + match error { + LogoutError::Recoverable(RecoverableError::Timeout(_)) => true, + LogoutError::Recoverable(RecoverableError::ServerLogoutFailed(msg)) => { + let msg_lower = msg.to_ascii_lowercase(); + msg_lower.contains("timeout") + || msg_lower.contains("timed out") + || msg_lower.contains("service unavailable") + || msg_lower.contains("bad gateway") + || msg_lower.contains("gateway timeout") + || msg_lower.contains("too many requests") + || msg_lower.contains("error sending request") + || msg_lower.contains("connection") + || msg_lower.contains("connect") + || msg_lower.contains("network") + || msg_lower.contains("dns") + || msg_lower.contains("i/o") + || msg_lower.contains("tls") + || msg_lower.contains("status code: 429") + || msg_lower.contains("status code: 502") + || msg_lower.contains("status code: 503") + || msg_lower.contains("status code: 504") + } + _ => false, + } +} + /// Global atomic flag indicating if the logout process has reached the "point of no return" /// where aborting the logout operation is no longer safe. static LOGOUT_POINT_OF_NO_RETURN: AtomicBool = AtomicBool::new(false); diff --git a/src/settings/account_settings.rs b/src/settings/account_settings.rs index 877f66bfc..4669039d4 100644 --- a/src/settings/account_settings.rs +++ b/src/settings/account_settings.rs @@ -368,6 +368,11 @@ impl MatchEvent for AccountSettings { } } + if self.view.button(cx, ids!(logout_button)).clicked(actions) { + cx.action(LogoutConfirmModalAction::Open); + return; + } + let Some(own_profile) = &self.own_profile else { return }; if upload_avatar_button.clicked(actions) { @@ -456,9 +461,6 @@ impl MatchEvent for AccountSettings { ); } - if self.view.button(cx, ids!(logout_button)).clicked(actions) { - cx.action(LogoutConfirmModalAction::Open); - } } } From dd9595eb4b569e6d2917e0090b951ea8e92f3b3e 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: Mon, 30 Mar 2026 11:31:12 +0800 Subject: [PATCH 45/66] feat: move room search to modal with local+remote results --- src/app.rs | 381 ++++++++++++++++++++++++++++++++- src/home/home_screen.rs | 21 -- src/home/navigation_tab_bar.rs | 3 +- src/home/rooms_list.rs | 33 +++ src/home/rooms_list_header.rs | 40 ++++ src/home/rooms_sidebar.rs | 6 +- src/home/spaces_bar.rs | 23 ++ src/sliding_sync.rs | 111 +++++++++- 8 files changed, 591 insertions(+), 27 deletions(-) diff --git a/src/app.rs b/src/app.rs index b9a75f6ee..171c208c1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,10 +8,10 @@ use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, OwnedUserId, RoomI use serde::{Deserialize, Serialize}; use crate::{ avatar_cache::clear_avatar_cache, home::{ - event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, invite_screen::InviteScreenWidgetRefExt, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, space_lobby::SpaceLobbyScreenWidgetRefExt + event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, invite_screen::InviteScreenWidgetRefExt, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, space_lobby::SpaceLobbyScreenWidgetRefExt, spaces_bar::SpacesBarRef }, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt - }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ + }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::AvatarWidgetRefExt, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, VerificationModalWidgetRefExt, } @@ -104,6 +104,101 @@ script_mod! { invite_modal_inner := InviteModal {} } } + room_filter_modal := Modal { + content +: { + room_filter_modal_inner := RoundedShadowView { + width: 420, + height: Fit + flow: Down + spacing: 8 + show_bg: true + draw_bg +: { + color: (COLOR_PRIMARY_DARKER) + border_radius: 4.0 + border_size: 0.0 + shadow_color: #0005 + shadow_radius: 15.0 + shadow_offset: vec2(1.0, 0.0) + } + padding: Inset{top: 15, left: 15, right: 15, bottom: 15} + + room_filter_input_bar := RoomFilterInputBar {} + + search_results_title := Label { + width: Fill, + height: Fit, + margin: Inset{left: 4, top: 2} + text: "Search Results" + draw_text +: { + color: (COLOR_TEXT_INPUT_IDLE) + text_style: REGULAR_TEXT {font_size: 10} + } + } + + search_results_scroll := ScrollYView { + width: Fill, + height: 260 + show_bg: false + + search_results := View { + width: Fill, + height: Fit, + flow: Down + spacing: 4 + + search_results_empty := Label { + width: Fill, + height: Fit, + flow: Flow.Right{wrap: true}, + text: "Type to search rooms and spaces..." + draw_text +: { + color: (COLOR_TEXT) + text_style: REGULAR_TEXT {font_size: 10} + } + } + + remote_search_options := View { + visible: false + width: Fill, + height: Fit, + flow: Right + spacing: 6 + margin: Inset{top: 6} + + remote_search_people_button := RobrixNeutralIconButton { + width: Fit, + text: "People" + } + remote_search_rooms_button := RobrixNeutralIconButton { + width: Fit, + text: "Rooms" + } + remote_search_spaces_button := RobrixNeutralIconButton { + width: Fit, + text: "Spaces" + } + } + + search_results_list := View { + width: Fill, + height: Fit, + flow: Down + spacing: 3 + + result_item_0 := 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 } } } + result_item_1 := 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 } } } + result_item_2 := 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 } } } + result_item_3 := 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 } } } + result_item_4 := 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 } } } + result_item_5 := 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 } } } + result_item_6 := 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 } } } + result_item_7 := 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 } } } + } + } + } + } + } + } // Show the logout confirmation modal. logout_confirm_modal := Modal { @@ -162,6 +257,29 @@ script_mod! { app_main!(App); +#[derive(Clone)] +enum RoomFilterResultTarget { + LocalSpace { room_name_id: RoomNameId, avatar: FetchedRoomAvatar }, + LocalRoom { room_name_id: RoomNameId, avatar: FetchedRoomAvatar }, + RemoteSpace(RoomNameId), + RemoteRoom(RoomNameId), + RemoteUser(UserProfile), +} + +#[derive(Clone, Debug)] +pub enum RoomFilterRemoteSearchAction { + Results { + query: String, + kind: RemoteDirectorySearchKind, + results: Vec, + }, + Failed { + query: String, + kind: RemoteDirectorySearchKind, + error: String, + }, +} + #[derive(Script)] pub struct App { #[live] ui: WidgetRef, @@ -174,6 +292,7 @@ pub struct App { /// A stack of previously-selected rooms for mobile navigation. /// When a view is popped off the stack, the previous `selected_room` is restored from here. #[rust] mobile_room_nav_stack: Vec, + #[rust] room_filter_modal_results: Vec, } impl ScriptHook for App { @@ -255,6 +374,52 @@ impl MatchEvent for App { self.ui.modal(cx, ids!(positive_confirmation_modal)).close(cx); } + if let Some(clicked_index) = self.clicked_room_filter_result_index(cx, actions) { + if let Some(target) = self.room_filter_modal_results.get(clicked_index).cloned() { + self.ui.modal(cx, ids!(room_filter_modal)).close(cx); + match target { + RoomFilterResultTarget::LocalSpace { room_name_id: space_name_id, .. } + | RoomFilterResultTarget::RemoteSpace(space_name_id) => { + cx.action(NavigationBarAction::GoToSpace { space_name_id }); + } + RoomFilterResultTarget::LocalRoom { room_name_id, .. } + | RoomFilterResultTarget::RemoteRoom(room_name_id) => { + self.navigate_to_room(cx, None, &BasicRoomDetails::RoomId(room_name_id)); + } + RoomFilterResultTarget::RemoteUser(user_profile) => { + submit_async_request(MatrixRequest::OpenOrCreateDirectMessage { + user_profile, + allow_create: false, + }); + } + } + return; + } + } + + if let Some(kind) = self.clicked_room_filter_remote_option(cx, actions) { + let room_filter_input = self.ui.text_input(cx, ids!(room_filter_modal_inner.room_filter_input_bar.input)); + let query = room_filter_input.text().trim().to_owned(); + if !query.is_empty() { + let kind_text = match &kind { + RemoteDirectorySearchKind::People => "people", + RemoteDirectorySearchKind::Rooms => "rooms", + RemoteDirectorySearchKind::Spaces => "spaces", + }; + self.set_room_filter_modal_empty_state( + cx, + &format!("Searching {} on server...", kind_text), + false, + ); + submit_async_request(MatrixRequest::SearchDirectory { + query, + kind, + limit: 16, + }); + } + return; + } + for action in actions { match action.downcast_ref() { Some(LogoutConfirmModalAction::Open) => { @@ -311,6 +476,71 @@ impl MatchEvent for App { continue; } + if let RoomFilterAction::Changed(keywords) = action.as_widget_action().cast_ref() { + self.update_room_filter_modal_results(cx, keywords); + continue; + } + + match action.downcast_ref() { + Some(RoomFilterRemoteSearchAction::Results { query, kind: _, results }) => { + let room_filter_input = self.ui.text_input(cx, ids!(room_filter_modal_inner.room_filter_input_bar.input)); + if room_filter_input.text().trim() != query.trim() { + continue; + } + self.room_filter_modal_results.clear(); + for result in results { + match result { + RemoteDirectorySearchResult::User(user_profile) => { + self.room_filter_modal_results.push(RoomFilterResultTarget::RemoteUser(user_profile.clone())); + } + RemoteDirectorySearchResult::Room(room_name_id) => { + self.room_filter_modal_results.push(RoomFilterResultTarget::RemoteRoom(room_name_id.clone())); + } + RemoteDirectorySearchResult::Space(space_name_id) => { + self.room_filter_modal_results.push(RoomFilterResultTarget::RemoteSpace(space_name_id.clone())); + } + } + if self.room_filter_modal_results.len() >= Self::ROOM_FILTER_RESULT_ITEM_IDS.len() { + break; + } + } + if self.room_filter_modal_results.is_empty() { + self.set_room_filter_modal_empty_state( + cx, + &format!("No server results for \"{}\".", query), + true, + ); + } else { + self.set_room_filter_modal_empty_state(cx, "", false); + } + self.refresh_room_filter_modal_result_buttons(cx); + continue; + } + Some(RoomFilterRemoteSearchAction::Failed { query, kind: _, error }) => { + let room_filter_input = self.ui.text_input(cx, ids!(room_filter_modal_inner.room_filter_input_bar.input)); + if room_filter_input.text().trim() != query.trim() { + continue; + } + self.room_filter_modal_results.clear(); + self.refresh_room_filter_modal_result_buttons(cx); + self.set_room_filter_modal_empty_state( + cx, + &format!("Server search failed: {}", error), + true, + ); + continue; + } + _ => {} + } + + if let Some(RoomsListHeaderAction::OpenRoomFilterModal) = action.downcast_ref() { + self.ui.modal(cx, ids!(room_filter_modal)).open(cx); + let room_filter_input = self.ui.text_input(cx, ids!(room_filter_modal_inner.room_filter_input_bar.input)); + room_filter_input.set_key_focus(cx); + self.update_room_filter_modal_results(cx, &room_filter_input.text()); + continue; + } + // Handle an action requesting to open the new message context menu. if let MessageAction::OpenMessageContextMenu { details, abs_pos } = action.as_widget_action().cast() { self.ui.callout_tooltip(cx, ids!(app_tooltip)).hide(cx); @@ -820,6 +1050,13 @@ impl AppMain for App { } impl App { + const ROOM_FILTER_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 update_login_visibility(&self, cx: &mut Cx) { let show_login = !self.app_state.logged_in; if !show_login { @@ -831,6 +1068,146 @@ impl App { self.ui.view(cx, ids!(home_screen_view)).set_visible(cx, !show_login); } + fn clicked_room_filter_result_index(&self, cx: &mut Cx, actions: &Actions) -> Option { + let list_view = self.ui.view(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.search_results_list)); + for (index, item_id) in Self::ROOM_FILTER_RESULT_ITEM_IDS.iter().enumerate() { + if list_view.button(cx, &[*item_id, live_id!(click_button)]).clicked(actions) { + return Some(index); + } + } + None + } + + fn clicked_room_filter_remote_option(&self, cx: &mut Cx, actions: &Actions) -> Option { + let options_view = self.ui.view(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_options)); + if options_view.button(cx, ids!(remote_search_people_button)).clicked(actions) { + return Some(RemoteDirectorySearchKind::People); + } + if options_view.button(cx, ids!(remote_search_rooms_button)).clicked(actions) { + return Some(RemoteDirectorySearchKind::Rooms); + } + if options_view.button(cx, ids!(remote_search_spaces_button)).clicked(actions) { + return Some(RemoteDirectorySearchKind::Spaces); + } + None + } + + fn set_room_filter_modal_empty_state( + &self, + cx: &mut Cx, + text: &str, + show_remote_options: bool, + ) { + let empty_label = self.ui.label(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.search_results_empty)); + empty_label.set_visible(cx, !text.is_empty()); + if !text.is_empty() { + empty_label.set_text(cx, text); + } + self.ui.view(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.remote_search_options)) + .set_visible(cx, show_remote_options); + } + + fn refresh_room_filter_modal_result_buttons(&self, cx: &mut Cx) { + let list_view = self.ui.view(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.search_results_list)); + for (index, item_id) in Self::ROOM_FILTER_RESULT_ITEM_IDS.iter().enumerate() { + let item = list_view.view(cx, &[*item_id]); + if let Some(target) = self.room_filter_modal_results.get(index) { + let (name, raw_id) = match target { + RoomFilterResultTarget::LocalSpace { room_name_id, .. } + | RoomFilterResultTarget::LocalRoom { room_name_id, .. } => { + (room_name_id.to_string(), room_name_id.room_id().to_string()) + } + RoomFilterResultTarget::RemoteSpace(space_name_id) + | RoomFilterResultTarget::RemoteRoom(space_name_id) => { + (space_name_id.to_string(), space_name_id.room_id().to_string()) + } + RoomFilterResultTarget::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 { + RoomFilterResultTarget::LocalSpace { avatar, .. } + | RoomFilterResultTarget::LocalRoom { avatar, .. } => { + match avatar { + FetchedRoomAvatar::Text(text) => { + avatar_ref.show_text(cx, None, None, text); + } + FetchedRoomAvatar::Image(image_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_err() { + avatar_ref.show_text(cx, None, None, &name); + } + } + } + } + RoomFilterResultTarget::RemoteSpace(_) + | RoomFilterResultTarget::RemoteRoom(_) + | RoomFilterResultTarget::RemoteUser(_) => { + avatar_ref.show_text(cx, None, None, &name); + } + } + + item.set_visible(cx, true); + } else { + item.set_visible(cx, false); + } + } + } + + fn update_room_filter_modal_results(&mut self, cx: &mut Cx, keywords: &str) { + let keywords = keywords.trim(); + self.room_filter_modal_results.clear(); + + if !keywords.is_empty() { + let space_items = cx.get_global::() + .get_matching_space_items(keywords, 4); + let room_items = cx.get_global::() + .get_matching_room_items(keywords, 8); + + for (room_name_id, avatar) in space_items { + self.room_filter_modal_results.push(RoomFilterResultTarget::LocalSpace { room_name_id, avatar }); + if self.room_filter_modal_results.len() >= Self::ROOM_FILTER_RESULT_ITEM_IDS.len() { + break; + } + } + if self.room_filter_modal_results.len() < Self::ROOM_FILTER_RESULT_ITEM_IDS.len() { + for (room_name_id, avatar) in room_items { + self.room_filter_modal_results.push(RoomFilterResultTarget::LocalRoom { room_name_id, avatar }); + if self.room_filter_modal_results.len() >= Self::ROOM_FILTER_RESULT_ITEM_IDS.len() { + break; + } + } + } + } + + if keywords.is_empty() { + self.set_room_filter_modal_empty_state( + cx, + "Type to search rooms and spaces...", + false, + ); + } else if self.room_filter_modal_results.is_empty() { + self.set_room_filter_modal_empty_state( + cx, + &format!("No local results for \"{}\". Choose a type below to search server.", keywords), + true, + ); + } else { + self.set_room_filter_modal_empty_state(cx, "", false); + } + + self.refresh_room_filter_modal_result_buttons(cx); + } + /// Navigates to the given `destination_room`, optionally closing the `room_to_close`. fn navigate_to_room( &mut self, diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index c4d34d2aa..de033c820 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -206,26 +206,6 @@ script_mod! { width: Fill, height: Fill flow: Down - View { - width: Fill, - height: 39, - flow: Right - padding: Inset{top: 2, bottom: 2} - margin: Inset{right: 2} - spacing: 2 - align: Align{y: 0.5} - - CachedWidget { - room_filter_input_bar := RoomFilterInputBar {} - } - - search_messages_button := SearchMessagesButton { - // make this button match/align with the RoomFilterInputBar - height: 32.5, - margin: Inset{right: 2} - } - } - mod.widgets.MainDesktopUI {} } @@ -512,4 +492,3 @@ impl HomeScreen { ) } } - diff --git a/src/home/navigation_tab_bar.rs b/src/home/navigation_tab_bar.rs index 95cec1317..19f848dc4 100644 --- a/src/home/navigation_tab_bar.rs +++ b/src/home/navigation_tab_bar.rs @@ -34,7 +34,7 @@ use crate::{ avatar_cache::{self, AvatarCacheEntry}, login::login_screen::LoginAction, logout::logout_confirm_modal::LogoutAction, profile::{ user_profile::UserProfile, user_profile_cache::{self, UserProfileUpdate}, - }, shared::{ + }, home::spaces_bar::SpacesBarWidgetExt, shared::{ avatar::{AvatarState, AvatarWidgetExt}, styles::*, verification_badge::VerificationBadgeWidgetExt }, sliding_sync::{current_user_id, AccountDataAction}, utils::{self, RoomNameId} }; @@ -425,6 +425,7 @@ impl ScriptHook for NavigationTabBar { if let Some(mut rb) = self.view.radio_button(cx, ids!(home_button)).borrow_mut() { rb.animator_play(cx, ids!(active.on)); } + cx.set_global(self.view.spaces_bar(cx, ids!(root_spaces_bar))); }); } } diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 73ce9375e..c3a5792f8 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -1582,6 +1582,39 @@ impl RoomsListRef { .get(space_id) .map(|smv| smv.parent_chain.clone()) } + + /// Returns local room results matching `keywords`, up to `max_results`. + pub fn get_matching_room_items(&self, keywords: &str, max_results: usize) -> Vec<(RoomNameId, FetchedRoomAvatar)> { + let Some(inner) = self.borrow() else { return Vec::new(); }; + let keywords = keywords.trim().to_lowercase(); + if keywords.is_empty() { + return Vec::new(); + } + let mut items = Vec::new(); + let invited_rooms = inner.invited_rooms.borrow(); + for ir in invited_rooms.values() { + let name = ir.room_name_id.to_string(); + let room_id = ir.room_name_id.room_id().to_string(); + if name.to_lowercase().contains(&keywords) || room_id.to_lowercase().contains(&keywords) { + items.push((ir.room_name_id.clone(), ir.room_avatar.clone())); + if items.len() >= max_results { + return items; + } + } + } + drop(invited_rooms); + for jr in inner.all_joined_rooms.values() { + let name = jr.room_name_id.to_string(); + let room_id = jr.room_name_id.room_id().to_string(); + if name.to_lowercase().contains(&keywords) || room_id.to_lowercase().contains(&keywords) { + items.push((jr.room_name_id.clone(), jr.room_avatar.clone())); + if items.len() >= max_results { + return items; + } + } + } + items + } } pub struct RoomsListScopeProps { diff --git a/src/home/rooms_list_header.rs b/src/home/rooms_list_header.rs index eac4372a4..d0eda85a0 100644 --- a/src/home/rooms_list_header.rs +++ b/src/home/rooms_list_header.rs @@ -41,6 +41,40 @@ script_mod! { } }, + open_room_filter_modal_button := Button { + width: Fit, + height: Fit + padding: Inset{top: 6, bottom: 6, left: 6, right: 6} + margin: Inset{bottom: 2} + spacing: 0, + text: "" + draw_bg +: { + color: #0000 + color_hover: #0000 + color_down: #0000 + border_color: #0000 + border_color_hover: #0000 + border_color_down: #0000 + border_color_focus: #0000 + border_size: 0.0 + border_radius: 0.0 + } + draw_text +: { + color: #0000 + color_hover: #0000 + color_down: #0000 + color_focus: #0000 + } + draw_icon +: { + svg: (ICON_SEARCH) + color: (COLOR_TEXT) + color_hover: (COLOR_TEXT) + color_down: (COLOR_TEXT) + color_focus: (COLOR_TEXT) + } + icon_walk: Walk{width: 16, height: Fit, margin: Inset{bottom: 2}} + } + View { width: Fit, height: Fit, margin: Inset{right: 3} @@ -93,6 +127,10 @@ pub struct RoomsListHeader { impl Widget for RoomsListHeader { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { if let Event::Actions(actions) = event { + if self.view.button(cx, ids!(open_room_filter_modal_button)).clicked(actions) { + cx.action(RoomsListHeaderAction::OpenRoomFilterModal); + } + for action in actions { match action.downcast_ref() { Some(RoomsListHeaderAction::SetSyncStatus(is_syncing)) => { @@ -186,6 +224,8 @@ impl Widget for RoomsListHeader { /// Actions that can be handled by the `RoomsListHeader`. #[derive(Debug)] pub enum RoomsListHeaderAction { + /// Open the rooms/spaces filter modal. + OpenRoomFilterModal, /// An action received by the RoomsListHeader that will show or hide /// its sync status indicator (and loading spinner) based on the given boolean. SetSyncStatus(bool), diff --git a/src/home/rooms_sidebar.rs b/src/home/rooms_sidebar.rs index c50ca5695..79e99abe4 100644 --- a/src/home/rooms_sidebar.rs +++ b/src/home/rooms_sidebar.rs @@ -54,7 +54,11 @@ script_mod! { View { height: 23 } CachedWidget { - rooms_list_header := RoomsListHeader {} + rooms_list_header := RoomsListHeader { + open_room_filter_modal_button +: { + visible: false + } + } } View { diff --git a/src/home/spaces_bar.rs b/src/home/spaces_bar.rs index 8f613dc93..9776183e5 100644 --- a/src/home/spaces_bar.rs +++ b/src/home/spaces_bar.rs @@ -825,3 +825,26 @@ impl SpacesBar { self.redraw(cx); } } + +impl SpacesBarRef { + /// Returns local spaces matching `keywords`, up to `max_results`. + pub fn get_matching_space_items(&self, keywords: &str, max_results: usize) -> Vec<(RoomNameId, FetchedRoomAvatar)> { + let Some(inner) = self.borrow() else { return Vec::new(); }; + let keywords = keywords.trim().to_lowercase(); + if keywords.is_empty() { + return Vec::new(); + } + let mut items = Vec::new(); + for space in inner.all_joined_spaces.values() { + let name = space.space_name_id.to_string(); + let space_id = space.space_name_id.room_id().to_string(); + if name.to_lowercase().contains(&keywords) || space_id.to_lowercase().contains(&keywords) { + items.push((space.space_name_id.clone(), space.space_avatar.clone())); + if items.len() >= max_results { + break; + } + } + } + items + } +} diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 131e6610f..56255a1c7 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -11,11 +11,12 @@ use matrix_sdk::{ config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ api::{Direction, client::{ account::register::v3::Request as RegistrationRequest, + directory::get_public_rooms_filtered, error::ErrorKind, profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType, uiaa::{AuthData, AuthType, Dummy}, - }}, events::{ + }}, directory::{Filter as PublicRoomsFilter, RoomTypeFilter}, events::{ relation::RelationType, room::{ message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource @@ -37,7 +38,7 @@ use std::{borrow::Cow, cmp::{max, min}, future::Future, hash::{BuildHasherDefaul use std::io; use hashbrown::{HashMap, HashSet}; use crate::{ - app::AppStateAction, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ + app::{AppStateAction, RoomFilterRemoteSearchAction}, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ user_profile::UserProfile, @@ -714,6 +715,12 @@ pub enum MatrixRequest { room_or_alias_id: OwnedRoomOrAliasId, via: Vec, }, + /// Request to search server-side directory for users, rooms, or spaces. + SearchDirectory { + query: String, + kind: RemoteDirectorySearchKind, + limit: u64, + }, /// Request to fetch the full details (the room preview) of a tombstoned room. GetSuccessorRoomDetails { tombstoned_room_id: OwnedRoomId, @@ -921,6 +928,20 @@ pub enum MatrixRequest { }, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RemoteDirectorySearchKind { + People, + Rooms, + Spaces, +} + +#[derive(Clone, Debug)] +pub enum RemoteDirectorySearchResult { + User(UserProfile), + Room(RoomNameId), + Space(RoomNameId), +} + /// Submits a request to the worker thread to be executed asynchronously. pub fn submit_async_request(req: MatrixRequest) { if let Some(sender) = REQUEST_SENDER.lock().unwrap().as_ref() { @@ -1450,6 +1471,92 @@ async fn matrix_worker_task( }); } + MatrixRequest::SearchDirectory { query, kind, limit } => { + let Some(client) = get_client() else { continue }; + let _search_task = Handle::current().spawn(async move { + let query = query.trim().to_owned(); + let action_kind = kind.clone(); + if query.is_empty() { + Cx::post_action(RoomFilterRemoteSearchAction::Results { + query, + kind: action_kind, + results: Vec::new(), + }); + return; + } + + let result = match &kind { + RemoteDirectorySearchKind::People => { + client.search_users(&query, limit).await + .map(|response| { + response.results.into_iter() + .map(|user| { + RemoteDirectorySearchResult::User(UserProfile { + username: user.display_name, + user_id: user.user_id, + avatar_state: AvatarState::Known(user.avatar_url), + }) + }) + .collect::>() + }) + .map_err(|e| e.to_string()) + } + RemoteDirectorySearchKind::Rooms | RemoteDirectorySearchKind::Spaces => { + let mut filter = PublicRoomsFilter::new(); + filter.generic_search_term = Some(query.clone()); + filter.room_types = match &kind { + RemoteDirectorySearchKind::Rooms => vec![RoomTypeFilter::Default], + RemoteDirectorySearchKind::Spaces => vec![RoomTypeFilter::Space], + RemoteDirectorySearchKind::People => Vec::new(), + }; + let mut request = get_public_rooms_filtered::v3::Request::new(); + request.filter = filter; + client.public_rooms_filtered(request).await + .map(|response| { + response.chunk.into_iter() + .take(limit as usize) + .map(|room| { + let display_name = room.name + .or_else(|| room.canonical_alias.as_ref().map(ToString::to_string)) + .unwrap_or_else(|| room.room_id.to_string()); + let room_name_id = RoomNameId::new( + RoomDisplayName::Named(display_name), + room.room_id.clone(), + ); + match &kind { + RemoteDirectorySearchKind::Spaces => { + RemoteDirectorySearchResult::Space(room_name_id) + } + _ => { + RemoteDirectorySearchResult::Room(room_name_id) + } + } + }) + .collect::>() + }) + .map_err(|e| e.to_string()) + } + }; + + match result { + Ok(results) => { + Cx::post_action(RoomFilterRemoteSearchAction::Results { + query, + kind: action_kind, + results, + }); + } + Err(error) => { + Cx::post_action(RoomFilterRemoteSearchAction::Failed { + query, + kind: action_kind, + error, + }); + } + } + }); + } + MatrixRequest::GetSuccessorRoomDetails { tombstoned_room_id } => { let Some(client) = get_client() else { continue }; let (sender, successor_room) = { From 6c710bfe2aead1524d74256980622dfd60650ba4 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: Mon, 30 Mar 2026 12:14:34 +0800 Subject: [PATCH 46/66] feat: polish search modal results and header alignment --- src/app.rs | 253 ++++++++++++++++++++++++++++------ src/home/rooms_list.rs | 35 ++++- src/home/rooms_list_header.rs | 68 +++++---- src/home/spaces_bar.rs | 30 +++- src/sliding_sync.rs | 32 ++++- src/space_service_sync.rs | 10 +- 6 files changed, 342 insertions(+), 86 deletions(-) diff --git a/src/app.rs b/src/app.rs index 171c208c1..471fdebb2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,14 +4,14 @@ use std::{cell::RefCell, collections::HashMap}; use makepad_widgets::*; -use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId}}; +use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UserId}}; use serde::{Deserialize, Serialize}; use crate::{ - avatar_cache::clear_avatar_cache, home::{ + avatar_cache::{self, AvatarCacheEntry, clear_avatar_cache}, home::{ event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, invite_screen::InviteScreenWidgetRefExt, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, space_lobby::SpaceLobbyScreenWidgetRefExt, spaces_bar::SpacesBarRef }, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt - }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::AvatarWidgetRefExt, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ + }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, VerificationModalWidgetRefExt, } @@ -21,6 +21,61 @@ script_mod! { use mod.prelude.widgets.* use mod.widgets.* + let RoomFilterResultItem = 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 + } + } + } + load_all_resources() do #(App::script_component(vm)) { ui: Root { main_window := Window { @@ -185,14 +240,14 @@ script_mod! { flow: Down spacing: 3 - result_item_0 := 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 } } } - result_item_1 := 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 } } } - result_item_2 := 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 } } } - result_item_3 := 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 } } } - result_item_4 := 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 } } } - result_item_5 := 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 } } } - result_item_6 := 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 } } } - result_item_7 := 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 } } } + result_item_0 := RoomFilterResultItem {} + result_item_1 := RoomFilterResultItem {} + result_item_2 := RoomFilterResultItem {} + result_item_3 := RoomFilterResultItem {} + result_item_4 := RoomFilterResultItem {} + result_item_5 := RoomFilterResultItem {} + result_item_6 := RoomFilterResultItem {} + result_item_7 := RoomFilterResultItem {} } } } @@ -261,8 +316,8 @@ app_main!(App); enum RoomFilterResultTarget { LocalSpace { room_name_id: RoomNameId, avatar: FetchedRoomAvatar }, LocalRoom { room_name_id: RoomNameId, avatar: FetchedRoomAvatar }, - RemoteSpace(RoomNameId), - RemoteRoom(RoomNameId), + RemoteSpace { space_name_id: RoomNameId, avatar_uri: Option }, + RemoteRoom { room_name_id: RoomNameId, avatar_uri: Option }, RemoteUser(UserProfile), } @@ -293,6 +348,8 @@ pub struct App { /// When a view is popped off the stack, the previous `selected_room` is restored from here. #[rust] mobile_room_nav_stack: Vec, #[rust] room_filter_modal_results: Vec, + #[rust(Timer::empty())] room_filter_debounce_timer: Timer, + #[rust] pending_room_filter_keywords: String, } impl ScriptHook for App { @@ -358,6 +415,19 @@ impl MatchEvent for App { } } + fn handle_signal(&mut self, cx: &mut Cx) { + avatar_cache::process_avatar_updates(cx); + self.refresh_room_filter_modal_result_buttons(cx); + } + + fn handle_timer(&mut self, cx: &mut Cx, event: &TimerEvent) { + if self.room_filter_debounce_timer.is_timer(event).is_some() { + self.room_filter_debounce_timer = Timer::empty(); + let keywords = std::mem::take(&mut self.pending_room_filter_keywords); + self.update_room_filter_modal_results(cx, &keywords); + } + } + fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { let invite_confirmation_modal_inner = self.ui.confirmation_modal(cx, ids!(invite_confirmation_modal_inner)); if let Some(_accepted) = invite_confirmation_modal_inner.closed(actions) { @@ -379,13 +449,27 @@ impl MatchEvent for App { self.ui.modal(cx, ids!(room_filter_modal)).close(cx); match target { RoomFilterResultTarget::LocalSpace { room_name_id: space_name_id, .. } - | RoomFilterResultTarget::RemoteSpace(space_name_id) => { + => { cx.action(NavigationBarAction::GoToSpace { space_name_id }); } RoomFilterResultTarget::LocalRoom { room_name_id, .. } - | RoomFilterResultTarget::RemoteRoom(room_name_id) => { + => { self.navigate_to_room(cx, None, &BasicRoomDetails::RoomId(room_name_id)); } + RoomFilterResultTarget::RemoteSpace { space_name_id, .. } => { + self.open_join_from_search_result( + cx, + BasicRoomDetails::Name(space_name_id), + true, + ); + } + RoomFilterResultTarget::RemoteRoom { room_name_id, .. } => { + self.open_join_from_search_result( + cx, + BasicRoomDetails::Name(room_name_id), + false, + ); + } RoomFilterResultTarget::RemoteUser(user_profile) => { submit_async_request(MatrixRequest::OpenOrCreateDirectMessage { user_profile, @@ -477,7 +561,9 @@ impl MatchEvent for App { } if let RoomFilterAction::Changed(keywords) = action.as_widget_action().cast_ref() { - self.update_room_filter_modal_results(cx, keywords); + cx.stop_timer(self.room_filter_debounce_timer); + self.pending_room_filter_keywords = keywords.clone(); + self.room_filter_debounce_timer = cx.start_timeout(0.12); continue; } @@ -493,11 +579,17 @@ impl MatchEvent for App { RemoteDirectorySearchResult::User(user_profile) => { self.room_filter_modal_results.push(RoomFilterResultTarget::RemoteUser(user_profile.clone())); } - RemoteDirectorySearchResult::Room(room_name_id) => { - self.room_filter_modal_results.push(RoomFilterResultTarget::RemoteRoom(room_name_id.clone())); + RemoteDirectorySearchResult::Room { room_name_id, avatar_uri } => { + self.room_filter_modal_results.push(RoomFilterResultTarget::RemoteRoom { + room_name_id: room_name_id.clone(), + avatar_uri: avatar_uri.clone(), + }); } - RemoteDirectorySearchResult::Space(space_name_id) => { - self.room_filter_modal_results.push(RoomFilterResultTarget::RemoteSpace(space_name_id.clone())); + RemoteDirectorySearchResult::Space { space_name_id, avatar_uri } => { + self.room_filter_modal_results.push(RoomFilterResultTarget::RemoteSpace { + space_name_id: space_name_id.clone(), + avatar_uri: avatar_uri.clone(), + }); } } if self.room_filter_modal_results.len() >= Self::ROOM_FILTER_RESULT_ITEM_IDS.len() { @@ -1057,6 +1149,21 @@ impl App { live_id!(result_item_6), live_id!(result_item_7), ]; + fn open_join_from_search_result( + &mut self, + cx: &mut Cx, + details: BasicRoomDetails, + is_space: bool, + ) { + cx.action(JoinLeaveRoomModalAction::Open { + kind: JoinLeaveModalKind::JoinRoom { + details, + is_space, + }, + show_tip: false, + }); + } + fn update_login_visibility(&self, cx: &mut Cx) { let show_login = !self.app_state.logged_in; if !show_login { @@ -1107,6 +1214,75 @@ impl App { .set_visible(cx, show_remote_options); } + fn set_room_filter_result_avatar( + &self, + cx: &mut Cx, + avatar_ref: &crate::shared::avatar::AvatarRef, + fallback_text: &str, + local_avatar: Option<&FetchedRoomAvatar>, + remote_avatar_uri: Option<&OwnedMxcUri>, + remote_avatar_state: Option<&AvatarState>, + ) { + if let Some(local_avatar) = local_avatar { + match local_avatar { + FetchedRoomAvatar::Text(text) => { + avatar_ref.show_text(cx, None, None, text); + } + FetchedRoomAvatar::Image(image_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_err() { + avatar_ref.show_text(cx, None, None, fallback_text); + } + } + } + return; + } + + 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_room_filter_modal_result_buttons(&self, cx: &mut Cx) { let list_view = self.ui.view(cx, ids!(room_filter_modal_inner.search_results_scroll.search_results.search_results_list)); for (index, item_id) in Self::ROOM_FILTER_RESULT_ITEM_IDS.iter().enumerate() { @@ -1117,8 +1293,8 @@ impl App { | RoomFilterResultTarget::LocalRoom { room_name_id, .. } => { (room_name_id.to_string(), room_name_id.room_id().to_string()) } - RoomFilterResultTarget::RemoteSpace(space_name_id) - | RoomFilterResultTarget::RemoteRoom(space_name_id) => { + RoomFilterResultTarget::RemoteSpace { space_name_id, .. } + | RoomFilterResultTarget::RemoteRoom { room_name_id: space_name_id, .. } => { (space_name_id.to_string(), space_name_id.room_id().to_string()) } RoomFilterResultTarget::RemoteUser(user_profile) => { @@ -1133,26 +1309,21 @@ impl App { match target { RoomFilterResultTarget::LocalSpace { avatar, .. } | RoomFilterResultTarget::LocalRoom { avatar, .. } => { - match avatar { - FetchedRoomAvatar::Text(text) => { - avatar_ref.show_text(cx, None, None, text); - } - FetchedRoomAvatar::Image(image_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_err() { - avatar_ref.show_text(cx, None, None, &name); - } - } - } + self.set_room_filter_result_avatar(cx, &avatar_ref, &name, Some(avatar), None, None); } - RoomFilterResultTarget::RemoteSpace(_) - | RoomFilterResultTarget::RemoteRoom(_) - | RoomFilterResultTarget::RemoteUser(_) => { - avatar_ref.show_text(cx, None, None, &name); + RoomFilterResultTarget::RemoteSpace { avatar_uri, .. } + | RoomFilterResultTarget::RemoteRoom { avatar_uri, .. } => { + self.set_room_filter_result_avatar(cx, &avatar_ref, &name, None, avatar_uri.as_ref(), None); + } + RoomFilterResultTarget::RemoteUser(user_profile) => { + self.set_room_filter_result_avatar( + cx, + &avatar_ref, + &name, + None, + None, + Some(&user_profile.avatar_state), + ); } } diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index c3a5792f8..24c08f762 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -268,6 +268,8 @@ impl ActionDefaultRef for RoomsListAction { pub struct JoinedRoomInfo { /// The displayable name of this room (includes room ID for fallback). pub room_name_id: RoomNameId, + /// Lowercased searchable text cached for fast local search. + pub search_text: String, /// The number of unread messages in this room. pub num_unread_messages: u64, /// The number of unread mentions in this room. @@ -310,6 +312,8 @@ pub struct JoinedRoomInfo { pub struct InvitedRoomInfo { /// The displayable name of this room (includes room ID for fallback). pub room_name_id: RoomNameId, + /// Lowercased searchable text cached for fast local search. + pub search_text: String, /// The canonical alias for this room, if any. pub canonical_alias: Option, /// The alternative aliases for this room, if any. @@ -340,6 +344,27 @@ pub struct InviterInfo { pub display_name: Option, pub avatar: Option>, } + +pub fn build_room_search_text( + room_name_id: &RoomNameId, + canonical_alias: &Option, + alt_aliases: &[OwnedRoomAliasId], +) -> String { + let mut search_text = format!( + "{} {}", + room_name_id.to_string().to_lowercase(), + room_name_id.room_id().as_str().to_lowercase(), + ); + if let Some(alias) = canonical_alias { + search_text.push(' '); + search_text.push_str(&alias.as_str().to_lowercase()); + } + for alias in alt_aliases { + search_text.push(' '); + search_text.push_str(&alias.as_str().to_lowercase()); + } + search_text +} impl std::fmt::Debug for InviterInfo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("InviterInfo") @@ -603,6 +628,7 @@ impl RoomsList { // Try to update joined room first if let Some(room) = self.all_joined_rooms.get_mut(&room_id) { room.room_name_id = new_room_name; + room.search_text = build_room_search_text(&room.room_name_id, &room.canonical_alias, &room.alt_aliases); let is_direct = room.is_direct; let should_display = should_display_room!(self, &room_id, room); let (pos_in_list, displayed_list) = if is_direct { @@ -629,6 +655,7 @@ impl RoomsList { let mut invited_rooms = self.invited_rooms.borrow_mut(); if let Some(invited_room) = invited_rooms.get_mut(&room_id) { invited_room.room_name_id = new_room_name; + invited_room.search_text = build_room_search_text(&invited_room.room_name_id, &invited_room.canonical_alias, &invited_room.alt_aliases); let should_display = should_display_room!(self, &room_id, invited_room); let pos_in_list = self.displayed_invited_rooms.iter() .position(|r| r == &room_id); @@ -1593,9 +1620,7 @@ impl RoomsListRef { let mut items = Vec::new(); let invited_rooms = inner.invited_rooms.borrow(); for ir in invited_rooms.values() { - let name = ir.room_name_id.to_string(); - let room_id = ir.room_name_id.room_id().to_string(); - if name.to_lowercase().contains(&keywords) || room_id.to_lowercase().contains(&keywords) { + if ir.search_text.contains(&keywords) { items.push((ir.room_name_id.clone(), ir.room_avatar.clone())); if items.len() >= max_results { return items; @@ -1604,9 +1629,7 @@ impl RoomsListRef { } drop(invited_rooms); for jr in inner.all_joined_rooms.values() { - let name = jr.room_name_id.to_string(); - let room_id = jr.room_name_id.room_id().to_string(); - if name.to_lowercase().contains(&keywords) || room_id.to_lowercase().contains(&keywords) { + if jr.search_text.contains(&keywords) { items.push((jr.room_name_id.clone(), jr.room_avatar.clone())); if items.len() >= max_results { return items; diff --git a/src/home/rooms_list_header.rs b/src/home/rooms_list_header.rs index d0eda85a0..d4aaa3a8e 100644 --- a/src/home/rooms_list_header.rs +++ b/src/home/rooms_list_header.rs @@ -26,13 +26,14 @@ script_mod! { height: Fit, padding: Inset{bottom: 4} flow: Right, + align: Align{y: 0.5} spacing: 3, header_title := Label { width: Fill, height: Fit, padding: 0 - margin: Inset{left: 5, top: -1} + margin: Inset{left: 5} flow: Right, // do not wrap text: "All Rooms" draw_text +: { @@ -41,38 +42,45 @@ script_mod! { } }, - open_room_filter_modal_button := Button { + open_room_filter_modal_button := View { width: Fit, height: Fit - padding: Inset{top: 6, bottom: 6, left: 6, right: 6} - margin: Inset{bottom: 2} - spacing: 0, - text: "" - draw_bg +: { - color: #0000 - color_hover: #0000 - color_down: #0000 - border_color: #0000 - border_color_hover: #0000 - border_color_down: #0000 - border_color_focus: #0000 - border_size: 0.0 - border_radius: 0.0 - } - draw_text +: { - color: #0000 - color_hover: #0000 - color_down: #0000 - color_focus: #0000 + margin: Inset{right: 1} + flow: Overlay, + + Icon { + draw_icon +: { + svg: (ICON_SEARCH) + color: (COLOR_TEXT) + } + icon_walk: Walk{width: 18, height: Fit, margin: Inset{bottom: 2}} } - draw_icon +: { - svg: (ICON_SEARCH) - color: (COLOR_TEXT) - color_hover: (COLOR_TEXT) - color_down: (COLOR_TEXT) - color_focus: (COLOR_TEXT) + + click_area := Button { + width: Fill, + height: Fill + padding: Inset{top: 6, bottom: 6, left: 6, right: 6} + spacing: 0, + text: "" + draw_bg +: { + color: #0000 + color_hover: #0000 + color_down: #0000 + border_color: #0000 + border_color_hover: #0000 + border_color_down: #0000 + border_color_focus: #0000 + border_size: 0.0 + border_radius: 0.0 + } + draw_text +: { + color: #0000 + color_hover: #0000 + color_down: #0000 + color_focus: #0000 + } + icon_walk: Walk{width: 0, height: 0} } - icon_walk: Walk{width: 16, height: Fit, margin: Inset{bottom: 2}} } View { @@ -127,7 +135,7 @@ pub struct RoomsListHeader { impl Widget for RoomsListHeader { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { if let Event::Actions(actions) = event { - if self.view.button(cx, ids!(open_room_filter_modal_button)).clicked(actions) { + if self.view.button(cx, ids!(open_room_filter_modal_button.click_area)).clicked(actions) { cx.action(RoomsListHeaderAction::OpenRoomFilterModal); } diff --git a/src/home/spaces_bar.rs b/src/home/spaces_bar.rs index 9776183e5..75b03765d 100644 --- a/src/home/spaces_bar.rs +++ b/src/home/spaces_bar.rs @@ -358,6 +358,8 @@ impl SpacesBarEntryRef { pub struct JoinedSpaceInfo { /// The display name and ID of the space. pub space_name_id: RoomNameId, + /// Lowercased searchable text cached for fast local search. + pub search_text: String, /// The canonical alias of the space, if any. pub canonical_alias: Option, /// The topic of the space, if any. @@ -376,6 +378,27 @@ pub struct JoinedSpaceInfo { pub children_count: u64, } +pub fn build_space_search_text( + space_name_id: &RoomNameId, + canonical_alias: &Option, + topic: &Option, +) -> String { + let mut search_text = format!( + "{} {}", + space_name_id.to_string().to_lowercase(), + space_name_id.room_id().as_str().to_lowercase(), + ); + if let Some(alias) = canonical_alias { + search_text.push(' '); + search_text.push_str(&alias.as_str().to_lowercase()); + } + if let Some(topic) = topic { + search_text.push(' '); + search_text.push_str(&topic.to_lowercase()); + } + search_text +} + /// The possible updates that should be displayed by the single list of all spaces. @@ -678,6 +701,7 @@ impl SpacesBar { if let Some(space) = self.all_joined_spaces.get_mut(&space_id) { let was_displayed = (self.display_filter)(space); space.canonical_alias = new_canonical_alias; + space.search_text = build_space_search_text(&space.space_name_id, &space.canonical_alias, &space.topic); let should_display = (self.display_filter)(space); adjust_displayed_spaces(was_displayed, should_display, space_id, &mut self.displayed_spaces); } else { @@ -692,6 +716,7 @@ impl SpacesBar { RoomDisplayName::Named(new_space_name), space_id.clone(), ); + space.search_text = build_space_search_text(&space.space_name_id, &space.canonical_alias, &space.topic); let should_display = (self.display_filter)(space); adjust_displayed_spaces(was_displayed, should_display, space_id, &mut self.displayed_spaces); } else { @@ -704,6 +729,7 @@ impl SpacesBar { // We don't currently support filtering by topic. // let was_displayed = (self.display_filter)(space); space.topic = topic; + space.search_text = build_space_search_text(&space.space_name_id, &space.canonical_alias, &space.topic); // let should_display = (self.display_filter)(space); // adjust_displayed_spaces(was_displayed, should_display, space_id, &mut self.displayed_spaces); } else { @@ -836,9 +862,7 @@ impl SpacesBarRef { } let mut items = Vec::new(); for space in inner.all_joined_spaces.values() { - let name = space.space_name_id.to_string(); - let space_id = space.space_name_id.room_id().to_string(); - if name.to_lowercase().contains(&keywords) || space_id.to_lowercase().contains(&keywords) { + if space.search_text.contains(&keywords) { items.push((space.space_name_id.clone(), space.space_avatar.clone())); if items.len() >= max_results { break; diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 56255a1c7..71b2a466e 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -39,7 +39,7 @@ use std::io; use hashbrown::{HashMap, HashSet}; use crate::{ app::{AppStateAction, RoomFilterRemoteSearchAction}, app_data_dir, avatar_cache::AvatarUpdate, event_preview::{BeforeText, TextPreview, text_preview_of_raw_timeline_event, text_preview_of_timeline_item}, home::{ - add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails + add_room::KnockResultAction, invite_screen::{JoinRoomResultAction, LeaveRoomResultAction}, link_preview::{LinkPreviewData, LinkPreviewDataNonNumeric, LinkPreviewRateLimitResponse}, room_screen::{InviteResultAction, TimelineUpdate}, rooms_list::{self, InvitedRoomInfo, InviterInfo, JoinedRoomInfo, RoomsListUpdate, build_room_search_text, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, tombstone_footer::SuccessorRoomDetails }, login::login_screen::LoginAction, logout::{logout_confirm_modal::LogoutAction, logout_state_machine::{LogoutConfig, is_logout_in_progress, logout_with_state_machine}}, media_cache::{MediaCacheEntry, MediaCacheEntryRef}, persistence::{self, ClientSessionPersisted, load_app_state}, profile::{ user_profile::UserProfile, user_profile_cache::{UserProfileUpdate, enqueue_user_profile_update}, @@ -938,8 +938,14 @@ pub enum RemoteDirectorySearchKind { #[derive(Clone, Debug)] pub enum RemoteDirectorySearchResult { User(UserProfile), - Room(RoomNameId), - Space(RoomNameId), + Room { + room_name_id: RoomNameId, + avatar_uri: Option, + }, + Space { + space_name_id: RoomNameId, + avatar_uri: Option, + }, } /// Submits a request to the worker thread to be executed asynchronously. @@ -1525,10 +1531,16 @@ async fn matrix_worker_task( ); match &kind { RemoteDirectorySearchKind::Spaces => { - RemoteDirectorySearchResult::Space(room_name_id) + RemoteDirectorySearchResult::Space { + space_name_id: room_name_id, + avatar_uri: room.avatar_url, + } } _ => { - RemoteDirectorySearchResult::Room(room_name_id) + RemoteDirectorySearchResult::Room { + room_name_id, + avatar_uri: room.avatar_url, + } } } }) @@ -3552,6 +3564,11 @@ async fn add_new_room( }; rooms_list::enqueue_rooms_list_update(RoomsListUpdate::AddInvitedRoom(InvitedRoomInfo { room_name_id: room_name_id.clone(), + search_text: build_room_search_text( + &room_name_id, + &new_room.room.canonical_alias(), + &new_room.room.alt_aliases(), + ), inviter_info, room_avatar, canonical_alias: new_room.room.canonical_alias(), @@ -3636,6 +3653,11 @@ async fn add_new_room( is_marked_unread: new_room.is_marked_unread, room_avatar, room_name_id: room_name_id.clone(), + search_text: build_room_search_text( + &room_name_id, + &new_room.room.canonical_alias(), + &new_room.room.alt_aliases(), + ), canonical_alias: new_room.room.canonical_alias(), alt_aliases: new_room.room.alt_aliases(), has_been_paginated: false, diff --git a/src/space_service_sync.rs b/src/space_service_sync.rs index c02bbc8a1..a77d0633c 100644 --- a/src/space_service_sync.rs +++ b/src/space_service_sync.rs @@ -10,7 +10,7 @@ use matrix_sdk::{Client, RoomState, media::MediaRequestParameters}; use matrix_sdk_ui::spaces::{SpaceRoom, SpaceRoomList, SpaceService, room_list::SpaceRoomListPaginationState}; use ruma::{OwnedMxcUri, OwnedRoomId, events::room::MediaSource, room::RoomType}; use tokio::{runtime::Handle, sync::mpsc::{UnboundedReceiver, UnboundedSender}, task::JoinHandle}; -use crate::{home::{rooms_list::{RoomsListUpdate, enqueue_rooms_list_update}, spaces_bar::{JoinedSpaceInfo, SpacesListUpdate, enqueue_spaces_list_update}}, room::FetchedRoomAvatar, utils::{self, RoomNameId}}; +use crate::{home::{rooms_list::{RoomsListUpdate, enqueue_rooms_list_update}, spaces_bar::{JoinedSpaceInfo, SpacesListUpdate, build_space_search_text, enqueue_spaces_list_update}}, room::FetchedRoomAvatar, utils::{self, RoomNameId}}; /// Whether to enable verbose logging of all spaces service diff updates. const LOG_SPACE_SERVICE_DIFFS: bool = cfg!(feature = "log_space_service_diffs"); @@ -350,6 +350,14 @@ async fn add_new_space(space: &SpaceRoom, client: &Client) { matrix_sdk::RoomDisplayName::Named(space.display_name.clone()), space.room_id.clone(), ), + search_text: build_space_search_text( + &RoomNameId::new( + matrix_sdk::RoomDisplayName::Named(space.display_name.clone()), + space.room_id.clone(), + ), + &space.canonical_alias, + &space.topic, + ), canonical_alias: space.canonical_alias.clone(), topic: space.topic.clone(), space_avatar, From 0d9df460d156a9df53970abd139167d9a2c78b20 Mon Sep 17 00:00:00 2001 From: alanpoon Date: Wed, 1 Apr 2026 15:34:12 +0800 Subject: [PATCH 47/66] remove unneccessary changes --- Cargo.lock | 162 +++++----- Cargo.toml | 7 +- resources/icon_home.svg | 5 + resources/icons/add_user.svg | 6 +- resources/icons/home.svg | 12 +- resources/icons/import.svg | 6 +- resources/icons/import2.svg | 6 + src/app.rs | 9 +- src/home/home_screen.rs | 129 +------- src/home/link_preview.rs | 2 +- src/home/main_desktop_ui.rs | 8 +- src/home/room_context_menu.rs | 30 +- src/home/room_screen.rs | 10 +- src/location.rs | 8 +- src/login/login_screen.rs | 13 + src/media_cache.rs | 10 +- src/room/reply_preview.rs | 2 +- src/settings/settings_screen.rs | 1 + src/shared/styles.rs | 8 +- src/sliding_sync.rs | 507 ++++++++++++++++++++++++++++--- src/tsp/create_did_modal.rs | 7 +- src/tsp/create_wallet_modal.rs | 2 +- src/tsp/sign_anycast_checkbox.rs | 5 - src/tsp/tsp_settings_screen.rs | 37 +-- src/tsp/wallet_entry/mod.rs | 11 +- 25 files changed, 665 insertions(+), 338 deletions(-) create mode 100644 resources/icon_home.svg create mode 100644 resources/icons/import2.svg diff --git a/Cargo.lock b/Cargo.lock index 89c2a4693..5e87a8380 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,7 +5,7 @@ version = 4 [[package]] name = "ab_glyph_rasterizer" version = "0.1.8" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "accessory" @@ -610,7 +610,7 @@ dependencies = [ [[package]] name = "bitflags" version = "2.10.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "bitmaps" @@ -728,7 +728,7 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytemuck" version = "1.25.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "byteorder" @@ -739,7 +739,7 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "byteorder" version = "1.5.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "bytes" @@ -1936,9 +1936,9 @@ dependencies = [ [[package]] name = "fxhash" version = "0.2.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ - "byteorder 1.5.0 (git+https://github.com/makepad/makepad?branch=dev)", + "byteorder 1.5.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", ] [[package]] @@ -2800,7 +2800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.4", + "windows-targets 0.48.5", ] [[package]] @@ -2953,7 +2953,7 @@ dependencies = [ [[package]] name = "makepad-apple-sys" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-objc-sys", ] @@ -2961,12 +2961,12 @@ dependencies = [ [[package]] name = "makepad-byteorder-lite" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-code-editor" version = "2.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-widgets", ] @@ -2974,7 +2974,7 @@ dependencies = [ [[package]] name = "makepad-derive-wasm-bridge" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-proc-macro", ] @@ -2982,7 +2982,7 @@ dependencies = [ [[package]] name = "makepad-derive-widget" version = "2.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-live-id", "makepad-micro-proc-macro", @@ -2991,7 +2991,7 @@ dependencies = [ [[package]] name = "makepad-draw" version = "2.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "ab_glyph_rasterizer", "fxhash", @@ -3005,15 +3005,15 @@ dependencies = [ "rustybuzz", "sdfer", "serde", - "unicode-bidi 0.3.18 (git+https://github.com/makepad/makepad?branch=dev)", + "unicode-bidi 0.3.18 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "unicode-linebreak", - "unicode-segmentation 1.12.0 (git+https://github.com/makepad/makepad?branch=dev)", + "unicode-segmentation 1.12.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", ] [[package]] name = "makepad-error-log" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-serde", ] @@ -3021,22 +3021,22 @@ dependencies = [ [[package]] name = "makepad-filesystem-watcher" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-futures" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-futures-legacy" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-html" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-live-id", ] @@ -3050,7 +3050,7 @@ checksum = "9775cbec5fa0647500c3e5de7c850280a88335d1d2d770e5aa2332b801ba7064" [[package]] name = "makepad-latex-math" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "ttf-parser", ] @@ -3058,7 +3058,7 @@ dependencies = [ [[package]] name = "makepad-live-id" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-live-id-macros", "serde", @@ -3067,7 +3067,7 @@ dependencies = [ [[package]] name = "makepad-live-id-macros" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-proc-macro", ] @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "makepad-live-reload-core" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-filesystem-watcher", ] @@ -3083,7 +3083,7 @@ dependencies = [ [[package]] name = "makepad-math" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-serde", ] @@ -3091,12 +3091,12 @@ dependencies = [ [[package]] name = "makepad-micro-proc-macro" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-micro-serde" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-live-id", "makepad-micro-serde-derive", @@ -3105,7 +3105,7 @@ dependencies = [ [[package]] name = "makepad-micro-serde-derive" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-proc-macro", ] @@ -3113,7 +3113,7 @@ dependencies = [ [[package]] name = "makepad-network" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-apple-sys", "makepad-error-log", @@ -3127,15 +3127,15 @@ dependencies = [ [[package]] name = "makepad-objc-sys" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-platform" version = "2.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "ash", - "bitflags 2.10.0 (git+https://github.com/makepad/makepad?branch=dev)", + "bitflags 2.10.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "hilog-sys", "makepad-android-state", "makepad-apple-sys", @@ -3155,7 +3155,7 @@ dependencies = [ "napi-derive-ohos", "napi-ohos", "ohos-sys", - "smallvec 1.15.1 (git+https://github.com/makepad/makepad?branch=dev)", + "smallvec 1.15.1 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "wayland-client", "wayland-egl", "wayland-protocols", @@ -3167,12 +3167,12 @@ dependencies = [ [[package]] name = "makepad-regex" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-script" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-error-log", "makepad-html", @@ -3180,13 +3180,13 @@ dependencies = [ "makepad-math", "makepad-regex", "makepad-script-derive", - "smallvec 1.15.1 (git+https://github.com/makepad/makepad?branch=dev)", + "smallvec 1.15.1 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", ] [[package]] name = "makepad-script-derive" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-micro-proc-macro", ] @@ -3194,7 +3194,7 @@ dependencies = [ [[package]] name = "makepad-script-std" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-network", "makepad-script", @@ -3203,14 +3203,14 @@ dependencies = [ [[package]] name = "makepad-shared-bytes" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-studio-protocol" version = "0.1.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/makepad/makepad?branch=dev)", + "bitflags 2.10.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "makepad-error-log", "makepad-live-id", "makepad-micro-serde", @@ -3220,7 +3220,7 @@ dependencies = [ [[package]] name = "makepad-svg" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-html", "makepad-live-id", @@ -3229,7 +3229,7 @@ dependencies = [ [[package]] name = "makepad-wasm-bridge" version = "1.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-derive-wasm-bridge", "makepad-live-id", @@ -3238,7 +3238,7 @@ dependencies = [ [[package]] name = "makepad-webp" version = "0.2.4" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-byteorder-lite", ] @@ -3246,7 +3246,7 @@ dependencies = [ [[package]] name = "makepad-widgets" version = "2.0.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-derive-widget", "makepad-draw", @@ -3255,18 +3255,18 @@ dependencies = [ "pulldown-cmark 0.12.2", "serde", "ttf-parser", - "unicode-segmentation 1.12.0 (git+https://github.com/makepad/makepad?branch=dev)", + "unicode-segmentation 1.12.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", ] [[package]] name = "makepad-zune-core" version = "0.5.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "makepad-zune-inflate" version = "0.2.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "simd-adler32", ] @@ -3274,7 +3274,7 @@ dependencies = [ [[package]] name = "makepad-zune-jpeg" version = "0.5.12" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-zune-core", ] @@ -3282,7 +3282,7 @@ dependencies = [ [[package]] name = "makepad-zune-png" version = "0.5.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "makepad-zune-core", "makepad-zune-inflate", @@ -3678,7 +3678,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memchr" version = "2.7.6" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "mime" @@ -4410,10 +4410,10 @@ dependencies = [ [[package]] name = "pulldown-cmark" version = "0.12.2" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/makepad/makepad?branch=dev)", - "memchr 2.7.6 (git+https://github.com/makepad/makepad?branch=dev)", + "bitflags 2.10.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", + "memchr 2.7.6 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "unicase 2.9.0", ] @@ -5172,12 +5172,12 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustybuzz" version = "0.18.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ - "bitflags 2.10.0 (git+https://github.com/makepad/makepad?branch=dev)", + "bitflags 2.10.0 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "bytemuck", "makepad-error-log", - "smallvec 1.15.1 (git+https://github.com/makepad/makepad?branch=dev)", + "smallvec 1.15.1 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", "ttf-parser", "unicode-bidi-mirroring", "unicode-ccc", @@ -5272,7 +5272,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sdfer" version = "0.2.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "sealed" @@ -5571,7 +5571,7 @@ dependencies = [ [[package]] name = "simd-adler32" version = "0.3.8" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "siphasher" @@ -5597,7 +5597,7 @@ dependencies = [ [[package]] name = "smallvec" version = "1.15.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "socket2" @@ -6363,7 +6363,7 @@ dependencies = [ [[package]] name = "ttf-parser" version = "0.24.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "tungstenite" @@ -6424,7 +6424,7 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicase" version = "2.9.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-bidi" @@ -6435,17 +6435,17 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-bidi" version = "0.3.18" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-bidi-mirroring" version = "0.3.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-ccc" version = "0.3.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-ident" @@ -6456,7 +6456,7 @@ checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-linebreak" version = "0.1.5" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-normalization" @@ -6476,12 +6476,12 @@ checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" [[package]] name = "unicode-properties" version = "0.1.4" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-script" version = "0.5.8" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-segmentation" @@ -6492,7 +6492,7 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-segmentation" version = "1.12.0" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "unicode-width" @@ -6772,7 +6772,7 @@ dependencies = [ [[package]] name = "wayland-backend" version = "0.3.12" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "downcast-rs", "libc", @@ -6784,7 +6784,7 @@ dependencies = [ [[package]] name = "wayland-client" version = "0.31.12" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "libc", @@ -6794,7 +6794,7 @@ dependencies = [ [[package]] name = "wayland-egl" version = "0.32.9" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "wayland-backend", "wayland-sys", @@ -6803,7 +6803,7 @@ dependencies = [ [[package]] name = "wayland-protocols" version = "0.32.10" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "wayland-backend", @@ -6813,7 +6813,7 @@ dependencies = [ [[package]] name = "wayland-sys" version = "0.31.8" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "log", "pkg-config", @@ -6912,7 +6912,7 @@ dependencies = [ [[package]] name = "windows" version = "0.62.2" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-collections 0.3.2", "windows-core 0.62.2", @@ -6931,7 +6931,7 @@ dependencies = [ [[package]] name = "windows-collections" version = "0.3.2" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-core 0.62.2", ] @@ -6964,7 +6964,7 @@ dependencies = [ [[package]] name = "windows-core" version = "0.62.2" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-link 0.2.1", "windows-result 0.4.1", @@ -6985,7 +6985,7 @@ dependencies = [ [[package]] name = "windows-future" version = "0.3.2" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-core 0.62.2", ] @@ -7049,7 +7049,7 @@ checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-link" version = "0.2.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" [[package]] name = "windows-numerics" @@ -7093,7 +7093,7 @@ dependencies = [ [[package]] name = "windows-result" version = "0.4.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-link 0.2.1", ] @@ -7110,7 +7110,7 @@ dependencies = [ [[package]] name = "windows-strings" version = "0.5.1" -source = "git+https://github.com/makepad/makepad?branch=dev#66075ff67f3912fc94eb473ee37042a63cc66d60" +source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "windows-link 0.2.1", ] diff --git a/Cargo.toml b/Cargo.toml index daf7ba9e2..8bd24357a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,11 @@ version = "0.0.1-pre-alpha-4" metadata.makepad-auto-version = "zqpv-Yj-K7WNVK2I8h5Okhho46Q=" [dependencies] -makepad-widgets = { git = "https://github.com/makepad/makepad", branch = "dev", features = ["serde"] } -makepad-code-editor = { git = "https://github.com/makepad/makepad", branch = "dev" } +# makepad-widgets = { git = "https://github.com/makepad/makepad", branch = "dev", features = ["serde"] } +# makepad-code-editor = { git = "https://github.com/makepad/makepad", branch = "dev" } + +makepad-widgets = { git = "https://github.com/kevinaboos/makepad", branch = "stack_nav_improvements", features = ["serde"] } +makepad-code-editor = { git = "https://github.com/kevinaboos/makepad", branch = "stack_nav_improvements" } ## Including this crate automatically configures all `robius-*` crates to work with Makepad. diff --git a/resources/icon_home.svg b/resources/icon_home.svg new file mode 100644 index 000000000..f5edd734b --- /dev/null +++ b/resources/icon_home.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/icons/add_user.svg b/resources/icons/add_user.svg index 640aa9d94..fad47b630 100644 --- a/resources/icons/add_user.svg +++ b/resources/icons/add_user.svg @@ -1,6 +1,4 @@ - - - - + + \ No newline at end of file diff --git a/resources/icons/home.svg b/resources/icons/home.svg index 5b5b85c8d..519a1bf2e 100644 --- a/resources/icons/home.svg +++ b/resources/icons/home.svg @@ -1,4 +1,10 @@ - - - + + + + + + + + \ No newline at end of file diff --git a/resources/icons/import.svg b/resources/icons/import.svg index b07d957e2..b23a1d1e6 100644 --- a/resources/icons/import.svg +++ b/resources/icons/import.svg @@ -1,4 +1,2 @@ - - - - + + \ No newline at end of file diff --git a/resources/icons/import2.svg b/resources/icons/import2.svg new file mode 100644 index 000000000..8eef3aa30 --- /dev/null +++ b/resources/icons/import2.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 0d3559138..2c0707fc4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -323,11 +323,6 @@ impl MatchEvent for App { self.app_state.selected_room = None; // Clear saved dock state so tabs will be closed self.app_state.saved_dock_state_home = Default::default(); - enqueue_popup_notification( - format!("Switching to account {}...", user_id), - PopupKind::Info, - Some(3.0), - ); self.ui.redraw(cx); continue; } @@ -335,8 +330,8 @@ impl MatchEvent for App { log!("Account switch completed to: {}", user_id); enqueue_popup_notification( format!("Switched to account {}", user_id), - PopupKind::Info, - Some(5.0), + PopupKind::Success, + Some(3.0), ); self.ui.redraw(cx); continue; diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index 069d5a35b..c4d34d2aa 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -1,16 +1,6 @@ use makepad_widgets::*; -use crate::{ - app::{AppState, AppStateAction, SelectedRoom}, - home::{ - invite_screen::InviteScreenWidgetExt, - navigation_tab_bar::{NavigationBarAction, SelectedTab}, - room_screen::RoomScreenWidgetExt, - rooms_list::RoomsListAction, - space_lobby::SpaceLobbyScreenWidgetExt, - }, - settings::settings_screen::SettingsScreenWidgetRefExt, -}; +use crate::{app::AppState, home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, settings::settings_screen::SettingsScreenWidgetRefExt}; script_mod! { use mod.prelude.widgets.* @@ -416,10 +406,6 @@ pub struct HomeScreen { /// other widgets can easily access it. #[rust] previous_selection: SelectedTab, #[rust] is_spaces_bar_shown: bool, - - /// A stack of previously-selected rooms for mobile stack navigation. - /// When a view is popped off the stack, the previous `selected_room` is restored. - #[rust] mobile_room_nav_stack: Vec, } impl Widget for HomeScreen { @@ -489,29 +475,6 @@ impl Widget for HomeScreen { Some(NavigationBarAction::TabSelected(_)) | None => { } } - - // Handle mobile stack navigation actions (push/pop room views). - // In Desktop mode, MainDesktopUI also handles RoomsListAction::Selected - // to manage dock tabs; the mobile push is harmless there (views aren't drawn). - match action.as_widget_action().cast() { - RoomsListAction::Selected(selected_room) => { - self.push_selected_room_view(cx, app_state, selected_room); - } - RoomsListAction::InviteAccepted { room_name_id } => { - cx.action(AppStateAction::UpgradedInviteToJoinedRoom( - room_name_id.room_id().clone(), - )); - } - _ => {} - } - - // When a stack navigation pop is initiated (back button pressed), - // pop the mobile nav stack so it stays in sync with StackNavigation. - if let StackNavigationAction::Pop = action.as_widget_action().cast() { - if app_state.selected_room.is_some() { - app_state.selected_room = self.mobile_room_nav_stack.pop(); - } - } } } @@ -548,95 +511,5 @@ impl HomeScreen { }, ) } - - /// Room StackNavigationView instances, one per stack depth. - /// Each depth gets its own dedicated view widget to avoid - /// complex state save/restore when views would otherwise be reused. - const ROOM_VIEW_IDS: [LiveId; 16] = [ - live_id!(room_view_0), live_id!(room_view_1), - live_id!(room_view_2), live_id!(room_view_3), - live_id!(room_view_4), live_id!(room_view_5), - live_id!(room_view_6), live_id!(room_view_7), - live_id!(room_view_8), live_id!(room_view_9), - live_id!(room_view_10), live_id!(room_view_11), - live_id!(room_view_12), live_id!(room_view_13), - live_id!(room_view_14), live_id!(room_view_15), - ]; - - /// The RoomScreen widget IDs inside each room view, - /// corresponding 1:1 with [`Self::ROOM_VIEW_IDS`]. - const ROOM_SCREEN_IDS: [LiveId; 16] = [ - live_id!(room_screen_0), live_id!(room_screen_1), - live_id!(room_screen_2), live_id!(room_screen_3), - live_id!(room_screen_4), live_id!(room_screen_5), - live_id!(room_screen_6), live_id!(room_screen_7), - live_id!(room_screen_8), live_id!(room_screen_9), - live_id!(room_screen_10), live_id!(room_screen_11), - live_id!(room_screen_12), live_id!(room_screen_13), - live_id!(room_screen_14), live_id!(room_screen_15), - ]; - - /// Returns the room view and room screen LiveIds for the given stack depth. - /// Clamps to the last available view if depth exceeds the pool size. - fn room_ids_for_depth(depth: usize) -> (LiveId, LiveId) { - let index = depth.min(Self::ROOM_VIEW_IDS.len() - 1); - (Self::ROOM_VIEW_IDS[index], Self::ROOM_SCREEN_IDS[index]) - } - - /// Pushes the appropriate StackNavigationView for the given `SelectedRoom`, - /// configuring the view's content widget and header title. - /// - /// Each stack depth gets its own dedicated room view widget, - /// supporting deep navigation (room → thread → room → thread → ...). - fn push_selected_room_view( - &mut self, - cx: &mut Cx, - app_state: &mut AppState, - selected_room: SelectedRoom, - ) { - let new_depth = self.view.stack_navigation(cx, ids!(view_stack)).depth(); - - let view_id = match &selected_room { - SelectedRoom::JoinedRoom { room_name_id } - | SelectedRoom::Thread { room_name_id, .. } => { - let (view_id, room_screen_id) = Self::room_ids_for_depth(new_depth); - let thread_root = if let SelectedRoom::Thread { thread_root_event_id, .. } = &selected_room { - Some(thread_root_event_id.clone()) - } else { - None - }; - self.view - .room_screen(cx, &[room_screen_id]) - .set_displayed_room(cx, room_name_id, thread_root); - view_id - } - SelectedRoom::InvitedRoom { room_name_id } => { - self.view - .invite_screen(cx, ids!(invite_screen)) - .set_displayed_invite(cx, room_name_id); - id!(invite_view) - } - SelectedRoom::Space { space_name_id } => { - self.view - .space_lobby_screen(cx, ids!(space_lobby_screen)) - .set_displayed_space(cx, space_name_id); - id!(space_lobby_view) - } - }; - - // Set the header title for the view being pushed. - let title_path = &[view_id, live_id!(header), live_id!(content), live_id!(title_container), live_id!(title)]; - self.view.label(cx, title_path).set_text(cx, &selected_room.display_name()); - - // Save the current selected_room onto the navigation stack before replacing it. - if let Some(prev) = app_state.selected_room.take() { - self.mobile_room_nav_stack.push(prev); - } - app_state.selected_room = Some(selected_room); - - // Push the view onto the mobile navigation stack. - self.view.stack_navigation(cx, ids!(view_stack)).push(cx, view_id); - self.view.redraw(cx); - } } diff --git a/src/home/link_preview.rs b/src/home/link_preview.rs index 10155dd03..1d605dc3d 100644 --- a/src/home/link_preview.rs +++ b/src/home/link_preview.rs @@ -647,7 +647,7 @@ impl LinkPreviewCache { LinkPreviewCacheEntry::Requested } - Entry::Occupied(occupied) => occupied.get().lock().unwrap_or_else(|e| e.into_inner()).entry.clone(), + Entry::Occupied(occupied) => occupied.get().lock().unwrap().entry.clone(), } } diff --git a/src/home/main_desktop_ui.rs b/src/home/main_desktop_ui.rs index 628d477bf..242ba76f0 100644 --- a/src/home/main_desktop_ui.rs +++ b/src/home/main_desktop_ui.rs @@ -3,7 +3,7 @@ use ruma::OwnedRoomId; use tokio::sync::Notify; use std::{collections::HashMap, sync::Arc}; -use crate::{app::{AppState, AppStateAction, SavedDockState, SelectedRoom}, home::{navigation_tab_bar::{NavigationBarAction, SelectedTab}, rooms_list::RoomsListRef, space_lobby::SpaceLobbyScreenWidgetRefExt}, utils::RoomNameId}; +use crate::{app::{AppState, AppStateAction, SavedDockState, SelectedRoom}, home::{navigation_tab_bar::{NavigationBarAction, SelectedTab}, rooms_list::RoomsListRef, space_lobby::SpaceLobbyScreenWidgetRefExt}, sliding_sync::AccountSwitchAction, utils::RoomNameId}; use super::{invite_screen::InviteScreenWidgetRefExt, room_screen::RoomScreenWidgetRefExt, rooms_list::RoomsListAction}; script_mod! { @@ -421,6 +421,12 @@ impl WidgetMatchEvent for MainDesktopUI { continue; } + // When switching accounts, close all room tabs (keeping only the home tab) + if let Some(AccountSwitchAction::Starting(_)) = action.downcast_ref() { + self.close_all_tabs(cx); + continue; + } + // If the currently-selected space has been changed, we must handle that // by switching the dock to show the layout for another space. if let Some(NavigationBarAction::TabSelected(tab)) = action.downcast_ref() { diff --git a/src/home/room_context_menu.rs b/src/home/room_context_menu.rs index 1db67ec77..796a43a86 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -249,13 +249,29 @@ impl WidgetMatchEvent for RoomContextMenu { current_user_id().as_deref(), ) { Ok(bot_user_id) => { - // TODO: implement SetRoomBotBinding request - let _ = (room_id, bot_user_id); - enqueue_popup_notification( - "BotFather binding feature is not yet implemented.", - PopupKind::Warning, - Some(4.0), - ); + if details.is_bot_bound { + submit_async_request(MatrixRequest::SetRoomBotBinding { + room_id, + bound: false, + bot_user_id: bot_user_id.clone(), + }); + enqueue_popup_notification( + format!("Removing BotFather {bot_user_id} from this room..."), + PopupKind::Info, + Some(4.0), + ); + } else { + submit_async_request(MatrixRequest::SetRoomBotBinding { + room_id, + bound: true, + bot_user_id: bot_user_id.clone(), + }); + enqueue_popup_notification( + format!("Inviting BotFather {bot_user_id} into this room..."), + PopupKind::Info, + Some(5.0), + ); + } } Err(error) => { enqueue_popup_notification(error, PopupKind::Error, Some(5.0)); diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index b4a1f5c3a..a74781e9b 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1367,12 +1367,16 @@ impl Widget for RoomScreen { ) { Ok(bot_user_id) => { - // TODO: implement SetRoomBotBinding request + submit_async_request(MatrixRequest::SetRoomBotBinding { + room_id: room_props.room_name_id.room_id().clone(), + bound: false, + bot_user_id: bot_user_id.clone(), + }); enqueue_popup_notification( format!( - "BotFather binding feature is not yet implemented for {bot_user_id}" + "Removing BotFather {bot_user_id} from this room..." ), - PopupKind::Warning, + PopupKind::Info, Some(4.0), ); } diff --git a/src/location.rs b/src/location.rs index 7ab43f100..515d00322 100644 --- a/src/location.rs +++ b/src/location.rs @@ -29,7 +29,7 @@ static LATEST_LOCATION: Mutex> = Mutex::new(None); /// Note that this function is guaranteed to return `None` if /// [`init_location_subscriber`] has not been called yet. pub fn get_latest_location() -> Option { - *(LATEST_LOCATION.lock().unwrap_or_else(|e| e.into_inner())) + *(LATEST_LOCATION.lock().unwrap()) } @@ -46,7 +46,7 @@ impl robius_location::Handler for LocationHandler { time: location.time().ok(), }; Cx::post_action(LocationAction::Update(update)); - *LATEST_LOCATION.lock().unwrap_or_else(|e| e.into_inner()) = Some(update); + *LATEST_LOCATION.lock().unwrap() = Some(update); } Err(e) => { error!("Error getting coordinates from location update: {e:?}"); @@ -98,7 +98,7 @@ static LOCATION_REQUEST_SENDER: Mutex>> = Mutex:: /// Submits a request to start, stop, or get a single new location update(s). pub fn request_location_update(request: LocationRequest) { - if let Some(sender) = LOCATION_REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { + if let Some(sender) = LOCATION_REQUEST_SENDER.lock().unwrap().as_ref() { if let Err(err) = sender.send(request) { error!("Error sending location request: {err:?}"); } @@ -120,7 +120,7 @@ pub fn request_location_update(request: LocationRequest) { /// which isn't used, but acts as a guarantee that this function /// must only be called by the main UI thread. pub fn init_location_subscriber(_cx: &mut Cx) -> Result<(), robius_location::Error> { - let mut lrs = LOCATION_REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()); + let mut lrs = LOCATION_REQUEST_SENDER.lock().unwrap(); if lrs.is_some() { log!("Location subscriber already initialized."); return Ok(()); diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index 5fdedfa1d..29070debd 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -123,6 +123,19 @@ script_mod! { is_password: true, } + confirm_password_wrapper := View { + width: 275, height: Fit, + visible: false, + + confirm_password_input := RobrixTextInput { + width: 275, height: Fit + flow: Right, // do not wrap + padding: 10, + empty_text: "Confirm password" + is_password: true, + } + } + View { width: 275, height: Fit, flow: Down, diff --git a/src/media_cache.rs b/src/media_cache.rs index ce482dbea..f87ae36da 100644 --- a/src/media_cache.rs +++ b/src/media_cache.rs @@ -95,7 +95,7 @@ impl MediaCache { MediaFormat::Thumbnail(ref requested_mts) => { if let Some((entry_ref, existing_mts)) = value.thumbnail.as_ref() { return ( - entry_ref.lock().unwrap_or_else(|e| e.into_inner()).deref().clone(), + entry_ref.lock().unwrap().deref().clone(), MediaFormat::Thumbnail(existing_mts.clone()), ); } else { @@ -104,7 +104,7 @@ impl MediaCache { value.thumbnail = Some((Arc::clone(&entry_ref), requested_mts.clone())); // If a full-size image is already loaded, return it. if let Some(existing_file) = value.full_file.as_ref() { - if let MediaCacheEntry::Loaded(d) = existing_file.lock().unwrap_or_else(|e| e.into_inner()).deref() { + if let MediaCacheEntry::Loaded(d) = existing_file.lock().unwrap().deref() { post_request_retval = ( MediaCacheEntry::Loaded(Arc::clone(d)), MediaFormat::File, @@ -117,7 +117,7 @@ impl MediaCache { MediaFormat::File => { if let Some(entry_ref) = value.full_file.as_ref() { return ( - entry_ref.lock().unwrap_or_else(|e| e.into_inner()).deref().clone(), + entry_ref.lock().unwrap().deref().clone(), MediaFormat::File, ); } else { @@ -126,7 +126,7 @@ impl MediaCache { value.full_file = Some(entry_ref.clone()); // If a thumbnail is already loaded, return it. if let Some((existing_thumbnail, existing_mts)) = value.thumbnail.as_ref() { - if let MediaCacheEntry::Loaded(d) = existing_thumbnail.lock().unwrap_or_else(|e| e.into_inner()).deref() { + if let MediaCacheEntry::Loaded(d) = existing_thumbnail.lock().unwrap().deref() { post_request_retval = ( MediaCacheEntry::Loaded(Arc::clone(d)), MediaFormat::Thumbnail(existing_mts.clone()), @@ -272,7 +272,7 @@ fn insert_into_cache>>( Err(e) => error_to_media_cache_entry(e, &request) }; - *value_ref.lock().unwrap_or_else(|e| e.into_inner()) = new_value; + *value_ref.lock().unwrap() = new_value; if let Some(sender) = update_sender { let _ = sender.send(TimelineUpdate::MediaFetched(request)); diff --git a/src/room/reply_preview.rs b/src/room/reply_preview.rs index 5a53687bb..03ec07948 100644 --- a/src/room/reply_preview.rs +++ b/src/room/reply_preview.rs @@ -107,7 +107,7 @@ script_mod! { padding: 13, spacing: 0, margin: Inset{left: 5, right: 0}, - draw_bg.border_radius: 4.0 + draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_CLOSE) icon_walk: Walk{width: 16, height: 16, margin: 0} } diff --git a/src/settings/settings_screen.rs b/src/settings/settings_screen.rs index b67fe998e..5d24945bc 100644 --- a/src/settings/settings_screen.rs +++ b/src/settings/settings_screen.rs @@ -7,6 +7,7 @@ script_mod! { use mod.prelude.widgets.* use mod.widgets.* + // The main, top-level settings screen widget. mod.widgets.SettingsScreen = #(SettingsScreen::register_widget(vm)) { width: Fill, height: Fill, diff --git a/src/shared/styles.rs b/src/shared/styles.rs index feb778dff..a80fa55e5 100644 --- a/src/shared/styles.rs +++ b/src/shared/styles.rs @@ -7,7 +7,7 @@ script_mod! { mod.widgets.ICON_ADD = crate_resource("self://resources/icons/add.svg") mod.widgets.ICON_ADD_REACTION = crate_resource("self://resources/icons/add_reaction.svg") - mod.widgets.ICON_ADD_USER = crate_resource("self://resources/icons/add_user.svg") + mod.widgets.ICON_ADD_USER = crate_resource("self://resources/icons/add_user.svg") // TODO: FIX mod.widgets.ICON_ADD_WALLET = crate_resource("self://resources/icons/add_wallet.svg") mod.widgets.ICON_FORBIDDEN = crate_resource("self://resources/icons/forbidden.svg") mod.widgets.ICON_CHECKMARK = crate_resource("self://resources/icons/checkmark.svg") @@ -19,7 +19,7 @@ script_mod! { mod.widgets.ICON_COPY = crate_resource("self://resources/icons/copy.svg") mod.widgets.ICON_EDIT = crate_resource("self://resources/icons/edit.svg") mod.widgets.ICON_EXTERNAL_LINK = crate_resource("self://resources/icons/external_link.svg") - mod.widgets.ICON_IMPORT = crate_resource("self://resources/icons/import.svg") + mod.widgets.ICON_IMPORT = crate_resource("self://resources/icons/import.svg") // TODO: FIX mod.widgets.ICON_HIERARCHY = crate_resource("self://resources/icons/hierarchy.svg") mod.widgets.ICON_HOME = crate_resource("self://resources/icons/home.svg") mod.widgets.ICON_HTML_FILE = crate_resource("self://resources/icons/html_file.svg") @@ -187,10 +187,6 @@ script_mod! { mod.widgets.COLOR_IMAGE_VIEWER_META_BACKGROUND = #E8E8E8 - // Ensure all settings buttons have a consistent height - mod.widgets.SETTINGS_BUTTON_HEIGHT = 40 - - // A text input widget styled for Robrix. mod.widgets.RobrixTextInput = TextInput { width: Fill, height: Fit diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index aa24dc767..9de350490 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -9,7 +9,13 @@ use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::{MediaFormat, MediaRequestParameters}, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ - api::{Direction, client::{profile::{AvatarUrl, DisplayName}, receipt::create_receipt::v3::ReceiptType}}, events::{ + api::{Direction, client::{ + account::register::v3::Request as RegistrationRequest, + error::ErrorKind, + profile::{AvatarUrl, DisplayName}, + receipt::create_receipt::v3::ReceiptType, + uiaa::{AuthData, AuthType, Dummy}, + }}, events::{ relation::RelationType, room::{ message::RoomMessageEventContent, power_levels::RoomPowerLevels, MediaSource @@ -85,9 +91,11 @@ impl std::fmt::Debug for Cli { impl From for Cli { fn from(login: LoginByPassword) -> Self { Self { - user_id: login.user_id, + user_id: login.user_id.trim().to_owned(), password: login.password, - homeserver: login.homeserver, + homeserver: login.homeserver + .map(|homeserver| homeserver.trim().to_owned()) + .filter(|homeserver| !homeserver.is_empty()), proxy: None, login_screen: false, verbose: false, @@ -95,6 +103,193 @@ impl From for Cli { } } +impl From for Cli { + fn from(registration: RegisterAccount) -> Self { + Self { + user_id: registration.user_id.trim().to_owned(), + password: registration.password, + homeserver: registration.homeserver + .map(|homeserver| homeserver.trim().to_owned()) + .filter(|homeserver| !homeserver.is_empty()), + proxy: None, + login_screen: false, + verbose: false, + } + } +} + +fn infer_homeserver_from_user_id(user_id: &str) -> Option { + let user_id: OwnedUserId = user_id.trim().try_into().ok()?; + Some(user_id.server_name().to_string()) +} + +async fn finalize_authenticated_client( + client: Client, + client_session: ClientSessionPersisted, + fallback_user_id: &str, + is_add_account: bool, +) -> Result<(Client, Option, bool, ClientSessionPersisted)> { + if client.matrix_auth().logged_in() { + let logged_in_user_id = client.user_id() + .map(ToString::to_string) + .unwrap_or_else(|| fallback_user_id.to_owned()); + log!("Logged in successfully."); + let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + if let Err(e) = persistence::save_session(&client, client_session.clone()).await { + let err_msg = format!("Failed to save session state to storage: {e}"); + error!("{err_msg}"); + enqueue_popup_notification(err_msg, PopupKind::Error, None); + } + Ok((client, None, is_add_account, client_session)) + } else { + let err_msg = format!( + "Authentication succeeded for {fallback_user_id}, but the homeserver did not return a login session." + ); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + bail!(err_msg); + } +} + +fn registration_localpart(user_id: &str) -> Result { + let trimmed = user_id.trim(); + if trimmed.is_empty() { + bail!("Please enter a valid username or Matrix user ID."); + } + + if let Ok(full_user_id) = >::try_from(trimmed) { + return Ok(full_user_id.localpart().to_owned()); + } + + let localpart = trimmed.trim_start_matches('@'); + if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) { + bail!("Please enter a valid username or full Matrix user ID."); + } + + Ok(localpart.to_owned()) +} + +fn registration_request( + username: &str, + password: &str, + session: Option, +) -> RegistrationRequest { + let mut request = RegistrationRequest::new(); + request.username = Some(username.to_owned()); + request.password = Some(password.to_owned()); + request.initial_device_display_name = Some("robrix-un-pw".to_owned()); + request.refresh_token = true; + if let Some(session) = session { + let mut dummy = Dummy::new(); + dummy.session = Some(session); + request.auth = Some(AuthData::Dummy(dummy)); + } + request +} + +fn registration_uiaa_error_message(error: &matrix_sdk::Error) -> String { + if let matrix_sdk::Error::Http(http_error) = error { + match http_error.client_api_error_kind() { + Some(ErrorKind::UserInUse) => { + return "That user ID is already taken. Please choose another one.".to_owned(); + } + Some(ErrorKind::InvalidUsername) => { + return "That user ID is invalid. Use a username like `alice` or a full Matrix ID like `@alice:matrix.org`.".to_owned(); + } + Some(ErrorKind::WeakPassword) => { + return "That password is too weak. Please choose a stronger password.".to_owned(); + } + Some(ErrorKind::Forbidden { .. }) => { + return "This homeserver does not allow open registration.".to_owned(); + } + Some(ErrorKind::LimitExceeded { .. }) => { + return "The homeserver is rate limiting account creation right now. Please try again shortly.".to_owned(); + } + _ => {} + } + } + + format!("Could not create account: {error}") +} + +fn unsupported_registration_flow_message( + flows: &[matrix_sdk::ruma::api::client::uiaa::AuthFlow], +) -> String { + let supports_registration_token = flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::RegistrationToken)) + }); + if supports_registration_token { + return "This homeserver requires a registration token. Robrix does not support token-based registration yet.".to_owned(); + } + + let supports_terms = flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::Terms)) + }); + if supports_terms { + return "This homeserver requires an interactive terms-of-service step. Robrix does not support that registration flow yet.".to_owned(); + } + + "This homeserver requires an unsupported registration flow. Please try another homeserver or register with a different client.".to_owned() +} + +async fn clear_persisted_session(user_id: Option<&UserId>) { + let Some(user_id) = user_id else { + return; + }; + + if let Err(e) = persistence::delete_session(user_id).await { + warning!("Failed to delete persisted session for {user_id}: {e}"); + } + + let latest_user_id = persistence::most_recent_user_id().await; + if latest_user_id.as_deref() == Some(user_id) { + if let Err(e) = persistence::delete_latest_user_id().await { + warning!("Failed to delete latest user id for {user_id}: {e}"); + } + } +} + +enum SessionResetAction { + Reauthenticate { message: String }, +} + +async fn reset_runtime_state_for_relogin() { + let sync_service = { SYNC_SERVICE.lock().unwrap().take() }; + if let Some(sync_service) = sync_service { + sync_service.stop().await; + } + + CLIENT.lock().unwrap().take(); + DEFAULT_SSO_CLIENT.lock().unwrap().take(); + IGNORED_USERS.lock().unwrap().clear(); + ALL_JOINED_ROOMS.lock().unwrap().clear(); + + let on_clear_appstate = Arc::new(Notify::new()); + Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); + + if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()).await.is_err() { + warning!("Timed out waiting for UI-side app state cleanup during re-login reset"); + } +} + +fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { + matches!( + error.client_api_error_kind(), + Some(ErrorKind::UnknownToken { .. } | ErrorKind::MissingToken) + ) +} + +fn is_invalid_batch_token_timeline_error(error: &matrix_sdk_ui::timeline::Error) -> bool { + let error_text = error.to_string().to_ascii_lowercase(); + error_text.contains("invalid batch token") + || error_text.contains("must start with 's' or 't'") +} + /// Build a new client. async fn build_client( @@ -117,7 +312,10 @@ async fn build_client( .collect() }; + let inferred_homeserver = infer_homeserver_from_user_id(&cli.user_id); let homeserver_url = cli.homeserver.as_deref() + .filter(|homeserver| !homeserver.trim().is_empty()) + .or(inferred_homeserver.as_deref()) .unwrap_or("https://matrix-client.matrix.org/"); // .unwrap_or("https://matrix.org/"); @@ -179,9 +377,9 @@ async fn login( LoginRequest::LoginByCli | LoginRequest::LoginByPassword(_) => { let (cli, is_add_account) = if let LoginRequest::LoginByPassword(login_by_password) = login_request { let is_add_account = login_by_password.is_add_account; - (Cli::from(login_by_password), is_add_account) + (&Cli::from(login_by_password), is_add_account) } else { - ((*cli).clone(), false) + (cli, false) }; let (client, client_session) = build_client(&cli, app_data_dir()).await?; Cx::post_action(LoginAction::Status { @@ -205,13 +403,71 @@ async fn login( error!("{err_msg}"); enqueue_popup_notification(err_msg, PopupKind::Error, None); } - Ok((client, None, is_add_account, client_session)) } else { let err_msg = format!("Failed to login as {}: {:?}", cli.user_id, login_result); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); bail!(err_msg); } + finalize_authenticated_client(client, client_session, &cli.user_id, is_add_account).await + } + + LoginRequest::Register(registration) => { + let cli = Cli::from(RegisterAccount { + user_id: registration.user_id.clone(), + password: registration.password.clone(), + homeserver: registration.homeserver.clone(), + }); + let localpart = registration_localpart(®istration.user_id)?; + let (client, client_session) = build_client(&cli, app_data_dir()).await?; + Cx::post_action(LoginAction::Status { + title: "Creating account".into(), + status: format!("Creating account {localpart}..."), + }); + + let auth = client.matrix_auth(); + let initial_request = registration_request(&localpart, ®istration.password, None); + let register_result = match auth.register(initial_request).await { + Ok(response) => Ok(response), + Err(error) => { + if let Some(uiaa_info) = error.as_uiaa_response() { + let supports_dummy = uiaa_info.flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::Dummy)) + }); + if supports_dummy { + Cx::post_action(LoginAction::Status { + title: "Completing sign up".into(), + status: "Confirming registration with the homeserver...".into(), + }); + auth.register(registration_request( + &localpart, + ®istration.password, + uiaa_info.session.clone(), + )) + .await + } else { + bail!(unsupported_registration_flow_message(&uiaa_info.flows)); + } + } else { + bail!(registration_uiaa_error_message(&error)); + } + } + }?; + + if !client.matrix_auth().logged_in() { + let err_msg = format!( + "Account {} was created, but the homeserver did not return a login session. Please log in manually.", + register_result.user_id, + ); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + bail!(err_msg); + } + + finalize_authenticated_client(client, client_session, register_result.user_id.as_str(), false) + .await } LoginRequest::LoginBySSOSuccess(client, client_session, is_add_account) => { @@ -453,6 +709,12 @@ pub enum MatrixRequest { room_id: OwnedRoomId, user_id: OwnedUserId, }, + /// Request to bind or unbind the configured botfather for the given room. + SetRoomBotBinding { + room_id: OwnedRoomId, + bound: bool, + bot_user_id: OwnedUserId, + }, /// Request to join the given room. JoinRoom { room_id: OwnedRoomId, @@ -712,6 +974,7 @@ pub fn submit_async_request(req: MatrixRequest) { /// Details of a login request that get submitted within [`MatrixRequest::Login`]. pub enum LoginRequest{ LoginByPassword(LoginByPassword), + Register(RegisterAccount), LoginBySSOSuccess(Client, ClientSessionPersisted, bool), LoginByCli, HomeserverLoginTypesQuery(String), @@ -726,6 +989,14 @@ pub struct LoginByPassword { pub is_add_account: bool, } +/// Information needed to register a new account on a Matrix homeserver. +#[derive(Clone)] +pub struct RegisterAccount { + pub user_id: String, + pub password: String, + pub homeserver: Option, +} + /// The entry point for the worker task that runs Matrix-related operations. /// @@ -803,13 +1074,8 @@ async fn matrix_worker_task( // Set the target account for switch set_account_switch_target(user_id.clone()); - // Notify UI that switch is starting + // Notify UI that switch is starting (app.rs handles the popup notification) Cx::post_action(AccountSwitchAction::Starting(user_id.clone())); - enqueue_popup_notification( - format!("Switching to {}...", user_id), - PopupKind::Info, - Some(2.0), - ); // Stop the sync service - this will cause the main loop to restart if let Some(sync_service) = get_sync_service() { @@ -854,6 +1120,7 @@ async fn matrix_worker_task( log!("Skipping pagination request for unknown {timeline_kind}"); continue; }; + let client = get_client(); // Spawn a new async task that will make the actual pagination request. let _paginate_task = Handle::current().spawn(async move { @@ -861,12 +1128,45 @@ async fn matrix_worker_task( sender.send(TimelineUpdate::PaginationRunning(direction)).unwrap(); SignalToUI::set_ui_signal(); - let res = if direction == PaginationDirection::Forwards { + let mut res = if direction == PaginationDirection::Forwards { timeline.paginate_forwards(num_events).await } else { timeline.paginate_backwards(num_events).await }; + if direction == PaginationDirection::Backwards + && res + .as_ref() + .err() + .is_some_and(is_invalid_batch_token_timeline_error) + { + warning!( + "Detected an invalid cached batch token for {timeline_kind}; clearing the room event cache and retrying once." + ); + let room_id = timeline_kind.room_id().clone(); + if let Some(room) = client.and_then(|client| client.get_room(&room_id)) { + match room.event_cache().await { + Ok((room_event_cache, _drop_handles)) => { + match room_event_cache.clear().await { + Ok(()) => { + res = timeline.paginate_backwards(num_events).await; + } + Err(clear_error) => { + warning!( + "Failed to clear event cache for room {room_id} after invalid batch token: {clear_error}" + ); + } + } + } + Err(event_cache_error) => { + warning!( + "Failed to access room event cache for room {room_id} after invalid batch token: {event_cache_error}" + ); + } + } + } + } + match res { Ok(fully_paginated) => { log!("Completed {direction} pagination request for {timeline_kind}, hit {} of timeline? {}", @@ -1116,6 +1416,68 @@ async fn matrix_worker_task( }); } + MatrixRequest::SetRoomBotBinding { + room_id, + bound, + bot_user_id, + } => { + let Some(client) = get_client() else { continue }; + let _bot_binding_task = Handle::current().spawn(async move { + let Some(room) = client.get_room(&room_id) else { + let error_message = + format!("Room {room_id} was not found for the bot binding request."); + error!("{error_message}"); + enqueue_popup_notification(error_message, PopupKind::Error, None); + return; + }; + + let membership_result = if bound { + room.invite_user_by_id(&bot_user_id).await + } else { + room.kick_user(&bot_user_id, Some("Robrix app service unbind")).await + }; + + match membership_result { + Ok(()) => { + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: None, + }); + } + Err(error) => { + let membership_exists = + room.get_member_no_sync(&bot_user_id).await.ok().flatten().is_some(); + let should_mark_bound = if bound { membership_exists } else { false }; + + if should_mark_bound != bound { + error!( + "Failed to {} BotFather {bot_user_id} for room {room_id}: {error:?}", + if bound { "invite" } else { "remove" } + ); + enqueue_popup_notification( + format!( + "Failed to {} BotFather {bot_user_id}: {error}", + if bound { "invite" } else { "remove" } + ), + PopupKind::Error, + None, + ); + return; + } + + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: Some(error.to_string()), + }); + } + } + }); + } + MatrixRequest::JoinRoom { room_id } => { let Some(client) = get_client() else { continue }; let _join_room_task = Handle::current().spawn(async move { @@ -1568,15 +1930,18 @@ async fn matrix_worker_task( let _typing_notices_task = Handle::current().spawn(async move { while let Ok(user_ids) = typing_notice_receiver.recv().await { // log!("Received typing notifications for room {room_id}: {user_ids:?}"); - let users = join_all(user_ids.into_iter().map(|user_id| { - let tl = main_timeline.clone(); - async move { - tl.room().get_member_no_sync(&user_id).await - .ok().flatten() + let mut users = Vec::with_capacity(user_ids.len()); + for user_id in user_ids { + users.push( + main_timeline.room() + .get_member_no_sync(&user_id) + .await + .ok() + .flatten() .and_then(|m| m.display_name().map(|d| d.to_owned())) .unwrap_or_else(|| user_id.to_string()) - } - })).await; + ); + } if let Err(e) = timeline_update_sender.send(TimelineUpdate::TypingUsers { users }) { error!("Error: timeline update sender couldn't send the list of typing users: {e:?}"); } @@ -1700,7 +2065,6 @@ async fn matrix_worker_task( MatrixRequest::FetchMedia { media_request, on_fetched, destination, update_sender } => { let Some(client) = get_client() else { continue }; - let _fetch_task = Handle::current().spawn(async move { // log!("Sending fetch media request for {media_request:?}..."); let res = client.media().get_media_content(&media_request, true).await; @@ -2094,6 +2458,7 @@ async fn matrix_worker_task( bail!("matrix_worker_task task ended unexpectedly") } + /// The single global Tokio runtime that is used by all async tasks. static TOKIO_RUNTIME: Mutex> = Mutex::new(None); @@ -2471,11 +2836,17 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { log!("Trying to restore session for user: {:?}", specified_username.as_ref().or(most_recent_user_id.as_ref()) ); - match persistence::restore_session(specified_username).await { - Ok((client, sync_token, session)) => Some((client, sync_token, session)), + match persistence::restore_session(specified_username.clone()).await { + Ok((client, sync_token, session)) => Some((client, sync_token, true, session)), Err(e) => { let status_err = "Could not restore previous user session.\n\nPlease login again."; log!("{status_err} Error: {e:?}"); + clear_persisted_session( + specified_username + .as_deref() + .or(most_recent_user_id.as_deref()), + ) + .await; Cx::post_action(LoginAction::LoginFailure(status_err.to_string())); if let Ok(cli) = &cli_parse_result { @@ -2485,7 +2856,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { homeserver: cli.homeserver.clone(), }); match login(cli, LoginRequest::LoginByCli).await { - Ok((client, sync_token, _is_add_account, session)) => Some((client, sync_token, session)), + Ok((client, sync_token, _is_add_account, session)) => Some((client, sync_token, false, session)), Err(e) => { error!("CLI-based login failed: {e:?}"); Cx::post_action(LoginAction::LoginFailure( @@ -2510,9 +2881,8 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // On subsequent iterations of the login loop (after a post-auth setup failure), it is `None`, // which causes the loop to wait for the user to submit a new manual login request. let mut initial_client_opt = new_login_opt; - let (client, sync_service, logged_in_user_id) = 'login_loop: loop { - let (client, _sync_token, session) = match initial_client_opt.take() { + let (client, _sync_token, validate_session, session) = match initial_client_opt.take() { Some(login) => login, None => { loop { @@ -2520,7 +2890,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { match login_receiver.recv().await { Some(login_request) => { match login(&cli, login_request).await { - Ok((client, sync_token, _is_add_account, session)) => break (client, sync_token, session), + Ok((client, sync_token, _is_add_account, session)) => break (client, sync_token, false, session), Err(e) => { error!("Login failed: {e:?}"); Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); @@ -2544,6 +2914,24 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { } }; + if validate_session { + match client.whoami().await { + Ok(_) => {} + Err(e) if is_invalid_token_http_error(&e) => { + clear_persisted_session(client.user_id()).await; + let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; + Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.to_string(), + }); + continue 'login_loop; + } + Err(e) => { + warning!("Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}"); + } + } + } + // Deallocate the default SSO client after a successful login. if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { let _ = client_opt.take(); @@ -2577,9 +2965,6 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // Listen for updates to the ignored user list. handle_ignore_user_list_subscriber(client.clone()); - // Listen for session changes, e.g., when the access token becomes invalid. - handle_session_changes(client.clone()); - Cx::post_action(LoginAction::Status { title: "Connecting".into(), status: "Setting up sync service...".into(), @@ -2597,6 +2982,9 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { } else { format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") }; + if is_invalid_token_error(&e) { + clear_persisted_session(client.user_id()).await; + } Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); @@ -2610,6 +2998,12 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { break 'login_loop (client, sync_service, logged_in_user_id); }; + let (session_reset_sender, mut session_reset_receiver) = + tokio::sync::mpsc::unbounded_channel::(); + // Listen for session changes, e.g., when the access token becomes invalid. + let session_change_handler_task = + handle_session_changes(client.clone(), session_reset_sender); + // Signal login success now that SyncService::build() has already succeeded (inside // 'login_loop), which is the only step that can fail with an invalid/expired token. // Doing this before sync_service.start() lets the UI transition to the home screen @@ -2634,9 +3028,21 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // Now, this task becomes an infinite loop that monitors the state of the // three core matrix-related background tasks that we just spawned above. #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. - loop { + let reauth_message = loop { tokio::select! { + session_reset = session_reset_receiver.recv() => { + match session_reset { + Some(SessionResetAction::Reauthenticate { message }) => { + break message; + } + None => { + warning!("Session reset receiver closed unexpectedly."); + continue; + } + } + } result = &mut matrix_worker_task_handle => { + session_change_handler_task.abort(); match result { Ok(Ok(())) => { // Check if this is due to logout @@ -2666,9 +3072,10 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { error!("BUG: failed to join matrix worker task: {e:?}"); } } - break; + return; } result = &mut room_list_service_task => { + session_change_handler_task.abort(); match result { Ok(Ok(())) => { error!("BUG: room list service loop task ended unexpectedly!"); @@ -2688,9 +3095,10 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { error!("BUG: failed to join room list service loop task: {e:?}"); } } - break; + return; } result = &mut space_service_task => { + session_change_handler_task.abort(); match result { Ok(Ok(())) => { error!("BUG: space service loop task ended unexpectedly!"); @@ -2710,10 +3118,10 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { error!("BUG: failed to join space service loop task: {e:?}"); } } - break; + return; } } - } + }; // Check if we need to restart for an account switch if let Some(switch_user_id) = get_account_switch_target() { @@ -2779,13 +3187,8 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); let mut space_service_task = rt.spawn(space_service_loop(client.clone())); - // Notify UI that switch is complete + // Notify UI that switch is complete (app.rs handles the popup notification) Cx::post_action(AccountSwitchAction::Switched(switch_user_id.clone())); - enqueue_popup_notification( - format!("Switched to {}", switch_user_id), - PopupKind::Success, - Some(3.0), - ); // Re-enter the main monitoring loop loop { @@ -2836,6 +3239,16 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { } } } + session_change_handler_task.abort(); + room_list_service_task.abort(); + space_service_task.abort(); + + reset_runtime_state_for_relogin().await; + Cx::post_action(LoginAction::LoginFailure(reauth_message.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: reauth_message, + }); + initial_client_opt = None; } @@ -3541,7 +3954,10 @@ fn is_invalid_token_error(e: &sync_service::Error) -> bool { /// When the homeserver rejects the access token with a 401 `M_UNKNOWN_TOKEN` error /// (e.g., the token was revoked or expired), this emits a [`LoginAction::LoginFailure`] /// so the user is prompted to log in again. -fn handle_session_changes(client: Client) { +fn handle_session_changes( + client: Client, + session_reset_sender: UnboundedSender, +) -> JoinHandle<()> { let mut receiver = client.subscribe_to_session_changes(); Handle::current().spawn(async move { loop { @@ -3554,6 +3970,11 @@ fn handle_session_changes(client: Client) { }; error!("Session token is no longer valid (soft_logout: {soft_logout}). Prompting re-login."); Cx::post_action(LoginAction::LoginFailure(msg.to_string())); + clear_persisted_session(client.user_id()).await; + let _ = session_reset_sender.send(SessionResetAction::Reauthenticate { + message: msg.to_string(), + }); + break; } Ok(SessionChange::TokensRefreshed) => {} Err(broadcast::error::RecvError::Lagged(n)) => { @@ -3564,7 +3985,7 @@ fn handle_session_changes(client: Client) { } } } - }); + }) } fn handle_sync_service_state_subscriber(mut subscriber: Subscriber) { diff --git a/src/tsp/create_did_modal.rs b/src/tsp/create_did_modal.rs index 361d62023..f51e8bccb 100644 --- a/src/tsp/create_did_modal.rs +++ b/src/tsp/create_did_modal.rs @@ -86,17 +86,14 @@ script_mod! { width: Fit, height: Fit, did_web := RadioButtonFlat { text: "Web" - draw_text +: { color: (COLOR_TEXT) } animator: { active: { default: on } } } did_webvh := RadioButtonFlat { text: "WebVH" - draw_text +: { color: (COLOR_TEXT) } animator: { disabled: { default: on } } } did_peer := RadioButtonFlat { text: "Peer", - draw_text +: { color: (COLOR_TEXT) } animator: { disabled: { default: on } } } } @@ -108,7 +105,7 @@ script_mod! { server_input := RobrixTextInput { width: Fill, height: Fit, flow: Right, // do not wrap - padding: Inset { left: 10, right: 10, top: 5, bottom: 5 } + padding: Inset{top: 3, bottom: 3} empty_text: "p.teaspoon.world", draw_text +: { text_style: REGULAR_TEXT {font_size: 10.0} @@ -150,7 +147,7 @@ script_mod! { did_server_input := RobrixTextInput { width: Fill, height: Fit, flow: Right, // do not wrap - padding: Inset { left: 10, right: 10, top: 5, bottom: 5 } + padding: Inset{top: 3, bottom: 3} empty_text: "did.teaspoon.world", draw_text +: { text_style: REGULAR_TEXT {font_size: 10.0} diff --git a/src/tsp/create_wallet_modal.rs b/src/tsp/create_wallet_modal.rs index 1e1709b30..79c477597 100644 --- a/src/tsp/create_wallet_modal.rs +++ b/src/tsp/create_wallet_modal.rs @@ -101,7 +101,7 @@ script_mod! { wallet_file_name_input := RobrixTextInput { width: Fill, height: Fit, flow: Right, // do not wrap - padding: Inset { left: 10, right: 10, top: 5, bottom: 5 } + padding: Inset{top: 3, bottom: 3} empty_text: "my_wallet_file", draw_text +: { text_style: REGULAR_TEXT {font_size: 10.0} diff --git a/src/tsp/sign_anycast_checkbox.rs b/src/tsp/sign_anycast_checkbox.rs index 971a4854d..8634c05ee 100644 --- a/src/tsp/sign_anycast_checkbox.rs +++ b/src/tsp/sign_anycast_checkbox.rs @@ -13,10 +13,5 @@ script_mod! { mod.widgets.TspSignAnycastCheckbox = CheckBoxFlat { text: "TSP", active: false, - draw_text +: { - color: COLOR_TEXT, - text_style: theme.font_regular {font_size: 11}, - mark_color_active: COLOR_TEXT, - } } } diff --git a/src/tsp/tsp_settings_screen.rs b/src/tsp/tsp_settings_screen.rs index 879ae3ded..83d0e6f87 100644 --- a/src/tsp/tsp_settings_screen.rs +++ b/src/tsp/tsp_settings_screen.rs @@ -3,12 +3,15 @@ use makepad_widgets::*; use crate::{shared::{popup_list::{enqueue_popup_notification, PopupKind}, styles::*}, tsp::{create_did_modal::CreateDidModalAction, create_wallet_modal::CreateWalletModalAction, submit_tsp_request, tsp_state_ref, TspIdentityAction, TspRequest, TspWalletAction, TspWalletEntry, TspWalletMetadata}}; +const REPUBLISH_IDENTITY_BUTTON_TEXT: &str = "Republish Current Identity to DID Server"; + script_mod! { link tsp_enabled use mod.prelude.widgets.* use mod.widgets.* + mod.widgets.REPUBLISH_IDENTITY_BUTTON_TEXT = "Republish Current Identity to DID Server" // The view containing all TSP-related settings. @@ -40,7 +43,7 @@ script_mod! { current_identity_label := Label { width: Fill, height: Fit flow: Flow.Right{wrap: true}, - margin: Inset{top: 8} + margin: Inset{top: 10} draw_text +: { text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, } @@ -48,13 +51,13 @@ script_mod! { } republish_identity_button := RobrixIconButton { - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, + width: Fit, height: Fit, padding: 10, margin: Inset{top: 8, bottom: 10, left: 5}, + draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_UPLOAD) icon_walk: Walk{width: 16, height: 16} - text: mod.widgets.REPUBLISH_IDENTITY_BUTTON_TEXT + text: (REPUBLISH_IDENTITY_BUTTON_TEXT) } @@ -108,36 +111,36 @@ script_mod! { spacing: 10 create_did_button := RobrixPositiveIconButton { - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, + width: Fit, height: Fit, padding: 10, margin: Inset{left: 5}, + draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_ADD_USER) - icon_walk: Walk{width: 19, height: Fit, margin: 0} + icon_walk: Walk{width: 21, height: Fit, margin: 0} text: "Create New Identity (DID)" } create_wallet_button := RobrixPositiveIconButton { - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, + width: Fit, height: Fit, padding: 10, margin: Inset{left: 5}, + draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_ADD_WALLET) icon_walk: Walk{width: 21, height: Fit, margin: 0} text: "Create New Wallet" } import_wallet_button := RobrixIconButton { - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 5} text: "Import Existing Wallet" - draw_icon +: { - svg: (ICON_IMPORT) - color: (COLOR_PRIMARY) - } - icon_walk: Walk{width: 16, height: 16} + // TODO: fix this icon, or pick a different SVG + // draw_icon +: { + // svg: (ICON_IMPORT) + // color: (COLOR_PRIMARY) + // } + // icon_walk: Walk{width: 16, height: 16} + icon_walk: Walk{width: 0, height: 0} } } } @@ -378,7 +381,7 @@ impl MatchEvent for TspSettingsScreen { // restore the republish button to its original state. script_apply_eval!(cx, republish_identity_button, { enabled: true, - text: mod.widgets.REPUBLISH_IDENTITY_BUTTON_TEXT, + text: #(REPUBLISH_IDENTITY_BUTTON_TEXT), }); match result { Ok(did) => { diff --git a/src/tsp/wallet_entry/mod.rs b/src/tsp/wallet_entry/mod.rs index 68bb2c4c0..2c2de8ab4 100644 --- a/src/tsp/wallet_entry/mod.rs +++ b/src/tsp/wallet_entry/mod.rs @@ -18,13 +18,11 @@ script_mod! { mod.widgets.WalletEntry = #(WalletEntry::register_widget(vm)) { width: Fill, height: Fit flow: Down - align: Align { y: 0.5 } View { width: Fill, height: Fit flow: Flow.Right{wrap: true}, padding: 10 - align: Align { y: 0.5 } wallet_name := Label { width: Fit, height: Fit @@ -52,11 +50,9 @@ script_mod! { visible: false, width: Fit, height: Fit margin: Inset{left: 20} - align: Align { y: 0.5 } Label { + margin: Inset{top: 2.9} width: Fit, height: Fit - margin: Inset{top: 3} - align: Align { y: 0.5 } flow: Right, draw_text +: { color: (COLOR_FG_ACCEPT_GREEN), @@ -70,12 +66,10 @@ script_mod! { visible: false, width: Fit, height: Fit margin: Inset{left: 20} - align: Align { y: 0.5 } Label { margin: Inset{top: 2.9} width: Fit, height: Fit flow: Right, - align: Align { y: 0.5 } draw_text +: { color: (COLOR_FG_DANGER_RED), text_style: MESSAGE_TEXT_STYLE { font_size: 11 }, @@ -85,7 +79,6 @@ script_mod! { } set_default_wallet_button := RobrixIconButton { - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 20} draw_icon.svg: (ICON_CHECKMARK) @@ -94,7 +87,6 @@ script_mod! { } remove_wallet_button := RobrixNegativeIconButton { - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 20} draw_icon.svg: (ICON_CLOSE) @@ -103,7 +95,6 @@ script_mod! { } delete_wallet_button := RobrixNegativeIconButton { - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 20} draw_icon.svg: (ICON_TRASH) From 2ab588b454a0bbaae587260bffa3ed06a1f5ec1f 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: Wed, 1 Apr 2026 17:20:40 +0800 Subject: [PATCH 48/66] feat: improve app-service bot targeting and in-room feedback - propagate target_user_id through RoomInputBar and SendMessage requests\n- route targeted messages/replies via raw payload with org.octos.target_user_id\n- ensure targeted bot is invited into room before targeted send\n- add App Service panel action to view bound bots from bindings plus room members\n- send App Service status/validation feedback as room notice messages instead of popup notifications --- src/app.rs | 18 +-- src/home/room_screen.rs | 242 ++++++++++++++++++++++++++----------- src/room/room_input_bar.rs | 44 ++++++- src/sliding_sync.rs | 152 ++++++++++++++++++++++- 4 files changed, 374 insertions(+), 82 deletions(-) diff --git a/src/app.rs b/src/app.rs index e2e72e28c..dfb8b7665 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,7 @@ use std::{fs::{File, OpenOptions}, io::Write, sync::Mutex}; use std::{cell::RefCell, collections::HashMap}; use makepad_widgets::*; -use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UserId}}; +use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, UserId, events::room::message::RoomMessageEventContent}}; use serde::{Deserialize, Serialize}; use crate::{ avatar_cache::{self, AvatarCacheEntry, clear_avatar_cache}, home::{ @@ -14,7 +14,7 @@ use crate::{ event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, invite_screen::InviteScreenWidgetRefExt, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, RoomScreenWidgetRefExt, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, rooms_list_header::RoomsListHeaderAction, space_lobby::SpaceLobbyScreenWidgetRefExt, spaces_bar::SpacesBarRef }, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt - }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ + }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::{user_profile::UserProfile, user_profile_cache::clear_user_profile_cache}, room::{BasicRoomDetails, FetchedRoomAvatar}, shared::{avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}, room_filter_input_bar::RoomFilterAction}, sliding_sync::{DirectMessageRoomAction, MatrixRequest, RemoteDirectorySearchKind, RemoteDirectorySearchResult, TimelineKind, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, VerificationModalWidgetRefExt, } @@ -972,11 +972,6 @@ impl MatchEvent for App { error!("Failed to persist app state after updating BotFather room binding. Error: {e}"); } } - let kind = if warning.is_some() { - PopupKind::Warning - } else { - PopupKind::Success - }; let message = match (*bound, bot_user_id.as_ref(), warning.as_deref()) { (true, Some(bot_user_id), Some(warning)) => { format!("BotFather {bot_user_id} is available for room {room_id}, but inviting it reported a warning: {warning}") @@ -1003,7 +998,14 @@ impl MatchEvent for App { format!("Bound room {room_id} to BotFather.") } }; - enqueue_popup_notification(message, kind, Some(5.0)); + submit_async_request(MatrixRequest::SendMessage { + timeline_kind: TimelineKind::MainRoom { room_id: room_id.clone() }, + message: RoomMessageEventContent::notice_plain(format!("[App Service] {message}")), + replied_to: None, + target_user_id: None, + #[cfg(feature = "tsp")] + sign_with_tsp: false, + }); self.ui.redraw(cx); continue; } diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 349d23326..b773f8ecd 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -271,6 +271,38 @@ fn detected_bot_binding_for_members( .then_some(bot_user_id) } +fn is_likely_bot_user_id( + user_id: &UserId, + resolved_parent_bot_user_id: Option<&UserId>, +) -> bool { + if resolved_parent_bot_user_id.is_some_and(|parent| parent == user_id) { + return true; + } + + let localpart = user_id.localpart().to_ascii_lowercase(); + localpart == "bot" + || localpart.starts_with("bot_") + || localpart.ends_with("_bot") + || (localpart.ends_with("bot") && localpart.len() > 3) +} + +fn is_likely_bot_member( + room_member: &RoomMember, + resolved_parent_bot_user_id: Option<&UserId>, +) -> bool { + if is_likely_bot_user_id(room_member.user_id(), resolved_parent_bot_user_id) { + return true; + } + + room_member.display_name().is_some_and(|display_name| { + let display_name = display_name.trim().to_ascii_lowercase(); + display_name == "bot" + || display_name.starts_with("bot ") + || display_name.ends_with(" bot") + || display_name.contains(" bot ") + }) +} + script_mod! { use mod.prelude.widgets.* use mod.widgets.* @@ -885,6 +917,15 @@ script_mod! { flow: Right spacing: 8 + view_bound_button := RobrixNeutralIconButton { + width: 156 + height: 46 + padding: 10 + draw_icon.svg: (ICON_SEARCH) + icon_walk: Walk{width: 14, height: 14, margin: Inset{left: -2, right: -1}} + text: "View Bound Bots" + } + unbind_button := RobrixNeutralIconButton { width: 156 height: 46 @@ -1421,16 +1462,24 @@ impl Widget for RoomScreen { let room_props = if let Some(tl) = self.tl_state.as_ref() { let room_id = tl.kind.room_id().clone(); let room_members = tl.room_members.clone(); - let (app_service_enabled, app_service_room_bound) = scope + let (app_service_enabled, app_service_room_bound, bound_bot_user_id) = scope .data .get::() .map(|app_state| { + let app_service_enabled = app_state.bot_settings.enabled; + let app_service_room_bound = self.is_app_service_room_bound(app_state, &room_id); + let bound_bot_user_id = if app_service_enabled && app_service_room_bound { + app_state.bot_settings.bound_bot_user_id(&room_id).map(ToOwned::to_owned) + } else { + None + }; ( - app_state.bot_settings.enabled, - self.is_app_service_room_bound(app_state, &room_id), + app_service_enabled, + app_service_room_bound, + bound_bot_user_id, ) }) - .unwrap_or((false, false)); + .unwrap_or((false, false, None)); RoomScreenProps { room_screen_widget_uid, @@ -1440,6 +1489,7 @@ impl Widget for RoomScreen { room_avatar_url: self.room_avatar_url.clone(), app_service_enabled, app_service_room_bound, + bound_bot_user_id, } } else if let Some(room_name) = &self.room_name_id { // Fallback case: we have a room_name but no tl_state yet @@ -1452,6 +1502,7 @@ impl Widget for RoomScreen { room_avatar_url: None, app_service_enabled: false, app_service_room_bound: false, + bound_bot_user_id: None, } } else { // No room selected yet, skip event handling that requires room context @@ -1469,6 +1520,7 @@ impl Widget for RoomScreen { room_avatar_url: None, app_service_enabled: false, app_service_room_bound: false, + bound_bot_user_id: None, } }; let mut room_scope = Scope::with_props(&room_props); @@ -1499,27 +1551,21 @@ impl Widget for RoomScreen { AppServicePanelAction::OpenCreateBotModal => { if let Some(app_state) = scope.data.get::() { if !app_state.bot_settings.enabled { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Enable App Service before creating bots in a room.", - PopupKind::Warning, - Some(4.0), ); self.set_app_service_actions_visible(cx, false); } else if !room_props.app_service_room_bound { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Bind BotFather to this room before creating a bot.", - PopupKind::Warning, - Some(4.0), ); self.set_app_service_actions_visible(cx, false); } else { self.open_create_bot_modal(cx); } } else { - enqueue_popup_notification( + self.send_app_service_feedback_message( "App state is unavailable, so bot creation is temporarily unavailable.", - PopupKind::Error, - Some(4.0), ); self.set_app_service_actions_visible(cx, false); } @@ -1528,27 +1574,21 @@ impl Widget for RoomScreen { AppServicePanelAction::OpenDeleteBotModal => { if let Some(app_state) = scope.data.get::() { if !app_state.bot_settings.enabled { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Enable App Service before deleting bots in a room.", - PopupKind::Warning, - Some(4.0), ); self.set_app_service_actions_visible(cx, false); } else if !room_props.app_service_room_bound { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Bind BotFather to this room before deleting a bot.", - PopupKind::Warning, - Some(4.0), ); self.set_app_service_actions_visible(cx, false); } else { self.open_delete_bot_modal(cx); } } else { - enqueue_popup_notification( + self.send_app_service_feedback_message( "App state is unavailable, so bot deletion is temporarily unavailable.", - PopupKind::Error, - Some(4.0), ); self.set_app_service_actions_visible(cx, false); } @@ -1576,13 +1616,73 @@ impl Widget for RoomScreen { } return false; } + AppServicePanelAction::ShowBoundBots => { + let room_id = room_props.room_name_id.room_id(); + let own_user_id = current_user_id(); + let mut bound_bots = Vec::::new(); + let mut push_unique_bot = |bot_user_id: OwnedUserId| { + if !bound_bots.iter().any(|existing| existing == &bot_user_id) { + bound_bots.push(bot_user_id); + } + }; + + if let Some(bound_bot_user_id) = room_props.bound_bot_user_id.as_ref() { + push_unique_bot(bound_bot_user_id.clone()); + } + + let mut resolved_parent_bot_user_id: Option = None; + if let Some(app_state) = scope.data.get::() { + for room_binding in &app_state.bot_settings.room_bindings { + if &room_binding.room_id == room_id { + push_unique_bot(room_binding.bot_user_id.clone()); + } + } + + resolved_parent_bot_user_id = app_state + .bot_settings + .resolved_bot_user_id_for_room(room_id, current_user_id().as_deref()) + .ok(); + if let Some(bot_user_id) = resolved_parent_bot_user_id.as_ref() { + push_unique_bot(bot_user_id.clone()); + } + } + + if let Some(room_members) = room_props.room_members.as_ref() { + for room_member in room_members.iter() { + if own_user_id + .as_deref() + .is_some_and(|own_user_id| own_user_id == room_member.user_id()) + { + continue; + } + if is_likely_bot_member( + room_member, + resolved_parent_bot_user_id.as_deref(), + ) { + push_unique_bot(room_member.user_id().to_owned()); + } + } + } + + if bound_bots.is_empty() { + self.send_app_service_feedback_message( + "No bots are currently bound to this room.", + ); + } else { + let mut message = String::from("Bots bound to this room:"); + for bot_user_id in &bound_bots { + message.push('\n'); + message.push_str(bot_user_id.as_str()); + } + self.send_app_service_feedback_message(message); + } + return false; + } AppServicePanelAction::Unbind => { if let Some(app_state) = scope.data.get::() { if !room_props.app_service_room_bound { - enqueue_popup_notification( + self.send_app_service_feedback_message( "This room is not currently bound to BotFather.", - PopupKind::Warning, - Some(4.0), ); } else { match app_state @@ -1598,28 +1698,22 @@ impl Widget for RoomScreen { bound: false, bot_user_id: bot_user_id.clone(), }); - enqueue_popup_notification( + self.send_app_service_feedback_message( format!( "Removing BotFather {bot_user_id} from this room..." ), - PopupKind::Info, - Some(4.0), ); } Err(error) => { - enqueue_popup_notification( + self.send_app_service_feedback_message( error, - PopupKind::Error, - Some(4.0), ); } } } } else { - enqueue_popup_notification( + self.send_app_service_feedback_message( "App state is unavailable, so BotFather could not be removed from this room.", - PopupKind::Error, - Some(4.0), ); } self.set_app_service_actions_visible(cx, false); @@ -1635,10 +1729,8 @@ impl Widget for RoomScreen { } Some(CreateBotModalAction::Submit(request)) => { let Some(app_state) = scope.data.get::() else { - enqueue_popup_notification( + self.send_app_service_feedback_message( "App state is unavailable, so the create-bot command was not sent.", - PopupKind::Error, - Some(4.0), ); self.close_create_bot_modal(cx); return false; @@ -1662,10 +1754,8 @@ impl Widget for RoomScreen { } Some(DeleteBotModalAction::Submit(request)) => { let Some(app_state) = scope.data.get::() else { - enqueue_popup_notification( + self.send_app_service_feedback_message( "App state is unavailable, so the delete-bot command was not sent.", - PopupKind::Error, - Some(4.0), ); self.close_delete_bot_modal(cx); return false; @@ -1682,22 +1772,16 @@ impl Widget for RoomScreen { .cast() { if room_props.timeline_kind.thread_root_event_id().is_some() { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Bot commands are only supported in the main room timeline.", - PopupKind::Warning, - Some(4.0), ); } else if !room_props.app_service_enabled { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Enable App Service in Settings before using /bot.", - PopupKind::Warning, - Some(4.0), ); } else if !room_props.app_service_room_bound { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Bind BotFather to this room before using /bot.", - PopupKind::Warning, - Some(4.0), ); } else { self.toggle_app_service_actions(cx); @@ -2042,6 +2126,21 @@ impl RoomScreen { app_state.bot_settings.is_room_bound(room_id) } + fn send_app_service_feedback_message(&self, message: impl Into) { + let Some(room_id) = self.room_id().cloned() else { + return; + }; + let message = format!("[App Service] {}", message.into()); + submit_async_request(MatrixRequest::SendMessage { + timeline_kind: TimelineKind::MainRoom { room_id }, + message: RoomMessageEventContent::notice_plain(message), + replied_to: None, + target_user_id: None, + #[cfg(feature = "tsp")] + sign_with_tsp: false, + }); + } + fn send_botfather_command( &mut self, cx: &mut Cx, @@ -2053,10 +2152,8 @@ impl RoomScreen { return false; }; if timeline_kind.thread_root_event_id().is_some() { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Bot commands are only supported in the main room timeline.", - PopupKind::Warning, - Some(4.0), ); return false; } @@ -2065,18 +2162,14 @@ impl RoomScreen { return false; }; if !app_state.bot_settings.enabled { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Enable App Service before using BotFather commands in a room.", - PopupKind::Warning, - Some(4.0), ); return false; } if !self.is_app_service_room_bound(app_state, &room_id) { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Bind BotFather to this room before using BotFather commands.", - PopupKind::Warning, - Some(4.0), ); return false; } @@ -2085,11 +2178,15 @@ impl RoomScreen { timeline_kind, message: RoomMessageEventContent::text_plain(command), replied_to: None, + target_user_id: app_state + .bot_settings + .bound_bot_user_id(room_id.as_ref()) + .map(ToOwned::to_owned), #[cfg(feature = "tsp")] sign_with_tsp: false, }); - enqueue_popup_notification(success_message.to_string(), PopupKind::Info, Some(4.0)); + self.send_app_service_feedback_message(success_message.to_string()); self.set_app_service_actions_visible(cx, false); true } @@ -2106,10 +2203,8 @@ impl RoomScreen { return; }; if timeline_kind.thread_root_event_id().is_some() { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Bot creation commands are only supported in the main room timeline.", - PopupKind::Warning, - Some(4.0), ); return; } @@ -2118,18 +2213,14 @@ impl RoomScreen { return; }; if !app_state.bot_settings.enabled { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Enable App Service before creating bots in a room.", - PopupKind::Warning, - Some(4.0), ); return; } if !self.is_app_service_room_bound(app_state, &room_id) { - enqueue_popup_notification( + self.send_app_service_feedback_message( "Bind BotFather to this room before creating a bot.", - PopupKind::Warning, - Some(4.0), ); return; } @@ -2155,7 +2246,7 @@ impl RoomScreen { match resolve_delete_bot_user_id(user_id_or_localpart, current_user_id().as_deref()) { Ok(user_id) => user_id, Err(error) => { - enqueue_popup_notification(error, PopupKind::Error, Some(4.0)); + self.send_app_service_feedback_message(error); return; } }; @@ -3673,6 +3764,7 @@ pub struct RoomScreenProps { pub room_avatar_url: Option, pub app_service_enabled: bool, pub app_service_room_bound: bool, + pub bound_bot_user_id: Option, } @@ -5690,6 +5782,7 @@ pub enum AppServicePanelAction { OpenDeleteBotModal, SendListBots, SendBotHelp, + ShowBoundBots, Unbind, #[default] None, @@ -5772,6 +5865,17 @@ impl Widget for AppServicePanel { ); } + if self + .view + .button(cx, ids!(keyboard.third_row.view_bound_button)) + .clicked(actions) + { + cx.widget_action( + room_screen_props.room_screen_widget_uid, + AppServicePanelAction::ShowBoundBots, + ); + } + if self .view .button(cx, ids!(keyboard.third_row.unbind_button)) diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index bf4563d65..9850752e2 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -19,7 +19,7 @@ use makepad_widgets::*; use matrix_sdk::room::reply::{EnforceThread, Reply}; use matrix_sdk_ui::timeline::{EmbeddedEvent, EventTimelineItem, TimelineEventItemId}; -use ruma::{events::room::message::{LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent}, OwnedRoomId}; +use ruma::{events::room::message::{LocationMessageEventContent, MessageType, ReplyWithinThread, RoomMessageEventContent}, OwnedRoomId, OwnedUserId}; use crate::{home::{editing_pane::{EditingPaneState, EditingPaneWidgetExt, EditingPaneWidgetRefExt}, location_preview::{LocationPreviewWidgetExt, LocationPreviewWidgetRefExt}, room_screen::{MessageAction, RoomScreenProps, populate_preview_of_timeline_item}, tombstone_footer::{SuccessorRoomDetails, TombstoneFooterWidgetExt}}, location::init_location_subscriber, shared::{avatar::AvatarWidgetRefExt, html_or_plaintext::HtmlOrPlaintextWidgetRefExt, mentionable_text_input::MentionableTextInputWidgetExt, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{MatrixRequest, TimelineKind, UserPowerLevels, submit_async_request}, utils}; script_mod! { @@ -169,6 +169,8 @@ pub struct RoomInputBar { #[rust] was_replying_preview_visible: bool, /// Info about the message event that the user is currently replying to, if any. #[rust] replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, + /// The most recently selected explicit bot target for this room. + #[rust] active_target_user_id: Option, } impl Widget for RoomInputBar { @@ -212,6 +214,23 @@ impl Widget for RoomInputBar { } impl RoomInputBar { + fn resolve_target_user_id( + &mut self, + explicit_target_user_id: Option, + reply_target_user_id: Option, + fallback_target_user_id: Option, + ) -> Option { + if let Some(explicit_target_user_id) = explicit_target_user_id { + self.active_target_user_id = Some(explicit_target_user_id.clone()); + Some(explicit_target_user_id) + } else if let Some(reply_target_user_id) = reply_target_user_id { + self.active_target_user_id = Some(reply_target_user_id.clone()); + Some(reply_target_user_id) + } else { + self.active_target_user_id.clone().or(fallback_target_user_id) + } + } + fn handle_actions( &mut self, cx: &mut Cx, @@ -255,6 +274,10 @@ impl RoomInputBar { LocationMessageEventContent::new(geo_uri.clone(), geo_uri) ) ); + let reply_target_user_id = self + .replying_to + .as_ref() + .map(|(event_tl_item, _emb)| event_tl_item.sender().to_owned()); let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| event_tl_item.event_id().map(|event_id| { let enforce_thread = if room_screen_props.timeline_kind.thread_root_event_id().is_some() { @@ -279,6 +302,11 @@ impl RoomInputBar { timeline_kind: room_screen_props.timeline_kind.clone(), message, replied_to, + target_user_id: self.resolve_target_user_id( + None, + reply_target_user_id, + room_screen_props.bound_bot_user_id.clone(), + ), #[cfg(feature = "tsp")] sign_with_tsp: self.is_tsp_signing_enabled(cx), }); @@ -306,6 +334,10 @@ impl RoomInputBar { self.redraw(cx); return; } + let reply_target_user_id = self + .replying_to + .as_ref() + .map(|(event_tl_item, _emb)| event_tl_item.sender().to_owned()); let message = mentionable_text_input.create_message_with_mentions(&entered_text); let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| event_tl_item.event_id().map(|event_id| { @@ -331,6 +363,11 @@ impl RoomInputBar { timeline_kind: room_screen_props.timeline_kind.clone(), message, replied_to, + target_user_id: self.resolve_target_user_id( + None, + reply_target_user_id, + room_screen_props.bound_bot_user_id.clone(), + ), #[cfg(feature = "tsp")] sign_with_tsp: self.is_tsp_signing_enabled(cx), }); @@ -664,6 +701,7 @@ impl RoomInputBarRef { RoomInputBarState { was_replying_preview_visible: inner.was_replying_preview_visible, replying_to: inner.replying_to.clone(), + active_target_user_id: inner.active_target_user_id.clone(), editing_pane_state: inner.child_by_path(ids!(editing_pane)).as_editing_pane().save_state(), text_input_state: inner.child_by_path(ids!(input_bar.mentionable_text_input.text_input)).as_text_input().save_state(), } @@ -683,6 +721,7 @@ impl RoomInputBarRef { was_replying_preview_visible, text_input_state, replying_to, + active_target_user_id, editing_pane_state, } = saved_state; @@ -704,6 +743,7 @@ impl RoomInputBarRef { inner.clear_replying_to(cx); } inner.was_replying_preview_visible = was_replying_preview_visible; + inner.active_target_user_id = active_target_user_id; // 3. Restore the state of the editing pane. if let Some(editing_pane_state) = editing_pane_state { @@ -732,6 +772,8 @@ pub struct RoomInputBarState { text_input_state: TextInputState, /// The event that the user is currently replying to, if any. replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, + /// The most recently selected explicit bot target for this room. + active_target_user_id: Option, /// The state of the `EditingPane`, if any message was being edited. editing_pane_state: Option, } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 29649c3fb..604c2e2d0 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -839,6 +839,7 @@ pub enum MatrixRequest { timeline_kind: TimelineKind, message: RoomMessageEventContent, replied_to: Option, + target_user_id: Option, #[cfg(feature = "tsp")] sign_with_tsp: bool, }, @@ -939,6 +940,75 @@ pub enum MatrixRequest { }, } +fn add_octos_target_user_id( + mut content: serde_json::Value, + target_user_id: &UserId, +) -> serde_json::Value { + if let Some(content_obj) = content.as_object_mut() { + content_obj.insert( + "org.octos.target_user_id".to_string(), + serde_json::Value::String(target_user_id.to_string()), + ); + } + content +} + +async fn ensure_target_user_joined_room( + room: &Room, + target_user_id: &UserId, +) -> Result<()> { + let already_present = room + .get_member_no_sync(target_user_id) + .await + .ok() + .flatten() + .is_some(); + if already_present { + return Ok(()); + } + + room.invite_user_by_id(target_user_id).await?; + + for _attempt in 0..20 { + let joined = room + .get_member_no_sync(target_user_id) + .await + .ok() + .flatten() + .is_some(); + if joined { + return Ok(()); + } + + tokio::time::sleep(Duration::from_millis(250)).await; + } + + Ok(()) +} + +#[cfg(test)] +mod matrix_request_tests { + use super::*; + + #[test] + fn should_add_octos_target_user_id_to_message_content() { + let target_user_id = OwnedUserId::try_from("@bot_weather:example.com").unwrap(); + let content = serde_json::json!({ + "msgtype": "m.text", + "body": "hello", + }); + + let content = add_octos_target_user_id(content, target_user_id.as_ref()); + + assert_eq!( + content + .get("org.octos.target_user_id") + .and_then(|value| value.as_str()), + Some("@bot_weather:example.com") + ); + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum RemoteDirectorySearchKind { People, @@ -2152,6 +2222,7 @@ async fn matrix_worker_task( timeline_kind, message, replied_to, + target_user_id, #[cfg(feature = "tsp")] sign_with_tsp, } => { @@ -2225,11 +2296,84 @@ async fn matrix_worker_task( return; } }; - match timeline.send(reply_content.into()).await { - Ok(_send_handle) => log!("Sent reply message to {timeline_kind}."), + + if let Some(target_user_id) = target_user_id.as_ref() { + if let Err(_e) = ensure_target_user_joined_room( + timeline.room(), + target_user_id.as_ref(), + ) + .await + { + error!("Failed to ensure targeted bot {target_user_id} joined {timeline_kind}: {_e:?}"); + enqueue_popup_notification( + format!("Failed to invite {target_user_id} into this room: {_e}"), + PopupKind::Error, + None, + ); + return; + } + + let raw_content = match serde_json::to_value(&reply_content) { + Ok(content) => add_octos_target_user_id(content, target_user_id.as_ref()), + Err(_e) => { + error!("Failed to serialize reply content for {timeline_kind}: {_e:?}"); + enqueue_popup_notification( + format!("Failed to send reply: {_e}"), + PopupKind::Error, + None, + ); + return; + } + }; + match timeline.room().send_raw("m.room.message", raw_content).await { + Ok(_response) => log!("Sent targeted reply message to {timeline_kind}."), + Err(_e) => { + error!("Failed to send targeted reply message to {timeline_kind}: {_e:?}"); + enqueue_popup_notification(format!("Failed to send reply: {_e}"), PopupKind::Error, None); + } + } + } else { + match timeline.send(reply_content.into()).await { + Ok(_send_handle) => log!("Sent reply message to {timeline_kind}."), + Err(_e) => { + error!("Failed to send reply message to {timeline_kind}: {_e:?}"); + enqueue_popup_notification(format!("Failed to send reply: {_e}"), PopupKind::Error, None); + } + } + } + } else if let Some(target_user_id) = target_user_id.as_ref() { + if let Err(_e) = ensure_target_user_joined_room( + timeline.room(), + target_user_id.as_ref(), + ) + .await + { + error!("Failed to ensure targeted bot {target_user_id} joined {timeline_kind}: {_e:?}"); + enqueue_popup_notification( + format!("Failed to invite {target_user_id} into this room: {_e}"), + PopupKind::Error, + None, + ); + return; + } + + let raw_content = match serde_json::to_value(&message) { + Ok(content) => add_octos_target_user_id(content, target_user_id.as_ref()), Err(_e) => { - error!("Failed to send reply message to {timeline_kind}: {_e:?}"); - enqueue_popup_notification(format!("Failed to send reply: {_e}"), PopupKind::Error, None); + error!("Failed to serialize message content for {timeline_kind}: {_e:?}"); + enqueue_popup_notification( + format!("Failed to send message: {_e}"), + PopupKind::Error, + None, + ); + return; + } + }; + match timeline.room().send_raw("m.room.message", raw_content).await { + Ok(_response) => log!("Sent targeted message to {timeline_kind}."), + Err(_e) => { + error!("Failed to send targeted message to {timeline_kind}: {_e:?}"); + enqueue_popup_notification(format!("Failed to send message: {_e}"), PopupKind::Error, None); } } } else { From 73a27561e92fa4774d6e1cdcf2cf839ba8f2b5e0 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: Wed, 1 Apr 2026 17:54:04 +0800 Subject: [PATCH 49/66] fix(search): improve people lookup and fallback empty local filters --- src/home/rooms_list.rs | 14 +++++++++++++- src/home/spaces_bar.rs | 5 +++++ src/sliding_sync.rs | 44 ++++++++++++++++++++++++++++++++---------- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 83e706223..444e0bd31 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -1013,7 +1013,19 @@ impl RoomsList { /// If `false`, the scroll position is preserved, unless it exceeds the new list length, /// in which case the logic in `draw_walk()` will limit it to the max valid index. fn update_displayed_rooms(&mut self, cx: &mut Cx, reset_scroll: bool) { - let (invited, regular, direct) = self.generate_displayed_rooms(); + let (mut invited, mut regular, mut direct) = self.generate_displayed_rooms(); + if self.display_filter.is_some() + && invited.is_empty() + && regular.is_empty() + && direct.is_empty() + { + self.display_filter = RoomDisplayFilter::default(); + self.sort_fn = None; + let (fallback_invited, fallback_regular, fallback_direct) = self.generate_displayed_rooms(); + invited = fallback_invited; + regular = fallback_regular; + direct = fallback_direct; + } self.displayed_invited_rooms = invited; self.displayed_regular_rooms = regular; self.displayed_direct_rooms = direct; diff --git a/src/home/spaces_bar.rs b/src/home/spaces_bar.rs index 75b03765d..b242ebf81 100644 --- a/src/home/spaces_bar.rs +++ b/src/home/spaces_bar.rs @@ -846,6 +846,11 @@ impl SpacesBar { } else { filtered_spaces_iter.map(|(space_id, _)| space_id.clone()).collect() }; + if self.displayed_spaces.is_empty() { + self.is_filtered = false; + self.display_filter = RoomDisplayFilter::default(); + self.displayed_spaces = self.all_joined_spaces.keys().cloned().collect(); + } portal_list.set_first_id_and_scroll(0, 0.0); self.redraw(cx); diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 29649c3fb..a1f880686 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1493,6 +1493,7 @@ async fn matrix_worker_task( let _search_task = Handle::current().spawn(async move { let query = query.trim().to_owned(); let action_kind = kind.clone(); + log!("Remote directory search request: kind={kind:?}, query=\"{query}\", limit={limit}"); if query.is_empty() { Cx::post_action(RoomFilterRemoteSearchAction::Results { query, @@ -1504,19 +1505,42 @@ async fn matrix_worker_task( let result = match &kind { RemoteDirectorySearchKind::People => { - client.search_users(&query, limit).await - .map(|response| { - response.results.into_iter() - .map(|user| { - RemoteDirectorySearchResult::User(UserProfile { + let mut users = Vec::new(); + let mut seen_user_ids = HashSet::new(); + + if let Ok(user_id) = UserId::parse(&query).map(|u| u.to_owned()) { + if let Ok(response) = client.account().fetch_user_profile_of(&user_id).await { + if seen_user_ids.insert(user_id.clone()) { + users.push(RemoteDirectorySearchResult::User(UserProfile { + username: response.get_static::().ok().flatten(), + user_id, + avatar_state: response.get_static::() + .ok() + .map_or(AvatarState::Unknown, AvatarState::Known), + })); + } + } + } + + match client.search_users(&query, limit).await { + Ok(response) => { + for user in response.results.into_iter() { + if seen_user_ids.insert(user.user_id.clone()) { + users.push(RemoteDirectorySearchResult::User(UserProfile { username: user.display_name, user_id: user.user_id, avatar_state: AvatarState::Known(user.avatar_url), - }) - }) - .collect::>() - }) - .map_err(|e| e.to_string()) + })); + } + if users.len() >= limit as usize { + break; + } + } + Ok(users) + } + Err(_e) if !users.is_empty() => Ok(users), + Err(e) => Err(e.to_string()), + } } RemoteDirectorySearchKind::Rooms | RemoteDirectorySearchKind::Spaces => { let mut filter = PublicRoomsFilter::new(); From 3024cb114ea7253a8a1d082ba5b8174b9b80c6b7 Mon Sep 17 00:00:00 2001 From: alanpoon Date: Wed, 1 Apr 2026 20:27:24 +0800 Subject: [PATCH 50/66] Fix navigation issue when loginAction::CancelAddAccount --- src/app.rs | 12 +++ src/login/login_screen.rs | 19 +++- src/settings/account_settings.rs | 2 +- src/sliding_sync.rs | 170 ++++++++++++++++++++----------- 4 files changed, 143 insertions(+), 60 deletions(-) diff --git a/src/app.rs b/src/app.rs index 2c0707fc4..5c1ca4148 100644 --- a/src/app.rs +++ b/src/app.rs @@ -314,6 +314,15 @@ impl MatchEvent for App { continue; } + // Handle cancellation of adding a new account - go back to previous screen + if let Some(LoginAction::CancelAddAccount) = action.downcast_ref() { + log!("Received LoginAction::CancelAddAccount, hiding login view."); + self.app_state.adding_account = false; + self.ui.view(cx, ids!(login_screen_view)).set_visible(cx, false); + self.ui.redraw(cx); + continue; + } + // Handle account switch actions match action.downcast_ref() { Some(AccountSwitchAction::Starting(user_id)) => { @@ -323,6 +332,9 @@ impl MatchEvent for App { self.app_state.selected_room = None; // Clear saved dock state so tabs will be closed self.app_state.saved_dock_state_home = Default::default(); + // Reset navigation to Home tab + self.app_state.selected_tab = SelectedTab::Home; + cx.action(NavigationBarAction::TabSelected(SelectedTab::Home)); self.ui.redraw(cx); continue; } diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index 29070debd..097d2bb18 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -3,7 +3,7 @@ use std::ops::Not; use makepad_widgets::*; use url::Url; -use crate::sliding_sync::{submit_async_request, LoginByPassword, LoginRequest, MatrixRequest}; +use crate::sliding_sync::{submit_async_request, AccountSwitchAction, LoginByPassword, LoginRequest, MatrixRequest}; use super::login_status_modal::{LoginStatusModalAction, LoginStatusModalWidgetExt}; @@ -482,6 +482,23 @@ impl MatchEvent for LoginScreen { } _ => { } } + + // Handle account switch actions - close modal when switch completes or fails + match action.downcast_ref() { + Some(AccountSwitchAction::Switched(_)) => { + login_status_modal.close(cx); + self.redraw(cx); + } + Some(AccountSwitchAction::Failed(error)) => { + login_status_modal_inner.set_title(cx, "Account Switch Failed"); + login_status_modal_inner.set_status(cx, error); + let login_status_modal_button = login_status_modal_inner.button_ref(cx); + login_status_modal_button.set_text(cx, "Okay"); + login_status_modal_button.set_enabled(cx, true); + self.redraw(cx); + } + _ => { } + } } // If the Login SSO screen's "cancel" button was clicked, send a http request to gracefully shutdown the SSO server diff --git a/src/settings/account_settings.rs b/src/settings/account_settings.rs index fc48794eb..f003808d5 100644 --- a/src/settings/account_settings.rs +++ b/src/settings/account_settings.rs @@ -312,7 +312,7 @@ script_mod! { manage_account_button := RobrixIconButton { height: mod.widgets.SETTINGS_BUTTON_HEIGHT, - padding: Inset{left: 12, right: 15} + padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 5} draw_icon.svg: (ICON_EXTERNAL_LINK) icon_walk: Walk{width: 16, height: 16} diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 9de350490..395e73338 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -1067,8 +1067,6 @@ async fn matrix_worker_task( } MatrixRequest::SwitchAccount { user_id } => { - log!("Received MatrixRequest::SwitchAccount for {}", user_id); - // Check if the account exists in AccountManager if account_manager::get_client_for_user(&user_id).is_some() { // Set the target account for switch @@ -1079,7 +1077,6 @@ async fn matrix_worker_task( // Stop the sync service - this will cause the main loop to restart if let Some(sync_service) = get_sync_service() { - log!("Stopping sync service for account switch"); sync_service.stop().await; } @@ -2528,7 +2525,6 @@ pub fn start_matrix_tokio() -> Result { Ok(rt_handle) } - /// A tokio::watch channel sender for sending requests from the RoomScreen UI widget /// to the corresponding background async task for that room (its `timeline_subscriber_handler`). pub type TimelineRequestSender = watch::Sender>; @@ -2655,8 +2651,13 @@ static SYNC_SERVICE: Mutex>> = Mutex::new(None); /// Contains the user_id to switch to, if any. static ACCOUNT_SWITCH_TARGET: Mutex> = Mutex::new(None); -/// Check if an account switch is pending. -fn get_account_switch_target() -> Option { +/// Check if an account switch is pending (non-consuming peek). +fn is_account_switch_pending() -> bool { + ACCOUNT_SWITCH_TARGET.lock().ok().map(|g| g.is_some()).unwrap_or(false) +} + +/// Take the account switch target, consuming it. Only call when ready to perform the switch. +fn take_account_switch_target() -> Option { ACCOUNT_SWITCH_TARGET.lock().ok()?.take() } @@ -2667,6 +2668,14 @@ fn set_account_switch_target(user_id: OwnedUserId) { } } +/// Clear the account switch target without taking it. +#[allow(dead_code)] +fn clear_account_switch_target() { + if let Ok(mut guard) = ACCOUNT_SWITCH_TARGET.lock() { + *guard = None; + } +} + /// Get a reference to the current sync service, if available. pub fn get_sync_service() -> Option> { @@ -3028,12 +3037,12 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // Now, this task becomes an infinite loop that monitors the state of the // three core matrix-related background tasks that we just spawned above. #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. - let reauth_message = loop { + let reauth_message: Option = loop { tokio::select! { session_reset = session_reset_receiver.recv() => { match session_reset { Some(SessionResetAction::Reauthenticate { message }) => { - break message; + break Some(message); } None => { warning!("Session reset receiver closed unexpectedly."); @@ -3045,17 +3054,21 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { session_change_handler_task.abort(); match result { Ok(Ok(())) => { - // Check if this is due to logout + // Check if this is due to logout or account switch if is_logout_in_progress() { log!("matrix worker task ended due to logout"); + } else if is_account_switch_pending() { + log!("matrix worker task ended due to account switch"); } else { error!("BUG: matrix worker task ended unexpectedly!"); } } Ok(Err(e)) => { - // Check if this is due to logout + // Check if this is due to logout or account switch if is_logout_in_progress() { log!("matrix worker task ended with error due to logout: {e:?}"); + } else if is_account_switch_pending() { + log!("matrix worker task ended with error due to account switch: {e:?}"); } else { error!("Error: matrix worker task ended:\n\t{e:?}"); rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { @@ -3072,61 +3085,71 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { error!("BUG: failed to join matrix worker task: {e:?}"); } } - return; + break None; } result = &mut room_list_service_task => { session_change_handler_task.abort(); match result { Ok(Ok(())) => { - error!("BUG: room list service loop task ended unexpectedly!"); + if is_logout_in_progress() || is_account_switch_pending() { + log!("room list service loop task ended due to logout/account switch"); + } else { + error!("BUG: room list service loop task ended unexpectedly!"); + } } Ok(Err(e)) => { - error!("Error: room list service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Room list service error: {e}"), - PopupKind::Error, - None, - ); + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Error: room list service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Room list service error: {e}"), + PopupKind::Error, + None, + ); + } }, Err(e) => { error!("BUG: failed to join room list service loop task: {e:?}"); } } - return; + break None; } result = &mut space_service_task => { session_change_handler_task.abort(); match result { Ok(Ok(())) => { - error!("BUG: space service loop task ended unexpectedly!"); + if is_logout_in_progress() || is_account_switch_pending() { + log!("space service loop task ended due to logout/account switch"); + } else { + error!("BUG: space service loop task ended unexpectedly!"); + } } Ok(Err(e)) => { - error!("Error: space service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Space service error: {e}"), - PopupKind::Error, - None, - ); + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Error: space service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Space service error: {e}"), + PopupKind::Error, + None, + ); + } }, Err(e) => { error!("BUG: failed to join space service loop task: {e:?}"); } } - return; + break None; } } }; - // Check if we need to restart for an account switch - if let Some(switch_user_id) = get_account_switch_target() { - log!("Account switch detected, restarting with user: {}", switch_user_id); - + // Check if we need to restart for an account switch (loop to handle consecutive switches) + while let Some(switch_user_id) = take_account_switch_target() { // Clear all backend state CLIENT.lock().unwrap_or_else(|e| e.into_inner()).take(); SYNC_SERVICE.lock().unwrap_or_else(|e| e.into_inner()).take(); @@ -3146,8 +3169,6 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // Restore session for the switched account match persistence::restore_session(Some(switch_user_id.clone())).await { Ok((client, _sync_token, _session)) => { - log!("Successfully restored session for {}", switch_user_id); - // Store the client CLIENT.lock().unwrap_or_else(|e| e.into_inner()).replace(client.clone()); @@ -3163,7 +3184,7 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { { Ok(ss) => ss, Err(e) => { - error!("Failed to create SyncService after account switch: {e:?}"); + error!("Failed to create SyncService: {e:?}"); Cx::post_action(AccountSwitchAction::Failed(format!("Failed to create sync service: {e}"))); return; } @@ -3183,6 +3204,12 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()).replace(sender); let (login_sender, _login_receiver) = tokio::sync::mpsc::channel(1); + // Set up session change handler for the switched account + let (session_reset_sender, mut session_reset_receiver) = + tokio::sync::mpsc::unbounded_channel::(); + let session_change_handler_task = + handle_session_changes(client.clone(), session_reset_sender); + let mut matrix_worker_task_handle = rt.spawn(matrix_worker_task(receiver, login_sender)); let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); let mut space_service_task = rt.spawn(space_service_loop(client.clone())); @@ -3193,19 +3220,34 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // Re-enter the main monitoring loop loop { tokio::select! { + session_reset = session_reset_receiver.recv() => { + match session_reset { + Some(SessionResetAction::Reauthenticate { message }) => { + error!("Session reset during account switch: {}", message); + session_change_handler_task.abort(); + room_list_service_task.abort(); + space_service_task.abort(); + Cx::post_action(AccountSwitchAction::Failed(message)); + break; + } + None => { + warning!("Session reset receiver closed unexpectedly."); + continue; + } + } + } result = &mut matrix_worker_task_handle => { + session_change_handler_task.abort(); match result { Ok(Ok(())) => { - if is_logout_in_progress() { - log!("matrix worker task ended due to logout"); - } else if get_account_switch_target().is_some() { - // Another account switch requested, will handle after loop - } else { + if !is_logout_in_progress() && !is_account_switch_pending() { error!("BUG: matrix worker task ended unexpectedly!"); } } Ok(Err(e)) => { - error!("Error: matrix worker task ended:\n\t{e:?}"); + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Error: matrix worker task ended:\n\t{e:?}"); + } } Err(e) => { error!("BUG: failed to join matrix worker task: {e:?}"); @@ -3214,19 +3256,26 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { break; } result = &mut room_list_service_task => { + session_change_handler_task.abort(); if let Err(e) = result { - error!("room list service task error: {e:?}"); + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Room list service task error: {e:?}"); + } } break; } result = &mut space_service_task => { + session_change_handler_task.abort(); if let Err(e) = result { - error!("space service task error: {e:?}"); + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Space service task error: {e:?}"); + } } break; } } } + // After inner loop breaks, outer while loop will check for another pending account switch } Err(e) => { error!("Failed to restore session for account switch: {e:?}"); @@ -3236,19 +3285,24 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { PopupKind::Error, None, ); + // Don't loop back - a failed switch shouldn't keep trying + break; } } } - session_change_handler_task.abort(); - room_list_service_task.abort(); - space_service_task.abort(); - - reset_runtime_state_for_relogin().await; - Cx::post_action(LoginAction::LoginFailure(reauth_message.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: reauth_message, - }); - initial_client_opt = None; + + // Only run reauth cleanup if we got a reauth message (not account switch or logout) + if let Some(reauth_msg) = reauth_message { + session_change_handler_task.abort(); + room_list_service_task.abort(); + space_service_task.abort(); + + reset_runtime_state_for_relogin().await; + Cx::post_action(LoginAction::LoginFailure(reauth_msg.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: reauth_msg, + }); + } } From 75e37f9e308071bbafc10fa1f20a22ed3405d8c4 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: Wed, 1 Apr 2026 21:31:25 +0800 Subject: [PATCH 51/66] Refine room input bar quick actions and emoji picker - Move location trigger into expandable quick action card - Show send button only when input has content - Add persistent more-actions button and themed styling - Add emoji picker button with preset emojis and inline insertion --- src/room/room_input_bar.rs | 235 ++++++++++++++++++++++++++++++------- 1 file changed, 194 insertions(+), 41 deletions(-) diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index bf4563d65..345b13a54 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -5,7 +5,7 @@ //! The widgets included in the RoomInputBar are: //! * a preview of the message the user is replying to. //! * the location preview (which allows you to send your current location to the room), -//! and a button to show the location preview. +//! and a location card to show the location preview. //! * If TSP is enabled, a checkbox to enable TSP signing for the outgoing message. //! * A MentionableTextInput, which allows the user to type a message //! and mention other users via the `@` key. @@ -28,6 +28,28 @@ script_mod! { mod.widgets.ICO_LOCATION_PERSON = crate_resource("self://resources/icons/location-person.svg") + mod.widgets.ICO_MENU = crate_resource("self://resources/icons/menu.svg") + + mod.widgets.RoomEmojiButton = mod.widgets.RobrixIconButton { + spacing: 0 + text: "" + margin: 0 + padding: Inset{left: 8, right: 8, top: 6, bottom: 6} + icon_walk: Walk{width: 0, height: 0} + draw_text +: { + color: (COLOR_TEXT) + color_hover: (COLOR_TEXT) + color_down: (COLOR_TEXT) + text_style: MESSAGE_TEXT_STYLE { font_size: 15.0 } + } + draw_bg +: { + color: (COLOR_PRIMARY) + color_hover: #F4F7FC + color_down: #E8EEF8 + border_size: 1.0 + border_color: (COLOR_SECONDARY) + } + } mod.widgets.RoomInputBar = set_type_default() do #(RoomInputBar::register_widget(vm)) { @@ -74,15 +96,17 @@ script_mod! { input_bar := View { width: Fill, height: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.75}} - flow: Right - // Bottom-align everything to ensure that buttons always stick to the bottom - // even when the mentionable_text_input box is very tall. - align: Align{y: 1.0}, + flow: Down padding: 6, - - location_button := RobrixIconButton { - margin: 4 - spacing: 0, + spacing: 4 + + location_card_button := RobrixIconButton { + visible: false + width: 230 + align: Align{x: 0.0, y: 0.5} + margin: Inset{top: 1, bottom: 1} + padding: Inset{left: 10, right: 10, top: 8, bottom: 8} + spacing: 8 draw_icon +: { svg: (mod.widgets.ICO_LOCATION_PERSON) color: (COLOR_ACTIVE_PRIMARY_DARKER) @@ -91,43 +115,110 @@ script_mod! { color: (COLOR_BG_PREVIEW) color_hover: #E0E8F0 color_down: #D0D8E8 + border_size: 1.0 + border_color: (COLOR_SECONDARY) + } + draw_text +: { + color: (COLOR_TEXT) + color_hover: (COLOR_TEXT) + color_down: (COLOR_TEXT) + text_style: MESSAGE_TEXT_STYLE { font_size: 10.5 } } - icon_walk: Walk{width: 23, height: 23, margin: Inset{bottom: -1}} - text: "", + icon_walk: Walk{width: 20, height: 20} + text: "Share your current location", } - // A checkbox that enables TSP signing for the outgoing message. - // If TSP is not enabled, this will be an empty invisible view. - tsp_sign_checkbox := TspSignAnycastCheckbox { - margin: Inset{bottom: 9, left: 6, right: 0} + emoji_picker_popup := View { + visible: false + width: Fit + height: Fit + flow: Right{wrap: true} + align: Align{x: 0.0, y: 0.5} + margin: Inset{left: 5, top: 1, bottom: 1} + padding: Inset{left: 0, right: 0, top: 0, bottom: 0} + spacing: 6 + + emoji_smile_button := mod.widgets.RoomEmojiButton { text: "😀" } + emoji_joy_button := mod.widgets.RoomEmojiButton { text: "😂" } + emoji_thumbsup_button := mod.widgets.RoomEmojiButton { text: "👍" } + emoji_heart_button := mod.widgets.RoomEmojiButton { text: "❤️" } + emoji_fire_button := mod.widgets.RoomEmojiButton { text: "🔥" } + emoji_party_button := mod.widgets.RoomEmojiButton { text: "🎉" } + emoji_think_button := mod.widgets.RoomEmojiButton { text: "🤔" } + emoji_clap_button := mod.widgets.RoomEmojiButton { text: "👏" } } - mentionable_text_input := MentionableTextInput { + input_row := View { width: Fill, height: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.75}} - margin: Inset { - top: 3, // add some space between the top border of the text input and the top border of the room input bar - bottom: 5.75, // to line up the middle of the text input with the middle of the buttons - left: 3, right: 3 // to give a bit of breathing room between the text input and the buttons on the sides - }, + flow: Right + // Bottom-align everything to ensure that buttons always stick to the bottom + // even when the mentionable_text_input box is very tall. + align: Align{y: 1.0}, + + // A checkbox that enables TSP signing for the outgoing message. + // If TSP is not enabled, this will be an empty invisible view. + tsp_sign_checkbox := TspSignAnycastCheckbox { + margin: Inset{bottom: 9, left: 6, right: 0} + } - persistent +: { - center +: { - text_input := RobrixTextInput { - empty_text: "Write a message (in Markdown) ..." + emoji_picker_button := RobrixIconButton { + margin: Inset{left: 3, right: 1, top: 4, bottom: 4} + spacing: 0, + draw_icon +: { + svg: (ICON_ADD_REACTION) + color: (COLOR_ACTIVE_PRIMARY_DARKER) + }, + draw_bg +: { + color: (COLOR_BG_PREVIEW) + color_hover: #E0E8F0 + color_down: #D0D8E8 + } + icon_walk: Walk{width: 19, height: 19} + text: "", + } + + mentionable_text_input := MentionableTextInput { + width: Fill, + height: Fit{max: FitBound.Rel{base: Base.Full, factor: 0.75}} + margin: Inset { + top: 3, // add some space between the top border of the text input and the top border of this row + bottom: 5.75, // to line up the middle of the text input with the middle of the buttons + left: 3, right: 3 // to give a bit of breathing room between the text input and the buttons on the sides + }, + + persistent +: { + center +: { + text_input := RobrixTextInput { + empty_text: "Write a message (in Markdown) ..." + } } } } - } - send_message_button := RobrixPositiveIconButton { - // Disabled by default; enabled when text is inputted - enabled: false, - spacing: 0, - text: "", - margin: 4 - draw_icon +: { svg: (ICON_SEND) } - icon_walk: Walk{width: 21, height: 21}, + send_message_button := RobrixPositiveIconButton { + visible: false, + // Disabled by default; enabled when text is inputted + enabled: false, + spacing: 0, + text: "", + margin: 4 + draw_icon +: { svg: (ICON_SEND) } + icon_walk: Walk{width: 21, height: 21}, + } + + more_actions_button := RobrixIconButton { + spacing: 0, + text: "", + margin: 4 + draw_icon +: { svg: (mod.widgets.ICO_MENU) } + draw_bg +: { + color: (COLOR_ACTIVE_PRIMARY) + color_hover: (COLOR_ACTIVE_PRIMARY_DARKER) + color_down: #0C5DAA + } + icon_walk: Walk{width: 19, height: 19}, + } } } @@ -169,6 +260,10 @@ pub struct RoomInputBar { #[rust] was_replying_preview_visible: bool, /// Info about the message event that the user is currently replying to, if any. #[rust] replying_to: Option<(EventTimelineItem, EmbeddedEvent)>, + /// Whether the location card is currently expanded. + #[rust] is_location_card_expanded: bool, + /// Whether the emoji picker popup is currently expanded. + #[rust] is_emoji_picker_expanded: bool, } impl Widget for RoomInputBar { @@ -230,9 +325,58 @@ impl RoomInputBar { self.redraw(cx); } - // Handle the add location button being clicked. - if self.button(cx, ids!(location_button)).clicked(actions) { - log!("Add location button clicked; requesting current location..."); + // Handle the more actions button being clicked. + if self.button(cx, ids!(more_actions_button)).clicked(actions) { + self.is_location_card_expanded = !self.is_location_card_expanded; + self.button(cx, ids!(location_card_button)).set_visible(cx, self.is_location_card_expanded); + self.redraw(cx); + } + + // Handle the emoji picker button being clicked. + if self.button(cx, ids!(emoji_picker_button)).clicked(actions) { + self.is_emoji_picker_expanded = !self.is_emoji_picker_expanded; + self.view.view(cx, ids!(emoji_picker_popup)).set_visible(cx, self.is_emoji_picker_expanded); + self.redraw(cx); + } + + let picked_emoji = if self.button(cx, ids!(emoji_smile_button)).clicked(actions) { + Some("😀") + } else if self.button(cx, ids!(emoji_joy_button)).clicked(actions) { + Some("😂") + } else if self.button(cx, ids!(emoji_thumbsup_button)).clicked(actions) { + Some("👍") + } else if self.button(cx, ids!(emoji_heart_button)).clicked(actions) { + Some("❤️") + } else if self.button(cx, ids!(emoji_fire_button)).clicked(actions) { + Some("🔥") + } else if self.button(cx, ids!(emoji_party_button)).clicked(actions) { + Some("🎉") + } else if self.button(cx, ids!(emoji_think_button)).clicked(actions) { + Some("🤔") + } else if self.button(cx, ids!(emoji_clap_button)).clicked(actions) { + Some("👏") + } else { + None + }; + + if let Some(emoji) = picked_emoji { + let mut text = mentionable_text_input.text(); + text.push_str(emoji); + mentionable_text_input.set_text(cx, &text); + self.enable_send_message_button(cx, !text.trim().is_empty()); + submit_async_request(MatrixRequest::SendTypingNotice { + room_id: room_screen_props.timeline_kind.room_id().clone(), + typing: !text.is_empty(), + }); + self.is_emoji_picker_expanded = false; + self.view.view(cx, ids!(emoji_picker_popup)).set_visible(cx, false); + self.text_input(cx, ids!(input_bar.input_row.mentionable_text_input.text_input)).set_key_focus(cx); + self.redraw(cx); + } + + // Handle the location card being clicked. + if self.button(cx, ids!(location_card_button)).clicked(actions) { + log!("Location card clicked; requesting current location..."); if let Err(_e) = init_location_subscriber(cx) { error!("Failed to initialize location subscriber"); enqueue_popup_notification( @@ -425,7 +569,7 @@ impl RoomInputBar { // so that the user can immediately start typing their reply // without having to manually click on the message input box. if grab_key_focus { - self.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)).set_key_focus(cx); + self.text_input(cx, ids!(input_bar.input_row.mentionable_text_input.text_input)).set_key_focus(cx); } self.button(cx, ids!(cancel_reply_button)).reset_hover(cx); self.redraw(cx); @@ -506,7 +650,7 @@ impl RoomInputBar { } } - /// Sets the send_message_button to be enabled and green, or disabled and gray. + /// Sets the send_message_button to be shown/enabled and green, or hidden/disabled and gray. /// /// This should be called to update the button state when the message TextInput content changes. fn enable_send_message_button(&mut self, cx: &mut Cx, enable: bool) { @@ -517,6 +661,7 @@ impl RoomInputBar { (COLOR_FG_DISABLED, COLOR_BG_DISABLED) }; script_apply_eval!(cx, send_message_button, { + visible: #(enable), enabled: #(enable), draw_icon.color: #(fg_color), draw_bg.color: #(bg_color), @@ -665,7 +810,7 @@ impl RoomInputBarRef { was_replying_preview_visible: inner.was_replying_preview_visible, replying_to: inner.replying_to.clone(), editing_pane_state: inner.child_by_path(ids!(editing_pane)).as_editing_pane().save_state(), - text_input_state: inner.child_by_path(ids!(input_bar.mentionable_text_input.text_input)).as_text_input().save_state(), + text_input_state: inner.child_by_path(ids!(input_bar.input_row.mentionable_text_input.text_input)).as_text_input().save_state(), } } @@ -694,8 +839,16 @@ impl RoomInputBarRef { inner.update_user_power_levels(cx, user_power_levels); // 1. Restore the state of the TextInput within the MentionableTextInput. - inner.text_input(cx, ids!(input_bar.mentionable_text_input.text_input)) + inner.text_input(cx, ids!(input_bar.input_row.mentionable_text_input.text_input)) .restore_state(cx, text_input_state); + let is_text_input_empty = inner.text_input(cx, ids!(input_bar.input_row.mentionable_text_input.text_input)) + .text() + .is_empty(); + inner.enable_send_message_button(cx, !is_text_input_empty); + inner.is_location_card_expanded = false; + inner.button(cx, ids!(location_card_button)).set_visible(cx, false); + inner.is_emoji_picker_expanded = false; + inner.view.view(cx, ids!(emoji_picker_popup)).set_visible(cx, false); // 2. Restore the state of the replying-to preview. if let Some(replying_to) = replying_to { From 3c69b1218a85d0ef8df9f2c612a12347dd82437e Mon Sep 17 00:00:00 2001 From: alanpoon Date: Wed, 1 Apr 2026 22:13:05 +0800 Subject: [PATCH 52/66] reduce code change --- src/app.rs | 132 ++++- src/login/login_screen.rs | 208 +++++--- src/settings/account_settings.rs | 25 +- src/sliding_sync.rs | 841 +++++++++++++++---------------- 4 files changed, 675 insertions(+), 531 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5c1ca4148..f00f7f771 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,7 +8,7 @@ use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, OwnedUserId, RoomI use serde::{Deserialize, Serialize}; use crate::{ avatar_cache::clear_avatar_cache, home::{ - event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, clear_timeline_states}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update} + event_source_modal::{EventSourceModalAction, EventSourceModalWidgetRefExt}, invite_modal::{InviteModalAction, InviteModalWidgetRefExt}, main_desktop_ui::MainDesktopUiAction, navigation_tab_bar::{NavigationBarAction, SelectedTab}, new_message_context_menu::NewMessageContextMenuWidgetRefExt, room_context_menu::RoomContextMenuWidgetRefExt, room_screen::{InviteAction, MessageAction, clear_timeline_states, RoomScreenWidgetRefExt}, rooms_list::{RoomsListAction, RoomsListRef, RoomsListUpdate, clear_all_invited_rooms, enqueue_rooms_list_update}, invite_screen::InviteScreenWidgetRefExt, space_lobby::SpaceLobbyScreenWidgetRefExt, }, join_leave_room_modal::{ JoinLeaveModalKind, JoinLeaveRoomModalAction, JoinLeaveRoomModalWidgetRefExt }, login::login_screen::LoginAction, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction, LogoutConfirmModalWidgetRefExt}, persistence, profile::user_profile_cache::clear_user_profile_cache, room::BasicRoomDetails, shared::{confirmation_modal::{ConfirmationModalContent, ConfirmationModalWidgetRefExt}, image_viewer::{ImageViewerAction, LoadState}, popup_list::{PopupKind, enqueue_popup_notification}}, sliding_sync::{AccountSwitchAction, DirectMessageRoomAction, MatrixRequest, current_user_id, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ @@ -171,6 +171,9 @@ pub struct App { /// This can be either a room we're waiting to join, or one we're waiting to be invited to. /// Also includes an optional room ID to be closed once the awaited room has been loaded. #[rust] waiting_to_navigate_to_room: Option<(BasicRoomDetails, Option)>, + /// A stack of previously-selected rooms for mobile navigation. + /// When a view is popped off the stack, the previous `selected_room` is restored from here. + #[rust] mobile_room_nav_stack: Vec, } impl ScriptHook for App { @@ -419,6 +422,33 @@ impl MatchEvent for App { continue; } + // A new room has been selected; push the appropriate view onto the mobile + // StackNavigation and update the app state. + // In Desktop mode, MainDesktopUI also handles this action to manage dock tabs; + // the mobile push is harmless there (the view isn't drawn). + match action.as_widget_action().cast() { + RoomsListAction::Selected(selected_room) => { + self.push_selected_room_view(cx, selected_room); + continue; + } + // An invite was accepted; upgrade the selected room from invite to joined. + // In Desktop mode, MainDesktopUI also handles this (harmless duplicate). + RoomsListAction::InviteAccepted { room_name_id } => { + cx.action(AppStateAction::UpgradedInviteToJoinedRoom(room_name_id.room_id().clone())); + continue; + } + _ => {} + } + + // When a stack navigation pop is initiated (back button pressed), + // pop the mobile nav stack so it stays in sync with StackNavigation. + if let StackNavigationAction::Pop = action.as_widget_action().cast() { + if self.app_state.selected_room.is_some() { + self.app_state.selected_room = self.mobile_room_nav_stack.pop(); + } + // Don't `continue` — let StackNavigation also process this Pop. + } + // Handle actions that instruct us to update the top-level app state. match action.downcast_ref() { Some(AppStateAction::RoomFocused(selected_room)) => { @@ -796,7 +826,7 @@ impl AppMain for App { } #[cfg(feature = "tsp")] { // Save the TSP wallet state, if it exists, with a 3-second timeout. - let tsp_state = std::mem::take(&mut *crate::tsp::tsp_state_ref().lock().unwrap_or_else(|e| e.into_inner())); + let tsp_state = std::mem::take(&mut *crate::tsp::tsp_state_ref().lock().unwrap()); let res = crate::sliding_sync::block_on_async_with_timeout( Some(std::time::Duration::from_secs(3)), async move { @@ -937,6 +967,104 @@ impl App { } } + /// Room StackNavigationView instances, one per stack depth. + /// Each depth gets its own dedicated view widget to avoid + /// complex state save/restore when views would otherwise be reused. + const ROOM_VIEW_IDS: [LiveId; 16] = [ + live_id!(room_view_0), live_id!(room_view_1), + live_id!(room_view_2), live_id!(room_view_3), + live_id!(room_view_4), live_id!(room_view_5), + live_id!(room_view_6), live_id!(room_view_7), + live_id!(room_view_8), live_id!(room_view_9), + live_id!(room_view_10), live_id!(room_view_11), + live_id!(room_view_12), live_id!(room_view_13), + live_id!(room_view_14), live_id!(room_view_15), + ]; + + /// The RoomScreen widget IDs inside each room view, + /// corresponding 1:1 with [`Self::ROOM_VIEW_IDS`]. + const ROOM_SCREEN_IDS: [LiveId; 16] = [ + live_id!(room_screen_0), live_id!(room_screen_1), + live_id!(room_screen_2), live_id!(room_screen_3), + live_id!(room_screen_4), live_id!(room_screen_5), + live_id!(room_screen_6), live_id!(room_screen_7), + live_id!(room_screen_8), live_id!(room_screen_9), + live_id!(room_screen_10), live_id!(room_screen_11), + live_id!(room_screen_12), live_id!(room_screen_13), + live_id!(room_screen_14), live_id!(room_screen_15), + ]; + + /// Returns the room view and room screen LiveIds for the given stack depth. + /// Clamps to the last available view if depth exceeds the pool size. + fn room_ids_for_depth(depth: usize) -> (LiveId, LiveId) { + let index = depth.min(Self::ROOM_VIEW_IDS.len() - 1); + (Self::ROOM_VIEW_IDS[index], Self::ROOM_SCREEN_IDS[index]) + } + + /// Pushes the appropriate StackNavigationView for the given `SelectedRoom`, + /// configuring the view's content widget and header title. + /// + /// Each stack depth gets its own dedicated room view widget, + /// supporting deep navigation (room → thread → room → thread → ...). + /// + /// In Desktop mode, the StackNavigation isn't drawn, so the push and + /// screen configuration are effectively no-ops — MainDesktopUI handles + /// room display via dock tabs instead. + fn push_selected_room_view(&mut self, cx: &mut Cx, selected_room: SelectedRoom) { + // Use the actual StackNavigation depth to pick the next room view slot. + let new_depth = self.ui.stack_navigation(cx, ids!(view_stack)).depth(); + + // Determine which view to push and configure its content. + // The `set_displayed_room` / `set_displayed_invite` / `set_displayed_space` calls + // configure the screen widget inside the mobile StackNavigationView. + // In Desktop mode, these widgets exist but aren't drawn; the configuration + // consumes timeline endpoints, but Desktop's MainDesktopUI processes the same + // `RoomsListAction::Selected` in its own handler to set up dock tabs. + let view_id = match &selected_room { + SelectedRoom::JoinedRoom { room_name_id } + | SelectedRoom::Thread { room_name_id, .. } => { + let (view_id, room_screen_id) = Self::room_ids_for_depth(new_depth); + + let thread_root = if let SelectedRoom::Thread { thread_root_event_id, .. } = &selected_room { + Some(thread_root_event_id.clone()) + } else { + None + }; + self.ui + .room_screen(cx, &[room_screen_id]) + .set_displayed_room(cx, room_name_id, thread_root); + + view_id + } + SelectedRoom::InvitedRoom { room_name_id } => { + self.ui + .invite_screen(cx, ids!(invite_screen)) + .set_displayed_invite(cx, room_name_id); + id!(invite_view) + } + SelectedRoom::Space { space_name_id } => { + self.ui + .space_lobby_screen(cx, ids!(space_lobby_screen)) + .set_displayed_space(cx, space_name_id); + id!(space_lobby_view) + } + }; + + // Set the header title for the view being pushed. + let title_path = &[view_id, live_id!(header), live_id!(content), live_id!(title_container), live_id!(title)]; + self.ui.label(cx, title_path).set_text(cx, &selected_room.display_name()); + + // Save the current selected_room onto the navigation stack before replacing it. + if let Some(prev) = self.app_state.selected_room.take() { + self.mobile_room_nav_stack.push(prev); + } + // Update app state (used by both Desktop and Mobile paths). + self.app_state.selected_room = Some(selected_room); + + // Push the view onto the mobile navigation stack. + self.ui.stack_navigation(cx, ids!(view_stack)).push(cx, view_id); + self.ui.redraw(cx); + } } diff --git a/src/login/login_screen.rs b/src/login/login_screen.rs index 097d2bb18..5a4c2fd1b 100644 --- a/src/login/login_screen.rs +++ b/src/login/login_screen.rs @@ -3,7 +3,7 @@ use std::ops::Not; use makepad_widgets::*; use url::Url; -use crate::sliding_sync::{submit_async_request, AccountSwitchAction, LoginByPassword, LoginRequest, MatrixRequest}; +use crate::sliding_sync::{submit_async_request, AccountSwitchAction, LoginByPassword, LoginRequest, MatrixRequest, RegisterAccount}; use super::login_status_modal::{LoginStatusModalAction, LoginStatusModalWidgetExt}; @@ -69,19 +69,13 @@ script_mod! { } } - RoundedView { + View { margin: Inset{top: 40, bottom: 40} width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit align: Align{x: 0.5, y: 0.5} flow: Overlay, - show_bg: true, - draw_bg +: { - color: (COLOR_SECONDARY) - border_radius: 6.0 - } - View { width: Fill // TODO: once Makepad supports it, use `Fill {max: 375}` height: Fit @@ -184,54 +178,61 @@ script_mod! { text: "Login" } - LineH { - width: 275 - margin: Inset{bottom: -5} - draw_bg.color: #C8C8C8 - } + login_only_view := View { + width: Fit, height: Fit, + flow: Down, + align: Align{x: 0.5, y: 0.5} + spacing: 15.0 - Label { - width: Fit, height: Fit - padding: 0, - draw_text +: { - color: (COLOR_TEXT) - text_style: TITLE_TEXT {font_size: 11.0} + LineH { + width: 275 + margin: Inset{bottom: -5} + draw_bg.color: #C8C8C8 } - text: "Or, login with an SSO provider:" - } - sso_view := View { - width: 275, height: Fit, - margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide - flow: Flow.Right{wrap: true}, - apple_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/apple.png") + Label { + width: Fit, height: Fit + padding: 0, + draw_text +: { + color: (COLOR_TEXT) + text_style: TITLE_TEXT {font_size: 11.0} } + text: "Or, login with an SSO provider:" } - facebook_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/facebook.png") + + sso_view := View { + width: 275, height: Fit, + margin: Inset{left: 30, right: 5} // make the inner view 240 pixels wide + flow: Flow.Right{wrap: true}, + apple_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/apple.png") + } } - } - github_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/github.png") + facebook_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/facebook.png") + } } - } - gitlab_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/gitlab.png") + github_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/github.png") + } } - } - google_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/google.png") + gitlab_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/gitlab.png") + } } - } - twitter_button := mod.widgets.SsoButton { - image := mod.widgets.SsoImage { - src: crate_resource("self://resources/img/x.png") + google_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/google.png") + } + } + twitter_button := mod.widgets.SsoButton { + image := mod.widgets.SsoImage { + src: crate_resource("self://resources/img/x.png") + } } } } @@ -246,7 +247,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } - Label { + account_prompt_label := Label { width: Fit, height: Fit padding: Inset{left: 1, right: 1, top: 0, bottom: 0} draw_text +: { @@ -259,7 +260,7 @@ script_mod! { LineH { draw_bg.color: #C8C8C8 } } - signup_button := RobrixIconButton { + mode_toggle_button := RobrixIconButton { width: Fit, height: Fit padding: Inset{left: 15, right: 15, top: 10, bottom: 10} margin: Inset{bottom: 5} @@ -293,20 +294,48 @@ script_mod! { } } -static MATRIX_SIGN_UP_URL: &str = "https://matrix.org/docs/chat_basics/matrix-for-im/#creating-a-matrix-account"; - #[derive(Script, ScriptHook, Widget)] pub struct LoginScreen { #[source] source: ScriptObjectRef, #[deref] view: View, + /// Whether the screen is showing the in-app sign-up flow. + #[rust] signup_mode: bool, /// Boolean to indicate if the SSO login process is still in flight #[rust] sso_pending: bool, /// The URL to redirect to after logging in with SSO. #[rust] sso_redirect_url: Option, + /// The most recent login failure message shown to the user. + #[rust] last_failure_message_shown: Option, /// Boolean to indicate if we're in "add account" mode (adding another Matrix account). #[rust] adding_account: bool, } +impl LoginScreen { + fn set_signup_mode(&mut self, cx: &mut Cx, signup_mode: bool) { + self.signup_mode = signup_mode; + self.view.view(cx, ids!(confirm_password_wrapper)).set_visible(cx, signup_mode); + self.view.view(cx, ids!(login_only_view)).set_visible(cx, !signup_mode); + self.view.label(cx, ids!(title)).set_text(cx, + if signup_mode { "Create your Robrix account" } else { "Login to Robrix" } + ); + self.view.button(cx, ids!(login_button)).set_text(cx, + if signup_mode { "Create account" } else { "Login" } + ); + self.view.label(cx, ids!(account_prompt_label)).set_text(cx, + if signup_mode { "Already have an account?" } else { "Don't have an account?" } + ); + self.view.button(cx, ids!(mode_toggle_button)).set_text(cx, + if signup_mode { "Back to login" } else { "Sign up here" } + ); + + if !signup_mode { + self.view.text_input(cx, ids!(confirm_password_input)).set_text(cx, ""); + } + + self.redraw(cx); + } +} + impl Widget for LoginScreen { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { @@ -322,10 +351,11 @@ impl Widget for LoginScreen { impl MatchEvent for LoginScreen { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { let login_button = self.view.button(cx, ids!(login_button)); - let signup_button = self.view.button(cx, ids!(signup_button)); + let mode_toggle_button = self.view.button(cx, ids!(mode_toggle_button)); let cancel_button = self.view.button(cx, ids!(cancel_button)); let user_id_input = self.view.text_input(cx, ids!(user_id_input)); let password_input = self.view.text_input(cx, ids!(password_input)); + let confirm_password_input = self.view.text_input(cx, ids!(confirm_password_input)); let homeserver_input = self.view.text_input(cx, ids!(homeserver_input)); let login_status_modal = self.view.modal(cx, ids!(login_status_modal)); @@ -338,24 +368,25 @@ impl MatchEvent for LoginScreen { self.view.label(cx, ids!(title)).set_text(cx, "Login to Robrix"); cancel_button.set_visible(cx, false); self.view.view(cx, ids!(sso_view)).set_visible(cx, true); - signup_button.set_visible(cx, true); + mode_toggle_button.set_visible(cx, true); cx.action(LoginAction::CancelAddAccount); self.redraw(cx); } - if signup_button.clicked(actions) { - log!("Opening URL \"{}\"", MATRIX_SIGN_UP_URL); - let _ = robius_open::Uri::new(MATRIX_SIGN_UP_URL).open(); + if mode_toggle_button.clicked(actions) { + self.set_signup_mode(cx, !self.signup_mode); } if login_button.clicked(actions) || user_id_input.returned(actions).is_some() || password_input.returned(actions).is_some() + || (self.signup_mode && confirm_password_input.returned(actions).is_some()) || homeserver_input.returned(actions).is_some() { - let user_id = user_id_input.text(); + let user_id = user_id_input.text().trim().to_owned(); let password = password_input.text(); - let homeserver = homeserver_input.text(); + let confirm_password = confirm_password_input.text(); + let homeserver = homeserver_input.text().trim().to_owned(); if user_id.is_empty() { login_status_modal_inner.set_title(cx, "Missing User ID"); login_status_modal_inner.set_status(cx, "Please enter a valid User ID."); @@ -364,16 +395,40 @@ impl MatchEvent for LoginScreen { login_status_modal_inner.set_title(cx, "Missing Password"); login_status_modal_inner.set_status(cx, "Please enter a valid password."); login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); + } else if self.signup_mode && password != confirm_password { + login_status_modal_inner.set_title(cx, "Passwords do not match"); + login_status_modal_inner.set_status(cx, "Please enter the same password in both password fields."); + login_status_modal_inner.button_ref(cx).set_text(cx, "Okay"); } else { - login_status_modal_inner.set_title(cx, "Logging in..."); - login_status_modal_inner.set_status(cx, "Waiting for a login response..."); + self.last_failure_message_shown = None; + login_status_modal_inner.set_title(cx, if self.signup_mode { + "Creating account..." + } else { + "Logging in..." + }); + login_status_modal_inner.set_status( + cx, + if self.signup_mode { + "Waiting for the homeserver to create your account..." + } else { + "Waiting for a login response..." + }, + ); login_status_modal_inner.button_ref(cx).set_text(cx, "Cancel"); - submit_async_request(MatrixRequest::Login(LoginRequest::LoginByPassword(LoginByPassword { - user_id, - password, - homeserver: homeserver.is_empty().not().then_some(homeserver), - is_add_account: self.adding_account, - }))); + submit_async_request(MatrixRequest::Login(if self.signup_mode { + LoginRequest::Register(RegisterAccount { + user_id, + password, + homeserver: homeserver.is_empty().not().then_some(homeserver), + }) + } else { + LoginRequest::LoginByPassword(LoginByPassword { + user_id, + password, + homeserver: homeserver.is_empty().not().then_some(homeserver), + is_add_account: self.adding_account, + }) + })); } login_status_modal.open(cx); self.redraw(cx); @@ -396,6 +451,7 @@ impl MatchEvent for LoginScreen { // Handle login-related actions received from background async tasks. match action.downcast_ref() { Some(LoginAction::CliAutoLogin { user_id, homeserver }) => { + self.last_failure_message_shown = None; user_id_input.set_text(cx, user_id); password_input.set_text(cx, ""); homeserver_input.set_text(cx, homeserver.as_deref().unwrap_or_default()); @@ -410,6 +466,7 @@ impl MatchEvent for LoginScreen { login_status_modal.open(cx); } Some(LoginAction::Status { title, status }) => { + self.last_failure_message_shown = None; login_status_modal_inner.set_title(cx, title); login_status_modal_inner.set_status(cx, status); let login_status_modal_button = login_status_modal_inner.button_ref(cx); @@ -421,19 +478,30 @@ impl MatchEvent for LoginScreen { Some(LoginAction::LoginSuccess) => { // The main `App` component handles showing the main screen // and hiding the login screen & login status modal. + self.last_failure_message_shown = None; + self.set_signup_mode(cx, false); self.adding_account = false; user_id_input.set_text(cx, ""); password_input.set_text(cx, ""); + confirm_password_input.set_text(cx, ""); homeserver_input.set_text(cx, ""); // Reset title and buttons in case we were in add-account mode self.view.label(cx, ids!(title)).set_text(cx, "Login to Robrix"); cancel_button.set_visible(cx, false); - signup_button.set_visible(cx, true); + mode_toggle_button.set_visible(cx, true); login_status_modal.close(cx); self.redraw(cx); } Some(LoginAction::LoginFailure(error)) => { - login_status_modal_inner.set_title(cx, "Login Failed."); + if self.last_failure_message_shown.as_deref() == Some(error.as_str()) { + continue; + } + self.last_failure_message_shown = Some(error.clone()); + login_status_modal_inner.set_title(cx, if self.signup_mode { + "Account Creation Failed." + } else { + "Login Failed." + }); login_status_modal_inner.set_status(cx, error); let login_status_modal_button = login_status_modal_inner.button_ref(cx); login_status_modal_button.set_text(cx, "Okay"); @@ -464,7 +532,7 @@ impl MatchEvent for LoginScreen { self.view.label(cx, ids!(title)).set_text(cx, "Add Another Account"); cancel_button.set_visible(cx, true); // Hide signup button in add-account mode (user already has an account) - signup_button.set_visible(cx, false); + mode_toggle_button.set_visible(cx, false); self.redraw(cx); } Some(LoginAction::AddAccountSuccess) => { @@ -476,7 +544,7 @@ impl MatchEvent for LoginScreen { // Reset title and buttons self.view.label(cx, ids!(title)).set_text(cx, "Login to Robrix"); cancel_button.set_visible(cx, false); - signup_button.set_visible(cx, true); + mode_toggle_button.set_visible(cx, true); login_status_modal.close(cx); self.redraw(cx); } diff --git a/src/settings/account_settings.rs b/src/settings/account_settings.rs index f003808d5..46ad09e1a 100644 --- a/src/settings/account_settings.rs +++ b/src/settings/account_settings.rs @@ -120,8 +120,7 @@ script_mod! { // their styles to RobrixNeutralIconButton / RobrixPositiveIconButton. cancel_display_name_button := RobrixNeutralIconButton { enabled: false, - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, + width: Fit, height: Fit, padding: 10, margin: Inset{left: 5}, draw_icon.svg: (ICON_FORBIDDEN) @@ -131,10 +130,10 @@ script_mod! { accept_display_name_button := RobrixPositiveIconButton { enabled: false, - width: Fit, - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, + width: Fit, height: Fit, padding: 10, margin: Inset{left: 5}, + draw_bg.border_radius: 5.0 draw_icon.svg: (ICON_CHECKMARK) icon_walk: Walk{width: 16, height: 16, margin: 0} text: "Save Name" @@ -311,7 +310,6 @@ script_mod! { spacing: 10 manage_account_button := RobrixIconButton { - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 5} draw_icon.svg: (ICON_EXTERNAL_LINK) @@ -320,7 +318,6 @@ script_mod! { } logout_button := RobrixNegativeIconButton { - height: mod.widgets.SETTINGS_BUTTON_HEIGHT, padding: Inset{top: 10, bottom: 10, left: 12, right: 15} margin: Inset{left: 5} draw_icon.svg: (ICON_LOGOUT) @@ -350,7 +347,7 @@ impl Widget for AccountSettings { match event.hits(cx, copy_user_id_button_area) { Hit::FingerHoverIn(_) | Hit::FingerLongPress(_) => { cx.widget_action( - copy_user_id_button.widget_uid(), + copy_user_id_button.widget_uid(), TooltipAction::HoverIn { text: "Copy User ID".to_string(), widget_rect: copy_user_id_button_area.rect(cx), @@ -363,7 +360,7 @@ impl Widget for AccountSettings { } Hit::FingerHoverOut(_) => { cx.widget_action( - copy_user_id_button.widget_uid(), + copy_user_id_button.widget_uid(), TooltipAction::HoverOut, ); } @@ -380,9 +377,6 @@ impl Widget for AccountSettings { impl MatchEvent for AccountSettings { fn handle_signal(&mut self, cx: &mut Cx) { - // Process avatar updates from the cache - avatar_cache::process_avatar_updates(cx); - // If we don't have a profile yet, try to get it if self.own_profile.is_none() { user_profile_cache::process_user_profile_updates(cx); @@ -398,6 +392,8 @@ impl MatchEvent for AccountSettings { } return; } + // Process avatar updates from the cache + avatar_cache::process_avatar_updates(cx); // Update avatar from cache if we have a profile if let Some(profile) = self.own_profile.as_mut() { @@ -521,10 +517,13 @@ impl MatchEvent for AccountSettings { let Some(own_profile) = &self.own_profile else { return }; if upload_avatar_button.clicked(actions) { + // TODO: uncomment the below once avatar uploading is implemented + // Self::enable_upload_avatar_button(cx, false, &upload_avatar_button); + // Self::enable_delete_avatar_button(cx, false, &delete_avatar_button); enqueue_popup_notification( "Avatar upload is not yet implemented.", - PopupKind::Info, - Some(3.0), + PopupKind::Warning, + Some(4.0), ); } diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 395e73338..9ad399e0e 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -8,7 +8,7 @@ use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ - config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::{MediaFormat, MediaRequestParameters}, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ + config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ api::{Direction, client::{ account::register::v3::Request as RegistrationRequest, error::ErrorKind, @@ -48,7 +48,7 @@ use crate::{ }, space_service_sync::space_service_loop, utils::{self, AVATAR_THUMBNAIL_FORMAT, RoomNameId, VecDiff, avatar_from_room_name}, verification::add_verification_event_handlers_and_sync_client }; -#[derive(Parser, Default, Clone)] +#[derive(Parser, Default)] struct Cli { /// The user ID to login with. #[clap(value_parser)] @@ -846,15 +846,6 @@ pub enum MatrixRequest { destination: MediaCacheEntryRef, update_sender: Option>, }, - /// Request to download a file from Matrix and save it to disk. - DownloadFile { - /// The media source of the file to download. - media_source: ruma::events::room::MediaSource, - /// The suggested filename for the downloaded file. - filename: String, - /// The destination path to save the file to. - destination_path: std::path::PathBuf, - }, /// Request to send a message to the given room. SendMessage { timeline_kind: TimelineKind, @@ -962,12 +953,9 @@ pub enum MatrixRequest { /// Submits a request to the worker thread to be executed asynchronously. pub fn submit_async_request(req: MatrixRequest) { - if let Some(sender) = REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref() { - if let Err(_e) = sender.send(req) { - // The receiver has been dropped, likely due to account switching or logout. - // This is expected during transitions, so we silently ignore the error. - log!("Note: matrix worker task receiver unavailable, request dropped (likely during account switch)"); - } + if let Some(sender) = REQUEST_SENDER.lock().unwrap().as_ref() { + sender.send(req) + .expect("BUG: matrix worker task receiver has died!"); } } @@ -1283,7 +1271,7 @@ async fn matrix_worker_task( MatrixRequest::CreateThreadTimeline { room_id, thread_root_event_id } => { let main_room_timeline = { - let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); + let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { error!("BUG: room info not found for create thread timeline request, room {room_id}"); continue; @@ -1311,7 +1299,7 @@ async fn matrix_worker_task( match build_result { Ok(thread_timeline) => { - let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); + let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get_mut(&room_id) else { return; }; @@ -1347,7 +1335,7 @@ async fn matrix_worker_task( } Err(error) => { error!("Failed to create thread-focused timeline for room {room_id}, thread {thread_root_event_id}: {error}"); - let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); + let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); if let Some(room_info) = all_joined_rooms.get_mut(&room_id) { room_info .pending_thread_timelines @@ -1572,7 +1560,7 @@ async fn matrix_worker_task( MatrixRequest::GetSuccessorRoomDetails { tombstoned_room_id } => { let Some(client) = get_client() else { continue }; let (sender, successor_room) = { - let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); + let all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(room_info) = all_joined_rooms.get(&tombstoned_room_id) else { error!("BUG: tombstoned room {tombstoned_room_id} info not found for get successor room details request"); continue; @@ -1901,7 +1889,7 @@ async fn matrix_worker_task( MatrixRequest::SubscribeToTypingNotices { room_id, subscribe } => { let (main_timeline, timeline_update_sender, mut typing_notice_receiver) = { - let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); + let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let Some(jrd) = all_joined_rooms.get_mut(&room_id) else { log!("BUG: room info not found for subscribe to typing notices request, room {room_id}"); continue; @@ -2062,6 +2050,7 @@ async fn matrix_worker_task( MatrixRequest::FetchMedia { media_request, on_fetched, destination, update_sender } => { let Some(client) = get_client() else { continue }; + let _fetch_task = Handle::current().spawn(async move { // log!("Sending fetch media request for {media_request:?}..."); let res = client.media().get_media_content(&media_request, true).await; @@ -2069,48 +2058,6 @@ async fn matrix_worker_task( }); } - MatrixRequest::DownloadFile { media_source, filename, destination_path } => { - let Some(client) = get_client() else { continue }; - - let _download_task = Handle::current().spawn(async move { - log!("Downloading file {filename} to {:?}...", destination_path); - let media_request = MediaRequestParameters { - source: media_source, - format: MediaFormat::File, - }; - match client.media().get_media_content(&media_request, true).await { - Ok(data) => { - match std::fs::write(&destination_path, &data) { - Ok(_) => { - log!("Successfully downloaded file to {:?}", destination_path); - enqueue_popup_notification( - format!("Downloaded: {filename}"), - PopupKind::Success, - None, - ); - } - Err(e) => { - error!("Failed to write file to {:?}: {e}", destination_path); - enqueue_popup_notification( - format!("Failed to save file: {e}"), - PopupKind::Error, - None, - ); - } - } - } - Err(e) => { - error!("Failed to download file {filename}: {e}"); - enqueue_popup_notification( - format!("Failed to download: {e}"), - PopupKind::Error, - None, - ); - } - } - }); - } - MatrixRequest::SendMessage { timeline_kind, message, @@ -2479,7 +2426,7 @@ pub fn block_on_async_with_timeout( timeout: Option, async_future: impl Future, ) -> Result { - let rt = TOKIO_RUNTIME.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(|| + let rt = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") ).handle().clone(); @@ -2499,7 +2446,7 @@ pub fn block_on_async_with_timeout( /// Returns a handle to the Tokio runtime that is used to run async background tasks. pub fn start_matrix_tokio() -> Result { // Create a Tokio runtime, and save it in a static variable to ensure it isn't dropped. - let rt_handle = TOKIO_RUNTIME.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(|| { + let rt_handle = TOKIO_RUNTIME.lock().unwrap().get_or_insert_with(|| { tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") }).handle().clone(); @@ -2508,7 +2455,7 @@ pub fn start_matrix_tokio() -> Result { rt_handle.spawn(async move { match build_client(&Cli::default(), app_data_dir()).await { Ok(client_and_session) => { - DEFAULT_SSO_CLIENT.lock().unwrap_or_else(|e| e.into_inner()) + DEFAULT_SSO_CLIENT.lock().unwrap() .get_or_insert(client_and_session); } Err(e) => error!("Error: could not create DEFAULT_SSO_CLIENT object: {e}"), @@ -2525,6 +2472,7 @@ pub fn start_matrix_tokio() -> Result { Ok(rt_handle) } + /// A tokio::watch channel sender for sending requests from the RoomScreen UI widget /// to the corresponding background async task for that room (its `timeline_subscriber_handler`). pub type TimelineRequestSender = watch::Sender>; @@ -2613,19 +2561,19 @@ fn get_per_timeline_details<'a>( /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the timeline for the given timeline kind. fn get_timeline(kind: &TimelineKind) -> Option> { - get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).deref_mut(), kind) + get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind) .map(|details| details.timeline.clone()) } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the timeline and timeline update sender for the given timeline kind. fn get_timeline_and_sender(kind: &TimelineKind) -> Option<(Arc, crossbeam_channel::Sender)> { - get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).deref_mut(), kind) + get_per_timeline_details(ALL_JOINED_ROOMS.lock().unwrap().deref_mut(), kind) .map(|details| (details.timeline.clone(), details.timeline_update_sender.clone())) } /// Obtains the lock on `ALL_JOINED_ROOMS` and returns the main timeline for the given room. fn get_room_timeline(room_id: &RoomId) -> Option> { - ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()) + ALL_JOINED_ROOMS.lock().unwrap() .get(room_id) .map(|jrd| jrd.main_timeline.timeline.clone()) } @@ -2634,12 +2582,12 @@ fn get_room_timeline(room_id: &RoomId) -> Option> { static CLIENT: Mutex> = Mutex::new(None); pub fn get_client() -> Option { - CLIENT.lock().unwrap_or_else(|e| e.into_inner()).clone() + CLIENT.lock().unwrap().clone() } /// Returns the user ID of the currently logged-in user, if any. pub fn current_user_id() -> Option { - CLIENT.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| + CLIENT.lock().unwrap().as_ref().and_then(|c| c.session_meta().map(|m| m.user_id.clone()) ) } @@ -2689,12 +2637,12 @@ static IGNORED_USERS: Mutex> = Mutex::new(Hash /// Returns a deep clone of the current list of ignored users. pub fn get_ignored_users() -> HashSet { - IGNORED_USERS.lock().unwrap_or_else(|e| e.into_inner()).clone() + IGNORED_USERS.lock().unwrap().clone() } /// Returns whether the given user ID is currently being ignored. pub fn is_user_ignored(user_id: &UserId) -> bool { - IGNORED_USERS.lock().unwrap_or_else(|e| e.into_inner()).contains(user_id) + IGNORED_USERS.lock().unwrap().contains(user_id) } @@ -2707,7 +2655,7 @@ pub fn is_user_ignored(user_id: &UserId) -> bool { /// This will only succeed once per room (or once per room thread), /// as only a single channel receiver can exist. pub fn take_timeline_endpoints(kind: &TimelineKind) -> Option { - let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()); + let mut all_joined_rooms = ALL_JOINED_ROOMS.lock().unwrap(); let jrd = all_joined_rooms.get_mut(kind.room_id())?; let details = match kind { TimelineKind::MainRoom { .. } => &mut jrd.main_timeline, @@ -2811,7 +2759,7 @@ impl RoomListServiceRoomInfo { async fn start_matrix_client_login_and_sync(rt: Handle) { // Create a channel for sending requests from the main UI thread to a background worker task. let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); - REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()).replace(sender); + REQUEST_SENDER.lock().unwrap().replace(sender); let (login_sender, mut login_receiver) = tokio::sync::mpsc::channel(1); @@ -2890,418 +2838,420 @@ async fn start_matrix_client_login_and_sync(rt: Handle) { // On subsequent iterations of the login loop (after a post-auth setup failure), it is `None`, // which causes the loop to wait for the user to submit a new manual login request. let mut initial_client_opt = new_login_opt; - let (client, sync_service, logged_in_user_id) = 'login_loop: loop { - let (client, _sync_token, validate_session, session) = match initial_client_opt.take() { - Some(login) => login, - None => { - loop { - log!("Waiting for login request..."); - match login_receiver.recv().await { - Some(login_request) => { - match login(&cli, login_request).await { - Ok((client, sync_token, _is_add_account, session)) => break (client, sync_token, false, session), - Err(e) => { - error!("Login failed: {e:?}"); - Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: format!("Login failed: {e}"), - }); + + loop { + let (client, sync_service, logged_in_user_id) = 'login_loop: loop { + let (client, _sync_token, validate_session, session) = match initial_client_opt.take() { + Some(login) => login, + None => { + loop { + log!("Waiting for login request..."); + match login_receiver.recv().await { + Some(login_request) => { + match login(&cli, login_request).await { + Ok((client, sync_token, _is_add_account, session)) => break (client, sync_token, false, session), + Err(e) => { + error!("Login failed: {e:?}"); + Cx::post_action(LoginAction::LoginFailure(format!("{e}"))); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: format!("Login failed: {e}"), + }); + } } + }, + None => { + error!("BUG: login_receiver hung up unexpectedly"); + let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); + Cx::post_action(LoginAction::LoginFailure(err.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err, + }); + return; } - }, - None => { - error!("BUG: login_receiver hung up unexpectedly"); - let err = String::from("Please restart Robrix.\n\nUnable to listen for login requests."); - Cx::post_action(LoginAction::LoginFailure(err.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err, - }); - return; } } } - } - }; + }; - if validate_session { - match client.whoami().await { - Ok(_) => {} - Err(e) if is_invalid_token_http_error(&e) => { - clear_persisted_session(client.user_id()).await; - let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; - Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: err_msg.to_string(), - }); - continue 'login_loop; - } - Err(e) => { - warning!("Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}"); + if validate_session { + match client.whoami().await { + Ok(_) => {} + Err(e) if is_invalid_token_http_error(&e) => { + clear_persisted_session(client.user_id()).await; + let err_msg = "Your login token is no longer valid.\n\nPlease log in again."; + Cx::post_action(LoginAction::LoginFailure(err_msg.to_string())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: err_msg.to_string(), + }); + continue 'login_loop; + } + Err(e) => { + warning!("Session validation via whoami failed, but the error was not an invalid token; continuing startup: {e}"); + } } } - } - - // Deallocate the default SSO client after a successful login. - if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { - let _ = client_opt.take(); - } - let logged_in_user_id: OwnedUserId = client.user_id() - .expect("BUG: Client::user_id() returned None after successful login!") - .to_owned(); - let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); - enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + // Deallocate the default SSO client after a successful login. + if let Ok(mut client_opt) = DEFAULT_SSO_CLIENT.lock() { + let _ = client_opt.take(); + } - // Add the account to the AccountManager - let account = account_manager::Account { - client: client.clone(), - user_id: logged_in_user_id.clone(), - session, - display_name: None, - avatar_url: None, - }; - let is_new = account_manager::add_account(account); - log!("Added account {} to AccountManager. New account: {}", logged_in_user_id, is_new); + let logged_in_user_id: OwnedUserId = client.user_id() + .expect("BUG: Client::user_id() returned None after successful login!") + .to_owned(); + let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + + // Add the account to the AccountManager + let account = account_manager::Account { + client: client.clone(), + user_id: logged_in_user_id.clone(), + session, + display_name: None, + avatar_url: None, + }; + let is_new = account_manager::add_account(account); + log!("Added account {} to AccountManager. New account: {}", logged_in_user_id, is_new); - // Store this active client in our global Client state so that other tasks can access it. - if let Some(_existing) = CLIENT.lock().unwrap_or_else(|e| e.into_inner()).replace(client.clone()) { - error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); - } + // Store this active client in our global Client state so that other tasks can access it. + if let Some(_existing) = CLIENT.lock().unwrap().replace(client.clone()) { + error!("BUG: unexpectedly replaced an existing client when initializing the matrix client."); + } - // Listen for changes to our verification status and incoming verification requests. - add_verification_event_handlers_and_sync_client(client.clone()); + // Listen for changes to our verification status and incoming verification requests. + add_verification_event_handlers_and_sync_client(client.clone()); - // Listen for updates to the ignored user list. - handle_ignore_user_list_subscriber(client.clone()); + // Listen for updates to the ignored user list. + handle_ignore_user_list_subscriber(client.clone()); - Cx::post_action(LoginAction::Status { - title: "Connecting".into(), - status: "Setting up sync service...".into(), - }); - let sync_service = match SyncService::builder(client.clone()) - .with_offline_mode() - .build() - .await - { - Ok(ss) => ss, - Err(e) => { - error!("Failed to create SyncService: {e:?}"); - let err_msg = if is_invalid_token_error(&e) { - "Your login token is no longer valid.\n\nPlease log in again.".to_string() - } else { - format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") - }; - if is_invalid_token_error(&e) { - clear_persisted_session(client.user_id()).await; + Cx::post_action(LoginAction::Status { + title: "Connecting".into(), + status: "Setting up sync service...".into(), + }); + let sync_service = match SyncService::builder(client.clone()) + .with_offline_mode() + .build() + .await + { + Ok(ss) => ss, + Err(e) => { + error!("Failed to create SyncService: {e:?}"); + let err_msg = if is_invalid_token_error(&e) { + "Your login token is no longer valid.\n\nPlease log in again.".to_string() + } else { + format!("Please restart Robrix.\n\nFailed to create Matrix sync service: {e}.") + }; + if is_invalid_token_error(&e) { + clear_persisted_session(client.user_id()).await; + } + Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); + // Clear the stored client so the next login attempt doesn't trigger the + // "unexpectedly replaced an existing client" warning. + let _ = CLIENT.lock().unwrap().take(); + continue 'login_loop; } - Cx::post_action(LoginAction::LoginFailure(err_msg.clone())); - enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); - enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg }); - // Clear the stored client so the next login attempt doesn't trigger the - // "unexpectedly replaced an existing client" warning. - let _ = CLIENT.lock().unwrap_or_else(|e| e.into_inner()).take(); - continue 'login_loop; - } - }; + }; - break 'login_loop (client, sync_service, logged_in_user_id); - }; + break 'login_loop (client, sync_service, logged_in_user_id); + }; - let (session_reset_sender, mut session_reset_receiver) = - tokio::sync::mpsc::unbounded_channel::(); - // Listen for session changes, e.g., when the access token becomes invalid. - let session_change_handler_task = - handle_session_changes(client.clone(), session_reset_sender); + let (session_reset_sender, mut session_reset_receiver) = + tokio::sync::mpsc::unbounded_channel::(); + // Listen for session changes, e.g., when the access token becomes invalid. + let session_change_handler_task = + handle_session_changes(client.clone(), session_reset_sender); - // Signal login success now that SyncService::build() has already succeeded (inside - // 'login_loop), which is the only step that can fail with an invalid/expired token. - // Doing this before sync_service.start() lets the UI transition to the home screen - // without waiting for the sync loop to begin. - Cx::post_action(LoginAction::LoginSuccess); + // Signal login success now that SyncService::build() has already succeeded (inside + // 'login_loop), which is the only step that can fail with an invalid/expired token. + // Doing this before sync_service.start() lets the UI transition to the home screen + // without waiting for the sync loop to begin. + Cx::post_action(LoginAction::LoginSuccess); - // Attempt to load the previously-saved app state. - handle_load_app_state(logged_in_user_id.to_owned()); - handle_sync_indicator_subscriber(&sync_service); - handle_sync_service_state_subscriber(sync_service.state()); - sync_service.start().await; + // Attempt to load the previously-saved app state. + handle_load_app_state(logged_in_user_id.to_owned()); + handle_sync_indicator_subscriber(&sync_service); + handle_sync_service_state_subscriber(sync_service.state()); + sync_service.start().await; - let room_list_service = sync_service.room_list_service(); + let room_list_service = sync_service.room_list_service(); - if let Some(_existing) = SYNC_SERVICE.lock().unwrap_or_else(|e| e.into_inner()).replace(Arc::new(sync_service)) { - error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); - } + if let Some(_existing) = SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)) { + error!("BUG: unexpectedly replaced an existing sync service when initializing the matrix client."); + } - let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); - let mut space_service_task = rt.spawn(space_service_loop(client)); - - // Now, this task becomes an infinite loop that monitors the state of the - // three core matrix-related background tasks that we just spawned above. - #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. - let reauth_message: Option = loop { - tokio::select! { - session_reset = session_reset_receiver.recv() => { - match session_reset { - Some(SessionResetAction::Reauthenticate { message }) => { - break Some(message); - } - None => { - warning!("Session reset receiver closed unexpectedly."); - continue; + let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); + let mut space_service_task = rt.spawn(space_service_loop(client)); + + // Now, this task becomes an infinite loop that monitors the + // matrix/background tasks for the currently-authenticated session. + #[allow(clippy::never_loop)] // unsure if needed, just following tokio's examples. + let reauth_message: Option = loop { + tokio::select! { + session_reset = session_reset_receiver.recv() => { + match session_reset { + Some(SessionResetAction::Reauthenticate { message }) => { + break Some(message); + } + None => { + warning!("Session reset receiver closed unexpectedly."); + continue; + } } } - } - result = &mut matrix_worker_task_handle => { - session_change_handler_task.abort(); - match result { - Ok(Ok(())) => { - // Check if this is due to logout or account switch - if is_logout_in_progress() { - log!("matrix worker task ended due to logout"); - } else if is_account_switch_pending() { - log!("matrix worker task ended due to account switch"); - } else { - error!("BUG: matrix worker task ended unexpectedly!"); + result = &mut matrix_worker_task_handle => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + // Check if this is due to logout or account switch + if is_logout_in_progress() { + log!("matrix worker task ended due to logout"); + } else if is_account_switch_pending() { + log!("matrix worker task ended due to account switch"); + } else { + error!("BUG: matrix worker task ended unexpectedly!"); + } } - } - Ok(Err(e)) => { - // Check if this is due to logout or account switch - if is_logout_in_progress() { - log!("matrix worker task ended with error due to logout: {e:?}"); - } else if is_account_switch_pending() { - log!("matrix worker task ended with error due to account switch: {e:?}"); - } else { - error!("Error: matrix worker task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Rooms list update error: {e}"), - PopupKind::Error, - None, - ); + Ok(Err(e)) => { + // Check if this is due to logout or account switch + if is_logout_in_progress() { + log!("matrix worker task ended with error due to logout: {e:?}"); + } else if is_account_switch_pending() { + log!("matrix worker task ended with error due to account switch: {e:?}"); + } else { + error!("Error: matrix worker task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Rooms list update error: {e}"), + PopupKind::Error, + None, + ); + } + }, + Err(e) => { + error!("BUG: failed to join matrix worker task: {e:?}"); } - }, - Err(e) => { - error!("BUG: failed to join matrix worker task: {e:?}"); } + break None; } - break None; - } - result = &mut room_list_service_task => { - session_change_handler_task.abort(); - match result { - Ok(Ok(())) => { - if is_logout_in_progress() || is_account_switch_pending() { - log!("room list service loop task ended due to logout/account switch"); - } else { - error!("BUG: room list service loop task ended unexpectedly!"); + result = &mut room_list_service_task => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + if is_logout_in_progress() || is_account_switch_pending() { + log!("room list service loop task ended due to logout/account switch"); + } else { + error!("BUG: room list service loop task ended unexpectedly!"); + } } - } - Ok(Err(e)) => { - if !is_logout_in_progress() && !is_account_switch_pending() { - error!("Error: room list service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Room list service error: {e}"), - PopupKind::Error, - None, - ); + Ok(Err(e)) => { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Error: room list service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Room list service error: {e}"), + PopupKind::Error, + None, + ); + } + }, + Err(e) => { + error!("BUG: failed to join room list service loop task: {e:?}"); } - }, - Err(e) => { - error!("BUG: failed to join room list service loop task: {e:?}"); } + break None; } - break None; - } - result = &mut space_service_task => { - session_change_handler_task.abort(); - match result { - Ok(Ok(())) => { - if is_logout_in_progress() || is_account_switch_pending() { - log!("space service loop task ended due to logout/account switch"); - } else { - error!("BUG: space service loop task ended unexpectedly!"); + result = &mut space_service_task => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + if is_logout_in_progress() || is_account_switch_pending() { + log!("space service loop task ended due to logout/account switch"); + } else { + error!("BUG: space service loop task ended unexpectedly!"); + } } - } - Ok(Err(e)) => { - if !is_logout_in_progress() && !is_account_switch_pending() { - error!("Error: space service loop task ended:\n\t{e:?}"); - rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { - status: e.to_string(), - }); - enqueue_popup_notification( - format!("Space service error: {e}"), - PopupKind::Error, - None, - ); + Ok(Err(e)) => { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Error: space service loop task ended:\n\t{e:?}"); + rooms_list::enqueue_rooms_list_update(RoomsListUpdate::Status { + status: e.to_string(), + }); + enqueue_popup_notification( + format!("Space service error: {e}"), + PopupKind::Error, + None, + ); + } + }, + Err(e) => { + error!("BUG: failed to join space service loop task: {e:?}"); } - }, - Err(e) => { - error!("BUG: failed to join space service loop task: {e:?}"); } + break None; } - break None; } - } - }; + }; - // Check if we need to restart for an account switch (loop to handle consecutive switches) - while let Some(switch_user_id) = take_account_switch_target() { - // Clear all backend state - CLIENT.lock().unwrap_or_else(|e| e.into_inner()).take(); - SYNC_SERVICE.lock().unwrap_or_else(|e| e.into_inner()).take(); - ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).clear(); - IGNORED_USERS.lock().unwrap_or_else(|e| e.into_inner()).clear(); - - // Clear the rooms list UI - enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); - enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); - - // Post action to clear UI state - Cx::post_action(AccountSwitchAction::Starting(switch_user_id.clone())); - - // Update active account - account_manager::set_active_account(&switch_user_id); - - // Restore session for the switched account - match persistence::restore_session(Some(switch_user_id.clone())).await { - Ok((client, _sync_token, _session)) => { - // Store the client - CLIENT.lock().unwrap_or_else(|e| e.into_inner()).replace(client.clone()); - - // Set up the new client - add_verification_event_handlers_and_sync_client(client.clone()); - handle_ignore_user_list_subscriber(client.clone()); - - // Create new sync service - let sync_service = match SyncService::builder(client.clone()) - .with_offline_mode() - .build() - .await - { - Ok(ss) => ss, - Err(e) => { - error!("Failed to create SyncService: {e:?}"); - Cx::post_action(AccountSwitchAction::Failed(format!("Failed to create sync service: {e}"))); - return; - } - }; + // Check if we need to restart for an account switch (loop to handle consecutive switches) + while let Some(switch_user_id) = take_account_switch_target() { + // Clear all backend state + CLIENT.lock().unwrap().take(); + SYNC_SERVICE.lock().unwrap().take(); + ALL_JOINED_ROOMS.lock().unwrap().clear(); + IGNORED_USERS.lock().unwrap().clear(); + + // Clear the rooms list UI + enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); + enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); + + // Post action to clear UI state + Cx::post_action(AccountSwitchAction::Starting(switch_user_id.clone())); + + // Update active account + account_manager::set_active_account(&switch_user_id); + // Recreate worker task and service loops + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); + REQUEST_SENDER.lock().unwrap().replace(sender); + // Restore session for the switched account + match persistence::restore_session(Some(switch_user_id.clone())).await { + Ok((client, _sync_token, _session)) => { + // Store the client + CLIENT.lock().unwrap().replace(client.clone()); + + // Set up the new client + add_verification_event_handlers_and_sync_client(client.clone()); + handle_ignore_user_list_subscriber(client.clone()); + + // Create new sync service + let sync_service = match SyncService::builder(client.clone()) + .with_offline_mode() + .build() + .await + { + Ok(ss) => ss, + Err(e) => { + error!("Failed to create SyncService: {e:?}"); + Cx::post_action(AccountSwitchAction::Failed(format!("Failed to create sync service: {e}"))); + return; + } + }; - // Load app state for the new user - handle_load_app_state(switch_user_id.clone()); - handle_sync_indicator_subscriber(&sync_service); - handle_sync_service_state_subscriber(sync_service.state()); - sync_service.start().await; - let room_list_service = sync_service.room_list_service(); - - SYNC_SERVICE.lock().unwrap_or_else(|e| e.into_inner()).replace(Arc::new(sync_service)); - - // Recreate worker task and service loops - let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); - REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()).replace(sender); - let (login_sender, _login_receiver) = tokio::sync::mpsc::channel(1); - - // Set up session change handler for the switched account - let (session_reset_sender, mut session_reset_receiver) = - tokio::sync::mpsc::unbounded_channel::(); - let session_change_handler_task = - handle_session_changes(client.clone(), session_reset_sender); - - let mut matrix_worker_task_handle = rt.spawn(matrix_worker_task(receiver, login_sender)); - let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); - let mut space_service_task = rt.spawn(space_service_loop(client.clone())); - - // Notify UI that switch is complete (app.rs handles the popup notification) - Cx::post_action(AccountSwitchAction::Switched(switch_user_id.clone())); - - // Re-enter the main monitoring loop - loop { - tokio::select! { - session_reset = session_reset_receiver.recv() => { - match session_reset { - Some(SessionResetAction::Reauthenticate { message }) => { - error!("Session reset during account switch: {}", message); - session_change_handler_task.abort(); - room_list_service_task.abort(); - space_service_task.abort(); - Cx::post_action(AccountSwitchAction::Failed(message)); - break; - } - None => { - warning!("Session reset receiver closed unexpectedly."); - continue; + // Load app state for the new user + handle_load_app_state(switch_user_id.clone()); + handle_sync_indicator_subscriber(&sync_service); + handle_sync_service_state_subscriber(sync_service.state()); + sync_service.start().await; + let room_list_service = sync_service.room_list_service(); + + SYNC_SERVICE.lock().unwrap().replace(Arc::new(sync_service)); + + let (login_sender, _login_receiver) = tokio::sync::mpsc::channel(1); + + // Set up session change handler for the switched account + let (session_reset_sender, mut session_reset_receiver) = + tokio::sync::mpsc::unbounded_channel::(); + let session_change_handler_task = + handle_session_changes(client.clone(), session_reset_sender); + + let mut matrix_worker_task_handle = rt.spawn(matrix_worker_task(receiver, login_sender)); + let mut room_list_service_task = rt.spawn(room_list_service_loop(room_list_service)); + let mut space_service_task = rt.spawn(space_service_loop(client.clone())); + + // Notify UI that switch is complete (app.rs handles the popup notification) + Cx::post_action(AccountSwitchAction::Switched(switch_user_id.clone())); + + // Re-enter the main monitoring loop + loop { + tokio::select! { + session_reset = session_reset_receiver.recv() => { + match session_reset { + Some(SessionResetAction::Reauthenticate { message }) => { + error!("Session reset during account switch: {}", message); + session_change_handler_task.abort(); + room_list_service_task.abort(); + space_service_task.abort(); + Cx::post_action(AccountSwitchAction::Failed(message)); + break; + } + None => { + warning!("Session reset receiver closed unexpectedly."); + continue; + } } } - } - result = &mut matrix_worker_task_handle => { - session_change_handler_task.abort(); - match result { - Ok(Ok(())) => { - if !is_logout_in_progress() && !is_account_switch_pending() { - error!("BUG: matrix worker task ended unexpectedly!"); + result = &mut matrix_worker_task_handle => { + session_change_handler_task.abort(); + match result { + Ok(Ok(())) => { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("BUG: matrix worker task ended unexpectedly!"); + } } - } - Ok(Err(e)) => { - if !is_logout_in_progress() && !is_account_switch_pending() { - error!("Error: matrix worker task ended:\n\t{e:?}"); + Ok(Err(e)) => { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Error: matrix worker task ended:\n\t{e:?}"); + } + } + Err(e) => { + error!("BUG: failed to join matrix worker task: {e:?}"); } } - Err(e) => { - error!("BUG: failed to join matrix worker task: {e:?}"); - } + break; } - break; - } - result = &mut room_list_service_task => { - session_change_handler_task.abort(); - if let Err(e) = result { - if !is_logout_in_progress() && !is_account_switch_pending() { - error!("Room list service task error: {e:?}"); + result = &mut room_list_service_task => { + session_change_handler_task.abort(); + if let Err(e) = result { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Room list service task error: {e:?}"); + } } + break; } - break; - } - result = &mut space_service_task => { - session_change_handler_task.abort(); - if let Err(e) = result { - if !is_logout_in_progress() && !is_account_switch_pending() { - error!("Space service task error: {e:?}"); + result = &mut space_service_task => { + session_change_handler_task.abort(); + if let Err(e) = result { + if !is_logout_in_progress() && !is_account_switch_pending() { + error!("Space service task error: {e:?}"); + } } + break; } - break; } } + // After inner loop breaks, outer while loop will check for another pending account switch + } + Err(e) => { + error!("Failed to restore session for account switch: {e:?}"); + Cx::post_action(AccountSwitchAction::Failed(format!("Failed to restore session: {e}"))); + enqueue_popup_notification( + format!("Account switch failed: {e}"), + PopupKind::Error, + None, + ); + // Don't loop back - a failed switch shouldn't keep trying + break; } - // After inner loop breaks, outer while loop will check for another pending account switch - } - Err(e) => { - error!("Failed to restore session for account switch: {e:?}"); - Cx::post_action(AccountSwitchAction::Failed(format!("Failed to restore session: {e}"))); - enqueue_popup_notification( - format!("Account switch failed: {e}"), - PopupKind::Error, - None, - ); - // Don't loop back - a failed switch shouldn't keep trying - break; } } - } - // Only run reauth cleanup if we got a reauth message (not account switch or logout) - if let Some(reauth_msg) = reauth_message { - session_change_handler_task.abort(); - room_list_service_task.abort(); - space_service_task.abort(); + // Only run reauth cleanup if we got a reauth message (not account switch or logout) + if let Some(reauth_msg) = reauth_message { + session_change_handler_task.abort(); + room_list_service_task.abort(); + space_service_task.abort(); - reset_runtime_state_for_relogin().await; - Cx::post_action(LoginAction::LoginFailure(reauth_msg.clone())); - enqueue_rooms_list_update(RoomsListUpdate::Status { - status: reauth_msg, - }); + reset_runtime_state_for_relogin().await; + Cx::post_action(LoginAction::LoginFailure(reauth_msg.clone())); + enqueue_rooms_list_update(RoomsListUpdate::Status { + status: reauth_msg, + }); + } } } @@ -3348,7 +3298,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu } // ALL_JOINED_ROOMS should already be empty due to successive calls to `remove_room()`, // so this is just a sanity check. - ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).clear(); + ALL_JOINED_ROOMS.lock().unwrap().clear(); enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); } else { @@ -3388,7 +3338,7 @@ async fn room_list_service_loop(room_list_service: Arc) -> Resu VectorDiff::Clear => { if LOG_ROOM_LIST_DIFFS { log!("room_list: diff Clear"); } all_known_rooms.clear(); - ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).clear(); + ALL_JOINED_ROOMS.lock().unwrap().clear(); enqueue_rooms_list_update(RoomsListUpdate::RoomOrderUpdate(VecDiff::Clear)); enqueue_rooms_list_update(RoomsListUpdate::ClearRooms); } @@ -3696,7 +3646,7 @@ async fn update_room( let mut __timeline_update_sender_opt = None; let mut get_timeline_update_sender = |room_id| { if __timeline_update_sender_opt.is_none() { - if let Some(jrd) = ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).get(room_id) { + if let Some(jrd) = ALL_JOINED_ROOMS.lock().unwrap().get(room_id) { __timeline_update_sender_opt = Some(jrd.main_timeline.timeline_update_sender.clone()); } } @@ -3747,7 +3697,7 @@ async fn update_room( /// Invoked when the room list service has received an update to remove an existing room. fn remove_room(room: &RoomListServiceRoomInfo) { - ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).remove(&room.room_id); + ALL_JOINED_ROOMS.lock().unwrap().remove(&room.room_id); enqueue_rooms_list_update( RoomsListUpdate::RemoveRoom { room_id: room.room_id.clone(), @@ -3855,7 +3805,7 @@ async fn add_new_room( // an `AddJoinedRoom` update to the RoomsList widget, because that widget might // immediately issue a `MatrixRequest` that relies on that room being in `ALL_JOINED_ROOMS`. log!("Adding new joined room {}, name: {:?}", new_room.room_id, new_room.display_name); - ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).insert( + ALL_JOINED_ROOMS.lock().unwrap().insert( new_room.room_id.clone(), JoinedRoomDetails { room_id: new_room.room_id.clone(), @@ -3932,7 +3882,7 @@ fn handle_ignore_user_list_subscriber(client: Client) { .collect::>(); // TODO: when we support persistent state, don't forget to update `IGNORED_USERS` upon app boot. - let mut ignored_users_old = IGNORED_USERS.lock().unwrap_or_else(|e| e.into_inner()); + let mut ignored_users_old = IGNORED_USERS.lock().unwrap(); let has_changed = *ignored_users_old != ignored_users_new; *ignored_users_old = ignored_users_new; @@ -4766,7 +4716,7 @@ async fn spawn_sso_server( // We do not clone it because a Client cannot be re-used again // once it has been used for a login attempt, so this forces us to create a new one // if that occurs. - let client_and_session_opt = DEFAULT_SSO_CLIENT.lock().unwrap_or_else(|e| e.into_inner()).take(); + let client_and_session_opt = DEFAULT_SSO_CLIENT.lock().unwrap().take(); Handle::current().spawn(async move { // Try to use the DEFAULT_SSO_CLIENT that we proactively created @@ -4843,7 +4793,6 @@ async fn spawn_sso_server( }) { Ok(identity_provider_res) => { if !is_logged_in { - // SSO login doesn't support add-account mode yet, so pass false if let Err(e) = login_sender.send(LoginRequest::LoginBySSOSuccess(client, client_session, false)).await { error!("Error sending login request to login_sender: {e:?}"); Cx::post_action(LoginAction::LoginFailure(String::from( @@ -5025,7 +4974,7 @@ impl UserPowerLevels { /// Shuts down the current Tokio runtime completely and takes ownership to ensure proper cleanup. pub fn shutdown_background_tasks() { - if let Some(runtime) = TOKIO_RUNTIME.lock().unwrap_or_else(|e| e.into_inner()).take() { + if let Some(runtime) = TOKIO_RUNTIME.lock().unwrap().take() { runtime.shutdown_background(); } } @@ -5033,11 +4982,11 @@ pub fn shutdown_background_tasks() { pub async fn clear_app_state(config: &LogoutConfig) -> Result<()> { // Clear resources normally, allowing them to be properly dropped // This prevents memory leaks when users logout and login again without closing the app - CLIENT.lock().unwrap_or_else(|e| e.into_inner()).take(); - SYNC_SERVICE.lock().unwrap_or_else(|e| e.into_inner()).take(); - REQUEST_SENDER.lock().unwrap_or_else(|e| e.into_inner()).take(); - IGNORED_USERS.lock().unwrap_or_else(|e| e.into_inner()).clear(); - ALL_JOINED_ROOMS.lock().unwrap_or_else(|e| e.into_inner()).clear(); + CLIENT.lock().unwrap().take(); + SYNC_SERVICE.lock().unwrap().take(); + REQUEST_SENDER.lock().unwrap().take(); + IGNORED_USERS.lock().unwrap().clear(); + ALL_JOINED_ROOMS.lock().unwrap().clear(); let on_clear_appstate = Arc::new(Notify::new()); Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); From 6526855a87ab89134a6a2cce260059d7dfdd90e2 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: Wed, 1 Apr 2026 22:17:06 +0800 Subject: [PATCH 53/66] feat(settings): support avatar upload and robust avatar deletion - add desktop avatar file picker upload flow in account settings\n- add MatrixRequest::UploadAvatar worker path with PNG/JPEG validation\n- add fallback delete-avatar request for homeservers returning M_UNRECOGNIZED\n- show not-supported notices for avatar actions on mobile platforms --- Cargo.lock | 496 ++++++++++++++++++++++++++++++- Cargo.toml | 4 + src/settings/account_settings.rs | 44 ++- src/sliding_sync.rs | 83 +++++- 4 files changed, 603 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e87a8380..73e88fd6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,6 +239,28 @@ dependencies = [ "libloading", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend 0.3.15", + "wayland-client 0.31.14", + "wayland-protocols 0.32.12", + "zbus", +] + [[package]] name = "askar-crypto" version = "0.3.7" @@ -334,6 +356,18 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f093eed78becd229346bf859eec0aa4dd7ddde0757287b2b4107a1f09c80002" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -359,6 +393,49 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.1", +] + [[package]] name = "async-lock" version = "3.4.1" @@ -370,12 +447,52 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-once-cell" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "async-rx" version = "0.1.3" @@ -386,6 +503,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.1", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -408,6 +543,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -691,6 +832,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bls12_381" version = "0.8.0" @@ -1459,7 +1613,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.1", ] [[package]] @@ -1469,6 +1623,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "block2", + "libc", "objc2", ] @@ -1483,6 +1639,15 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1592,6 +1757,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1605,7 +1797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.1", ] [[package]] @@ -2800,7 +2992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.53.4", ] [[package]] @@ -3156,9 +3348,9 @@ dependencies = [ "napi-ohos", "ohos-sys", "smallvec 1.15.1 (git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements)", - "wayland-client", + "wayland-client 0.31.12", "wayland-egl", - "wayland-protocols", + "wayland-protocols 0.32.10", "windows 0.62.2", "windows-core 0.62.2", "windows-targets 0.52.6", @@ -3680,6 +3872,15 @@ name = "memchr" version = "2.7.6" source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -3949,6 +4150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "block2", "objc2", "objc2-foundation", ] @@ -4077,6 +4279,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "p256" version = "0.13.2" @@ -4246,6 +4458,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -4273,6 +4496,26 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.1", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "poly1305" version = "0.8.0" @@ -4435,6 +4678,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr 2.7.6 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4580,6 +4832,12 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223" +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "readlock" version = "0.1.9" @@ -4722,6 +4980,30 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -4847,11 +5129,13 @@ dependencies = [ "matrix-sdk", "matrix-sdk-base", "matrix-sdk-ui", + "mime", "percent-encoding", "quinn", "rand 0.8.5", "rangemap", "reqwest", + "rfd", "robius-directories", "robius-location", "robius-open", @@ -5101,7 +5385,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.1", ] [[package]] @@ -5448,6 +5732,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "serde_spanned" version = "1.0.3" @@ -5949,7 +6244,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.1", ] [[package]] @@ -6405,6 +6700,17 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e36a83ea2b3c704935a01b4642946aadd445cea40b10935e3f8bd8052b8193d6" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.1", +] + [[package]] name = "ulid" version = "1.2.1" @@ -6778,7 +7084,21 @@ dependencies = [ "libc", "scoped-tls", "smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-sys", + "wayland-sys 0.31.8", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec 1.15.1 (registry+https://github.com/rust-lang/crates.io-index)", + "wayland-sys 0.31.11", ] [[package]] @@ -6788,7 +7108,19 @@ source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvement dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", "libc", - "wayland-backend", + "wayland-backend 0.3.12", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "rustix", + "wayland-backend 0.3.15", + "wayland-scanner", ] [[package]] @@ -6796,8 +7128,8 @@ name = "wayland-egl" version = "0.32.9" source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ - "wayland-backend", - "wayland-sys", + "wayland-backend 0.3.12", + "wayland-sys 0.31.8", ] [[package]] @@ -6806,8 +7138,31 @@ version = "0.32.10" source = "git+https://github.com/kevinaboos/makepad?branch=stack_nav_improvements#461b05134a501b8e67f431b2706fb200d4bcf68a" dependencies = [ "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", - "wayland-backend", - "wayland-client", + "wayland-backend 0.3.12", + "wayland-client 0.31.12", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "wayland-backend 0.3.15", + "wayland-client 0.31.14", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", ] [[package]] @@ -6819,6 +7174,17 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.84" @@ -6883,7 +7249,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.1", ] [[package]] @@ -7484,6 +7850,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.1", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -7583,3 +8010,44 @@ name = "zmij" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.106", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 8bd24357a..28eeb092f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ hashbrown = { version = "0.16", features = ["raw-entry"] } htmlize = "1.0.5" indexmap = "2.6.0" imghdr = "0.7.0" +mime = "0.3" linkify = "0.10.0" matrix-sdk-base = { git = "https://github.com/matrix-org/matrix-rust-sdk", branch = "main" } matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk", branch = "main", default-features = false, features = [ @@ -78,6 +79,9 @@ tracing-subscriber = "0.3.17" unicode-segmentation = "1.11.0" url = "2.5.0" +[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] +rfd = "0.15" + ## Dependencies for TSP support. ## Commit "f0bc4625dcd729e07e4a36257df2f1d94c81cef4" is the most recent one without the invalid change to pin serde to 1.0.219. diff --git a/src/settings/account_settings.rs b/src/settings/account_settings.rs index 4669039d4..b3226e7ec 100644 --- a/src/settings/account_settings.rs +++ b/src/settings/account_settings.rs @@ -1,6 +1,8 @@ use std::cell::RefCell; use makepad_widgets::{text::selection::Cursor, *}; +#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] +use rfd::FileDialog; use crate::{app::ConfirmDeleteAction, avatar_cache::{self}, logout::logout_confirm_modal::{LogoutAction, LogoutConfirmModalAction}, profile::user_profile::UserProfile, shared::{avatar::{AvatarState, AvatarWidgetExt}, confirmation_modal::ConfirmationModalContent, popup_list::{PopupKind, enqueue_popup_notification}, styles::*}, sliding_sync::{AccountDataAction, MatrixRequest, submit_async_request}, utils}; @@ -376,17 +378,34 @@ impl MatchEvent for AccountSettings { let Some(own_profile) = &self.own_profile else { return }; if upload_avatar_button.clicked(actions) { - // TODO: uncomment the below once avatar uploading is implemented - // Self::enable_upload_avatar_button(cx, false, &upload_avatar_button); - // Self::enable_delete_avatar_button(cx, false, &delete_avatar_button); - enqueue_popup_notification( - "Avatar uploading is not yet implemented.", - PopupKind::Warning, - Some(4.0), - ); + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + { + if let Some(avatar_path) = FileDialog::new() + .add_filter("Image", &["png", "jpg", "jpeg"]) + .pick_file() + { + submit_async_request(MatrixRequest::UploadAvatar { avatar_path }); + cx.action(AccountSettingsAction::AvatarUploadStarted); + enqueue_popup_notification( + "Uploading avatar...", + PopupKind::Info, + Some(5.0), + ); + } + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + enqueue_popup_notification( + "Avatar uploading is not yet supported on this platform.", + PopupKind::Warning, + Some(4.0), + ); + } } if delete_avatar_button.clicked(actions) { + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + { // Don't immediately disable the buttons. Instead, we wait for the user // to confirm the action in the confirmation modal, // and then we disable the buttons in the AvatarDeleteStarted action handler. @@ -406,6 +425,15 @@ impl MatchEvent for AccountSettings { ..Default::default() }; cx.action(ConfirmDeleteAction::Show(RefCell::new(Some(content)))); + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + enqueue_popup_notification( + "Deleting avatar is not yet supported on this platform.", + PopupKind::Warning, + Some(4.0), + ); + } } // Enable the name change buttons if the user modified the display name to be different. diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 29649c3fb..a89c121d5 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -6,6 +6,7 @@ use eyeball_im::VectorDiff; use futures_util::{future::join_all, pin_mut, StreamExt}; use imbl::Vector; use makepad_widgets::{error, log, warning, Cx, SignalToUI}; +use mime::{IMAGE_JPEG, IMAGE_PNG}; use matrix_sdk_base::crypto::{DecryptionSettings, TrustRequirement}; use matrix_sdk::{ config::RequestConfig, encryption::EncryptionSettings, event_handler::EventHandlerDropGuard, media::MediaRequestParameters, room::{edit::EditedContent, reply::Reply, IncludeRelations, RelationsOptions, RoomMember}, ruma::{ @@ -14,7 +15,7 @@ use matrix_sdk::{ room::create_room::v3::{Request as CreateRoomRequest, RoomPreset}, directory::get_public_rooms_filtered, error::ErrorKind, - profile::{AvatarUrl, DisplayName}, + profile::{AvatarUrl, DisplayName, set_avatar_url}, receipt::create_receipt::v3::ReceiptType, uiaa::{AuthData, AuthType, Dummy}, }}, directory::{Filter as PublicRoomsFilter, RoomTypeFilter}, events::{ @@ -37,7 +38,7 @@ use tokio::{ sync::{broadcast, mpsc::{Sender, UnboundedReceiver, UnboundedSender}, watch, Notify}, task::JoinHandle, time::error::Elapsed, }; use url::Url; -use std::{borrow::Cow, cmp::{max, min}, future::Future, hash::{BuildHasherDefault, DefaultHasher}, iter::Peekable, ops::{Deref, DerefMut, Not}, path:: Path, sync::{Arc, LazyLock, Mutex}, time::Duration}; +use std::{borrow::Cow, cmp::{max, min}, future::Future, hash::{BuildHasherDefault, DefaultHasher}, iter::Peekable, ops::{Deref, DerefMut, Not}, path::{ Path, PathBuf }, sync::{Arc, LazyLock, Mutex}, time::Duration}; use std::io; use hashbrown::{HashMap, HashSet}; use crate::{ @@ -803,6 +804,11 @@ pub enum MatrixRequest { /// which is only needed because it isn't present in the `RoomMember` object. room_id: OwnedRoomId, }, + /// Request to upload and set the avatar of the current user's account. + UploadAvatar { + /// The path to a local PNG or JPEG image file. + avatar_path: PathBuf, + }, /// Request to set or remove the avatar of the current user's account. SetAvatar { /// * If `Some`, the avatar will be set to the given MXC URI. @@ -1840,6 +1846,55 @@ async fn matrix_worker_task( }); } + MatrixRequest::UploadAvatar { avatar_path } => { + let Some(client) = get_client() else { continue }; + let _upload_avatar_task = Handle::current().spawn(async move { + let data = match std::fs::read(&avatar_path) { + Ok(data) => data, + Err(e) => { + Cx::post_action(AccountDataAction::AvatarChangeFailed( + format!("Failed to read selected avatar file {:?}: {e}", avatar_path) + )); + return; + } + }; + + let content_type = match imghdr::from_bytes(&data) { + Some(imghdr::Type::Png) => IMAGE_PNG, + Some(imghdr::Type::Jpeg) => IMAGE_JPEG, + _ => { + let ext = avatar_path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()); + match ext.as_deref() { + Some("png") => IMAGE_PNG, + Some("jpg") | Some("jpeg") => IMAGE_JPEG, + _ => { + Cx::post_action(AccountDataAction::AvatarChangeFailed( + "Unsupported avatar format. Please choose a PNG or JPEG image.".to_string() + )); + return; + } + } + } + }; + + log!("Uploading avatar from file: {:?}", avatar_path); + match client.account().upload_avatar(&content_type, data).await { + Ok(new_avatar_uri) => { + log!("Successfully uploaded avatar."); + Cx::post_action(AccountDataAction::AvatarChanged(Some(new_avatar_uri))); + } + Err(e) => { + Cx::post_action(AccountDataAction::AvatarChangeFailed( + format!("Failed to upload avatar: {e}") + )); + } + } + }); + } + MatrixRequest::SetAvatar { avatar_url } => { let Some(client) = get_client() else { continue }; let _set_avatar_task = Handle::current().spawn(async move { @@ -1852,6 +1907,30 @@ async fn matrix_worker_task( Cx::post_action(AccountDataAction::AvatarChanged(avatar_url)); } Err(e) => { + if is_removing && e.client_api_error_kind() == Some(&ErrorKind::Unrecognized) { + log!("Avatar delete endpoint not recognized by homeserver, retrying fallback request..."); + let Some(user_id) = client.user_id() else { + Cx::post_action(AccountDataAction::AvatarChangeFailed( + "Failed to remove avatar: not authenticated.".to_string() + )); + return; + }; + #[allow(deprecated)] + let fallback_result = client.send( + set_avatar_url::v3::Request::new(user_id.to_owned(), None) + ).await; + match fallback_result { + Ok(_) => { + log!("Successfully removed avatar via fallback endpoint."); + Cx::post_action(AccountDataAction::AvatarChanged(None)); + } + Err(fallback_err) => { + let err_msg = format!("Failed to remove avatar: {fallback_err}"); + Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); + } + } + return; + } let err_msg = format!("Failed to {} avatar: {e}", if is_removing { "remove" } else { "set" }); Cx::post_action(AccountDataAction::AvatarChangeFailed(err_msg)); } From 0df3aca6900f0c772221d5506198c42b43ad51ee Mon Sep 17 00:00:00 2001 From: alanpoon Date: Wed, 1 Apr 2026 22:34:49 +0800 Subject: [PATCH 54/66] fix clippy --- src/app.rs | 1 + src/sliding_sync.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 1ec79ceda..a23a4ac8f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -473,6 +473,7 @@ fn init_file_logging() -> Option<()> { /// Writes a log message to the log file (if file logging is enabled). #[cfg(not(any(target_os = "android", target_os = "ios")))] +#[allow(dead_code)] fn write_to_log_file(message: &str) { if let Some(Some(file_mutex)) = LOG_FILE.get() { if let Ok(mut file) = file_mutex.lock() { diff --git a/src/sliding_sync.rs b/src/sliding_sync.rs index 02a5cfaf9..e5f6d55ab 100644 --- a/src/sliding_sync.rs +++ b/src/sliding_sync.rs @@ -385,7 +385,7 @@ async fn login( } else { (cli, false) }; - let (client, client_session) = build_client(&cli, app_data_dir()).await?; + let (client, client_session) = build_client(cli, app_data_dir()).await?; Cx::post_action(LoginAction::Status { title: "Authenticating".into(), status: format!("Logging in as {}...", cli.user_id), From c2323655d5d340d8f4bdc8befbc7310160a842ad 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: Thu, 2 Apr 2026 01:17:04 +0800 Subject: [PATCH 55/66] feat(i18n): localize major UI copy across app screens - add English and Simplified Chinese translation resources - introduce i18n module and wire AppLanguage-driven text updates - replace hardcoded UI strings in home/login/settings/room/tsp flows --- resources/i18n/en.json | 420 ++++++++++++++++++++++++++ resources/i18n/zh-CN.json | 420 ++++++++++++++++++++++++++ src/app.rs | 53 +++- src/home/add_room.rs | 372 +++++++++++++++++------ src/home/home_screen.rs | 2 +- src/home/invite_modal.rs | 73 ++++- src/home/invite_screen.rs | 54 +++- src/home/loading_pane.rs | 81 +++-- src/home/room_context_menu.rs | 61 ++-- src/home/room_screen.rs | 446 ++++++++++++++++++++-------- src/home/rooms_list.rs | 37 +-- src/home/rooms_list_entry.rs | 29 +- src/home/rooms_list_header.rs | 42 ++- src/home/search_messages.rs | 21 ++ src/home/space_lobby.rs | 144 ++++++--- src/home/spaces_bar.rs | 54 +++- src/home/welcome_screen.rs | 64 +++- src/i18n.rs | 115 +++++++ src/lib.rs | 2 + src/login/login_screen.rs | 125 +++++--- src/room/room_input_bar.rs | 3 +- src/settings/account_settings.rs | 120 ++++++-- src/settings/bot_settings.rs | 60 +++- src/settings/settings_screen.rs | 299 ++++++++++++++++--- src/shared/collapsible_header.rs | 28 +- src/shared/room_filter_input_bar.rs | 24 ++ src/tsp/tsp_settings_screen.rs | 120 +++++--- src/tsp/wallet_entry/mod.rs | 55 ++-- src/tsp_dummy/mod.rs | 54 +++- 29 files changed, 2827 insertions(+), 551 deletions(-) create mode 100644 resources/i18n/en.json create mode 100644 resources/i18n/zh-CN.json create mode 100644 src/i18n.rs diff --git a/resources/i18n/en.json b/resources/i18n/en.json new file mode 100644 index 000000000..acdca7351 --- /dev/null +++ b/resources/i18n/en.json @@ -0,0 +1,420 @@ +{ + "settings.all_settings_title": "All Settings", + "settings.category.account": "Account", + "settings.category.preferences": "Preferences", + "settings.category.labs": "Labs", + "settings.preferences.language.title": "Language", + "settings.preferences.language.application_label": "Application language", + "settings.preferences.language.reload_hint": "The app will reload after selecting another language", + "language.option.english": "English", + "language.option.chinese_simplified": "Simplified Chinese", + + "login.title.login_to_robrix": "Login to Robrix", + "login.title.create_account": "Create your Robrix account", + "login.input.user_id": "User ID", + "login.input.password": "Password", + "login.input.confirm_password": "Confirm password", + "login.input.homeserver": "matrix.org", + "login.label.homeserver_optional": "Homeserver URL (optional)", + "login.button.login": "Login", + "login.button.create_account": "Create account", + "login.sso.prompt": "Or, login with an SSO provider:", + "login.account_prompt.no_account": "Don't have an account?", + "login.account_prompt.already_have": "Already have an account?", + "login.mode_toggle.sign_up_here": "Sign up here", + "login.mode_toggle.back_to_login": "Back to login", + "login.status.missing_user_id.title": "Missing User ID", + "login.status.missing_user_id.body": "Please enter a valid User ID.", + "login.status.missing_password.title": "Missing Password", + "login.status.missing_password.body": "Please enter a valid password.", + "login.status.password_mismatch.title": "Passwords do not match", + "login.status.password_mismatch.body": "Please enter the same password in both password fields.", + "login.status.creating_account.title": "Creating account...", + "login.status.creating_account.body": "Waiting for the homeserver to create your account...", + "login.status.logging_in.title": "Logging in...", + "login.status.logging_in.body": "Waiting for a login response...", + "login.status.logging_in_cli.title": "Logging in via CLI...", + "login.status.auto_logging_in_as_user": "Auto-logging in as user {user_id}...", + "login.status.account_creation_failed": "Account Creation Failed.", + "login.status.login_failed": "Login Failed.", + "login.status.okay": "Okay", + "login.status.cancel": "Cancel", + "login_status_modal.title": "Login Status", + "login_status_modal.button.cancel": "Cancel", + + "room_context_menu.button.mark_unread": "Mark as Unread", + "room_context_menu.button.mark_read": "Mark as Read", + "room_context_menu.button.favorite": "Favorite", + "room_context_menu.button.unfavorite": "Un-favorite", + "room_context_menu.button.set_low_priority": "Set Low Priority", + "room_context_menu.button.unset_low_priority": "Un-set Low Priority", + "room_context_menu.button.copy_link_to_room": "Copy Link to Room", + "room_context_menu.button.settings": "Settings", + "room_context_menu.button.notifications": "Notifications", + "room_context_menu.button.invite": "Invite", + "room_context_menu.button.bind_botfather": "Bind BotFather", + "room_context_menu.button.unbind_botfather": "Unbind BotFather", + "room_context_menu.button.leave_room": "Leave Room", + "room_context_menu.popup.settings_not_implemented": "The room settings page is not yet implemented.", + "room_context_menu.popup.notifications_not_implemented": "The room notifications page is not yet implemented.", + "room_context_menu.popup.removing_botfather": "Removing BotFather {bot_user_id} from this room...", + "room_context_menu.popup.inviting_botfather": "Inviting BotFather {bot_user_id} into this room...", + "room_context_menu.popup.bot_settings_unavailable": "Bot settings are unavailable right now.", + + "add_room.title": "Add/Explore Rooms and Spaces", + "add_room.section.create_new_room": "Create a new room:", + "add_room.section.add_friend": "Add a friend:", + "add_room.section.join_existing": "Join an existing room or space:", + "add_room.create_room.help.default": "Create a standalone room, or attach it under a space where you can create child rooms.", + "add_room.create_room.help.fixed_parent": "Enter a room name. It will be created directly in this space.", + "add_room.create_room.dropdown.no_space": "Create without a space", + "add_room.create_room.dropdown.hint.choose_space": "Choose a space where you have permission to create child rooms.", + "add_room.create_room.dropdown.hint.no_creatable_spaces": "No joined space currently allows you to create child rooms.", + "add_room.create_room.dropdown.hint.new_room_under": "New room will be added under: {selected_name}", + "add_room.create_room.dropdown.hint.default": "Create a standalone room, or choose a space from the dropdown.", + "add_room.create_room.input.placeholder": "Enter the new room name...", + "add_room.create_room.button.create": "Create room", + "add_room.create_room.button.syncing": "Syncing...", + "add_room.create_room.modal.title": "Create New Room", + "add_room.create_room.modal.subtitle": "Create a new room directly inside the selected space.", + "add_room.button.cancel": "Cancel", + "add_room.add_friend.help": "Enter a Matrix user ID to open or create a direct message room.", + "add_room.add_friend.input.placeholder": "Enter a Matrix user ID, like @alice:matrix.org...", + "add_room.add_friend.button": "Add friend", + "add_room.join.input.placeholder": "Enter alias, ID, or Matrix link...", + "add_room.join.button.go": "Go", + "add_room.join.help_html": "