diff --git a/src/app.rs b/src/app.rs index f04e177d5..22e89299f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,14 +4,15 @@ 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 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, } @@ -413,6 +414,82 @@ 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(), + 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 { + 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::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; @@ -703,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); @@ -950,6 +1055,134 @@ 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. + 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, + /// 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 { + fn default() -> Self { + Self { + enabled: false, + botfather_user_id: Self::DEFAULT_BOTFATHER_LOCALPART.to_string(), + room_bindings: Vec::new(), + } + } +} + +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_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_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. + pub fn set_room_bound( + &mut self, + room_id: OwnedRoomId, + bot_user_id: Option, + bound: bool, + ) { + if bound { + let Some(bot_user_id) = bot_user_id else { return }; + 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 { + if let Ok(existing_index) = self.room_binding_index(room_id.as_ref()) { + self.room_bindings.remove(existing_index); + } + } + } + + /// 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}")) + } + + /// 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. @@ -1093,6 +1326,18 @@ 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, + }, + /// 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 new file mode 100644 index 000000000..384510f48 --- /dev/null +++ b/src/home/create_bot_modal.rs @@ -0,0 +1,306 @@ +//! 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] + 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.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..634171936 --- /dev/null +++ b/src/home/delete_bot_modal.rs @@ -0,0 +1,246 @@ +//! 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] + 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.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..c4d34d2aa 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -451,7 +451,7 @@ impl Widget for HomeScreen { if let Some(settings_page) = self.update_active_page_from_selection(cx, app_state) { settings_page .settings_screen(cx, ids!(settings_screen)) - .populate(cx, None); + .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..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/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_context_menu.rs b/src/home/room_context_menu.rs index c55b7fa54..796a43a86 100644 --- a/src/home/room_context_menu.rs +++ b/src/home/room_context_menu.rs @@ -3,7 +3,7 @@ use makepad_widgets::*; use matrix_sdk::ruma::OwnedRoomId; -use crate::{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; @@ -99,6 +99,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 +128,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 @@ -178,7 +185,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 }; let mut close_menu = false; @@ -234,6 +241,51 @@ impl WidgetMatchEvent for RoomContextMenu { 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_for_room( + &room_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; @@ -285,6 +337,14 @@ 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); @@ -294,13 +354,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 + // 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) { diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index 61f20ced9..a74781e9b 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -8,12 +8,12 @@ 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::{ ImageInfo, MediaSource, message::{ - AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, TextMessageEventContent, VideoMessageEventContent + AudioMessageEventContent, EmoteMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent, KeyVerificationRequestEventContent, LocationMessageEventContent, MessageFormat, MessageType, NoticeMessageEventContent, RoomMessageEventContent, TextMessageEventContent, VideoMessageEventContent } }, sticker::{StickerEventContent, StickerMediaSource}, @@ -26,7 +26,7 @@ use matrix_sdk_ui::timeline::{ use ruma::{OwnedUserId, api::client::receipt::create_receipt::v3::ReceiptType, events::{AnySyncMessageLikeEvent, AnySyncTimelineEvent, SyncMessageLikeEvent}, owned_room_id}; use crate::{ - app::{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::{ + 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, }, @@ -34,7 +34,7 @@ use crate::{ shared::{ avatar::{AvatarState, AvatarWidgetRefExt}, confirmation_modal::ConfirmationModalContent, html_or_plaintext::{HtmlOrPlaintextRef, HtmlOrPlaintextWidgetRefExt, RobrixHtmlLinkAction}, image_viewer::{ImageViewerAction, ImageViewerMetaData, LoadState}, jump_to_bottom_button::{JumpToBottomButtonWidgetExt, UnreadMessageCount}, popup_list::{PopupKind, enqueue_popup_notification}, restore_status_view::RestoreStatusViewWidgetExt, styles::*, text_or_image::{TextOrImageAction, TextOrImageRef, TextOrImageWidgetRefExt}, timestamp::TimestampWidgetRefExt }, - sliding_sync::{BackwardsPaginateUntilEventRequest, MatrixRequest, PaginationDirection, TimelineEndpoints, TimelineKind, TimelineRequestSender, UserPowerLevels, get_client, submit_async_request, take_timeline_endpoints}, 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; @@ -62,6 +62,85 @@ 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}")) +} + +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.* @@ -504,6 +583,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 +792,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 +848,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. @@ -612,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. @@ -622,6 +902,8 @@ pub struct RoomScreen { #[rust] is_loaded: bool, /// Whether or not all rooms have been loaded (received from the homeserver). #[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 { @@ -868,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. @@ -913,22 +1195,25 @@ 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(); - - // 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)); + let (app_service_enabled, app_service_room_bound) = scope + .data + .get::() + .map(|app_state| { + ( + app_state.bot_settings.enabled, + self.is_app_service_room_bound(app_state, &room_id), + ) + }) + .unwrap_or((false, false)); 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, } } else if let Some(room_name) = &self.room_name_id { // Fallback case: we have a room_name but no tl_state yet @@ -939,6 +1224,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: false, + app_service_room_bound: false, } } else { // No room selected yet, skip event handling that requires room context @@ -954,6 +1241,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); @@ -972,6 +1261,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_for_room( + room_props.room_name_id.room_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 => {} + } + + if let MessageAction::ToggleAppServiceActions = action + .as_widget_action() + .widget_uid_eq(room_screen_widget_uid) + .cast() + { + 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( @@ -1066,7 +1573,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,6 +1581,9 @@ impl Widget for RoomScreen { while let Some(item_id) = list.next_visible_item(cx) { let item = { let tl_idx = item_id; + 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. @@ -1211,6 +1721,7 @@ impl Widget for RoomScreen { tl_state.profile_drawn_since_last_update.insert(tl_idx .. tl_idx + 1); } item + } }; item.draw_all(cx, scope); } @@ -1235,10 +1746,193 @@ 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 is_app_service_room_bound(&self, app_state: &AppState, room_id: &OwnedRoomId) -> bool { + app_state.bot_settings.is_room_bound(room_id) + } + + fn send_botfather_command( + &mut self, + cx: &mut Cx, + app_state: &AppState, + command: &str, + success_message: &str, + ) -> bool { + let Some(timeline_kind) = self.timeline_kind.clone() else { + return false; + }; + if timeline_kind.thread_root_event_id().is_some() { + enqueue_popup_notification( + "Bot commands are only supported in the main room timeline.", + PopupKind::Warning, + Some(4.0), + ); + return false; + } + + let Some(room_id) = self.room_id().cloned() else { + return false; + }; + 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 false; + } + 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, + Some(4.0), + ); + return false; + } + + 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); + true + } + + 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 !self.is_app_service_room_bound(app_state, &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); + 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( + &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()); + if 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. - 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(); @@ -1510,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()); @@ -2082,6 +2789,7 @@ impl RoomScreen { MessageAction::ActionBarOpen { .. } => { } // This isn't yet handled, as we need to completely redesign it. MessageAction::ActionBarClose => { } + MessageAction::ToggleAppServiceActions => { } MessageAction::None => { } } } @@ -2347,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); } @@ -2378,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 @@ -2465,14 +3174,21 @@ 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; } 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(); 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 @@ -2609,6 +3325,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, } @@ -3477,6 +4195,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, @@ -4583,6 +5302,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,6 +5315,113 @@ 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 { diff --git a/src/home/rooms_list.rs b/src/home/rooms_list.rs index 7cf7d5106..73ce9375e 100644 --- a/src/home/rooms_list.rs +++ b/src/home/rooms_list.rs @@ -1219,11 +1219,14 @@ impl Widget for RoomsList { 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(), 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/room/room_input_bar.rs b/src/room/room_input_bar.rs index 93b8d4a9d..bf4563d65 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -295,6 +295,17 @@ 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.take().and_then(|(event_tl_item, _emb)| event_tl_item.event_id().map(|event_id| { @@ -512,6 +523,52 @@ impl RoomInputBar { }); } + 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. diff --git a/src/settings/bot_settings.rs b/src/settings/bot_settings.rs new file mode 100644 index 000000000..bc23b9c14 --- /dev/null +++ b/src/settings/bot_settings.rs @@ -0,0 +1,199 @@ +use makepad_widgets::*; + +use crate::{ + app::{AppState, BotSettingsState}, + persistence, + shared::popup_list::{PopupKind, enqueue_popup_notification}, + sliding_sync::current_user_id, +}; + +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; + persist_bot_settings(app_state); + 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(); + persist_bot_settings(app_state); + 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); + } +} + +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/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..5d24945bc 100644 --- a/src/settings/settings_screen.rs +++ b/src/settings/settings_screen.rs @@ -1,7 +1,7 @@ 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 +58,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 {} @@ -164,12 +168,13 @@ 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.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 +183,8 @@ 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 30fccc5a2..131e6610f 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,192 @@ impl From for Cli { } } +impl From for Cli { + fn from(registration: RegisterAccount) -> Self { + Self { + user_id: registration.user_id.trim().to_owned(), + password: registration.password, + homeserver: registration.homeserver + .map(|homeserver| homeserver.trim().to_owned()) + .filter(|homeserver| !homeserver.is_empty()), + proxy: None, + login_screen: false, + verbose: false, + } + } +} + +fn infer_homeserver_from_user_id(user_id: &str) -> Option { + let user_id: OwnedUserId = user_id.trim().try_into().ok()?; + Some(user_id.server_name().to_string()) +} + +async fn finalize_authenticated_client( + client: Client, + client_session: ClientSessionPersisted, + fallback_user_id: &str, +) -> Result<(Client, Option)> { + if client.matrix_auth().logged_in() { + let logged_in_user_id = client.user_id() + .map(ToString::to_string) + .unwrap_or_else(|| fallback_user_id.to_owned()); + log!("Logged in successfully."); + let status = format!("Logged in as {}.\n → Loading rooms...", logged_in_user_id); + enqueue_rooms_list_update(RoomsListUpdate::Status { status }); + if let Err(e) = persistence::save_session(&client, client_session).await { + let err_msg = format!("Failed to save session state to storage: {e}"); + error!("{err_msg}"); + enqueue_popup_notification(err_msg, PopupKind::Error, None); + } + Ok((client, None)) + } else { + let err_msg = format!( + "Authentication succeeded for {fallback_user_id}, but the homeserver did not return a login session." + ); + enqueue_popup_notification(err_msg.clone(), PopupKind::Error, None); + enqueue_rooms_list_update(RoomsListUpdate::Status { status: err_msg.clone() }); + bail!(err_msg); + } +} + +fn registration_localpart(user_id: &str) -> Result { + let trimmed = user_id.trim(); + if trimmed.is_empty() { + bail!("Please enter a valid username or Matrix user ID."); + } + + if let Ok(full_user_id) = >::try_from(trimmed) { + return Ok(full_user_id.localpart().to_owned()); + } + + let localpart = trimmed.trim_start_matches('@'); + if localpart.is_empty() || localpart.contains(':') || localpart.chars().any(char::is_whitespace) { + bail!("Please enter a valid username or full Matrix user ID."); + } + + Ok(localpart.to_owned()) +} + +fn registration_request( + username: &str, + password: &str, + session: Option, +) -> RegistrationRequest { + let mut request = RegistrationRequest::new(); + request.username = Some(username.to_owned()); + request.password = Some(password.to_owned()); + request.initial_device_display_name = Some("robrix-un-pw".to_owned()); + request.refresh_token = true; + if let Some(session) = session { + let mut dummy = Dummy::new(); + dummy.session = Some(session); + request.auth = Some(AuthData::Dummy(dummy)); + } + request +} + +fn registration_uiaa_error_message(error: &matrix_sdk::Error) -> String { + if let matrix_sdk::Error::Http(http_error) = error { + match http_error.client_api_error_kind() { + Some(ErrorKind::UserInUse) => { + return "That user ID is already taken. Please choose another one.".to_owned(); + } + Some(ErrorKind::InvalidUsername) => { + return "That user ID is invalid. Use a username like `alice` or a full Matrix ID like `@alice:matrix.org`.".to_owned(); + } + Some(ErrorKind::WeakPassword) => { + return "That password is too weak. Please choose a stronger password.".to_owned(); + } + Some(ErrorKind::Forbidden { .. }) => { + return "This homeserver does not allow open registration.".to_owned(); + } + Some(ErrorKind::LimitExceeded { .. }) => { + return "The homeserver is rate limiting account creation right now. Please try again shortly.".to_owned(); + } + _ => {} + } + } + + format!("Could not create account: {error}") +} + +fn unsupported_registration_flow_message( + flows: &[matrix_sdk::ruma::api::client::uiaa::AuthFlow], +) -> String { + let supports_registration_token = flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::RegistrationToken)) + }); + if supports_registration_token { + return "This homeserver requires a registration token. Robrix does not support token-based registration yet.".to_owned(); + } + + let supports_terms = flows.iter().any(|flow| { + flow.stages + .iter() + .any(|stage| matches!(stage, AuthType::Terms)) + }); + if supports_terms { + return "This homeserver requires an interactive terms-of-service step. Robrix does not support that registration flow yet.".to_owned(); + } + + "This homeserver requires an unsupported registration flow. Please try another homeserver or register with a different client.".to_owned() +} + +async fn clear_persisted_session(user_id: Option<&UserId>) { + let Some(user_id) = user_id else { + return; + }; + + if let Err(e) = persistence::delete_session(user_id).await { + warning!("Failed to delete persisted session for {user_id}: {e}"); + } + + let latest_user_id = persistence::most_recent_user_id().await; + if latest_user_id.as_deref() == Some(user_id) { + if let Err(e) = persistence::delete_latest_user_id().await { + warning!("Failed to delete latest user id for {user_id}: {e}"); + } + } +} + +enum SessionResetAction { + Reauthenticate { message: String }, +} + +async fn reset_runtime_state_for_relogin() { + let sync_service = { SYNC_SERVICE.lock().unwrap().take() }; + if let Some(sync_service) = sync_service { + sync_service.stop().await; + } + + CLIENT.lock().unwrap().take(); + DEFAULT_SSO_CLIENT.lock().unwrap().take(); + IGNORED_USERS.lock().unwrap().clear(); + ALL_JOINED_ROOMS.lock().unwrap().clear(); + + let on_clear_appstate = Arc::new(Notify::new()); + Cx::post_action(LogoutAction::ClearAppState { on_clear_appstate: on_clear_appstate.clone() }); + + if tokio::time::timeout(Duration::from_secs(5), on_clear_appstate.notified()).await.is_err() { + warning!("Timed out waiting for UI-side app state cleanup during re-login reset"); + } +} + +fn is_invalid_token_http_error(error: &matrix_sdk::HttpError) -> bool { + matches!( + error.client_api_error_kind(), + Some(ErrorKind::UnknownToken { .. } | ErrorKind::MissingToken) + ) +} + +fn is_invalid_batch_token_timeline_error(error: &matrix_sdk_ui::timeline::Error) -> bool { + let error_text = error.to_string().to_ascii_lowercase(); + error_text.contains("invalid batch token") + || error_text.contains("must start with 's' or 't'") +} + /// Build a new client. async fn build_client( @@ -116,7 +310,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 +388,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) => { @@ -434,6 +679,12 @@ pub enum MatrixRequest { room_id: OwnedRoomId, user_id: OwnedUserId, }, + /// Request to bind or unbind the configured botfather for the given room. + SetRoomBotBinding { + room_id: OwnedRoomId, + bound: bool, + bot_user_id: OwnedUserId, + }, /// Request to join the given room. JoinRoom { room_id: OwnedRoomId, @@ -681,6 +932,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), @@ -693,6 +945,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. /// @@ -740,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 { @@ -747,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? {}", @@ -1002,6 +1296,68 @@ async fn matrix_worker_task( }); } + MatrixRequest::SetRoomBotBinding { + room_id, + bound, + bot_user_id, + } => { + let Some(client) = get_client() else { continue }; + let _bot_binding_task = Handle::current().spawn(async move { + let Some(room) = client.get_room(&room_id) else { + let error_message = + format!("Room {room_id} was not found for the bot binding request."); + error!("{error_message}"); + enqueue_popup_notification(error_message, PopupKind::Error, None); + return; + }; + + let membership_result = if bound { + room.invite_user_by_id(&bot_user_id).await + } else { + room.kick_user(&bot_user_id, Some("Robrix app service unbind")).await + }; + + match membership_result { + Ok(()) => { + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: None, + }); + } + Err(error) => { + let membership_exists = + room.get_member_no_sync(&bot_user_id).await.ok().flatten().is_some(); + let should_mark_bound = if bound { membership_exists } else { false }; + + if should_mark_bound != bound { + error!( + "Failed to {} BotFather {bot_user_id} for room {room_id}: {error:?}", + if bound { "invite" } else { "remove" } + ); + enqueue_popup_notification( + format!( + "Failed to {} BotFather {bot_user_id}: {error}", + if bound { "invite" } else { "remove" } + ), + PopupKind::Error, + None, + ); + return; + } + + Cx::post_action(AppStateAction::BotRoomBindingUpdated { + room_id, + bound, + bot_user_id: Some(bot_user_id), + warning: Some(error.to_string()), + }); + } + } + }); + } + MatrixRequest::JoinRoom { room_id } => { let Some(client) = get_client() else { continue }; let _join_room_task = Handle::current().spawn(async move { @@ -2291,7 +2647,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, @@ -2301,11 +2657,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 { @@ -2315,7 +2677,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( @@ -2341,197 +2703,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; } } @@ -3238,7 +3650,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 { @@ -3250,7 +3665,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)) => { @@ -3261,7 +3680,7 @@ fn handle_session_changes(client: Client) { } } } - }); + }) } fn handle_sync_service_state_subscriber(mut subscriber: Subscriber) {