From ad2028e4735e5fe95020dcf62216e537d98bbd26 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 24 Sep 2025 15:32:05 +0200 Subject: [PATCH] Update offline banner UX (#1267) --- apps/mobile-app/app/initialize.tsx | 24 +++- apps/mobile-app/app/reinitialize.tsx | 23 +++- apps/mobile-app/components/OfflineBanner.tsx | 112 ++++++++++++------- 3 files changed, 107 insertions(+), 52 deletions(-) diff --git a/apps/mobile-app/app/initialize.tsx b/apps/mobile-app/app/initialize.tsx index 0a292ceeb..ff18cd7fa 100644 --- a/apps/mobile-app/app/initialize.tsx +++ b/apps/mobile-app/app/initialize.tsx @@ -22,7 +22,7 @@ export default function Initialize() : React.ReactNode { const hasInitialized = useRef(false); const offlineButtonTimeoutRef = useRef(null); const { t } = useTranslation(); - const { initializeAuth } = useAuth(); + const { initializeAuth, setOfflineMode } = useAuth(); const { syncVault } = useVaultSync(); const dbContext = useDb(); const webApi = useWebApi(); @@ -81,6 +81,9 @@ export default function Initialize() : React.ReactNode { return; } + // Set offline mode + setOfflineMode(true); + // Success - navigate to credentials router.replace('/(tabs)/credentials'); } catch { @@ -94,13 +97,26 @@ export default function Initialize() : React.ReactNode { * Handle retrying the connection. */ onPress: () : void => { - // Re-trigger initialization + setStatus(t('app.status.retryingConnection')); + setShowOfflineButton(false); + + // Clear any existing timeout + if (offlineButtonTimeoutRef.current) { + clearTimeout(offlineButtonTimeoutRef.current); + offlineButtonTimeoutRef.current = null; + } + + /** + * Reset the hasInitialized flag and navigate to the same route + * to force a re-render and trigger the useEffect again + */ hasInitialized.current = false; + router.replace('/initialize'); } } ] ); - }, [dbContext, router, initializeAuth, t]); + }, [dbContext, router, initializeAuth, t, setOfflineMode]); useEffect(() => { // Ensure this only runs once. @@ -187,7 +203,7 @@ export default function Initialize() : React.ReactNode { if (message === t('vault.checkingVaultUpdates')) { offlineButtonTimeoutRef.current = setTimeout(() => { setShowOfflineButton(true); - }, 2000) as NodeJS.Timeout; + }, 2000) as unknown as NodeJS.Timeout; } else { setShowOfflineButton(false); } diff --git a/apps/mobile-app/app/reinitialize.tsx b/apps/mobile-app/app/reinitialize.tsx index a5af0236f..26411027c 100644 --- a/apps/mobile-app/app/reinitialize.tsx +++ b/apps/mobile-app/app/reinitialize.tsx @@ -1,5 +1,5 @@ import { Href, router } from 'expo-router'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View, Alert } from 'react-native'; @@ -31,7 +31,7 @@ export default function ReinitializeScreen() : React.ReactNode { /** * Handle offline scenario - show alert with options to open local vault or retry sync. */ - const handleOfflineFlow = (): void => { + const handleOfflineFlow = useCallback((): void => { Alert.alert( t('app.alerts.syncIssue'), t('app.alerts.syncIssueMessage'), @@ -125,13 +125,26 @@ export default function ReinitializeScreen() : React.ReactNode { * Handle retrying the connection. */ onPress: () : void => { - // Re-trigger initialization + setStatus(t('app.status.retryingConnection')); + setShowOfflineButton(false); + + // Clear any existing timeout + if (offlineButtonTimeoutRef.current) { + clearTimeout(offlineButtonTimeoutRef.current); + offlineButtonTimeoutRef.current = null; + } + + /** + * Reset the hasInitialized flag and navigate to reinitialize route + * to force a re-render and trigger the useEffect again + */ hasInitialized.current = false; + router.replace('/reinitialize'); } } ] ); - }; + }, [authContext, dbContext, t, router]); useEffect(() => { if (hasInitialized.current) { @@ -258,7 +271,7 @@ export default function ReinitializeScreen() : React.ReactNode { if (message === t('vault.checkingVaultUpdates')) { offlineButtonTimeoutRef.current = setTimeout(() => { setShowOfflineButton(true); - }, 2000) as NodeJS.Timeout; + }, 2000) as unknown as NodeJS.Timeout; } else { setShowOfflineButton(false); } diff --git a/apps/mobile-app/components/OfflineBanner.tsx b/apps/mobile-app/components/OfflineBanner.tsx index fe0b2e2ce..097196f39 100644 --- a/apps/mobile-app/components/OfflineBanner.tsx +++ b/apps/mobile-app/components/OfflineBanner.tsx @@ -1,5 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; -import { StyleSheet, View } from 'react-native'; +import { useState } from 'react'; +import { ActivityIndicator, StyleSheet, View } from 'react-native'; import Toast from 'react-native-toast-message'; import { useColors } from '@/hooks/useColorScheme'; @@ -20,6 +21,7 @@ export function OfflineBanner(): React.ReactNode { const colors = useColors(); const { t } = useTranslation(); const { syncVault } = useVaultSync(); + const [isRetrying, setIsRetrying] = useState(false); if (!isOffline) { return null; @@ -30,47 +32,62 @@ export function OfflineBanner(): React.ReactNode { * @returns {Promise} */ const handleRetry = async (): Promise => { - await syncVault({ - /** - * Handle status updates during sync. - * @param {string} _message - The status message - */ - onStatus: (_message: string) => { - // Status updates will be shown in the toast - }, - /** - * Handle successful sync. - */ - onSuccess: () => { - Toast.show({ - type: 'success', - text1: t('app.offline.backOnline'), - position: 'bottom' - }); - }, - /** - * Handle offline. - */ - onOffline: () => { - Toast.show({ - type: 'error', - text1: t('app.offline.stillOffline'), - position: 'bottom' - }); - }, - /** - * Handle sync errors. - * @param {string} error - The error message - */ - onError: (error: string) => { - Toast.show({ - type: 'error', - text1: t('app.offline.stillOffline'), - text2: error, - position: 'bottom' - }); - } - }); + // Prevent multiple simultaneous retry attempts + if (isRetrying) { + return; + } + + setIsRetrying(true); + + try { + await syncVault({ + /** + * Handle status updates during sync. + * @param {string} _message - The status message + */ + onStatus: (_message: string) => { + // Status updates will be shown in the toast + }, + /** + * Handle successful sync. + */ + onSuccess: () => { + Toast.show({ + type: 'success', + text1: t('app.offline.backOnline'), + position: 'bottom' + }); + setIsRetrying(false); + }, + /** + * Handle offline. + */ + onOffline: () => { + Toast.show({ + type: 'error', + text1: t('app.offline.stillOffline'), + position: 'bottom' + }); + setIsRetrying(false); + }, + /** + * Handle sync errors. + * @param {string} error - The error message + */ + onError: (error: string) => { + Toast.show({ + type: 'error', + text1: t('app.offline.stillOffline'), + text2: error, + position: 'bottom' + }); + setIsRetrying(false); + } + }); + } catch { + // In case of unexpected errors, ensure loading state is cleared + setIsRetrying(false); + } }; const styles = StyleSheet.create({ @@ -88,6 +105,10 @@ export function OfflineBanner(): React.ReactNode { retryButton: { marginLeft: 8, padding: 4, + minWidth: 28, + minHeight: 28, + alignItems: 'center', + justifyContent: 'center', }, text: { color: colors.primarySurfaceText, @@ -106,8 +127,13 @@ export function OfflineBanner(): React.ReactNode { - + {isRetrying ? ( + + ) : ( + + )}