mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-02-19 07:28:47 -05:00
246 lines
6.9 KiB
TypeScript
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;
|
|
}
|