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/18] 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/18] 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 (`

` 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/18] 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/18] 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 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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 15/18] 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 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 16/18] 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 17/18] 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 60b448347b28f9956edc03a01a0ff8189fd71b97 Mon Sep 17 00:00:00 2001 From: alanpoon Date: Fri, 27 Mar 2026 10:31:08 +0800 Subject: [PATCH 18/18] Add app pause/resume handling for offline mode (issue #435) - Stop sync service when app is paused (mobile background) - Restart sync service when app resumes - Saves battery and network resources when app is not in foreground - Foundation for offline mode support Co-Authored-By: Claude Opus 4.5 --- src/app.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index b9a75f6ee..22e89299f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,12 +6,13 @@ use std::{cell::RefCell, collections::HashMap}; use makepad_widgets::*; use matrix_sdk::{RoomState, ruma::{OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId}}; use serde::{Deserialize, Serialize}; +use tokio::runtime::Handle; 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::{ + }, 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, get_sync_service, submit_async_request}, utils::RoomNameId, verification::VerificationAction, verification_modal::{ VerificationModalAction, VerificationModalWidgetRefExt, } @@ -779,7 +780,35 @@ impl AppMain for App { } } } - + + if let Event::Pause = event { + // App is being paused (e.g., on mobile when another app comes to foreground) + // Stop the sync service to save battery and network resources + log!("App paused - stopping sync service"); + if let Some(sync_service) = get_sync_service() { + if let Ok(handle) = Handle::try_current() { + handle.spawn(async move { + sync_service.stop().await; + log!("Sync service stopped due to app pause"); + }); + } + } + } + + if let Event::Resume = event { + // App is resuming from a paused state + // Restart the sync service to resume real-time updates + log!("App resumed - restarting sync service"); + if let Some(sync_service) = get_sync_service() { + if let Ok(handle) = Handle::try_current() { + handle.spawn(async move { + sync_service.start().await; + log!("Sync service restarted due to app resume"); + }); + } + } + } + // Forward events to the MatchEvent trait implementation. self.match_event(cx, event); let scope = &mut Scope::with_data(&mut self.app_state);