From 704e105d0b1095dac843f6b28e689ee260e7bade Mon Sep 17 00:00:00 2001 From: Khaled FERJANI Date: Thu, 9 Apr 2026 10:21:24 +0200 Subject: [PATCH] fix(public): use browser language for anonymous visitors on shared links Anonymous visitors on public sharing links were seeing the file owner's language instead of their own. Use the browser locale for unauthenticated visitors while keeping the instance locale for authenticated owners and older cozy-stack versions without the isLoggedIn flag. Co-authored-by: Quentin Valmori <1107936+crash--@users.noreply.github.com> Co-authored-by: lethemanh <16944047+lethemanh@users.noreply.github.com> --- src/targets/public/index.jsx | 8 +- src/targets/public/localeHelper.js | 48 ++++++++++++ src/targets/public/localeHelper.spec.js | 97 +++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 src/targets/public/localeHelper.js create mode 100644 src/targets/public/localeHelper.spec.js diff --git a/src/targets/public/index.jsx b/src/targets/public/index.jsx index 2c506eef02..4ce7075de3 100644 --- a/src/targets/public/index.jsx +++ b/src/targets/public/index.jsx @@ -33,6 +33,8 @@ import registerClientPlugins from '@/lib/registerClientPlugins' import configureStore from '@/store/configureStore' import styles from '@/styles/main.styl' +import { getPublicPageLocale } from './localeHelper' + const renderError = (lang, root) => createRoot(root).render( require(`@/locales/${lang}`)}> @@ -45,9 +47,9 @@ const renderError = (lang, root) => ) const init = async () => { - const lang = document.documentElement.getAttribute('lang') || 'en' const root = document.querySelector('[role=application]') const dataset = JSON.parse(root.dataset.cozy) + const lang = getPublicPageLocale(dataset) const { sharecode, isOnlyOfficeDocShared, onlyOfficeDocId, username } = getQueryParameter() @@ -67,8 +69,8 @@ const init = async () => { Document.registerClient(client) } - const polyglot = initTranslation(dataset.locale, lang => - require(`@/locales/${lang}`) + const polyglot = initTranslation(lang, locale => + require(`@/locales/${locale}`) ) const store = configureStore({ diff --git a/src/targets/public/localeHelper.js b/src/targets/public/localeHelper.js new file mode 100644 index 0000000000..0401dc8272 --- /dev/null +++ b/src/targets/public/localeHelper.js @@ -0,0 +1,48 @@ +import { locales } from '@/locales' + +const supportedLocales = new Set(Object.keys(locales)) + +/** + * Returns the best matching supported locale from the browser's language + * preferences. Iterates through `navigator.languages` (or `navigator.language` + * as a fallback), normalizing BCP 47 tags (e.g. `zh-CN` → `zh_CN`) and + * trying the primary subtag (e.g. `fr` from `fr-CA`) before moving on. + * + * @returns {string} A supported locale key (e.g. `'fr'`, `'zh_CN'`), or `'en'` + * if no browser language matches. + */ +export const getBrowserLocale = () => { + const languages = navigator.languages ?? [navigator.language || 'en'] + + for (const language of languages) { + // BCP 47 uses hyphens (zh-CN) but our locale keys use underscores (zh_CN) + const normalized = language.replaceAll('-', '_') + if (supportedLocales.has(normalized)) { + return normalized + } + const primary = normalized.split('_')[0] + if (supportedLocales.has(primary)) { + return primary + } + } + + return 'en' +} + +/** + * Determines the locale for the public sharing page. + * + * When `isLoggedIn` is `false`, the visitor is anonymous so the browser locale + * is used. When `isLoggedIn` is `true` or absent (cozy-stack < PR#4719), the + * instance locale from the dataset is used for backward compatibility. + * + * @param {object} dataset - The parsed `data-cozy` dataset from the DOM root. + * @param {boolean} [dataset.isLoggedIn] - Whether the current user is + * authenticated. Absent on older cozy-stack versions. + * @param {string} [dataset.locale] - The Cozy instance locale (e.g. `'fr'`). + * @returns {string} The resolved locale key to use for translations. + */ +export const getPublicPageLocale = dataset => + 'isLoggedIn' in dataset && !dataset.isLoggedIn + ? getBrowserLocale() + : dataset.locale || 'en' diff --git a/src/targets/public/localeHelper.spec.js b/src/targets/public/localeHelper.spec.js new file mode 100644 index 0000000000..5d0c7a8330 --- /dev/null +++ b/src/targets/public/localeHelper.spec.js @@ -0,0 +1,97 @@ +import { getBrowserLocale, getPublicPageLocale } from './localeHelper' + +describe('getBrowserLocale', () => { + const originalNavigator = { ...navigator } + + const mockLanguages = (languages, language) => { + Object.defineProperty(navigator, 'languages', { + value: languages, + configurable: true + }) + Object.defineProperty(navigator, 'language', { + value: language ?? (languages?.[0] || 'en'), + configurable: true + }) + } + + afterEach(() => { + Object.defineProperty(navigator, 'languages', { + value: originalNavigator.languages, + configurable: true + }) + Object.defineProperty(navigator, 'language', { + value: originalNavigator.language, + configurable: true + }) + }) + + it('returns an exact match', () => { + mockLanguages(['fr']) + expect(getBrowserLocale()).toBe('fr') + }) + + it('normalizes BCP 47 hyphens to underscores', () => { + mockLanguages(['zh-CN']) + expect(getBrowserLocale()).toBe('zh_CN') + }) + + it('falls back to the primary subtag when the full tag is unsupported', () => { + mockLanguages(['fr-CA']) + expect(getBrowserLocale()).toBe('fr') + }) + + it('returns the first supported language from the preference list', () => { + mockLanguages(['sv', 'de', 'fr']) + expect(getBrowserLocale()).toBe('de') + }) + + it('falls back to en when no language matches', () => { + mockLanguages(['xx', 'yy']) + expect(getBrowserLocale()).toBe('en') + }) + + it('uses navigator.language when navigator.languages is undefined', () => { + mockLanguages(undefined, 'ja') + expect(getBrowserLocale()).toBe('ja') + }) + + it('falls back to en when both navigator.languages and navigator.language are undefined', () => { + mockLanguages(undefined, undefined) + expect(getBrowserLocale()).toBe('en') + }) +}) + +describe('getPublicPageLocale', () => { + const originalNavigator = { ...navigator } + + const mockLanguages = languages => { + Object.defineProperty(navigator, 'languages', { + value: languages, + configurable: true + }) + } + + afterEach(() => { + Object.defineProperty(navigator, 'languages', { + value: originalNavigator.languages, + configurable: true + }) + }) + + it('uses browser locale when isLoggedIn is false', () => { + mockLanguages(['es']) + expect(getPublicPageLocale({ isLoggedIn: false, locale: 'fr' })).toBe('es') + }) + + it('uses instance locale when isLoggedIn is true', () => { + expect(getPublicPageLocale({ isLoggedIn: true, locale: 'fr' })).toBe('fr') + }) + + it('uses instance locale when isLoggedIn is absent (backward compat)', () => { + expect(getPublicPageLocale({ locale: 'de' })).toBe('de') + }) + + it('falls back to en when isLoggedIn is absent and locale is missing', () => { + expect(getPublicPageLocale({})).toBe('en') + }) +})