diff --git a/src/components/settings/DownloadsSettings.tsx b/src/components/settings/DownloadsSettings.tsx index a6aaad5f..c8fd1d97 100644 --- a/src/components/settings/DownloadsSettings.tsx +++ b/src/components/settings/DownloadsSettings.tsx @@ -17,6 +17,7 @@ import { type EmailFilenameOptions, } from '../../lib/download-filename'; import { useLocaleStore } from '../../stores/locale-store'; +import { usePolicyFeature } from '../../stores/policy-store'; const SAMPLE = buildSampleEmail(); const SAMPLE_ATTACHMENT = { name: 'Rechnung 2026.pdf', type: 'application/pdf' }; @@ -34,7 +35,11 @@ export function DownloadsSettings() { const spaceReplacement = useSettingsStore((s) => s.exportSpaceReplacement); const lowercase = useSettingsStore((s) => s.exportLowercase); const stripDiacritics = useSettingsStore((s) => s.exportStripDiacritics); - const postExportAction = useSettingsStore((s) => s.postExportAction); + // Without the organisation's Archive action there is no "move to archive" + // after an export (webmail downloads-settings.tsx). + const archiveEnabled = usePolicyFeature('archiveActionEnabled'); + const storedPostExportAction = useSettingsStore((s) => s.postExportAction); + const postExportAction = archiveEnabled || storedPostExportAction !== 'archive' ? storedPostExportAction : 'keep'; useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]); @@ -136,7 +141,7 @@ export function DownloadsSettings() { onChange={(v) => update('postExportAction', v as PostExportAction)} options={[ { value: 'keep', label: t('settings.downloads.after_export.keep', "Keep in mailbox") }, - { value: 'archive', label: t('settings.downloads.after_export.archive', "Move to archive") }, + ...(archiveEnabled ? [{ value: 'archive', label: t('settings.downloads.after_export.archive', "Move to archive") }] : []), { value: 'trash', label: t('settings.downloads.after_export.trash', "Move to trash") }, ]} /> diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index ae61ec09..a72324f7 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -41,6 +41,8 @@ import { type EmailFilters, } from '../stores/email-store'; import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store'; +import { usePolicyFeature } from '../stores/policy-store'; +import { swipeActionShown } from '../lib/swipe-actions'; import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../stores/keywords-store'; import { mergeSharedTags } from '../lib/shared-tags'; import { useBackWhileFocused } from '../lib/use-back-while-focused'; @@ -538,8 +540,10 @@ export default function EmailListScreen({ const retryFailedOps = useOutboxStore((s) => s.retryFailed); const discardFailedOps = useOutboxStore((s) => s.discardFailed); - const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction); - const swipeRightAction = useSettingsStore((s) => s.swipeRightAction); + // The organisation's Archive gate: a stored Archive swipe does nothing while it is off. + const archiveEnabled = usePolicyFeature('archiveActionEnabled'); + const swipeLeftAction = swipeActionShown(useSettingsStore((s) => s.swipeLeftAction), archiveEnabled); + const swipeRightAction = swipeActionShown(useSettingsStore((s) => s.swipeRightAction), archiveEnabled); const swipeMode = useSettingsStore((s) => s.swipeMode); const showPreview = useSettingsStore((s) => s.showPreview); const disableThreading = useSettingsStore((s) => s.disableThreading); @@ -1041,7 +1045,7 @@ export default function EmailListScreen({ }; const canArchiveSelection = - archiveMailboxId != null && currentMailboxId !== archiveMailboxId; + archiveEnabled && archiveMailboxId != null && currentMailboxId !== archiveMailboxId; const canSpamSelection = currentRole !== 'sent' && currentRole !== 'drafts' && (inJunk || junkMailboxId != null); diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 04707a78..3aa9d2b9 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -33,10 +33,11 @@ import { useEmailStore, listRowsOfAccount } from '../stores/email-store'; import { toast } from '../stores/toast-store'; import { useSettingsStore, - normalizeBottomQuickActions, REPLY_QUICK_ACTIONS, type QuickAction, } from '../stores/settings-store'; +import { usePolicyFeature } from '../stores/policy-store'; +import { bottomQuickActionsFor } from '../lib/swipe-actions'; import { patchKeywordsForEmails } from '../api/email'; import { loadDetail, loadDetails, loadThread, patchDetail, peekDetail, peekRow, peekThread, subscribeEmailCache, @@ -356,9 +357,12 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay); const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions); + // The organisation's Archive gate (webmail lib/archive-gate.ts): off, a + // stored Archive quick action gives way and no Archive button shows. + const archiveEnabled = usePolicyFeature('archiveActionEnabled'); const bottomActions = React.useMemo( - () => normalizeBottomQuickActions(bottomQuickActionsRaw), - [bottomQuickActionsRaw], + () => bottomQuickActionsFor(bottomQuickActionsRaw, archiveEnabled), + [bottomQuickActionsRaw, archiveEnabled], ); // Any reply-family action the user pulled out of the bottom bar is surfaced // in the top toolbar so it stays reachable. @@ -488,7 +492,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr [scopedMailboxes, email, currentMailboxId], ); const isInTrash = !!(trashMailbox && sourceMailbox?.id === trashMailbox.id); - const canArchive = !!archiveMailbox && sourceMailbox?.id !== archiveMailbox.id; + const canArchive = archiveEnabled && !!archiveMailbox && sourceMailbox?.id !== archiveMailbox.id; // Not spam files back into the Inbox, so it needs one. const canToggleSpam = isInJunk ? !!inboxMailbox : !!junkMailbox; diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index 274bcfbf..86ac0825 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -22,6 +22,8 @@ import { SearchSnippetText } from '../components/email/SearchSnippetText'; import { useAccountStore } from '../stores/account-store'; import { useAuthStore } from '../stores/auth-store'; import { useSettingsStore, type SwipeAction } from '../stores/settings-store'; +import { usePolicyFeature } from '../stores/policy-store'; +import { swipeActionShown } from '../lib/swipe-actions'; import { useLocaleStore } from '../stores/locale-store'; import { formatListDate } from '../lib/date-format'; import { singleLine } from '../lib/single-line'; @@ -79,8 +81,10 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { const crossAccount = useSettingsStore((s) => s.unifiedCrossAccount); const deleteAction = useSettingsStore((s) => s.deleteAction); const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk); - const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction); - const swipeRightAction = useSettingsStore((s) => s.swipeRightAction); + // The organisation's Archive gate: a stored Archive swipe does nothing while it is off. + const archiveEnabled = usePolicyFeature('archiveActionEnabled'); + const swipeLeftAction = swipeActionShown(useSettingsStore((s) => s.swipeLeftAction), archiveEnabled); + const swipeRightAction = swipeActionShown(useSettingsStore((s) => s.swipeRightAction), archiveEnabled); const swipeMode = useSettingsStore((s) => s.swipeMode); const showAvatarsInJunk = useSettingsStore((s) => s.showAvatarsInJunk); const locale = useLocaleStore((s) => s.locale); @@ -303,7 +307,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { const handleSwipe = (email: UnifiedEmail, action: SwipeAction) => { switch (action) { - case 'archive': if (role !== 'archive') void archive([email]); break; + case 'archive': if (archiveEnabled && role !== 'archive') void archive([email]); break; case 'delete': void remove([email]); break; case 'spam': if (role !== 'sent' && role !== 'drafts') spam([email]); break; case 'read': setRead([email], !email.keywords?.$seen); break; @@ -482,7 +486,7 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { {role === 'junk' ? : } )} - {role !== 'archive' && ( + {archiveEnabled && role !== 'archive' && ( { void archive(selectedEmails).then((done) => { if (done) clearSelection(); }); }} style={styles.headerBtn} diff --git a/src/screens/__tests__/archive-gate-wiring.test.ts b/src/screens/__tests__/archive-gate-wiring.test.ts new file mode 100644 index 00000000..fbaa3b50 --- /dev/null +++ b/src/screens/__tests__/archive-gate-wiring.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// The organisation's archiveActionEnabled gate (webmail lib/archive-gate.ts, +// email-list.tsx, email-viewer.tsx, downloads-settings.tsx): off, no swipe, +// selection bar, reader toolbar, quick action, more-menu or after-export +// Archive. No React Native renderer in the test env, so this checks the +// wiring; the helpers have unit tests in lib/__tests__/swipe-actions.test.ts. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + +describe('archive gate wiring', () => { + it('drops a stored Archive swipe and the selection Archive in the folder list', () => { + const src = read('screens/EmailListScreen.tsx'); + expect(src).toMatch(/const archiveEnabled = usePolicyFeature\('archiveActionEnabled'\);/); + expect(src).toMatch(/const swipeLeftAction = swipeActionShown\(useSettingsStore\(\(s\) => s\.swipeLeftAction\), archiveEnabled\);/); + expect(src).toMatch(/const swipeRightAction = swipeActionShown\(useSettingsStore\(\(s\) => s\.swipeRightAction\), archiveEnabled\);/); + expect(src).toMatch(/const canArchiveSelection =\s+archiveEnabled && /); + }); + + it('drops them in the unified inbox', () => { + const src = read('screens/UnifiedInboxScreen.tsx'); + expect(src).toMatch(/const archiveEnabled = usePolicyFeature\('archiveActionEnabled'\);/); + expect(src).toMatch(/const swipeLeftAction = swipeActionShown\(useSettingsStore\(\(s\) => s\.swipeLeftAction\), archiveEnabled\);/); + expect(src).toMatch(/const swipeRightAction = swipeActionShown\(useSettingsStore\(\(s\) => s\.swipeRightAction\), archiveEnabled\);/); + expect(src).toMatch(/\{archiveEnabled && role !== 'archive' && \(\s+ \{ void archive\(selectedEmails\)/); + expect(src).toMatch(/case 'archive': if \(archiveEnabled && role !== 'archive'\)/); + }); + + it('drops the reader Archive button, quick action and menu entry', () => { + const src = read('screens/EmailThreadScreen.tsx'); + expect(src).toMatch(/bottomQuickActionsFor\(bottomQuickActionsRaw, archiveEnabled\)/); + expect(src).not.toMatch(/normalizeBottomQuickActions\(bottomQuickActionsRaw\)/); + // canArchive feeds the toolbar button, the quick action, the more menu + // and the after-export move. + expect(src).toMatch(/const canArchive = archiveEnabled && !!archiveMailbox/); + }); + + it('offers no move to archive after an export', () => { + const src = read('components/settings/DownloadsSettings.tsx'); + expect(src).toMatch(/archiveEnabled \|\| storedPostExportAction !== 'archive' \? storedPostExportAction : 'keep'/); + expect(src).toMatch(/\.\.\.\(archiveEnabled \? \[\{ value: 'archive'/); + }); +});