feat(sync): implement background sync and visibility handling for mobile
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
} from 'folds';
|
} 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 FocusTrap from 'focus-trap-react';
|
||||||
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
|
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +36,7 @@ import { SyncStatus } from './SyncStatus';
|
|||||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
import { getFallbackSession } from '../../state/sessions';
|
import { getFallbackSession } from '../../state/sessions';
|
||||||
import { CallProviderWrapper } from '../../features/call/CallProviderWrapper';
|
import { CallProviderWrapper } from '../../features/call/CallProviderWrapper';
|
||||||
|
import { isTauriMobile, startBackgroundSync, stopBackgroundSync } from '../../utils/tauri';
|
||||||
|
|
||||||
function ClientRootLoading() {
|
function ClientRootLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -139,6 +140,90 @@ const useLogoutListener = (mx?: MatrixClient) => {
|
|||||||
}, [mx]);
|
}, [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 = {
|
type ClientRootProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -161,6 +246,8 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useLogoutListener(mx);
|
useLogoutListener(mx);
|
||||||
|
useVisibilitySync(mx);
|
||||||
|
useBackgroundSync(mx);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadState.status === AsyncStatus.Idle) {
|
if (loadState.status === AsyncStatus.Idle) {
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ export const getReactCustomHtmlParser = (
|
|||||||
const htmlSrc = mxcUrlToHttp(mx, props.src, params.useAuthentication);
|
const htmlSrc = mxcUrlToHttp(mx, props.src, params.useAuthentication);
|
||||||
if (htmlSrc && props.src.startsWith('mxc://') === false) {
|
if (htmlSrc && props.src.startsWith('mxc://') === false) {
|
||||||
return (
|
return (
|
||||||
<a href={htmlSrc} target="_blank" rel="noreferrer noopener">
|
<a href={htmlSrc} target="_blank" rel="noreferrer noopener" onClick={handleExternalLinkClick}>
|
||||||
{props.alt || props.title || htmlSrc}
|
{props.alt || props.title || htmlSrc}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,26 +9,28 @@ export const openExternalUrl = async (url: string): Promise<void> => {
|
|||||||
// Only run this in Tauri builds
|
// Only run this in Tauri builds
|
||||||
if (typeof window !== 'undefined' && (window as any).__TAURI__) {
|
if (typeof window !== 'undefined' && (window as any).__TAURI__) {
|
||||||
try {
|
try {
|
||||||
console.log('[openExternalUrl] invoking open_external_url command...');
|
// First, try the plugin directly (works on both desktop and mobile)
|
||||||
const { invoke } = await import('@tauri-apps/api/core');
|
console.log('[openExternalUrl] trying opener plugin...');
|
||||||
await invoke('open_external_url', { url });
|
|
||||||
console.log('[openExternalUrl] command succeeded');
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[openExternalUrl] Tauri command failed:', err);
|
|
||||||
// Try the plugin as fallback (for mobile)
|
|
||||||
try {
|
|
||||||
console.log('[openExternalUrl] trying opener plugin fallback...');
|
|
||||||
const { openUrl } = await import('@tauri-apps/plugin-opener');
|
const { openUrl } = await import('@tauri-apps/plugin-opener');
|
||||||
await openUrl(url);
|
await openUrl(url);
|
||||||
console.log('[openExternalUrl] plugin fallback succeeded');
|
console.log('[openExternalUrl] plugin succeeded');
|
||||||
return;
|
return;
|
||||||
} catch (pluginErr) {
|
} catch (pluginErr) {
|
||||||
console.error('[openExternalUrl] plugin fallback also failed:', 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 invoke command fallback...');
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
await invoke('open_external_url', { url });
|
||||||
|
console.log('[openExternalUrl] command fallback succeeded');
|
||||||
return;
|
return;
|
||||||
|
} catch (invokeErr) {
|
||||||
|
console.error('[openExternalUrl] command fallback also failed:', invokeErr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[openExternalUrl] falling back to window.open');
|
console.log('[openExternalUrl] falling back to window.open');
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
};
|
};
|
||||||
@@ -269,3 +271,69 @@ export const readClipboardImage = async (): Promise<File | null> => {
|
|||||||
return null;
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<string> => {
|
||||||
|
if (!isTauriMobile()) return 'NotApplicable';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
return await invoke<string>('get_background_sync_state');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BackgroundSync] Failed to get state:', err);
|
||||||
|
return 'Error';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user