feat: lead from the trash to recover deleted items when the archive has some

This commit is contained in:
Linus Rath
2026-10-31 05:43:55 +01:00
parent b35d15cada
commit 7467764e43
4 changed files with 198 additions and 0 deletions
@@ -0,0 +1,72 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RotateCcw } from '../icons';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { setPendingSettingsTab } from '../../navigation/pending-settings-tab';
import { recoveryPolicy } from '../../lib/recovery/policy';
import { useRecoveryStore } from '../../lib/recovery/recovery-store';
import { recoveryClientFor, useDeletedItemsRecovery } from '../../lib/recovery/use-deleted-items-recovery';
import { isOwnTrash, trashRecoveryLinkVisible } from '../../lib/recovery/trash-link';
import type { Mailbox } from '../../api/types';
import type { RootStackParamList } from '../../navigation/types';
/**
* A line in the Trash folder's header that leads to Settings > Recover
* deleted items (webmail components/email/trash-recovery-link.tsx): only
* when the server offers it for this account and its archive holds at least
* one message inside the days window, so it never leads to an empty page.
* Only in the account's own Trash: a shared or group Trash has an archive of
* its own that the page doesn't show.
*/
export function TrashRecoveryLink({ mailbox }: { mailbox: Mailbox | undefined }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { available, accountId, key } = useDeletedItemsRecovery();
const { days } = recoveryPolicy();
const recent = useRecoveryStore((s) => (key ? s.recent[key] : undefined));
const checkRecent = useRecoveryStore((s) => s.checkRecent);
const ownTrash = isOwnTrash(mailbox, accountId);
React.useEffect(() => {
if (!available || !ownTrash || !accountId || !key) return;
void checkRecent(recoveryClientFor(accountId), key, days);
}, [available, ownTrash, accountId, key, days, checkRecent]);
if (!trashRecoveryLinkVisible({ available, ownTrash, recent, days })) return null;
const open = () => {
setPendingSettingsTab('recovery');
navigation.navigate('MainTabs', { screen: 'Settings' } as never);
};
return (
<View style={styles.row} testID="trash-recovery-link">
<Text style={styles.hint} numberOfLines={1}>
{t('recover_deleted.trash_hint', 'Deleted for good by mistake?')}
</Text>
<Pressable onPress={open} style={styles.link} hitSlop={8} accessibilityRole="button">
<RotateCcw size={14} color={c.primary} />
<Text style={styles.linkText}>{t('recover_deleted.trash_link', 'Recover deleted items')}</Text>
</Pressable>
</View>
);
}
const makeStyles = (c: ThemePalette) => StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginHorizontal: spacing.lg,
marginBottom: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
},
hint: { ...typography.caption, color: c.textSecondary, flex: 1, minWidth: 0 },
link: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingVertical: 4 },
linkText: { ...typography.captionMedium, color: c.primary },
});
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { isOwnTrash, trashRecoveryLinkVisible } from '../trash-link';
import { useRecoveryStore, type RecentCheck, type RecoveryClient } from '../recovery-store';
import type { Mailbox } from '../../../api/types';
// The Trash header leads to Settings > Recover deleted items (webmail
// components/email/trash-recovery-link.tsx and its test): only in the
// account's own Trash, when the server offers recovery, and when the archive
// holds a message inside the days window it was looked up for.
const TRASH = { id: 't', name: 'Trash', role: 'trash', accountId: 'ci' } as Mailbox;
const HAS: RecentCheck = { has: true, days: 14, checkedAt: 0 };
function visible(over: Partial<Parameters<typeof trashRecoveryLinkVisible>[0]> = {}): boolean {
return trashRecoveryLinkVisible({ available: true, ownTrash: true, recent: HAS, days: 14, ...over });
}
afterEach(() => useRecoveryStore.getState().reset());
describe('isOwnTrash', () => {
it("is the account's own Trash, with or without an account id on the folder", () => {
expect(isOwnTrash(TRASH, 'ci')).toBe(true);
expect(isOwnTrash({ ...TRASH, accountId: undefined }, 'ci')).toBe(true);
});
it("is not another folder, a shared or group account's Trash, or anything before the session", () => {
expect(isOwnTrash({ ...TRASH, role: 'inbox' }, 'ci')).toBe(false);
expect(isOwnTrash({ ...TRASH, accountId: 'ch', isShared: true }, 'ci')).toBe(false);
expect(isOwnTrash({ ...TRASH, accountId: undefined, isShared: true }, 'ci')).toBe(false);
expect(isOwnTrash({ ...TRASH, accountId: 'ch' }, 'ci')).toBe(false);
expect(isOwnTrash(undefined, 'ci')).toBe(false);
expect(isOwnTrash(TRASH, null)).toBe(false);
});
});
describe('trashRecoveryLinkVisible', () => {
it('shows when recovery is offered and the archive has a recent message', () => {
expect(visible()).toBe(true);
});
it('hides when the archive holds nothing inside the days, or has not been looked at', () => {
expect(visible({ recent: { ...HAS, has: false } })).toBe(false);
expect(visible({ recent: undefined })).toBe(false);
});
it('hides when the server refuses, outside the own Trash, or when the look was for other days', () => {
expect(visible({ available: false })).toBe(false);
expect(visible({ ownTrash: false })).toBe(false);
expect(visible({ days: 30 })).toBe(false);
});
it("follows the store's look at the archive, and hides again after a restore", async () => {
const client: RecoveryClient = {
advertisesArchivedItems: () => true,
queryArchivedItemIds: vi.fn(async () => ({ ids: [], total: null }) as never),
hasArchivedEmail: vi.fn(async () => true),
};
const key = 'acc-alice|ci';
await useRecoveryStore.getState().checkRecent(client, key, 14);
expect(client.hasArchivedEmail).toHaveBeenCalledWith({ days: 14 });
expect(visible({ recent: useRecoveryStore.getState().recent[key] })).toBe(true);
useRecoveryStore.getState().forgetRecent(key);
expect(visible({ recent: useRecoveryStore.getState().recent[key] })).toBe(false);
});
});
describe('Trash link wiring', () => {
const src = (rel: string) => fs.readFileSync(path.resolve(__dirname, '../../..', rel), 'utf8');
it('is mounted in the list header only when the open folder is the Trash', () => {
const list = src('screens/EmailListScreen.tsx');
expect(list).toMatch(/\{currentMailbox\?\.role === 'trash' && <TrashRecoveryLink mailbox=\{currentMailbox\} \/>\}/);
});
it("looks at the open folder's account and opens Settings > Recover deleted items", () => {
const link = src('components/email/TrashRecoveryLink.tsx');
expect(link).toMatch(/const \{ available, accountId, key \} = useDeletedItemsRecovery\(\);/);
expect(link).toMatch(/const ownTrash = isOwnTrash\(mailbox, accountId\);/);
expect(link).toMatch(/if \(!available \|\| !ownTrash \|\| !accountId \|\| !key\) return;/);
expect(link).toMatch(/checkRecent\(recoveryClientFor\(accountId\), key, days\)/);
expect(link).toMatch(/if \(!trashRecoveryLinkVisible\(\{ available, ownTrash, recent, days \}\)\) return null;/);
expect(link).toMatch(/setPendingSettingsTab\('recovery'\);\s*navigation\.navigate\('MainTabs', \{ screen: 'Settings' \}/);
expect(link).toMatch(/t\('recover_deleted\.trash_hint', /);
expect(link).toMatch(/t\('recover_deleted\.trash_link', /);
// A selector that builds a new object or array each time loops the render.
expect(link).not.toMatch(/Store\(\(s\) => \(\{/);
expect(link).not.toMatch(/Store\(\(s\) => [^;]*\.(filter|map)\(/);
// The tab the link parks is one Settings opens.
expect(src('screens/SettingsScreen.tsx')).toMatch(/\{ id: 'recovery', [^}]*implemented: true\s*\}/);
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { Mailbox } from '../../api/types';
import type { RecentCheck } from './recovery-store';
/**
* The account's own Trash, the one whose archive Settings > Recover deleted
* items shows (webmail components/email/trash-recovery-link.tsx). A shared
* or group Trash has an archive of its own that the page doesn't show.
* `accountId` is the JMAP account the page is for (useDeletedItemsRecovery).
*/
export function isOwnTrash(mailbox: Mailbox | undefined, accountId: string | null): boolean {
return !!accountId && mailbox?.role === 'trash' && !mailbox.isShared
&& (!mailbox.accountId || mailbox.accountId === accountId);
}
/**
* Whether the Trash header leads to Recover deleted items: the server offers
* it for this account and its archive holds a message inside the days
* window, looked up for that same window, so the link never opens an empty
* page.
*/
export function trashRecoveryLinkVisible(options: {
available: boolean;
ownTrash: boolean;
recent: RecentCheck | undefined;
days: number;
}): boolean {
const { available, ownTrash, recent, days } = options;
return available && ownTrash && !!recent?.has && recent.days === days;
}
+4
View File
@@ -23,6 +23,7 @@ import { SwipeableRow } from '../components/SwipeableRow';
import { MoveSheet } from '../components/MoveSheet';
import { TagSheet } from '../components/TagSheet';
import { OfflineBanner } from '../components/OfflineBanner';
import { TrashRecoveryLink } from '../components/email/TrashRecoveryLink';
import {
ListAttachmentChips, ListAttachmentOpener, useListAttachments, useListRowAttachments,
} from '../components/email/ListAttachmentChips';
@@ -1666,6 +1667,9 @@ export default function EmailListScreen({
</View>
)}
{/* Recover deleted items, from the account's own Trash (mounted only there) */}
{currentMailbox?.role === 'trash' && <TrashRecoveryLink mailbox={currentMailbox} />}
{canEmptyFolder && !selectionMode && (
<View style={styles.emptyFolderBanner}>
<Text style={styles.emptyFolderHint} numberOfLines={2}>