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`;