Update offline banner UX (#1267)

This commit is contained in:
Leendert de Borst
2025-09-24 15:32:05 +02:00
committed by Leendert de Borst
parent 7cb7c02bb2
commit ad2028e473
3 changed files with 107 additions and 52 deletions

View File

@@ -22,7 +22,7 @@ export default function Initialize() : React.ReactNode {
const hasInitialized = useRef(false);
const offlineButtonTimeoutRef = useRef<NodeJS.Timeout | null>(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);
}

View File

@@ -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);
}

View File

@@ -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<void>}
*/
const handleRetry = async (): Promise<void> => {
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 {
<RobustPressable
style={styles.retryButton}
onPress={handleRetry}
disabled={isRetrying}
>
<Ionicons name="refresh" size={20} color={colors.primarySurfaceText} />
{isRetrying ? (
<ActivityIndicator size="small" color={colors.primarySurfaceText} />
) : (
<Ionicons name="refresh" size={20} color={colors.primarySurfaceText} />
)}
</RobustPressable>
</View>
</ThemedView>