diff --git a/lib/__tests__/plugin-hook-permissions.test.ts b/lib/__tests__/plugin-hook-permissions.test.ts index ea3718e65..4f6557044 100644 --- a/lib/__tests__/plugin-hook-permissions.test.ts +++ b/lib/__tests__/plugin-hook-permissions.test.ts @@ -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() })); diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index 7875fd435..052e46ca5 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -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()); }); }); diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index 85cc01750..1b9de3251 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -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), diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts index 747f5f7f3..ba80723cf 100644 --- a/lib/plugin-loader.ts +++ b/lib/plugin-loader.ts @@ -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 diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index d46c0b878..85688b64a 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -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); + 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; @@ -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); diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 024320427..a3299d46d 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -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 }); } diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 101ff3d7a..5a8f59457 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -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; 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; } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 6f91bcab2..7b9c3c478 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -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 void; reject: (err // place on 'settings-change', so code that kept a reference sees new values. const settingsState: Record = {}; -// 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) => void>(); function applySettings(next: Record): 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 { return interpolate(lookup(localeTables, currentLocale(), key) ?? key, vars); }, @@ -1076,7 +1131,7 @@ async function handleInit(payload: InitPayload): Promise { 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': diff --git a/lib/utils.ts b/lib/utils.ts index 981130b3e..a8e85329b 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -52,8 +52,8 @@ export function generateUUID(): string { * - `en-GB` → `en-GB` (DD/MM/YYYY) * - `en-US` → `en-US` (MM/DD/YYYY) */ -function resolveDateLocale( - dateLocale: DateLocale, +export function resolveDateLocale( + dateLocale: DateLocale | undefined, fallback: T, ): string | T { switch (dateLocale) { diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index e3dd189d3..9a20b34b6 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -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 | 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()( // 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