mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: mirror regional presentation settings to plugin sandboxes
Plugins running in iframe sandboxes previously lacked access to the user's regional formatting settings (dateLocale, timeFormat 12h/24h, timeZone, and firstDayOfWeek) unless they requested privileged settings:read permissions. This mirrors the host's resolved presentation preferences into the plugin sandbox: - Adds RegionalSnapshot (locale, dateLocale, hour12, timeZone, firstDayOfWeek) to BackgroundInit, SlotInit, and LocaleChangeMsg. - Implements snapshotRegionalSettings() in host-bridge, reusing host resolveDateLocale() and getEffectiveTimeZone(). - Subscribes stores/plugin-store.ts to useSettingsStore to broadcast updates to running sandboxes when regional preferences change. - Exposes api.i18n.formatTime(date, options) and api.i18n.formatDate(date, options) along with regional getters on api.i18n.
This commit is contained in:
@@ -29,6 +29,13 @@ vi.mock('../plugin-sandbox/host-bridge', () => ({
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
},
|
||||
snapshotRegionalSettings: vi.fn((locale: string) => ({
|
||||
locale,
|
||||
dateLocale: locale,
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
firstDayOfWeek: 1,
|
||||
})),
|
||||
SandboxInstance: class {},
|
||||
}));
|
||||
vi.mock('../plugin-sandbox/host-api', () => ({ cancelPluginDialogs: vi.fn() }));
|
||||
|
||||
@@ -23,7 +23,14 @@ vi.mock('../plugin-sandbox/bundle-fetch', () => ({
|
||||
}));
|
||||
vi.mock('../plugin-sandbox/host-bridge', () => ({
|
||||
createBackgroundInstance: (...args: unknown[]) => mocks.createBackgroundInstance(...args),
|
||||
forEachInstance: (_id: string | null, fn: (i: { setLocale: (l: string) => void }) => void) => fn({ setLocale: mocks.setLocale }),
|
||||
forEachInstance: (_id: string | null, fn: (i: { setLocale: (l: string, r?: unknown) => void }) => void) => fn({ setLocale: mocks.setLocale }),
|
||||
snapshotRegionalSettings: vi.fn((locale: string) => ({
|
||||
locale,
|
||||
dateLocale: locale,
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
firstDayOfWeek: 1,
|
||||
})),
|
||||
SandboxInstance: class {},
|
||||
}));
|
||||
vi.mock('../plugin-sandbox/host-api', () => ({
|
||||
@@ -216,7 +223,7 @@ describe('setSandboxLocale', () => {
|
||||
|
||||
setSandboxLocale('de');
|
||||
|
||||
expect(mocks.setLocale).toHaveBeenCalledWith('de');
|
||||
expect(mocks.setLocale).toHaveBeenCalledWith('de', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,13 @@ vi.mock('@/lib/browser-navigation', () => ({
|
||||
const bridge = vi.hoisted(() => ({ setSettings: vi.fn() }));
|
||||
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
|
||||
forEachInstance: (_id: string, fn: (i: { setSettings: typeof bridge.setSettings }) => void) => fn({ setSettings: bridge.setSettings }),
|
||||
snapshotRegionalSettings: vi.fn((locale: string) => ({
|
||||
locale,
|
||||
dateLocale: locale,
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
firstDayOfWeek: 1,
|
||||
})),
|
||||
}));
|
||||
vi.mock('@/lib/plugin-sandbox/bundle-fetch', () => ({
|
||||
downloadManagedBundle: (...args: unknown[]) => mocks.downloadManagedBundle(...args),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { all as allActive, get as getActive } from './plugin-sandbox/registry';
|
||||
// facade, instead of importing lib/plugin-sandbox/loader directly (which would
|
||||
// also pull the hook buses into consumers' module graphs).
|
||||
export { setSandboxLocale } from './plugin-sandbox/loader';
|
||||
export { snapshotRegionalSettings, type RegionalSnapshot } from './plugin-sandbox/host-bridge';
|
||||
|
||||
/**
|
||||
* Historically re-published React/ReactDOM on `globalThis` for the blob-import
|
||||
|
||||
@@ -9,11 +9,16 @@
|
||||
|
||||
import type { InstalledPlugin, SlotName, PluginTier } from '../plugin-types';
|
||||
import { dispatchApiCall } from './host-api';
|
||||
import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH } from './protocol';
|
||||
import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH, type RegionalSnapshot } from './protocol';
|
||||
export type { RegionalSnapshot };
|
||||
import { withBasePath } from '../browser-navigation';
|
||||
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
|
||||
import { effectiveSettings } from '../plugin-settings';
|
||||
import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
|
||||
import { resolveDateLocale } from '../utils';
|
||||
import { getEffectiveTimeZone } from '../timezone';
|
||||
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||
import { useSettingsStore, type DateLocale } from '@/stores/settings-store';
|
||||
import type {
|
||||
SandboxToHost, HostToSandbox, InitMsg, InitPayload, StreamChunkMsg, StreamEndMsg, StreamProgressMsg,
|
||||
FloatingDropdownOpenMsg, FloatingDropdownUpdateMsg, FloatingDropdownCloseMsg,
|
||||
@@ -140,12 +145,44 @@ function encodeCallbacks(
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Regional settings ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves the user's current regional formatting preferences (date locale,
|
||||
* time format, effective time zone, first day of week) so sandboxed plugin
|
||||
* runtimes can format dates and times consistently without needing
|
||||
* privileged `settings:read` permissions or duplicating formatting logic.
|
||||
*/
|
||||
export function snapshotRegionalSettings(localeOverride?: string): RegionalSnapshot {
|
||||
const appLocale = localeOverride ?? (typeof getEffectiveLocale === 'function' ? getEffectiveLocale() : 'en');
|
||||
const settings = typeof useSettingsStore?.getState === 'function' ? useSettingsStore.getState() : ({} as Record<string, unknown>);
|
||||
const dateLocale = resolveDateLocale(settings?.dateLocale as DateLocale | undefined, appLocale) ?? appLocale;
|
||||
const hour12 = settings?.timeFormat === '12h';
|
||||
const timeZone = typeof getEffectiveTimeZone === 'function'
|
||||
? getEffectiveTimeZone()
|
||||
: (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' : 'UTC');
|
||||
const rawFirstDay = settings?.firstDayOfWeek;
|
||||
const firstDayOfWeek: 0 | 1 | 6 = (rawFirstDay === 0 || rawFirstDay === 6 || rawFirstDay === 1)
|
||||
? rawFirstDay
|
||||
: 1;
|
||||
|
||||
return {
|
||||
locale: appLocale,
|
||||
dateLocale,
|
||||
hour12,
|
||||
timeZone,
|
||||
firstDayOfWeek,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Public option types ─────────────────────────────────────
|
||||
|
||||
export interface BackgroundOptions {
|
||||
plugin: InstalledPlugin;
|
||||
code: string;
|
||||
locale: string;
|
||||
/** Resolved presentation/regional preferences (time format, date locale, time zone). */
|
||||
regional?: RegionalSnapshot;
|
||||
/** Resolved execution tier (from `resolvePluginTier`). */
|
||||
tier: PluginTier;
|
||||
/** Where the hidden iframe should attach. Defaults to document.body. */
|
||||
@@ -157,6 +194,8 @@ export interface SlotOptions {
|
||||
slot: SlotName;
|
||||
code: string;
|
||||
locale: string;
|
||||
/** Resolved presentation/regional preferences (time format, date locale, time zone). */
|
||||
regional?: RegionalSnapshot;
|
||||
/** Resolved execution tier (from `resolvePluginTier`). */
|
||||
tier: PluginTier;
|
||||
extraProps: Record<string, unknown>;
|
||||
@@ -540,9 +579,9 @@ export class SandboxInstance {
|
||||
this.send(msg);
|
||||
}
|
||||
|
||||
setLocale(locale: string): void {
|
||||
setLocale(locale: string, regional?: RegionalSnapshot): void {
|
||||
if (this.destroyed) return;
|
||||
this.send({ type: 'locale-change', locale });
|
||||
this.send({ type: 'locale-change', locale, regional: regional ?? snapshotRegionalSettings(locale) });
|
||||
}
|
||||
|
||||
/** Push a new resolved theme so the slot iframe re-injects its theme CSS. */
|
||||
@@ -642,6 +681,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan
|
||||
},
|
||||
code: opts.code,
|
||||
locale: opts.locale,
|
||||
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
|
||||
};
|
||||
return new SandboxInstance(
|
||||
opts.plugin,
|
||||
@@ -669,6 +709,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
|
||||
extraProps: opts.extraProps,
|
||||
...(opts.fill ? { fill: true } : {}),
|
||||
locale: opts.locale,
|
||||
regional: opts.regional ?? snapshotRegionalSettings(opts.locale),
|
||||
theme: snapshotHostTheme(),
|
||||
};
|
||||
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useEmailStore } from '@/stores/email-store';
|
||||
import { emailToReadView } from '../plugin-projection';
|
||||
import { verifyBundle } from './bundle-integrity';
|
||||
import { downloadManagedBundle } from './bundle-fetch';
|
||||
import { createBackgroundInstance, forEachInstance } from './host-bridge';
|
||||
import { createBackgroundInstance, forEachInstance, snapshotRegionalSettings, type RegionalSnapshot } from './host-bridge';
|
||||
import { resolvePluginTier } from './tier';
|
||||
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries, get as getActive } from './registry';
|
||||
import { cancelPluginDialogs } from './host-api';
|
||||
@@ -60,13 +60,18 @@ export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor
|
||||
// ─── Locale (kept in step with the app locale) ────────────────
|
||||
|
||||
let currentLocale = 'en';
|
||||
export function setSandboxLocale(locale: string): void {
|
||||
let currentRegionalKey = '';
|
||||
|
||||
export function setSandboxLocale(locale: string, regional?: RegionalSnapshot): void {
|
||||
// Ignore empty/falsy values so a not-yet-seeded locale store can't clobber a
|
||||
// good locale back to '' - the initial 'en' default stands until the real
|
||||
// locale arrives via the store subscription.
|
||||
if (!locale) return;
|
||||
const changed = locale !== currentLocale;
|
||||
const reg = regional ?? (typeof snapshotRegionalSettings === 'function' ? snapshotRegionalSettings(locale) : undefined);
|
||||
const regKey = reg ? JSON.stringify(reg) : '';
|
||||
const changed = locale !== currentLocale || regKey !== currentRegionalKey;
|
||||
currentLocale = locale;
|
||||
currentRegionalKey = regKey;
|
||||
// Background instances read `currentLocale` at load time; the slot-iframe
|
||||
// component reads this global at spawn time (plugin-iframe-slot.tsx). Keep
|
||||
// both in step from one place.
|
||||
@@ -74,7 +79,7 @@ export function setSandboxLocale(locale: string): void {
|
||||
if (!changed) return;
|
||||
// Running plugins switch too: their slots re-render in the new language
|
||||
// and their onLocaleChange handlers run.
|
||||
forEachInstance(null, (instance) => instance.setLocale(locale));
|
||||
forEachInstance(null, (instance) => instance.setLocale(locale, reg));
|
||||
void themeHooks.onLocaleChange.emit({ locale });
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,20 @@
|
||||
import type { SlotName, PluginTier } from '../plugin-types';
|
||||
import type { ThemeSnapshot } from './host-theme';
|
||||
|
||||
/** Resolved presentation/regional settings mirrored into plugin runtimes. */
|
||||
export interface RegionalSnapshot {
|
||||
/** The effective UI language, e.g. 'en', 'de', 'fr'. */
|
||||
locale: string;
|
||||
/** Resolved BCP-47 tag for date rendering (respects dateLocale override, e.g. 'en-CA' for iso). */
|
||||
dateLocale: string;
|
||||
/** True for 12-hour clock (AM/PM), false for 24-hour clock. */
|
||||
hour12: boolean;
|
||||
/** The user's effective time zone, e.g. 'Europe/Berlin'. */
|
||||
timeZone: string;
|
||||
/** First day of week (0 = Sunday, 1 = Monday, 6 = Saturday). */
|
||||
firstDayOfWeek: 0 | 1 | 6;
|
||||
}
|
||||
|
||||
/** Initialisation payload for a background-instance iframe (one per plugin). */
|
||||
export interface BackgroundInit {
|
||||
mode: 'background';
|
||||
@@ -34,6 +48,8 @@ export interface BackgroundInit {
|
||||
code: string;
|
||||
/** Initial app locale; host pushes updates via 'locale-change'. */
|
||||
locale: string;
|
||||
/** Resolved presentation/regional preferences (time format, date locale, time zone). */
|
||||
regional?: RegionalSnapshot;
|
||||
}
|
||||
|
||||
/** Initialisation payload for a slot-instance iframe (one per slot mount). */
|
||||
@@ -73,6 +89,8 @@ export interface SlotInit {
|
||||
*/
|
||||
extraProps: Record<string, unknown>;
|
||||
locale: string;
|
||||
/** Resolved presentation/regional preferences (time format, date locale, time zone). */
|
||||
regional?: RegionalSnapshot;
|
||||
/**
|
||||
* Resolved host theme (colour tokens, font stack, dark flag). The sandbox
|
||||
* can't load globals.css/fonts cross-origin, so the runtime replays this as
|
||||
@@ -225,7 +243,11 @@ export interface HookInvokeMsg {
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
export interface LocaleChangeMsg { type: 'locale-change'; locale: string; }
|
||||
export interface LocaleChangeMsg {
|
||||
type: 'locale-change';
|
||||
locale: string;
|
||||
regional?: RegionalSnapshot;
|
||||
}
|
||||
|
||||
/** Host → sandbox: the resolved theme changed; re-inject the slot's theme CSS. */
|
||||
export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; }
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
InitPayload,
|
||||
BackgroundInit,
|
||||
SlotInit,
|
||||
RegionalSnapshot,
|
||||
} from './protocol';
|
||||
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
|
||||
import { pluginUiKit, SlotPropsContext } from './ui-kit';
|
||||
@@ -75,13 +76,31 @@ const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err
|
||||
// place on 'settings-change', so code that kept a reference sees new values.
|
||||
const settingsState: Record<string, unknown> = {};
|
||||
|
||||
// The app language, pushed by the host on init and on every change.
|
||||
// The app language and regional settings, pushed by the host on init and on every change.
|
||||
let currentRegional: RegionalSnapshot = {
|
||||
locale: 'en',
|
||||
dateLocale: 'en',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
firstDayOfWeek: 1,
|
||||
};
|
||||
|
||||
const localeListeners = new Set<(locale: string) => void>();
|
||||
function currentLocale(): string {
|
||||
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
|
||||
}
|
||||
function applyLocale(locale: string): void {
|
||||
|
||||
function applyLocale(locale: string, regional?: RegionalSnapshot): void {
|
||||
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = locale;
|
||||
if (regional) {
|
||||
currentRegional = regional;
|
||||
} else {
|
||||
currentRegional = {
|
||||
...currentRegional,
|
||||
locale,
|
||||
dateLocale: locale,
|
||||
};
|
||||
}
|
||||
document.documentElement.lang = locale;
|
||||
// Slots sit inside an RTL page in Arabic, Hebrew and Persian; their own
|
||||
// document has to flip too.
|
||||
@@ -90,6 +109,25 @@ function applyLocale(locale: string): void {
|
||||
try { listener(locale); } catch (err) { console.error('[plugin] locale listener failed', err); }
|
||||
}
|
||||
}
|
||||
|
||||
function formatIntl(date: Date | number | string, options: Intl.DateTimeFormatOptions): string {
|
||||
try {
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const locale = currentRegional.dateLocale || currentRegional.locale || 'en';
|
||||
return new Intl.DateTimeFormat(locale, { timeZone: currentRegional.timeZone, ...options }).format(d);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date | number | string, options?: Intl.DateTimeFormatOptions): string {
|
||||
return formatIntl(date, { hour12: currentRegional.hour12, hour: 'numeric', minute: 'numeric', ...options });
|
||||
}
|
||||
|
||||
function formatDate(date: Date | number | string, options?: Intl.DateTimeFormatOptions): string {
|
||||
return formatIntl(date, options ?? {});
|
||||
}
|
||||
const settingsListeners = new Set<(settings: Record<string, unknown>) => void>();
|
||||
|
||||
function applySettings(next: Record<string, unknown>): void {
|
||||
@@ -842,6 +880,23 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
return currentLocale();
|
||||
},
|
||||
getLocale: () => currentLocale(),
|
||||
get dateLocale(): string {
|
||||
return currentRegional.dateLocale;
|
||||
},
|
||||
get timeFormat(): '12h' | '24h' {
|
||||
return currentRegional.hour12 ? '12h' : '24h';
|
||||
},
|
||||
get hour12(): boolean {
|
||||
return currentRegional.hour12;
|
||||
},
|
||||
get timeZone(): string {
|
||||
return currentRegional.timeZone;
|
||||
},
|
||||
get firstDayOfWeek(): 0 | 1 | 6 {
|
||||
return currentRegional.firstDayOfWeek;
|
||||
},
|
||||
formatTime,
|
||||
formatDate,
|
||||
t(key: string, vars?: Record<string, string | number>): string {
|
||||
return interpolate(lookup(localeTables, currentLocale(), key) ?? key, vars);
|
||||
},
|
||||
@@ -1076,7 +1131,7 @@ async function handleInit(payload: InitPayload): Promise<void> {
|
||||
mode = payload.mode;
|
||||
// Make the active locale available to plugin code (api.i18n) right away -
|
||||
// not only after the first 'locale-change' push.
|
||||
applyLocale(payload.locale);
|
||||
applyLocale(payload.locale, payload.regional);
|
||||
try {
|
||||
if (payload.mode === 'background') {
|
||||
await bootBackground(payload);
|
||||
@@ -1158,7 +1213,7 @@ function handleHostMessage(ev: MessageEvent): void {
|
||||
}
|
||||
|
||||
case 'locale-change':
|
||||
applyLocale(msg.locale);
|
||||
applyLocale(msg.locale, msg.regional);
|
||||
break;
|
||||
|
||||
case 'theme-change':
|
||||
|
||||
+2
-2
@@ -52,8 +52,8 @@ export function generateUUID(): string {
|
||||
* - `en-GB` → `en-GB` (DD/MM/YYYY)
|
||||
* - `en-US` → `en-US` (MM/DD/YYYY)
|
||||
*/
|
||||
function resolveDateLocale<T extends string | undefined>(
|
||||
dateLocale: DateLocale,
|
||||
export function resolveDateLocale<T extends string | undefined>(
|
||||
dateLocale: DateLocale | undefined,
|
||||
fallback: T,
|
||||
): string | T {
|
||||
switch (dateLocale) {
|
||||
|
||||
+14
-4
@@ -9,6 +9,7 @@ import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { extractPlugin } from '@/lib/plugin-validator';
|
||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { getEffectiveLocale } from '@/i18n/detect-locale';
|
||||
import { removeAllPluginHooks } from '@/lib/plugin-hooks';
|
||||
import { requestConsent } from '@/lib/plugin-sandbox/consent';
|
||||
@@ -26,8 +27,8 @@ import type { Permission } from '@/lib/plugin-types';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
|
||||
let pluginInitializationPromise: Promise<void> | null = null;
|
||||
// One-time guard so we attach the locale->sandbox subscription only once.
|
||||
let localeSubscribed = false;
|
||||
// One-time guard so we attach the locale and regional subscriptions only once.
|
||||
let regionalSubscribed = false;
|
||||
|
||||
// ─── Store Interface ─────────────────────────────────────────
|
||||
|
||||
@@ -286,9 +287,18 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
// the visitor's browser language. Found via a plugin dev report:
|
||||
// native UI in Hungarian, plugin's own i18n stuck on English.
|
||||
setSandboxLocale(getEffectiveLocale());
|
||||
if (!localeSubscribed) {
|
||||
localeSubscribed = true;
|
||||
if (!regionalSubscribed) {
|
||||
regionalSubscribed = true;
|
||||
useLocaleStore.subscribe(() => setSandboxLocale(getEffectiveLocale()));
|
||||
|
||||
let lastKey = `${useSettingsStore.getState().dateLocale}|${useSettingsStore.getState().timeFormat}|${useSettingsStore.getState().timeZone}|${useSettingsStore.getState().firstDayOfWeek}`;
|
||||
useSettingsStore.subscribe((state) => {
|
||||
const key = `${state.dateLocale}|${state.timeFormat}|${state.timeZone}|${state.firstDayOfWeek}`;
|
||||
if (key !== lastKey) {
|
||||
lastKey = key;
|
||||
setSandboxLocale(getEffectiveLocale());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sync server-managed plugins before loading
|
||||
|
||||
Reference in New Issue
Block a user