mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
fix: switch running plugins to a new language right away and fall back from de-CH to de
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { lookup, interpolate, localeChain } from '../plugin-sandbox/plugin-i18n';
|
||||
|
||||
const tables = {
|
||||
en: { hello: 'Hello {name}', only_en: 'English only' },
|
||||
de: { hello: 'Hallo {name}' },
|
||||
'zh-TW': { hello: '你好 {name}' },
|
||||
};
|
||||
|
||||
describe('plugin translations', () => {
|
||||
it('falls back from a regional locale to its language', () => {
|
||||
// de-CH used to skip straight to English.
|
||||
expect(lookup(tables, 'de-CH', 'hello')).toBe('Hallo {name}');
|
||||
});
|
||||
|
||||
it('matches locale names regardless of case', () => {
|
||||
expect(lookup(tables, 'zh-tw', 'hello')).toBe('你好 {name}');
|
||||
});
|
||||
|
||||
it('falls back to English, then reports a missing key', () => {
|
||||
expect(lookup(tables, 'de', 'only_en')).toBe('English only');
|
||||
expect(lookup(tables, 'de', 'missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds the chain most specific first', () => {
|
||||
expect(localeChain('pt-BR')).toEqual(['pt-BR', 'pt', 'en']);
|
||||
});
|
||||
|
||||
it('fills placeholders', () => {
|
||||
expect(interpolate('Hallo {name}', { name: 'Ana' })).toBe('Hallo Ana');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
||||
downloadManagedBundle: vi.fn(),
|
||||
createBackgroundInstance: vi.fn(),
|
||||
registerShortcuts: vi.fn((..._args: unknown[]) => () => {}),
|
||||
setLocale: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../plugin-storage', () => ({
|
||||
@@ -22,6 +23,7 @@ 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 }),
|
||||
SandboxInstance: class {},
|
||||
}));
|
||||
vi.mock('../plugin-sandbox/host-api', () => ({
|
||||
@@ -31,7 +33,7 @@ vi.mock('../plugin-sandbox/shortcuts', () => ({
|
||||
registerShortcuts: (...args: unknown[]) => mocks.registerShortcuts(...args),
|
||||
}));
|
||||
|
||||
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader';
|
||||
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor, setSandboxLocale } from '../plugin-sandbox/loader';
|
||||
import { get as getActive } from '../plugin-sandbox/registry';
|
||||
|
||||
const CODE = 'export default { activate() {} }';
|
||||
@@ -205,3 +207,15 @@ describe('plugin shortcuts', () => {
|
||||
expect(mocks.registerShortcuts).toHaveBeenCalledWith(expect.anything(), SHORTCUT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSandboxLocale', () => {
|
||||
it('switches running plugins to the new language', () => {
|
||||
// Running plugins used to keep the old language until a reload.
|
||||
setSandboxLocale('en');
|
||||
mocks.setLocale.mockClear();
|
||||
|
||||
setSandboxLocale('de');
|
||||
|
||||
expect(mocks.setLocale).toHaveBeenCalledWith('de');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
|
||||
import { verifyBundle } from './bundle-integrity';
|
||||
import { downloadManagedBundle } from './bundle-fetch';
|
||||
import { createBackgroundInstance } from './host-bridge';
|
||||
import { createBackgroundInstance, forEachInstance } from './host-bridge';
|
||||
import { resolvePluginTier } from './tier';
|
||||
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
|
||||
import { cancelPluginDialogs } from './host-api';
|
||||
@@ -51,12 +51,17 @@ export function setSandboxLocale(locale: string): void {
|
||||
// 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;
|
||||
currentLocale = locale;
|
||||
// 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. Already-running instances are not re-pushed,
|
||||
// so a locale switch only affects plugins/slots loaded afterwards.
|
||||
// both in step from one place.
|
||||
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale;
|
||||
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));
|
||||
void themeHooks.onLocaleChange.emit({ locale });
|
||||
}
|
||||
|
||||
// ─── Bundle fetch ─────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// Translation lookup for plugin strings (api.i18n.t). Pure, so the runtime
|
||||
// inside the sandbox and the tests share it.
|
||||
|
||||
export type LocaleTables = Record<string, Record<string, string>>;
|
||||
|
||||
/**
|
||||
* Locales to try for `locale`, most specific first: `de-CH` → `de-CH`, `de`,
|
||||
* then `en`. Matching ignores case (`zh-tw` finds `zh-TW`).
|
||||
*/
|
||||
export function localeChain(locale: string): string[] {
|
||||
const chain: string[] = [];
|
||||
const parts = (locale || 'en').split('-');
|
||||
for (let i = parts.length; i > 0; i--) chain.push(parts.slice(0, i).join('-'));
|
||||
if (!chain.includes('en')) chain.push('en');
|
||||
return chain;
|
||||
}
|
||||
|
||||
function findTable(tables: LocaleTables, locale: string): Record<string, string> | undefined {
|
||||
if (tables[locale]) return tables[locale];
|
||||
const lower = locale.toLowerCase();
|
||||
const key = Object.keys(tables).find((k) => k.toLowerCase() === lower);
|
||||
return key ? tables[key] : undefined;
|
||||
}
|
||||
|
||||
/** The text for `key`, or undefined when no table has it. */
|
||||
export function lookup(tables: LocaleTables, locale: string, key: string): string | undefined {
|
||||
for (const loc of localeChain(locale)) {
|
||||
const value = findTable(tables, loc)?.[key];
|
||||
if (typeof value === 'string') return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Replace `{name}` placeholders. */
|
||||
export function interpolate(text: string, vars?: Record<string, string | number>): string {
|
||||
if (!vars) return text;
|
||||
let out = text;
|
||||
for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v));
|
||||
return out;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
SlotInit,
|
||||
} from './protocol';
|
||||
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
|
||||
import { interpolate, lookup, type LocaleTables } from './plugin-i18n';
|
||||
import type { SlotName } from '../plugin-types';
|
||||
import { AddressBook, ContactCard } from '../jmap/types';
|
||||
import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store';
|
||||
@@ -66,6 +67,19 @@ const pendingApi = new Map<string, { resolve: (v: unknown) => void; reject: (err
|
||||
// The plugin's settings. One object for the iframe's lifetime, updated in
|
||||
// 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.
|
||||
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 {
|
||||
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = locale;
|
||||
document.documentElement.lang = locale;
|
||||
for (const listener of localeListeners) {
|
||||
try { listener(locale); } catch (err) { console.error('[plugin] locale listener failed', err); }
|
||||
}
|
||||
}
|
||||
const settingsListeners = new Set<(settings: Record<string, unknown>) => void>();
|
||||
|
||||
function applySettings(next: Record<string, unknown>): void {
|
||||
@@ -229,6 +243,7 @@ interface PluginKeywordCounts {
|
||||
|
||||
function buildPluginApi(manifest: PluginManifest) {
|
||||
applySettings({ ...manifest.settings });
|
||||
const localeTables: LocaleTables = JSON.parse(JSON.stringify(manifest.locales ?? {}));
|
||||
return {
|
||||
plugin: {
|
||||
id: manifest.id,
|
||||
@@ -495,22 +510,27 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
},
|
||||
// Localization for plugins. The host pushes the active locale (init +
|
||||
// 'locale-change'); `t` resolves a key against the plugin's declared
|
||||
// `locales` map (manifest.locales), falling back to English then the key
|
||||
// itself, with optional {placeholder} interpolation.
|
||||
// `locales` map (manifest.locales) plus anything added at runtime,
|
||||
// trying `de-CH`, then `de`, then English, then the key itself, with
|
||||
// optional {placeholder} interpolation.
|
||||
i18n: {
|
||||
get locale(): string {
|
||||
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
|
||||
return currentLocale();
|
||||
},
|
||||
getLocale: () => currentLocale(),
|
||||
t(key: string, vars?: Record<string, string | number>): string {
|
||||
const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
|
||||
const tables = manifest.locales || {};
|
||||
let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
out = out.split('{' + k + '}').join(String(v));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
return interpolate(lookup(localeTables, currentLocale(), key) ?? key, vars);
|
||||
},
|
||||
/** Whether any table has `key`, for `has(k) ? t(k) : fallback`. */
|
||||
has: (key: string) => lookup(localeTables, currentLocale(), key) !== undefined,
|
||||
/** Add or override strings for one locale. */
|
||||
addTranslations(locale: string, strings: Record<string, string>): void {
|
||||
localeTables[locale] = { ...(localeTables[locale] ?? {}), ...strings };
|
||||
},
|
||||
/** Called with the new locale when the user switches language. Returns an unsubscribe function. */
|
||||
onLocaleChange: (listener: (locale: string) => void) => {
|
||||
localeListeners.add(listener);
|
||||
return () => { localeListeners.delete(listener); };
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -664,7 +684,11 @@ function bootSlot(payload: SlotInit): void {
|
||||
React.useEffect(() => {
|
||||
const bump = () => setSettingsVersion((v) => v + 1);
|
||||
settingsListeners.add(bump);
|
||||
return () => { settingsListeners.delete(bump); };
|
||||
localeListeners.add(bump);
|
||||
return () => {
|
||||
settingsListeners.delete(bump);
|
||||
localeListeners.delete(bump);
|
||||
};
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!wrapRef.current) return;
|
||||
@@ -698,7 +722,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.
|
||||
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale;
|
||||
applyLocale(payload.locale);
|
||||
try {
|
||||
if (payload.mode === 'background') {
|
||||
await bootBackground(payload);
|
||||
@@ -780,7 +804,7 @@ function handleHostMessage(ev: MessageEvent): void {
|
||||
}
|
||||
|
||||
case 'locale-change':
|
||||
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
|
||||
applyLocale(msg.locale);
|
||||
break;
|
||||
|
||||
case 'theme-change':
|
||||
|
||||
Reference in New Issue
Block a user