Files
webmail/lib/offline/scope.ts
T
Linus Rath b001cbf109 feat: show what each account keeps on the device and let it be changed and cleared
The offline section of Settings > Backup & reset now shows, per account, the
space each part takes, the last sync, a first sync's progress with the time
left, the window and attachment choices, and clearing the account. All
conflicts, calendar and contacts too, are listed there and counted in the
banner.

- the organisation's caps beat the user's choice and the desktop default, and
  every path that takes a scope goes through `effectiveScope`
- the desktop app keeps mail without asking, says so once, and stays off for
  an account where the user turned it off
- `navigator.storage.persist()` is asked on turning it on, and a refusal
  warns with a way to ask again; Safari gets the 7-day hint when not installed
- a store the browser deleted is noticed, says what was lost and syncs again
- a full disk says so, with what to do
- those who had offline mode keep it, with the 30 days they had
- Keep offline in the Files app's menu
2026-11-03 08:57:41 +01:00

126 lines
4.9 KiB
TypeScript

/**
* What an account keeps on this device. Three sources, in this order: the
* organisation's policy, the user's choice, the platform's default. The policy
* only ever lowers: nothing below it can raise a cap or turn back on what it
* forbids, and that includes the desktop app's default of keeping mail.
*/
import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area';
import type { DevicePolicy } from '@/lib/device-policy';
import { useOfflineStore, type UserChoice } from './state';
const MB = 1024 * 1024;
/** The windows a user picks from. Null keeps everything. */
export const WINDOW_CHOICES: readonly (number | null)[] = [30, 90, 365, null];
/** The largest attachment a user may keep, in MB. */
export const ATTACHMENT_CAP_CHOICES: readonly number[] = [5, 25, 100, 500];
/** What the old offline mode kept, for those who had it on before the local store. */
export const LEGACY_CHOICE: UserChoice = { on: true, days: 30, attachments: true, maxAttachmentMb: 5 };
export interface PlatformDefault {
on: boolean;
scope: StoreScope;
}
export interface EffectiveScope {
account: string;
/** Whether the account keeps mail here at all. */
enabled: boolean;
scope: StoreScope;
/** What the policy decides, for the settings page: these show "Set by your organisation". */
locked: {
offline: boolean;
days: boolean;
attachments: boolean;
maxAttachment: boolean;
maxTotal: boolean;
};
}
/** The desktop app keeps mail without asking. Browsers stay opt-in. Feature-detected: the bridge is only there in the app. */
export function offlineDefaultOn(): boolean {
return typeof window !== 'undefined' && Boolean((window as { bulwarkDesktop?: unknown }).bulwarkDesktop);
}
export function platformDefault(): PlatformDefault {
return { on: offlineDefaultOn(), scope: DEFAULT_SCOPE };
}
/** Null means everything, so any cap is lower. */
function cappedDays(days: number | null, cap: number): number | null {
if (!(cap > 0)) return days;
return days === null ? cap : Math.min(days, cap);
}
function cappedBytes(bytes: number, capMb: number): number {
return capMb > 0 ? Math.min(bytes, capMb * MB) : bytes;
}
export function effectiveScope(
account: string,
userChoice: UserChoice | null | undefined,
policy: Pick<DevicePolicy, 'noOffline' | 'offlineMaxDays' | 'noOfflineAttachments' | 'offlineMaxAttachmentMb' | 'offlineMaxTotalMb'>,
defaults: PlatformDefault,
): EffectiveScope {
const choice = userChoice ?? {};
const days = choice.days !== undefined ? choice.days : defaults.scope.days;
const maxAttachment = choice.maxAttachmentMb !== undefined ? choice.maxAttachmentMb * MB : defaults.scope.maxAttachmentBytes;
return {
account,
enabled: !policy.noOffline && (choice.on ?? defaults.on),
scope: {
days: cappedDays(days, policy.offlineMaxDays),
attachments: !policy.noOfflineAttachments && (choice.attachments ?? defaults.scope.attachments),
maxAttachmentBytes: cappedBytes(maxAttachment, policy.offlineMaxAttachmentMb),
maxTotalBytes: cappedBytes(defaults.scope.maxTotalBytes, policy.offlineMaxTotalMb),
},
locked: {
offline: policy.noOffline,
days: policy.offlineMaxDays > 0,
attachments: policy.noOfflineAttachments,
maxAttachment: policy.offlineMaxAttachmentMb > 0,
maxTotal: policy.offlineMaxTotalMb > 0,
},
};
}
/**
* What the user chose for an account. The switch used to be the only thing
* stored, so an account that has it on and no choice yet was turned on under
* the old offline mode, and keeps the window it had.
*/
export function userChoiceFor(account: string): UserChoice | null {
const { choices, enabled } = useOfflineStore.getState();
const stored = choices[account];
const on = enabled.includes(account);
if (!stored) return on ? LEGACY_CHOICE : null;
return on && stored.on === undefined ? { ...stored, on } : stored;
}
/** The windows on offer under a cap: the ones that fit, and the cap itself when it falls between them. */
export function windowOptions(capDays: number): (number | null)[] {
if (!(capDays > 0)) return [...WINDOW_CHOICES];
const fit = WINDOW_CHOICES.filter((days): days is number => days !== null && days < capDays);
return [...fit, capDays];
}
/** The attachment sizes on offer under a cap, in MB. */
export function attachmentCapOptions(capMb: number): number[] {
if (!(capMb > 0)) return [...ATTACHMENT_CAP_CHOICES];
const fit = ATTACHMENT_CAP_CHOICES.filter((mb) => mb < capMb);
return [...fit, capMb];
}
export function maxAttachmentMb(scope: StoreScope): number {
return Math.round(scope.maxAttachmentBytes / MB);
}
/** A message older than the organisation's cap isn't kept, opened or not. */
export function olderThanCap(receivedAt: unknown, capDays: number, now: number): boolean {
if (!(capDays > 0) || typeof receivedAt !== 'string') return false;
const at = Date.parse(receivedAt);
return Number.isFinite(at) && at < now - capDays * 86_400_000;
}