From 762ec6bab063f218c86926bc2d409086d9b5d4a1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:50:45 +0100 Subject: [PATCH] fix: leave archive off the android widget when the organisation turns archiving off --- src/widgets/__tests__/actions.test.ts | 7 +++ src/widgets/__tests__/archive-policy.test.ts | 50 ++++++++++++++++++++ src/widgets/__tests__/layouts.test.ts | 14 ++++++ src/widgets/actions.ts | 8 +++- src/widgets/build.ts | 26 ++++++++++ src/widgets/layouts/mail.tsx | 14 ++++-- src/widgets/snapshot.ts | 5 ++ 7 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 src/widgets/__tests__/archive-policy.test.ts diff --git a/src/widgets/__tests__/actions.test.ts b/src/widgets/__tests__/actions.test.ts index a6a7ff7d..8a436e21 100644 --- a/src/widgets/__tests__/actions.test.ts +++ b/src/widgets/__tests__/actions.test.ts @@ -178,4 +178,11 @@ describe('widget archive', () => { calls.moves[0].resolve(true); await tap; }); + + it('does nothing for a tap on an older drawing once the organisation turned archiving off', async () => { + await saveSnapshot({ ...snapshotOf(server.inbox), archiveEnabled: false }); + await handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7); + expect(calls.moves).toEqual([]); + expect(await shown()).toEqual(['m1', 'm2', 'm3']); + }); }); diff --git a/src/widgets/__tests__/archive-policy.test.ts b/src/widgets/__tests__/archive-policy.test.ts new file mode 100644 index 00000000..04411c14 --- /dev/null +++ b/src/widgets/__tests__/archive-policy.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { readArchiveEnabled } from '../build'; + +// The organisation's archiveActionEnabled gate reaches the widgets through +// the policy the app cached (stores/policy-store.ts, key org-policy-cache). + +const WEBMAIL = 'https://webmail.example.org'; +const account = { id: 'acc', email: 'ada@example.org', webmailUrl: WEBMAIL }; + +function cache(features: Record, webmailUrl = WEBMAIL, scopes: unknown[] = []) { + return AsyncStorage.setItem('org-policy-cache', JSON.stringify({ + state: { + byAccount: { + acc: { + policy: { restrictions: {}, features, defaults: {}, themePolicy: {}, scopes }, + partial: false, + fetchedAt: 1, + webmailUrl, + }, + }, + }, + version: 0, + })); +} + +beforeEach(async () => { + await AsyncStorage.clear(); +}); + +describe('readArchiveEnabled', () => { + it('is off when the cached policy turns archiving off', async () => { + await cache({ archiveActionEnabled: false }); + expect(await readArchiveEnabled(account)).toBe(false); + }); + + it('is on when the policy allows it, without a policy and without a webmail', async () => { + expect(await readArchiveEnabled(account)).toBe(true); + await cache({ archiveActionEnabled: true }); + expect(await readArchiveEnabled(account)).toBe(true); + await cache({ archiveActionEnabled: false }); + expect(await readArchiveEnabled({ ...account, webmailUrl: undefined })).toBe(true); + expect(await readArchiveEnabled(undefined)).toBe(true); + }); + + it('ignores a policy cached from another webmail', async () => { + await cache({ archiveActionEnabled: false }, 'https://elsewhere.example.org'); + expect(await readArchiveEnabled(account)).toBe(true); + }); +}); diff --git a/src/widgets/__tests__/layouts.test.ts b/src/widgets/__tests__/layouts.test.ts index 00033fdd..74f2d19a 100644 --- a/src/widgets/__tests__/layouts.test.ts +++ b/src/widgets/__tests__/layouts.test.ts @@ -159,6 +159,20 @@ describe('widget layouts', () => { for (const w of catalog.widgets) build(w.name, { ...sample, images }, w.cols, w.rows); }); + it('leaves Archive off the triage card when the organisation turned archiving off', () => { + const actions = (s: WidgetSnapshot, cols: number) => { + const trees = renderFor('TriageWidget', s, { width: dp(cols), height: dp(2), widgetId: 1 }, {}, PREVIEW_NOW); + const tree = 'light' in trees ? trees.light : trees; + return [...JSON.stringify(buildWidgetTree(tree)).matchAll(/"clickAction":"(\w+)"/g)].map((m) => m[1]); + }; + expect(actions(sample, 4)).toContain('archive'); + for (const cols of [3, 4, 5]) { + const shown = actions({ ...sample, archiveEnabled: false }, cols); + expect(shown).not.toContain('archive'); + expect(shown).toContain('trash'); + } + }); + it('builds the triage card on a message that is gone', () => { build('TriageWidget', sample, 4, 2, { triageId: 'no-such-message' }); build('TriageWidget', { ...sample, mail: { ...sample.mail, inbox: [] } }, 4, 2, { triageId: 'm1' }); diff --git a/src/widgets/actions.ts b/src/widgets/actions.ts index b9ffea6a..d514122e 100644 --- a/src/widgets/actions.ts +++ b/src/widgets/actions.ts @@ -94,7 +94,13 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num case 'markRead': { const id = str(data.id); if (!id) return; - const subject = (await currentView()).mail.inbox.find((m) => m.id === id)?.subject ?? ''; + const view = await currentView(); + // A drawing from before the organisation turned archiving off. + if (name === 'archive' && view.archiveEnabled === false) { + await redrawAll(); + return; + } + const subject = view.mail.inbox.find((m) => m.id === id)?.subject ?? ''; // The triage card moves on to the next unread message, not back to // the first one. if (name !== 'markRead') await moveTriagePointer(widgetId, id); diff --git a/src/widgets/build.ts b/src/widgets/build.ts index ed5478f5..7c0ae1e1 100644 --- a/src/widgets/build.ts +++ b/src/widgets/build.ts @@ -16,6 +16,7 @@ import { getEventEndDate, getEventStartDate } from '../lib/calendar-utils'; import { getUserParticipantId, getUserStatus } from '../lib/calendar-participants'; import { getBirthday, getContactDisplayName, getDateParts } from '../lib/contact-utils'; import { notificationLocale } from '../lib/push-background-task'; +import { isFeatureEnabled, resolvePolicy, sanitizePolicy } from '../lib/org-policy'; import { useSettingsStore } from '../stores/settings-store'; import { useKeywordsStore, keywordToken } from '../stores/keywords-store'; import { LIGHT_COLORS } from '../theme/tokens'; @@ -46,6 +47,30 @@ interface RegistryAccount { displayName?: string; username?: string; avatarColor?: string; + webmailUrl?: string; +} + +/** + * Whether the account's organisation lets it archive (policy + * archiveActionEnabled), from the policy the app cached for it + * (stores/policy-store.ts, which this runtime may not have loaded). On + * without a webmail or a cached policy from it. + */ +export async function readArchiveEnabled(account: RegistryAccount | undefined): Promise { + if (!account?.webmailUrl) return true; + try { + const raw = await AsyncStorage.getItem('org-policy-cache'); + const byAccount = raw + ? (JSON.parse(raw) as { state?: { byAccount?: Record } }).state?.byAccount + : undefined; + const cached = byAccount?.[account.id]; + const policy = cached?.webmailUrl === account.webmailUrl ? sanitizePolicy(cached.policy) : null; + if (!policy) return true; + const email = (account.email || account.username || '').trim().toLowerCase(); + return isFeatureEnabled(resolvePolicy(policy, { email, groups: [] }), 'archiveActionEnabled'); + } catch { + return true; + } } export async function readRegistry(): Promise<{ accounts: RegistryAccount[]; activeAccountId: string | null }> { @@ -390,6 +415,7 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise hour12: timeFormat === '12h', weekStart: calendarFirstDayOfWeek === 0 ? 0 : 1, activeAccountId, + archiveEnabled: await readArchiveEnabled(accounts.find((a) => a.id === activeAccountId)), }; if (previous.activeAccountId !== activeAccountId) { // Nothing of another account's data may linger in a widget. diff --git a/src/widgets/layouts/mail.tsx b/src/widgets/layouts/mail.tsx index ff925653..4af81a33 100644 --- a/src/widgets/layouts/mail.tsx +++ b/src/widgets/layouts/mail.tsx @@ -112,9 +112,11 @@ export const TriageLayout: Layout = ({ s, p, f, now, width, height, widgetId, lo trash: f.t('widgets.mail.delete', 'Delete'), reply: f.t('widgets.mail.reply', 'Reply'), }; - const room = width - 2 - 24 - 32 - 18; - const allFit = buttonWidth(labels.archive) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room; - const replyFits = 2 * 32 + buttonWidth(labels.reply) <= room; + // No Archive button when the organisation turned archiving off. + const archive = s.archiveEnabled !== false; + const room = width - 2 - 24 - 32 - (archive ? 18 : 12); + const allFit = (archive ? buttonWidth(labels.archive) : 0) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room; + const replyFits = (archive ? 2 : 1) * 32 + buttonWidth(labels.reply) <= room; return ( // The buttons draw 32dp tall in a 48dp tap area (slop 8), which takes the // gap above them and 8dp of the bottom padding. @@ -146,8 +148,10 @@ export const TriageLayout: Layout = ({ s, p, f, now, width, height, widgetId, lo {notice ? : null} -