mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-02-02 18:33:21 -05:00
Refresh sync state on app boot to try and push local changes if pending (#1404)
This commit is contained in:
@@ -792,74 +792,66 @@ export default function AddEditItemScreen(): React.ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
await executeVaultMutation(async () => {
|
||||
if (isEditMode) {
|
||||
await dbContext.sqliteClient!.items.update(itemToSave, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes);
|
||||
/*
|
||||
* Execute mutation - local save + background sync (non-blocking).
|
||||
* Navigate immediately after local save; sync happens in background via ServerSyncIndicator.
|
||||
*/
|
||||
try {
|
||||
await executeVaultMutation(async () => {
|
||||
if (isEditMode) {
|
||||
await dbContext.sqliteClient!.items.update(itemToSave, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes);
|
||||
|
||||
// Delete passkeys if marked for deletion
|
||||
if (passkeyIdsMarkedForDeletion.length > 0) {
|
||||
for (const passkeyId of passkeyIdsMarkedForDeletion) {
|
||||
await dbContext.sqliteClient!.passkeys.delete(passkeyId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await dbContext.sqliteClient!.items.create(itemToSave, attachments, totpCodes);
|
||||
}
|
||||
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Handle successful save
|
||||
*/
|
||||
onSuccess: () => {
|
||||
/*
|
||||
* Emit event to notify list and detail views to refresh
|
||||
* Must be after sync completes so merged data is available
|
||||
*/
|
||||
emitter.emit('credentialChanged', itemToSave.Id);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
if (serviceUrl && !isEditMode) {
|
||||
router.replace('/items/autofill-item-created');
|
||||
} else {
|
||||
setIsSaving(false);
|
||||
setIsSaveDisabled(false);
|
||||
router.dismiss();
|
||||
|
||||
setTimeout(() => {
|
||||
if (isEditMode) {
|
||||
Toast.show({
|
||||
type: 'success',
|
||||
text1: t('items.toasts.itemUpdated'),
|
||||
position: 'bottom'
|
||||
});
|
||||
} else {
|
||||
Toast.show({
|
||||
type: 'success',
|
||||
text1: t('items.toasts.itemCreated'),
|
||||
position: 'bottom'
|
||||
});
|
||||
router.push(`/items/${itemToSave.Id}`);
|
||||
// Delete passkeys if marked for deletion
|
||||
if (passkeyIdsMarkedForDeletion.length > 0) {
|
||||
for (const passkeyId of passkeyIdsMarkedForDeletion) {
|
||||
await dbContext.sqliteClient!.passkeys.delete(passkeyId);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
await dbContext.sqliteClient!.items.create(itemToSave, attachments, totpCodes);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handle error saving item
|
||||
*/
|
||||
onError: (error) => {
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: t('items.errors.saveFailed'),
|
||||
text2: error.message,
|
||||
position: 'bottom'
|
||||
});
|
||||
console.error('Error saving item:', error.message);
|
||||
setIsSaving(false);
|
||||
setIsSaveDisabled(false);
|
||||
});
|
||||
|
||||
// Emit event to notify list and detail views to refresh
|
||||
emitter.emit('credentialChanged', itemToSave.Id);
|
||||
setHasUnsavedChanges(false);
|
||||
setIsSaving(false);
|
||||
setIsSaveDisabled(false);
|
||||
|
||||
// Navigate immediately - sync continues in background
|
||||
if (serviceUrl && !isEditMode) {
|
||||
router.replace('/items/autofill-item-created');
|
||||
} else {
|
||||
router.dismiss();
|
||||
|
||||
setTimeout(() => {
|
||||
if (isEditMode) {
|
||||
Toast.show({
|
||||
type: 'success',
|
||||
text1: t('items.toasts.itemUpdated'),
|
||||
position: 'bottom'
|
||||
});
|
||||
} else {
|
||||
Toast.show({
|
||||
type: 'success',
|
||||
text1: t('items.toasts.itemCreated'),
|
||||
position: 'bottom'
|
||||
});
|
||||
router.push(`/items/${itemToSave.Id}`);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: t('items.errors.saveFailed'),
|
||||
text2: error instanceof Error ? error.message : t('common.errors.unknownError'),
|
||||
position: 'bottom'
|
||||
});
|
||||
console.error('Error saving item:', error);
|
||||
setIsSaving(false);
|
||||
setIsSaveDisabled(false);
|
||||
}
|
||||
}, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, webApi, isSaveDisabled, item, fieldValues, applicableSystemFields, customFields, t, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes, passkeyIdsMarkedForDeletion]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
|
||||
import { VaultUnlockHelper } from '@/utils/VaultUnlockHelper';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { useVaultSync } from '@/hooks/useVaultSync';
|
||||
|
||||
import LoadingIndicator from '@/components/LoadingIndicator';
|
||||
@@ -22,159 +20,20 @@ import NativeVaultManager from '@/specs/NativeVaultManager';
|
||||
export default function Initialize() : React.ReactNode {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState('');
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
const hasInitialized = useRef(false);
|
||||
const skipButtonTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastStatusRef = useRef<string>('');
|
||||
const canShowSkipButtonRef = useRef(false); // Only allow skip button after vault unlock
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const app = useApp();
|
||||
const navigation = useNavigation();
|
||||
const { syncVault } = useVaultSync();
|
||||
const dbContext = useDb();
|
||||
const colors = useColors();
|
||||
|
||||
/**
|
||||
* Update status with smart skip button logic.
|
||||
* Normalizes status by removing animation dots and manages skip button visibility.
|
||||
* Update status message.
|
||||
*/
|
||||
const updateStatus = useCallback((message: string): void => {
|
||||
setStatus(message);
|
||||
|
||||
// Normalize status by removing animation dots for comparison
|
||||
const normalizedMessage = message.replace(/\.+$/, '');
|
||||
const normalizedLastStatus = lastStatusRef.current.replace(/\.+$/, '');
|
||||
|
||||
// Clear any existing timeout
|
||||
if (skipButtonTimeoutRef.current) {
|
||||
clearTimeout(skipButtonTimeoutRef.current);
|
||||
skipButtonTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// If status changed (excluding dots), hide skip button and reset timer
|
||||
if (normalizedMessage !== normalizedLastStatus) {
|
||||
setShowSkipButton(false);
|
||||
lastStatusRef.current = message;
|
||||
|
||||
// Start new timer for the new status (only if skip button is allowed)
|
||||
if (message && canShowSkipButtonRef.current) {
|
||||
skipButtonTimeoutRef.current = setTimeout(() => {
|
||||
setShowSkipButton(true);
|
||||
}, 5000) as unknown as NodeJS.Timeout;
|
||||
}
|
||||
} else {
|
||||
// Same status (excluding dots) - update ref but keep timer running
|
||||
lastStatusRef.current = message;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle offline scenario - show alert with options to open local vault or retry sync.
|
||||
*/
|
||||
const handleOfflineFlow = useCallback((): void => {
|
||||
// Don't show the alert if we're already in offline mode
|
||||
if (app.isOffline) {
|
||||
console.debug('Already in offline mode, skipping offline flow alert');
|
||||
navigation.navigateAfterUnlock();
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
t('app.alerts.syncIssue'),
|
||||
t('app.alerts.syncIssueMessage'),
|
||||
[
|
||||
{
|
||||
text: t('app.alerts.openLocalVault'),
|
||||
/**
|
||||
* Handle opening vault in read-only mode.
|
||||
*/
|
||||
onPress: async () : Promise<void> => {
|
||||
updateStatus(t('app.status.openingVaultReadOnly'));
|
||||
const { enabledAuthMethods } = await app.initializeAuth();
|
||||
|
||||
try {
|
||||
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
|
||||
|
||||
// No encrypted database
|
||||
if (!hasEncryptedDatabase) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set offline mode
|
||||
app.setOfflineMode(true);
|
||||
|
||||
// FaceID not enabled
|
||||
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
|
||||
if (!isFaceIDEnabled) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to unlock vault
|
||||
updateStatus(t('app.status.unlockingVault'));
|
||||
const isUnlocked = await dbContext.unlockVault();
|
||||
|
||||
// Vault couldn't be unlocked
|
||||
if (!isUnlocked) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vault successfully unlocked - proceed with decryption
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Migrations pending
|
||||
if (await dbContext.hasPendingMigrations()) {
|
||||
router.replace('/upgrade');
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - use centralized navigation logic
|
||||
navigation.navigateAfterUnlock();
|
||||
} catch (err) {
|
||||
console.error('Error during offline vault unlock:', err);
|
||||
router.replace('/unlock');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
text: t('app.alerts.retrySync'),
|
||||
/**
|
||||
* Handle retrying the connection.
|
||||
*/
|
||||
onPress: () : void => {
|
||||
updateStatus(t('app.status.retryingConnection'));
|
||||
setShowSkipButton(false);
|
||||
|
||||
// Abort any pending sync operation
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
// Clear any existing timeout
|
||||
if (skipButtonTimeoutRef.current) {
|
||||
clearTimeout(skipButtonTimeoutRef.current);
|
||||
skipButtonTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Reset status tracking
|
||||
lastStatusRef.current = '';
|
||||
|
||||
/**
|
||||
* 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, app, navigation, t, updateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure this only runs once.
|
||||
if (hasInitialized.current) {
|
||||
@@ -231,9 +90,6 @@ export default function Initialize() : React.ReactNode {
|
||||
router.replace('/upgrade');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vault unlocked successfully - now allow skip button for network operations
|
||||
canShowSkipButtonRef.current = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error during initial vault unlock:', err);
|
||||
@@ -249,98 +105,61 @@ export default function Initialize() : React.ReactNode {
|
||||
router.replace('/upgrade');
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow skip button for sync operations since vault is already unlocked.
|
||||
*/
|
||||
canShowSkipButtonRef.current = true;
|
||||
}
|
||||
|
||||
// Create abort controller for sync operations
|
||||
abortControllerRef.current = new AbortController();
|
||||
/*
|
||||
* Perform vault sync in background - don't block app access.
|
||||
* The ServerSyncIndicator will show sync progress/offline status.
|
||||
* This also handles uploading pending local changes (isDirty) from previous sessions.
|
||||
*/
|
||||
dbContext.setIsSyncing(true);
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
await syncVault({
|
||||
/**
|
||||
* Handle successful vault sync.
|
||||
*/
|
||||
onSuccess: async () => {
|
||||
// Sync completed - ServerSyncIndicator will update
|
||||
await dbContext.refreshSyncState();
|
||||
},
|
||||
/**
|
||||
* Handle offline state - just set offline mode and continue.
|
||||
* The ServerSyncIndicator will show offline status.
|
||||
*/
|
||||
onOffline: async () => {
|
||||
await dbContext.setIsOffline(true);
|
||||
await dbContext.refreshSyncState();
|
||||
},
|
||||
/**
|
||||
* Handle error during vault sync.
|
||||
*/
|
||||
onError: async (error: string) => {
|
||||
console.error('Vault sync error during initialize:', error);
|
||||
await dbContext.refreshSyncState();
|
||||
},
|
||||
/**
|
||||
* On upgrade required.
|
||||
*/
|
||||
onUpgradeRequired: () : void => {
|
||||
router.replace('/upgrade');
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
dbContext.setIsSyncing(false);
|
||||
await dbContext.refreshSyncState();
|
||||
}
|
||||
})();
|
||||
|
||||
// Now perform vault sync (network operations - these are skippable)
|
||||
await syncVault({
|
||||
abortSignal: abortControllerRef.current.signal,
|
||||
/**
|
||||
* Handle the status update.
|
||||
*/
|
||||
onStatus: (message) => {
|
||||
updateStatus(message);
|
||||
},
|
||||
/**
|
||||
* Handle successful vault sync.
|
||||
*/
|
||||
onSuccess: async () => {
|
||||
// Use centralized navigation logic
|
||||
navigation.navigateAfterUnlock();
|
||||
},
|
||||
/**
|
||||
* Handle offline state and prompt user for action.
|
||||
*/
|
||||
onOffline: () => {
|
||||
handleOfflineFlow();
|
||||
},
|
||||
/**
|
||||
* Handle error during vault sync.
|
||||
*/
|
||||
onError: async (error: string) => {
|
||||
/**
|
||||
* Authentication errors are already handled in useVaultSync
|
||||
* Show modal with error message for other errors
|
||||
*/
|
||||
Alert.alert(
|
||||
t('common.error'),
|
||||
error,
|
||||
[{ text: t('common.ok'), style: 'default' }]
|
||||
);
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
},
|
||||
/**
|
||||
* On upgrade required.
|
||||
*/
|
||||
onUpgradeRequired: () : void => {
|
||||
router.replace('/upgrade');
|
||||
},
|
||||
});
|
||||
// Navigate immediately - don't wait for sync
|
||||
navigation.navigateAfterUnlock();
|
||||
};
|
||||
|
||||
initialize();
|
||||
};
|
||||
|
||||
initializeApp();
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
return (): void => {
|
||||
if (skipButtonTimeoutRef.current) {
|
||||
clearTimeout(skipButtonTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [dbContext, syncVault, app, router, navigation, t, handleOfflineFlow, updateStatus]);
|
||||
|
||||
/**
|
||||
* Handle skip button press by calling the offline handler.
|
||||
*/
|
||||
const handleSkipPress = (): void => {
|
||||
// Abort any pending sync operation
|
||||
if (abortControllerRef.current) {
|
||||
console.debug('Aborting pending sync operation');
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
// Clear any existing timeout
|
||||
if (skipButtonTimeoutRef.current) {
|
||||
clearTimeout(skipButtonTimeoutRef.current);
|
||||
skipButtonTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setShowSkipButton(false);
|
||||
lastStatusRef.current = '';
|
||||
|
||||
handleOfflineFlow();
|
||||
};
|
||||
}, [dbContext, syncVault, app, router, navigation, t, updateStatus]);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -350,23 +169,6 @@ export default function Initialize() : React.ReactNode {
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: '40%', // Position above center to avoid Face ID prompt obstruction
|
||||
},
|
||||
skipButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
width: 200,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.accentBorder,
|
||||
},
|
||||
skipButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 16,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -374,11 +176,6 @@ export default function Initialize() : React.ReactNode {
|
||||
<View>
|
||||
<LoadingIndicator status={status || ''} />
|
||||
</View>
|
||||
{showSkipButton && (
|
||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkipPress}>
|
||||
<Ionicons name="play-forward-outline" size={20} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleSheet, View, Alert, TouchableOpacity } from 'react-native';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
|
||||
import { VaultUnlockHelper } from '@/utils/VaultUnlockHelper';
|
||||
|
||||
@@ -27,141 +26,17 @@ export default function ReinitializeScreen() : React.ReactNode {
|
||||
const navigation = useNavigation();
|
||||
const { syncVault } = useVaultSync();
|
||||
const [status, setStatus] = useState('');
|
||||
const [showSkipButton, setShowSkipButton] = useState(false);
|
||||
const hasInitialized = useRef(false);
|
||||
const skipButtonTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastStatusRef = useRef<string>('');
|
||||
const canShowSkipButtonRef = useRef(false); // Only allow skip button after vault unlock
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* Update status with smart skip button logic.
|
||||
* Normalizes status by removing animation dots and manages skip button visibility.
|
||||
* Update status message.
|
||||
*/
|
||||
const updateStatus = useCallback((message: string): void => {
|
||||
setStatus(message);
|
||||
|
||||
// Normalize status by removing animation dots for comparison
|
||||
const normalizedMessage = message.replace(/\.+$/, '');
|
||||
const normalizedLastStatus = lastStatusRef.current.replace(/\.+$/, '');
|
||||
|
||||
// Clear any existing timeout
|
||||
if (skipButtonTimeoutRef.current) {
|
||||
clearTimeout(skipButtonTimeoutRef.current);
|
||||
skipButtonTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// If status changed (excluding dots), hide skip button and reset timer
|
||||
if (normalizedMessage !== normalizedLastStatus) {
|
||||
setShowSkipButton(false);
|
||||
lastStatusRef.current = message;
|
||||
|
||||
// Start new timer for the new status (only if skip button is allowed)
|
||||
if (message && canShowSkipButtonRef.current) {
|
||||
skipButtonTimeoutRef.current = setTimeout(() => {
|
||||
setShowSkipButton(true);
|
||||
}, 5000) as unknown as NodeJS.Timeout;
|
||||
}
|
||||
} else {
|
||||
// Same status (excluding dots) - update ref but keep timer running
|
||||
lastStatusRef.current = message;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle offline scenario - show alert with options to open local vault or retry sync.
|
||||
*/
|
||||
const handleOfflineFlow = useCallback((): void => {
|
||||
Alert.alert(
|
||||
t('app.alerts.syncIssue'),
|
||||
t('app.alerts.syncIssueMessage'),
|
||||
[
|
||||
{
|
||||
text: t('app.alerts.openLocalVault'),
|
||||
/**
|
||||
* Handle opening vault in read-only mode.
|
||||
*/
|
||||
onPress: async () : Promise<void> => {
|
||||
updateStatus(t('app.status.openingVaultReadOnly'));
|
||||
const { enabledAuthMethods } = await app.initializeAuth();
|
||||
|
||||
try {
|
||||
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
|
||||
|
||||
// No encrypted database
|
||||
if (!hasEncryptedDatabase) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set offline mode
|
||||
app.setOfflineMode(true);
|
||||
|
||||
// FaceID not enabled
|
||||
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
|
||||
if (!isFaceIDEnabled) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to unlock vault
|
||||
updateStatus(t('app.status.unlockingVault'));
|
||||
const isUnlocked = await dbContext.unlockVault();
|
||||
|
||||
// Vault couldn't be unlocked
|
||||
if (!isUnlocked) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vault successfully unlocked - proceed with decryption
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Migrations pending
|
||||
if (await dbContext.hasPendingMigrations()) {
|
||||
router.replace('/upgrade');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use centralized navigation logic
|
||||
navigation.navigateAfterUnlock();
|
||||
} catch (err) {
|
||||
console.error('Error during offline vault unlock:', err);
|
||||
router.replace('/unlock');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
text: t('app.alerts.retrySync'),
|
||||
/**
|
||||
* Handle retrying the connection.
|
||||
*/
|
||||
onPress: () : void => {
|
||||
updateStatus(t('app.status.retryingConnection'));
|
||||
setShowSkipButton(false);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (skipButtonTimeoutRef.current) {
|
||||
clearTimeout(skipButtonTimeoutRef.current);
|
||||
skipButtonTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Reset status tracking
|
||||
lastStatusRef.current = '';
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
}, [app, dbContext, navigation, t, updateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) {
|
||||
return;
|
||||
@@ -214,9 +89,6 @@ export default function ReinitializeScreen() : React.ReactNode {
|
||||
router.replace('/upgrade');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vault unlocked successfully - now allow skip button for network operations
|
||||
canShowSkipButtonRef.current = true;
|
||||
} else {
|
||||
// No encrypted database, redirect to unlock screen
|
||||
router.replace('/unlock');
|
||||
@@ -236,69 +108,59 @@ export default function ReinitializeScreen() : React.ReactNode {
|
||||
router.replace('/upgrade');
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow skip button for sync operations since vault is already unlocked.
|
||||
*/
|
||||
canShowSkipButtonRef.current = true;
|
||||
}
|
||||
|
||||
// Now perform vault sync (network operations - these are skippable)
|
||||
await syncVault({
|
||||
/**
|
||||
* Handle the status update.
|
||||
*/
|
||||
onStatus: (message) => {
|
||||
updateStatus(message);
|
||||
},
|
||||
/**
|
||||
* Handle successful vault sync.
|
||||
*/
|
||||
onSuccess: async () => {
|
||||
navigation.navigateAfterUnlock();
|
||||
},
|
||||
/**
|
||||
* Handle error during vault sync.
|
||||
* Authentication errors are already handled in useVaultSync.
|
||||
*/
|
||||
onError: (error: string) => {
|
||||
console.error('Vault sync error during reinitialize:', error);
|
||||
// Even if sync fails, vault is already unlocked, use centralized navigation
|
||||
navigation.navigateAfterUnlock();
|
||||
},
|
||||
/**
|
||||
* Handle offline state and prompt user for action.
|
||||
*/
|
||||
onOffline: () => {
|
||||
handleOfflineFlow();
|
||||
},
|
||||
/**
|
||||
* On upgrade required.
|
||||
*/
|
||||
onUpgradeRequired: () : void => {
|
||||
router.replace('/upgrade');
|
||||
},
|
||||
});
|
||||
/*
|
||||
* Perform vault sync in background - don't block app access.
|
||||
* The ServerSyncIndicator will show sync progress/offline status.
|
||||
* This also handles uploading pending local changes (isDirty) from previous sessions.
|
||||
*/
|
||||
dbContext.setIsSyncing(true);
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
await syncVault({
|
||||
/**
|
||||
* Handle successful vault sync.
|
||||
*/
|
||||
onSuccess: async () => {
|
||||
// Sync completed - ServerSyncIndicator will update
|
||||
await dbContext.refreshSyncState();
|
||||
},
|
||||
/**
|
||||
* Handle error during vault sync.
|
||||
* Authentication errors are already handled in useVaultSync.
|
||||
*/
|
||||
onError: async (error: string) => {
|
||||
console.error('Vault sync error during reinitialize:', error);
|
||||
await dbContext.refreshSyncState();
|
||||
},
|
||||
/**
|
||||
* Handle offline state - just set offline mode and continue.
|
||||
* The ServerSyncIndicator will show offline status.
|
||||
*/
|
||||
onOffline: async () => {
|
||||
await dbContext.setIsOffline(true);
|
||||
await dbContext.refreshSyncState();
|
||||
},
|
||||
/**
|
||||
* On upgrade required.
|
||||
*/
|
||||
onUpgradeRequired: () : void => {
|
||||
router.replace('/upgrade');
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
dbContext.setIsSyncing(false);
|
||||
await dbContext.refreshSyncState();
|
||||
}
|
||||
})();
|
||||
|
||||
// Navigate immediately - don't wait for sync
|
||||
navigation.navigateAfterUnlock();
|
||||
};
|
||||
|
||||
initialize();
|
||||
}, [syncVault, app, dbContext, navigation, t, handleOfflineFlow, updateStatus]);
|
||||
|
||||
/**
|
||||
* Handle skip button press by calling the offline handler.
|
||||
*/
|
||||
const handleSkipPress = (): void => {
|
||||
// Clear any existing timeout
|
||||
if (skipButtonTimeoutRef.current) {
|
||||
clearTimeout(skipButtonTimeoutRef.current);
|
||||
skipButtonTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setShowSkipButton(false);
|
||||
lastStatusRef.current = '';
|
||||
|
||||
handleOfflineFlow();
|
||||
};
|
||||
}, [syncVault, app, dbContext, navigation, t, updateStatus]);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -326,23 +188,6 @@ export default function ReinitializeScreen() : React.ReactNode {
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
skipButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 8,
|
||||
width: 200,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.accentBorder,
|
||||
},
|
||||
skipButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 16,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -352,11 +197,6 @@ export default function ReinitializeScreen() : React.ReactNode {
|
||||
{status ? <LoadingIndicator status={status} /> : null}
|
||||
<ThemedText style={styles.message1}>{t('app.reinitialize.vaultAutoLockedMessage')}</ThemedText>
|
||||
<ThemedText style={styles.message2}>{t('app.reinitialize.attemptingToUnlockMessage')}</ThemedText>
|
||||
{showSkipButton && (
|
||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkipPress}>
|
||||
<Ionicons name="play-forward-outline" size={20} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React, { useState } from 'react';
|
||||
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';
|
||||
@@ -13,13 +13,19 @@ 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
|
||||
* 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
|
||||
*/
|
||||
@@ -32,6 +38,38 @@ export function ServerSyncIndicator(): React.ReactNode {
|
||||
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;
|
||||
@@ -152,7 +190,8 @@ export function ServerSyncIndicator(): React.ReactNode {
|
||||
}
|
||||
|
||||
// Priority 2: Syncing indicator (not tappable, shows progress)
|
||||
if (dbContext.isSyncing) {
|
||||
// Uses showSyncing which has minimum display time to prevent flickering
|
||||
if (showSyncing) {
|
||||
return (
|
||||
<View style={[styles.container, styles.syncing]}>
|
||||
<ActivityIndicator size="small" color={colors.success ?? '#16a34a'} />
|
||||
|
||||
@@ -179,6 +179,17 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh sync state when database becomes available.
|
||||
* This ensures isDirty is populated from native storage on app boot,
|
||||
* so ServerSyncIndicator shows pending changes from previous sessions.
|
||||
*/
|
||||
useEffect(() : void => {
|
||||
if (dbAvailable) {
|
||||
void refreshSyncState();
|
||||
}
|
||||
}, [dbAvailable, refreshSyncState]);
|
||||
|
||||
/**
|
||||
* Set syncing state - exposed for use by sync hooks.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user