diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
index 96d97e3..eb0d5ee 100644
--- a/src/app/pages/client/ClientRoot.tsx
+++ b/src/app/pages/client/ClientRoot.tsx
@@ -13,7 +13,7 @@ import {
Spinner,
Text,
} from 'folds';
-import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
+import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
import {
@@ -36,6 +36,7 @@ import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { CallProviderWrapper } from '../../features/call/CallProviderWrapper';
+import { isTauriMobile, startBackgroundSync, stopBackgroundSync } from '../../utils/tauri';
function ClientRootLoading() {
return (
@@ -139,6 +140,90 @@ const useLogoutListener = (mx?: MatrixClient) => {
}, [mx]);
};
+/**
+ * Hook to handle visibility changes and restart sync on mobile
+ * When the app goes to background and comes back, sync may be stale/stopped
+ */
+const useVisibilitySync = (mx?: MatrixClient) => {
+ useEffect(() => {
+ if (!mx || !isTauriMobile()) return;
+
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible') {
+ console.log('[useVisibilitySync] App became visible, checking sync state');
+ const syncState = mx.getSyncState();
+ console.log('[useVisibilitySync] Current sync state:', syncState);
+
+ // If sync is not running or errored, restart it
+ if (!mx.clientRunning || syncState === SyncState.Error || syncState === SyncState.Stopped || syncState === null) {
+ console.log('[useVisibilitySync] Restarting sync...');
+ startClient(mx).catch((err) => {
+ console.error('[useVisibilitySync] Failed to restart sync:', err);
+ });
+ } else {
+ // Force a sync even if client is running (to catch up quickly)
+ console.log('[useVisibilitySync] Triggering immediate sync');
+ mx.retryImmediately();
+ }
+ }
+ };
+
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+
+ // Also handle resume event for mobile (if available)
+ window.addEventListener('resume', handleVisibilityChange);
+
+ return () => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ window.removeEventListener('resume', handleVisibilityChange);
+ };
+ }, [mx]);
+};
+
+/**
+ * Hook to start/stop native Rust background sync on mobile
+ * This allows notifications to work even when the app is backgrounded
+ */
+const useBackgroundSync = (mx?: MatrixClient) => {
+ useEffect(() => {
+ if (!mx || !isTauriMobile()) return;
+
+ // Start background sync with credentials from the Matrix client
+ const startSync = async () => {
+ try {
+ const homeserverUrl = mx.getHomeserverUrl();
+ const userId = mx.getUserId();
+ const accessToken = mx.getAccessToken();
+ const deviceId = mx.getDeviceId();
+
+ if (!homeserverUrl || !userId || !accessToken || !deviceId) {
+ console.warn('[useBackgroundSync] Missing credentials, cannot start background sync');
+ return;
+ }
+
+ await startBackgroundSync({
+ homeserverUrl,
+ userId,
+ accessToken,
+ deviceId,
+ });
+ console.log('[useBackgroundSync] Background sync started');
+ } catch (err) {
+ console.error('[useBackgroundSync] Failed to start background sync:', err);
+ }
+ };
+
+ startSync();
+
+ // Stop background sync on cleanup
+ return () => {
+ stopBackgroundSync().catch((err) => {
+ console.error('[useBackgroundSync] Failed to stop background sync:', err);
+ });
+ };
+ }, [mx]);
+};
+
type ClientRootProps = {
children: ReactNode;
};
@@ -161,6 +246,8 @@ export function ClientRoot({ children }: ClientRootProps) {
);
useLogoutListener(mx);
+ useVisibilitySync(mx);
+ useBackgroundSync(mx);
useEffect(() => {
if (loadState.status === AsyncStatus.Idle) {
diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx
index 9b433c7..bad01d5 100644
--- a/src/app/plugins/react-custom-html-parser.tsx
+++ b/src/app/plugins/react-custom-html-parser.tsx
@@ -506,7 +506,7 @@ export const getReactCustomHtmlParser = (
const htmlSrc = mxcUrlToHttp(mx, props.src, params.useAuthentication);
if (htmlSrc && props.src.startsWith('mxc://') === false) {
return (
-
+
{props.alt || props.title || htmlSrc}
);
diff --git a/src/app/utils/tauri.ts b/src/app/utils/tauri.ts
index b43e879..02c0d78 100644
--- a/src/app/utils/tauri.ts
+++ b/src/app/utils/tauri.ts
@@ -9,26 +9,28 @@ export const openExternalUrl = async (url: string): Promise => {
// Only run this in Tauri builds
if (typeof window !== 'undefined' && (window as any).__TAURI__) {
try {
- console.log('[openExternalUrl] invoking open_external_url command...');
- const { invoke } = await import('@tauri-apps/api/core');
- await invoke('open_external_url', { url });
- console.log('[openExternalUrl] command succeeded');
+ // First, try the plugin directly (works on both desktop and mobile)
+ console.log('[openExternalUrl] trying opener plugin...');
+ const { openUrl } = await import('@tauri-apps/plugin-opener');
+ await openUrl(url);
+ console.log('[openExternalUrl] plugin succeeded');
return;
- } catch (err) {
- console.error('[openExternalUrl] Tauri command failed:', err);
- // Try the plugin as fallback (for mobile)
+ } catch (pluginErr) {
+ console.warn('[openExternalUrl] opener plugin failed:', pluginErr);
+
+ // Fallback: try the custom command (useful if plugin fails due to ACL)
try {
- console.log('[openExternalUrl] trying opener plugin fallback...');
- const { openUrl } = await import('@tauri-apps/plugin-opener');
- await openUrl(url);
- console.log('[openExternalUrl] plugin fallback succeeded');
+ console.log('[openExternalUrl] trying invoke command fallback...');
+ const { invoke } = await import('@tauri-apps/api/core');
+ await invoke('open_external_url', { url });
+ console.log('[openExternalUrl] command fallback succeeded');
return;
- } catch (pluginErr) {
- console.error('[openExternalUrl] plugin fallback also failed:', pluginErr);
+ } catch (invokeErr) {
+ console.error('[openExternalUrl] command fallback also failed:', invokeErr);
}
- return;
}
}
+
console.log('[openExternalUrl] falling back to window.open');
window.open(url, '_blank');
};
@@ -269,3 +271,69 @@ export const readClipboardImage = async (): Promise => {
return null;
}
};
+
+/**
+ * Background Sync API for mobile platforms
+ * Starts a native Rust-based Matrix sync that runs even when the app is backgrounded
+ */
+export interface BackgroundSyncCredentials {
+ homeserverUrl: string;
+ userId: string;
+ accessToken: string;
+ deviceId: string;
+}
+
+/**
+ * Start background Matrix sync on mobile
+ * This runs the sync in native Rust code, allowing notifications even when the app is backgrounded
+ */
+export const startBackgroundSync = async (credentials: BackgroundSyncCredentials): Promise => {
+ if (!isTauriMobile()) {
+ console.log('[BackgroundSync] Not on mobile, skipping');
+ return;
+ }
+
+ try {
+ const { invoke } = await import('@tauri-apps/api/core');
+ await invoke('start_background_sync', {
+ homeserverUrl: credentials.homeserverUrl,
+ userId: credentials.userId,
+ accessToken: credentials.accessToken,
+ deviceId: credentials.deviceId,
+ });
+ console.log('[BackgroundSync] Started successfully');
+ } catch (err) {
+ console.error('[BackgroundSync] Failed to start:', err);
+ throw err;
+ }
+};
+
+/**
+ * Stop background Matrix sync on mobile
+ */
+export const stopBackgroundSync = async (): Promise => {
+ if (!isTauriMobile()) return;
+
+ try {
+ const { invoke } = await import('@tauri-apps/api/core');
+ await invoke('stop_background_sync');
+ console.log('[BackgroundSync] Stopped');
+ } catch (err) {
+ console.error('[BackgroundSync] Failed to stop:', err);
+ }
+};
+
+/**
+ * Get the current background sync state
+ */
+export const getBackgroundSyncState = async (): Promise => {
+ if (!isTauriMobile()) return 'NotApplicable';
+
+ try {
+ const { invoke } = await import('@tauri-apps/api/core');
+ return await invoke('get_background_sync_state');
+ } catch (err) {
+ console.error('[BackgroundSync] Failed to get state:', err);
+ return 'Error';
+ }
+};