Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dd8fe41
Fix stale session restore and in-app signup flow
tyreseluo Mar 23, 2026
7149b00
Commit remaining workspace changes
tyreseluo Mar 23, 2026
5ac8c40
Finish migrate app service and register to makepad 2.0
tyreseluo Mar 23, 2026
fbd0c56
Fix app service cleanup issues
tyreseluo Mar 24, 2026
e7e7f27
Recover from invalid Matrix sessions
tyreseluo Mar 25, 2026
5895787
Simplify login screen layout
tyreseluo Mar 25, 2026
9d674e7
Remove login panel container styling
tyreseluo Mar 25, 2026
69c7886
Reback fmt
tyreseluo Mar 25, 2026
53f5147
Merge pull request #9 from tyreseluo/fix-session-restore-signup
ZhangHanDong Mar 25, 2026
271ad5f
Migrate app service and BotFather management to robrix2
tyreseluo Mar 25, 2026
36f9139
Merge remote branch 'origin/app-service-bot-management'
tyreseluo Mar 25, 2026
6a01687
Finish app service and botfather
tyreseluo Mar 25, 2026
9f1f5fd
chore: remove diff noise from bot management changes
tyreseluo Mar 26, 2026
71b2885
refactor: drop login and persistence changes from bot management
tyreseluo Mar 26, 2026
d417e92
refactor: align bot management branch with robrix2 main
tyreseluo Mar 26, 2026
bc9b49f
fix: persist botfather bindings and recover room state
tyreseluo Mar 26, 2026
6dbe704
feat: add thread entry points to the message context menu
tyreseluo Mar 26, 2026
353ab30
Merge pull request #16 from tyreseluo/matrix-thread
ZhangHanDong Mar 26, 2026
df0e774
perf: reduce bot binding scans in app state
tyreseluo Mar 26, 2026
8ebcf9b
refactor: cache bot binding detection in room ui
tyreseluo Mar 26, 2026
12d8f23
Merge pull request #15 from tyreseluo/app-service-bot-management
ZhangHanDong Mar 26, 2026
60b4483
Add app pause/resume handling for offline mode (issue #435)
alanpoon Mar 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 248 additions & 3 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -950,6 +1055,134 @@ pub struct AppState {
pub saved_dock_state_per_space: HashMap<OwnedRoomId, SavedDockState>,
/// 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<RoomBotBindingState>,
}

/// 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<usize, usize> {
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<OwnedUserId>,
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<OwnedUserId, String> {
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<OwnedUserId, String> {
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.
Expand Down Expand Up @@ -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<OwnedUserId>,
warning: Option<String>,
},
/// 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.
///
Expand Down
Loading