Files
aliasvault/apps/mobile-app/components/ServerSyncIndicator.tsx
2026-01-22 14:13:18 +01:00

246 lines
6.9 KiB
TypeScript

import { Ionicons } from '@expo/vector-icons';
import React, { useState, useEffect, useRef } from 'react';
import { ActivityIndicator, Platform, StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Toast from 'react-native-toast-message';
import { useColors } from '@/hooks/useColorScheme';
import { useTranslation } from '@/hooks/useTranslation';
import { useVaultSync } from '@/hooks/useVaultSync';
import { ThemedText } from '@/components/themed/ThemedText';
import { RobustPressable } from '@/components/ui/RobustPressable';
import { useApp } from '@/context/AppContext';
import { useDb } from '@/context/DbContext';
/**
* Minimum time (ms) to show the syncing indicator.
* Prevents flickering when sync completes quickly.
*/
const MIN_SYNC_DISPLAY_TIME = 1500;
/**
* Floating sync status indicator component.
* Displays sync state badges for offline mode, syncing, and pending sync.
*
* Priority order (highest to lowest):
* 1. Offline (amber) - network unavailable
* 2. Syncing (green spinner) - sync in progress (minimum 1.5s display)
* 3. Pending (blue spinner) - local changes waiting to be uploaded
* 4. Hidden - when synced
*/
export function ServerSyncIndicator(): React.ReactNode {
const { t } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const app = useApp();
const dbContext = useDb();
const { syncVault } = useVaultSync();
const [isRetrying, setIsRetrying] = useState(false);
// Track syncing state with minimum display time
const [showSyncing, setShowSyncing] = useState(false);
const syncStartTimeRef = useRef<number | null>(null);
/**
* Handle syncing state changes with minimum display time.
* When syncing starts, show indicator immediately.
* When syncing ends, wait until minimum time has passed.
*/
useEffect(() => {
if (dbContext.isSyncing) {
// Sync started - show immediately and record start time
setShowSyncing(true);
syncStartTimeRef.current = Date.now();
} else if (syncStartTimeRef.current !== null) {
// Sync ended - wait for minimum display time
const elapsed = Date.now() - syncStartTimeRef.current;
const remaining = MIN_SYNC_DISPLAY_TIME - elapsed;
if (remaining > 0) {
const timer = setTimeout(() => {
setShowSyncing(false);
syncStartTimeRef.current = null;
}, remaining);
return () => clearTimeout(timer);
} else {
setShowSyncing(false);
syncStartTimeRef.current = null;
}
}
}, [dbContext.isSyncing]);
// Only show when logged in AND vault is unlocked (dbAvailable)
if (!app.isLoggedIn || !dbContext.dbAvailable) {
return null;
}
/**
* Handle tap to force sync retry.
*/
const handleRetry = async (): Promise<void> => {
if (isRetrying) {
return;
}
setIsRetrying(true);
dbContext.setIsSyncing(true);
try {
await syncVault({
onSuccess: async () => {
await dbContext.refreshSyncState();
if (dbContext.isOffline) {
// We were offline but now succeeded
Toast.show({
type: 'success',
text1: t('sync.backOnline'),
position: 'bottom',
});
}
setIsRetrying(false);
},
onOffline: () => {
Toast.show({
type: 'error',
text1: t('sync.stillOffline'),
position: 'bottom',
});
setIsRetrying(false);
},
onError: (error: string) => {
Toast.show({
type: 'error',
text1: t('sync.syncFailed'),
text2: error,
position: 'bottom',
});
setIsRetrying(false);
},
});
} catch {
setIsRetrying(false);
} finally {
dbContext.setIsSyncing(false);
await dbContext.refreshSyncState();
}
};
const styles = StyleSheet.create({
container: {
alignItems: 'center',
borderRadius: 24,
bottom: insets.bottom + 60,
elevation: 4,
flexDirection: 'row',
gap: 6,
left: 16,
paddingHorizontal: 14,
paddingVertical: 10,
position: 'absolute',
shadowColor: colors.black,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
zIndex: 999,
},
offline: {
backgroundColor: colors.warningBackground,
},
syncing: {
backgroundColor: colors.successBackground,
},
pending: {
backgroundColor: colors.infoBackground,
},
text: {
fontSize: 13,
fontWeight: '600',
},
offlineText: {
color: colors.warning,
},
syncingText: {
color: colors.success,
},
pendingText: {
color: colors.info,
},
badge: {
backgroundColor: colors.destructive,
borderRadius: 4,
height: 8,
width: 8,
position: 'absolute',
right: -2,
top: -2,
},
});
// Priority 1: Offline indicator (tappable to retry)
// Shows offline status with badge dot if there are pending changes
if (dbContext.isOffline) {
return (
<RobustPressable
style={[styles.container, styles.offline]}
onPress={handleRetry}
disabled={isRetrying}
testID="sync-indicator-offline"
>
<View>
{isRetrying ? (
<ActivityIndicator size="small" color={colors.warning} />
) : (
<>
<Ionicons name="cloud-offline" size={18} color={colors.warning} />
{dbContext.isDirty && (
<View style={styles.badge} />
)}
</>
)}
</View>
<ThemedText style={[styles.text, styles.offlineText]}>
{t('sync.offline')}
</ThemedText>
</RobustPressable>
);
}
// Priority 2: Syncing indicator (not tappable, shows progress)
// Uses showSyncing which has minimum display time to prevent flickering
if (showSyncing) {
return (
<View style={[styles.container, styles.syncing]} testID="sync-indicator-syncing">
<ActivityIndicator size="small" color={colors.success ?? '#16a34a'} />
<ThemedText style={[styles.text, styles.syncingText]}>
{t('sync.syncing')}
</ThemedText>
</View>
);
}
// Priority 3: Pending indicator (tappable to force sync)
if (dbContext.isDirty) {
return (
<RobustPressable
style={[styles.container, styles.pending]}
onPress={handleRetry}
disabled={isRetrying}
testID="sync-indicator-pending"
>
{isRetrying ? (
<ActivityIndicator size="small" color={colors.info} />
) : (
<Ionicons name="cloud-upload" size={18} color={colors.info} />
)}
<ThemedText style={[styles.text, styles.pendingText]}>
{t('sync.pending')}
</ThemedText>
</RobustPressable>
);
}
// Synced - no indicator needed
return null;
}