From fcad09fdfd1ff00bac18d35c16ce4831959e3d59 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 28 Dec 2025 19:25:50 +0100 Subject: [PATCH] Add deleted items scaffolding logic (#1404) --- apps/mobile-app/app/(tabs)/items/_layout.tsx | 8 + apps/mobile-app/app/(tabs)/items/deleted.tsx | 405 ++++++++++++++++++ apps/mobile-app/app/(tabs)/items/index.tsx | 43 +- .../components/items/ConfirmDeleteModal.tsx | 169 ++++++++ apps/mobile-app/i18n/locales/en.json | 19 + apps/mobile-app/utils/SqliteClient.tsx | 8 + .../utils/db/queries/ItemQueries.ts | 14 +- 7 files changed, 662 insertions(+), 4 deletions(-) create mode 100644 apps/mobile-app/app/(tabs)/items/deleted.tsx create mode 100644 apps/mobile-app/components/items/ConfirmDeleteModal.tsx diff --git a/apps/mobile-app/app/(tabs)/items/_layout.tsx b/apps/mobile-app/app/(tabs)/items/_layout.tsx index 252c27baf..d975f5a15 100644 --- a/apps/mobile-app/app/(tabs)/items/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/items/_layout.tsx @@ -65,6 +65,14 @@ export default function ItemsLayout(): React.ReactNode { title: t('items.emailPreview'), }} /> + ); } \ No newline at end of file diff --git a/apps/mobile-app/app/(tabs)/items/deleted.tsx b/apps/mobile-app/app/(tabs)/items/deleted.tsx new file mode 100644 index 000000000..914e5fddb --- /dev/null +++ b/apps/mobile-app/app/(tabs)/items/deleted.tsx @@ -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([]); + const [isLoadingItems, setIsLoadingItems] = useState(true); + const [selectedItemId, setSelectedItemId] = useState(null); + const [showConfirmDelete, setShowConfirmDelete] = useState(false); + const [showConfirmEmptyAll, setShowConfirmEmptyAll] = useState(false); + + /** + * Load recently deleted items. + */ + const loadItems = useCallback(async (): Promise => { + 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 => { + 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 => { + 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 => { + 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 ( + + + {/* Item logo */} + {item.Logo ? ( + + ) : ( + + + + )} + + {/* Item info */} + + + {item.Name || t('items.untitled')} + + + {daysRemaining > 0 + ? t('items.recentlyDeleted.daysRemaining', { count: daysRemaining }) + : t('items.recentlyDeleted.expiringSoon')} + + + + {/* Action buttons */} + + handleRestore(item.Id)} + > + + {t('items.recentlyDeleted.restore')} + + + { + setSelectedItemId(item.Id); + setShowConfirmDelete(true); + }} + > + + {t('common.delete')} + + + + + + ); + }; + + return ( + + {isLoading && } + + {isLoadingItems ? ( + + + + ) : ( + + {items.length > 0 ? ( + <> + + + {items.length} {items.length === 1 ? 'item' : 'items'} + + setShowConfirmEmptyAll(true)} + > + + {t('items.recentlyDeleted.emptyAll')} + + + + + {t('items.recentlyDeleted.description')} + + {items.map(renderItem)} + + ) : ( + + + {t('items.recentlyDeleted.noItems')} + + + {t('items.recentlyDeleted.noItemsDescription')} + + + )} + + )} + + {/* Confirm Delete Modal */} + + + {/* Confirm Empty All Modal */} + setShowConfirmEmptyAll(false)} + onConfirm={handleEmptyAll} + title={t('items.recentlyDeleted.confirmEmptyAllTitle')} + message={t('items.recentlyDeleted.confirmEmptyAllMessage', { count: items.length })} + confirmText={t('items.recentlyDeleted.emptyAll')} + /> + + ); +} diff --git a/apps/mobile-app/app/(tabs)/items/index.tsx b/apps/mobile-app/app/(tabs)/items/index.tsx index 7586d9918..f466395ec 100644 --- a/apps/mobile-app/app/(tabs)/items/index.tsx +++ b/apps/mobile-app/app/(tabs)/items/index.tsx @@ -93,6 +93,9 @@ export default function ItemsScreen(): React.ReactNode { const [filterType, setFilterType] = useState('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 => { 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')} + + + + {/* Recently deleted link */} + { + setShowFilterMenu(false); + router.push('/(tabs)/items/deleted'); + }} + > + + + {t('items.recentlyDeleted.title')} + + {recentlyDeletedCount > 0 && ( + + {recentlyDeletedCount} + + )} + + ); }; diff --git a/apps/mobile-app/components/items/ConfirmDeleteModal.tsx b/apps/mobile-app/components/items/ConfirmDeleteModal.tsx new file mode 100644 index 000000000..2a666ee80 --- /dev/null +++ b/apps/mobile-app/components/items/ConfirmDeleteModal.tsx @@ -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; + title: string; + message: string; + confirmText?: string; +} + +/** + * Modal for confirming permanent deletion of items. + */ +export const ConfirmDeleteModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText, +}) => { + const { t } = useTranslation(); + const colors = useColors(); + const [isSubmitting, setIsSubmitting] = useState(false); + + /** + * Handle confirm action. + */ + const handleConfirm = async (): Promise => { + 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 ( + + + + {title} + {message} + + + + {t('common.cancel')} + + + + {isSubmitting && ( + + )} + + {confirmText || t('common.delete')} + + + + + + + ); +}; + +export default ConfirmDeleteModal; diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index 06d891e2f..cea88108d 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -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": { diff --git a/apps/mobile-app/utils/SqliteClient.tsx b/apps/mobile-app/utils/SqliteClient.tsx index 9f885bf92..91689e7eb 100644 --- a/apps/mobile-app/utils/SqliteClient.tsx +++ b/apps/mobile-app/utils/SqliteClient.tsx @@ -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 { + return this.itemRepository.getRecentlyDeletedCount(); + } + /** * Create a new item with its fields and related entities. * @param item - The item to create diff --git a/apps/mobile-app/utils/db/queries/ItemQueries.ts b/apps/mobile-app/utils/db/queries/ItemQueries.ts index 4df11eaf8..71f2c950a 100644 --- a/apps/mobile-app/utils/db/queries/ItemQueries.ts +++ b/apps/mobile-app/utils/db/queries/ItemQueries.ts @@ -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`;