From 674845df4e9c3951f5f94d586676f6496e136f0e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:20:02 +0100 Subject: [PATCH] fix: keep the desktop menus and tray in the language the app shows --- .../desktop/__tests__/desktop-host.test.tsx | 24 +++++++++++++++++++ components/desktop/desktop-host.tsx | 13 ++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx index 61f8a3b8d..2d757cbf2 100644 --- a/components/desktop/__tests__/desktop-host.test.tsx +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -14,6 +14,14 @@ import { offerReturnedComposer, takeReturnedComposer } from '@/lib/popout/return const push = vi.fn(); vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) })); +// The catalog on screen: its language, and text that says which one it came from. +const intl = vi.hoisted(() => ({ locale: 'en', prefix: '', messages: {} as object })); +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => `${intl.prefix}${key}`, + useLocale: () => intl.locale, + useMessages: () => intl.messages, +})); + const canPrint = vi.fn(() => true); const canDownload = vi.fn(() => true); vi.mock('@/lib/device-policy', () => ({ canPrintAnywhere: () => canPrint(), canDownloadAnywhere: () => canDownload() })); @@ -68,6 +76,7 @@ function fakeBridge() { const original = useAuthStore.getState(); beforeEach(() => { + Object.assign(intl, { locale: 'en', prefix: '', messages: {} }); push.mockClear(); resolveExternalLink.mockClear(); resolveExternalLink.mockImplementation(async (ctx) => ctx.href); @@ -170,11 +179,26 @@ describe('DesktopHost', () => { it('says which languages are in use', async () => { const shell = fakeBridge(); act(() => useLocaleStore.setState({ locale: 'de' })); + intl.locale = 'de'; render(); await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled()); expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'de' }); }); + it('follows the text on screen, not only the stored choice', async () => { + const shell = fakeBridge(); + // English chosen, but the first render still has the catalog the server picked from the OS. + act(() => useLocaleStore.setState({ locale: 'en' })); + Object.assign(intl, { locale: 'de', prefix: 'de:', messages: { de: true } }); + const view = render(); + await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled()); + // The chosen catalog lands a render later. + Object.assign(intl, { locale: 'en', prefix: 'en:', messages: { en: true } }); + view.rerender(); + await waitFor(() => expect(shell.setLanguages.mock.lastCall![0].labels.menuFile).toBe('en:desktopUi.menu.file')); + expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'en' }); + }); + describe('Open Link in a native menu', () => { const openLink = (url: string) => `link.open:${JSON.stringify({ url, text: 'click' })}`; diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx index 909ca657b..b80396b80 100644 --- a/components/desktop/desktop-host.tsx +++ b/components/desktop/desktop-host.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useMessages, useTranslations } from "next-intl"; import { useStrictestDevices } from "@/hooks/use-device-policy"; import { useRouter } from "@/i18n/navigation"; import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands"; @@ -45,6 +45,8 @@ export function DesktopHost() { const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts); const writingLanguage = useSettingsStore((s) => s.writingLanguage); const uiLocale = useLocaleStore((s) => s.locale); + const activeLocale = useLocale(); + const messages = useMessages(); const strictest = useStrictestDevices(); const { restoreWindows } = useDesktopPrefs(); const theme = useThemeStore((s) => s.theme); @@ -116,11 +118,14 @@ export function DesktopHost() { }); }, [isAuthenticated, singleKeys, strictest]); - // Menus and spellcheck follow the interface. + // Menus, the tray and spellcheck follow the interface: the language its text is in now, + // with that text. The stored choice isn't it: on the first render the catalog can still + // be the one the server picked from the OS language, and the chosen one lands a render + // later. Going by the choice alone left the menus in the OS language for good. useEffect(() => { - const ui = uiLocale || document.documentElement.lang; + const ui = activeLocale || uiLocale || document.documentElement.lang; if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as Parameters[0])); - }, [uiLocale]); + }, [activeLocale, uiLocale, messages]); // What the user writes in, whichever composer asks for it too. useEffect(() => {