Add deleted items scaffolding logic (#1404)

This commit is contained in:
Leendert de Borst
2025-12-28 19:25:50 +01:00
parent 2b1029e7f2
commit fcad09fdfd
7 changed files with 662 additions and 4 deletions

View File

@@ -65,6 +65,14 @@ export default function ItemsLayout(): React.ReactNode {
title: t('items.emailPreview'),
}}
/>
<Stack.Screen
name="deleted"
options={{
title: t('items.recentlyDeleted.title'),
headerBackTitle: t('items.title'),
...defaultHeaderOptions,
}}
/>
</Stack>
);
}

View File

@@ -0,0 +1,405 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useFocusEffect } from '@react-navigation/native';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Image,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import Toast from 'react-native-toast-message';
import type { ItemWithDeletedAt } from '@/utils/db/mappers/ItemMapper';
import emitter from '@/utils/EventEmitter';
import { useColors } from '@/hooks/useColorScheme';
import { useVaultMutate } from '@/hooks/useVaultMutate';
import { ConfirmDeleteModal } from '@/components/items/ConfirmDeleteModal';
import LoadingOverlay from '@/components/LoadingOverlay';
import { ThemedContainer } from '@/components/themed/ThemedContainer';
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
import { ThemedText } from '@/components/themed/ThemedText';
import { useDb } from '@/context/DbContext';
/**
* Calculate days remaining until permanent deletion.
* @param deletedAt - ISO timestamp when item was deleted
* @param retentionDays - Number of days to retain (default 30)
* @returns Number of days remaining, or 0 if already expired
*/
const getDaysRemaining = (deletedAt: string, retentionDays: number = 30): number => {
const deletedDate = new Date(deletedAt);
const expiryDate = new Date(deletedDate.getTime() + retentionDays * 24 * 60 * 60 * 1000);
const now = new Date();
const daysRemaining = Math.ceil((expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000));
return Math.max(0, daysRemaining);
};
/**
* Recently Deleted page - shows items in trash that can be restored or permanently deleted.
*/
export default function RecentlyDeletedScreen(): React.ReactNode {
const { t } = useTranslation();
const colors = useColors();
const dbContext = useDb();
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
const [items, setItems] = useState<ItemWithDeletedAt[]>([]);
const [isLoadingItems, setIsLoadingItems] = useState(true);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [showConfirmEmptyAll, setShowConfirmEmptyAll] = useState(false);
/**
* Load recently deleted items.
*/
const loadItems = useCallback(async (): Promise<void> => {
if (!dbContext.sqliteClient) {
return;
}
try {
const results = await dbContext.sqliteClient.getRecentlyDeletedItems();
setItems(results);
} catch (err) {
console.error('Error loading deleted items:', err);
Toast.show({
type: 'error',
text1: t('items.errorLoadingItems'),
text2: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsLoadingItems(false);
}
}, [dbContext.sqliteClient, t]);
// Load items when screen is focused
useFocusEffect(
useCallback(() => {
setIsLoadingItems(true);
loadItems();
}, [loadItems])
);
/**
* Restore an item from Recently Deleted.
*/
const handleRestore = useCallback(async (itemId: string): Promise<void> => {
if (!dbContext.sqliteClient) {
return;
}
await executeVaultMutation(async () => {
await dbContext.sqliteClient!.restoreItem(itemId);
});
await loadItems();
emitter.emit('credentialChanged');
Toast.show({
type: 'success',
text1: t('items.recentlyDeleted.itemRestored'),
});
}, [dbContext.sqliteClient, executeVaultMutation, loadItems, t]);
/**
* Permanently delete an item.
*/
const handlePermanentDelete = useCallback(async (): Promise<void> => {
if (!dbContext.sqliteClient || !selectedItemId) {
return;
}
await executeVaultMutation(async () => {
await dbContext.sqliteClient!.permanentlyDeleteItem(selectedItemId);
});
await loadItems();
emitter.emit('credentialChanged');
setShowConfirmDelete(false);
setSelectedItemId(null);
Toast.show({
type: 'success',
text1: t('items.recentlyDeleted.itemDeleted'),
});
}, [dbContext.sqliteClient, executeVaultMutation, loadItems, selectedItemId, t]);
/**
* Empty all items from Recently Deleted (permanent delete all).
*/
const handleEmptyAll = useCallback(async (): Promise<void> => {
if (!dbContext.sqliteClient) {
return;
}
await executeVaultMutation(async () => {
for (const item of items) {
await dbContext.sqliteClient!.permanentlyDeleteItem(item.Id);
}
});
await loadItems();
emitter.emit('credentialChanged');
setShowConfirmEmptyAll(false);
Toast.show({
type: 'success',
text1: t('items.recentlyDeleted.allItemsDeleted'),
});
}, [dbContext.sqliteClient, executeVaultMutation, items, loadItems, t]);
/**
* Handle closing the delete confirmation modal.
*/
const handleCloseDeleteModal = useCallback((): void => {
setShowConfirmDelete(false);
setSelectedItemId(null);
}, []);
const styles = StyleSheet.create({
headerText: {
color: colors.textMuted,
fontSize: 13,
marginBottom: 16,
},
headerRow: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
itemCount: {
color: colors.textMuted,
fontSize: 13,
},
emptyAllButton: {
paddingVertical: 4,
},
emptyAllButtonText: {
color: colors.destructive,
fontSize: 14,
fontWeight: '500',
},
emptyContainer: {
alignItems: 'center',
paddingTop: 40,
},
emptyTitle: {
color: colors.textMuted,
fontSize: 16,
marginBottom: 8,
},
emptyDescription: {
color: colors.textMuted,
fontSize: 14,
opacity: 0.7,
textAlign: 'center',
},
itemCard: {
backgroundColor: colors.accentBackground,
borderRadius: 8,
marginBottom: 8,
padding: 12,
},
itemContent: {
alignItems: 'center',
flexDirection: 'row',
},
itemLogo: {
borderRadius: 4,
height: 32,
marginRight: 12,
width: 32,
},
itemLogoPlaceholder: {
alignItems: 'center',
backgroundColor: colors.primary + '20',
borderRadius: 4,
height: 32,
justifyContent: 'center',
marginRight: 12,
width: 32,
},
itemInfo: {
flex: 1,
},
itemName: {
color: colors.text,
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
itemExpiry: {
color: colors.textMuted,
fontSize: 14,
},
itemExpiryWarning: {
color: colors.destructive,
},
itemActions: {
flexDirection: 'row',
gap: 8,
},
restoreButton: {
backgroundColor: colors.primary + '15',
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 6,
},
restoreButtonText: {
color: colors.primary,
fontSize: 14,
fontWeight: '500',
},
deleteButton: {
backgroundColor: colors.destructive + '15',
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 6,
},
deleteButtonText: {
color: colors.destructive,
fontSize: 14,
fontWeight: '500',
},
loadingContainer: {
alignItems: 'center',
flex: 1,
justifyContent: 'center',
paddingTop: 60,
},
});
/**
* Render an item card.
*/
const renderItem = (item: ItemWithDeletedAt): React.ReactElement => {
const daysRemaining = item.DeletedAt ? getDaysRemaining(item.DeletedAt) : 30;
return (
<View key={item.Id} style={styles.itemCard}>
<View style={styles.itemContent}>
{/* Item logo */}
{item.Logo ? (
<Image
source={{ uri: `data:image/png;base64,${Buffer.from(item.Logo).toString('base64')}` }}
style={styles.itemLogo}
/>
) : (
<View style={styles.itemLogoPlaceholder}>
<MaterialIcons name="lock" size={18} color={colors.primary} />
</View>
)}
{/* Item info */}
<View style={styles.itemInfo}>
<Text style={styles.itemName} numberOfLines={1}>
{item.Name || t('items.untitled')}
</Text>
<Text style={[
styles.itemExpiry,
daysRemaining <= 3 && styles.itemExpiryWarning
]}>
{daysRemaining > 0
? t('items.recentlyDeleted.daysRemaining', { count: daysRemaining })
: t('items.recentlyDeleted.expiringSoon')}
</Text>
</View>
{/* Action buttons */}
<View style={styles.itemActions}>
<TouchableOpacity
style={styles.restoreButton}
onPress={() => handleRestore(item.Id)}
>
<Text style={styles.restoreButtonText}>
{t('items.recentlyDeleted.restore')}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => {
setSelectedItemId(item.Id);
setShowConfirmDelete(true);
}}
>
<Text style={styles.deleteButtonText}>
{t('common.delete')}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
};
return (
<ThemedContainer>
{isLoading && <LoadingOverlay status={syncStatus} />}
{isLoadingItems ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : (
<ThemedScrollView>
{items.length > 0 ? (
<>
<View style={styles.headerRow}>
<ThemedText style={styles.itemCount}>
{items.length} {items.length === 1 ? 'item' : 'items'}
</ThemedText>
<TouchableOpacity
style={styles.emptyAllButton}
onPress={() => setShowConfirmEmptyAll(true)}
>
<Text style={styles.emptyAllButtonText}>
{t('items.recentlyDeleted.emptyAll')}
</Text>
</TouchableOpacity>
</View>
<ThemedText style={styles.headerText}>
{t('items.recentlyDeleted.description')}
</ThemedText>
{items.map(renderItem)}
</>
) : (
<View style={styles.emptyContainer}>
<ThemedText style={styles.emptyTitle}>
{t('items.recentlyDeleted.noItems')}
</ThemedText>
<ThemedText style={styles.emptyDescription}>
{t('items.recentlyDeleted.noItemsDescription')}
</ThemedText>
</View>
)}
</ThemedScrollView>
)}
{/* Confirm Delete Modal */}
<ConfirmDeleteModal
isOpen={showConfirmDelete && !!selectedItemId}
onClose={handleCloseDeleteModal}
onConfirm={handlePermanentDelete}
title={t('items.recentlyDeleted.confirmDeleteTitle')}
message={t('items.recentlyDeleted.confirmDeleteMessage')}
confirmText={t('items.recentlyDeleted.deletePermanently')}
/>
{/* Confirm Empty All Modal */}
<ConfirmDeleteModal
isOpen={showConfirmEmptyAll}
onClose={() => setShowConfirmEmptyAll(false)}
onConfirm={handleEmptyAll}
title={t('items.recentlyDeleted.confirmEmptyAllTitle')}
message={t('items.recentlyDeleted.confirmEmptyAllMessage', { count: items.length })}
confirmText={t('items.recentlyDeleted.emptyAll')}
/>
</ThemedContainer>
);
}

View File

@@ -93,6 +93,9 @@ export default function ItemsScreen(): React.ReactNode {
const [filterType, setFilterType] = useState<FilterType>('all');
const [showFilterMenu, setShowFilterMenu] = useState(false);
// Recently deleted count state
const [recentlyDeletedCount, setRecentlyDeletedCount] = useState(0);
const authContext = useApp();
const dbContext = useDb();
@@ -197,16 +200,18 @@ export default function ItemsScreen(): React.ReactNode {
}, [itemsList, searchQuery, filterType]);
/**
* Load items (credentials) and folders.
* Load items (credentials), folders, and recently deleted count.
*/
const loadItems = useCallback(async (): Promise<void> => {
try {
const [items, loadedFolders] = await Promise.all([
const [items, loadedFolders, deletedCount] = await Promise.all([
dbContext.sqliteClient!.getAllItems(),
dbContext.sqliteClient!.getAllFolders()
dbContext.sqliteClient!.getAllFolders(),
dbContext.sqliteClient!.getRecentlyDeletedCount()
]);
setItemsList(items);
setFolders(loadedFolders);
setRecentlyDeletedCount(deletedCount);
setIsLoadingItems(false);
} catch (err) {
Toast.show({
@@ -474,6 +479,16 @@ export default function ItemsScreen(): React.ReactNode {
height: 1,
marginVertical: 4,
},
filterMenuItemWithBadge: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
},
filterMenuItemBadge: {
color: colors.textMuted,
fontSize: 14,
},
// Folder pills styles
folderPillsContainer: {
flexDirection: 'row',
@@ -682,6 +697,28 @@ export default function ItemsScreen(): React.ReactNode {
{t('common.attachments')}
</ThemedText>
</TouchableOpacity>
<ThemedView style={styles.filterMenuSeparator} />
{/* Recently deleted link */}
<TouchableOpacity
style={styles.filterMenuItem}
onPress={() => {
setShowFilterMenu(false);
router.push('/(tabs)/items/deleted');
}}
>
<View style={styles.filterMenuItemWithBadge}>
<ThemedText style={styles.filterMenuItemText}>
{t('items.recentlyDeleted.title')}
</ThemedText>
{recentlyDeletedCount > 0 && (
<ThemedText style={styles.filterMenuItemBadge}>
{recentlyDeletedCount}
</ThemedText>
)}
</View>
</TouchableOpacity>
</ThemedView>
);
};

View File

@@ -0,0 +1,169 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
ActivityIndicator,
} from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
interface IConfirmDeleteModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
title: string;
message: string;
confirmText?: string;
}
/**
* Modal for confirming permanent deletion of items.
*/
export const ConfirmDeleteModal: React.FC<IConfirmDeleteModalProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
}) => {
const { t } = useTranslation();
const colors = useColors();
const [isSubmitting, setIsSubmitting] = useState(false);
/**
* Handle confirm action.
*/
const handleConfirm = async (): Promise<void> => {
setIsSubmitting(true);
try {
await onConfirm();
} catch (err) {
console.error('Error during confirm action:', err);
} finally {
setIsSubmitting(false);
}
};
/**
* Handle close - only allow if not submitting.
*/
const handleClose = (): void => {
if (!isSubmitting) {
onClose();
}
};
const styles = StyleSheet.create({
backdrop: {
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
flex: 1,
justifyContent: 'center',
},
container: {
backgroundColor: colors.background,
borderRadius: 12,
marginHorizontal: 20,
maxWidth: 400,
padding: 20,
width: '90%',
},
title: {
color: colors.text,
fontSize: 18,
fontWeight: '600',
marginBottom: 12,
},
message: {
color: colors.textMuted,
fontSize: 14,
lineHeight: 20,
marginBottom: 20,
},
buttonsContainer: {
flexDirection: 'row',
gap: 12,
},
cancelButton: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
borderColor: colors.accentBorder,
borderRadius: 8,
borderWidth: 1,
flex: 1,
paddingVertical: 12,
},
cancelButtonText: {
color: colors.text,
fontSize: 16,
fontWeight: '500',
},
confirmButton: {
alignItems: 'center',
backgroundColor: colors.destructive,
borderRadius: 8,
flex: 1,
flexDirection: 'row',
gap: 8,
justifyContent: 'center',
paddingVertical: 12,
},
confirmButtonDisabled: {
opacity: 0.6,
},
confirmButtonText: {
color: colors.white,
fontSize: 16,
fontWeight: '600',
},
});
return (
<Modal
visible={isOpen}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<View style={styles.backdrop}>
<View style={styles.container}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.message}>{message}</Text>
<View style={styles.buttonsContainer}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={isSubmitting}
>
<Text style={styles.cancelButtonText}>{t('common.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.confirmButton,
isSubmitting && styles.confirmButtonDisabled
]}
onPress={handleConfirm}
disabled={isSubmitting}
>
{isSubmitting && (
<ActivityIndicator size="small" color={colors.white} />
)}
<Text style={styles.confirmButtonText}>
{confirmText || t('common.delete')}
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
export default ConfirmDeleteModal;

View File

@@ -613,6 +613,25 @@
"emptyFolderHint": "This folder is empty. To move items to this folder, edit the item and select this folder.",
"noFolder": "No Folder",
"selectFolder": "Select Folder"
},
"recentlyDeleted": {
"title": "Recently Deleted",
"noItems": "No deleted items",
"noItemsDescription": "Items you delete will appear here for 30 days before being permanently removed.",
"description": "These items will be permanently deleted after 30 days. You can restore them or delete them immediately.",
"restore": "Restore",
"deletePermanently": "Delete Permanently",
"emptyAll": "Empty All",
"daysRemaining": "{{count}} day remaining",
"daysRemaining_plural": "{{count}} days remaining",
"expiringSoon": "Expiring soon",
"confirmDeleteTitle": "Delete Permanently?",
"confirmDeleteMessage": "This item will be permanently deleted and cannot be recovered.",
"confirmEmptyAllTitle": "Empty Recently Deleted?",
"confirmEmptyAllMessage": "All {{count}} items will be permanently deleted and cannot be recovered.",
"itemRestored": "Item restored",
"itemDeleted": "Item permanently deleted",
"allItemsDeleted": "All items permanently deleted"
}
},
"emails": {

View File

@@ -350,6 +350,14 @@ class SqliteClient implements IDatabaseClient {
return this.itemRepository.getRecentlyDeleted();
}
/**
* Get count of items in trash.
* @returns Number of items in trash
*/
public async getRecentlyDeletedCount(): Promise<number> {
return this.itemRepository.getRecentlyDeletedCount();
}
/**
* Create a new item with its fields and related entities.
* @param item - The item to create

View File

@@ -115,10 +115,22 @@ export class ItemQueries {
* Get all recently deleted items (in trash).
*/
public static readonly GET_RECENTLY_DELETED = `
${ItemQueries.BASE_SELECT},
SELECT DISTINCT
i.Id,
i.Name,
i.ItemType,
i.FolderId,
f.Name as FolderPath,
l.FileData as Logo,
CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey,
CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment,
CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp,
i.CreatedAt,
i.UpdatedAt,
i.DeletedAt
FROM Items i
LEFT JOIN Logos l ON i.LogoId = l.Id
LEFT JOIN Folders f ON i.FolderId = f.Id
WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL
ORDER BY i.DeletedAt DESC`;