fix: leave archive off the android widget when the organisation turns archiving off

This commit is contained in:
Linus Rath
2026-11-02 22:50:45 +01:00
parent f173358e86
commit 762ec6bab0
7 changed files with 118 additions and 6 deletions
+7
View File
@@ -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']);
});
});
@@ -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<string, unknown>, 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);
});
});
+14
View File
@@ -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' });
+7 -1
View File
@@ -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);
+26
View File
@@ -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<boolean> {
if (!account?.webmailUrl) return true;
try {
const raw = await AsyncStorage.getItem('org-policy-cache');
const byAccount = raw
? (JSON.parse(raw) as { state?: { byAccount?: Record<string, { policy?: unknown; webmailUrl?: string }> } }).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<WidgetSnapshot>
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.
+9 -5
View File
@@ -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
</FlexWidget>
{notice ? <NoticeBar p={p} f={f} notice={notice} /> : null}
<FlexWidget style={{ flexDirection: 'row', width: 'match_parent' }}>
<Button p={p} label={allFit ? labels.archive : undefined} iconName="archive" flex={1} slop={8} click={action('archive', target)} />
<Spacer size={6} horizontal />
{archive ? (
<Button p={p} label={allFit ? labels.archive : undefined} iconName="archive" flex={1} slop={8} click={action('archive', target)} />
) : null}
{archive ? <Spacer size={6} horizontal /> : null}
<Button p={p} label={allFit ? labels.trash : undefined} iconName="trash" flex={1} slop={8} click={action('trash', target)} />
<Spacer size={6} horizontal />
<Button p={p} label={allFit || replyFits ? labels.reply : undefined} iconName="reply" variant="primary" flex={1} slop={8} click={open(links.reply(current))} />
+5
View File
@@ -200,6 +200,11 @@ export interface WidgetSnapshot {
accounts: AccountSummary[];
/** The signed-in account the single-account widgets show. */
activeAccountId: string | null;
/**
* False when the active account's organisation turned archiving off
* (policy archiveActionEnabled); absent counts as on.
*/
archiveEnabled?: boolean;
mail: {
folders: FolderCount[];
inbox: MailItem[];