diff --git a/packages/i18n/src/DateLocaleResolver.spec.ts b/packages/i18n/src/DateLocaleResolver.spec.ts index 2bf79f04400..e9704d7e680 100644 --- a/packages/i18n/src/DateLocaleResolver.spec.ts +++ b/packages/i18n/src/DateLocaleResolver.spec.ts @@ -1,4 +1,4 @@ -import { resolveActiveDateLocales, toDayjsLocale, toIntlLocale } from './DateLocaleResolver' +import { resolveActiveDateLocales, resolveIntlLocale, toDayjsLocale, toIntlLocale } from './DateLocaleResolver' describe('toIntlLocale', () => { it('maps known app locales to BCP 47 tags', () => { @@ -26,7 +26,61 @@ describe('toDayjsLocale', () => { }) }) +describe('resolveIntlLocale', () => { + it('uses a preferred language that matches the app language regardless of region', () => { + expect(resolveIntlLocale('en_US', ['en-GB'])).toBe('en-GB') + expect(resolveIntlLocale('es_LA', ['es-AR'])).toBe('es-AR') + expect(resolveIntlLocale('pt_BR', ['pt-PT'])).toBe('pt-PT') + expect(resolveIntlLocale('en_US', ['en'])).toBe('en') + }) + + it('uses the first matching preferred language', () => { + expect(resolveIntlLocale('en_US', ['fr-FR', 'en-AU', 'en-GB'])).toBe('en-AU') + }) + + it('falls back to the app locale when no preferred language matches', () => { + expect(resolveIntlLocale('en_US', ['fr-FR', 'de-DE'])).toBe('en-US') + expect(resolveIntlLocale('es_LA', [])).toBe('es-419') + expect(resolveIntlLocale('fr_FR')).toBe('fr-FR') + }) + + it('does not match a preferred language written in a different script', () => { + expect(resolveIntlLocale('zh_CN', ['zh-TW', 'zh-HK'])).toBe('zh-CN') + expect(resolveIntlLocale('zh_CN', ['zh-TW', 'zh-SG'])).toBe('zh-SG') + }) + + it('ignores invalid preferred language tags', () => { + expect(resolveIntlLocale('en_US', ['not a locale', 'en-GB'])).toBe('en-GB') + }) +}) + describe('resolveActiveDateLocales', () => { + it('uses the preferred regional variant for intl and the app locale for dayjs', () => { + expect( + resolveActiveDateLocales({ + appLocale: 'en_US', + localizationEnabled: true, + preferredLocales: ['en-GB'], + }), + ).toEqual({ + intlLocale: 'en-GB', + dayjsLocale: 'en', + }) + }) + + it('ignores preferred languages when localization is disabled', () => { + expect( + resolveActiveDateLocales({ + appLocale: 'en_US', + localizationEnabled: false, + preferredLocales: ['en-GB'], + }), + ).toEqual({ + intlLocale: undefined, + dayjsLocale: 'en', + }) + }) + it('uses app locale mappings when localization is enabled', () => { expect( resolveActiveDateLocales({ diff --git a/packages/i18n/src/DateLocaleResolver.ts b/packages/i18n/src/DateLocaleResolver.ts index bc302cf545b..053c7710959 100644 --- a/packages/i18n/src/DateLocaleResolver.ts +++ b/packages/i18n/src/DateLocaleResolver.ts @@ -15,12 +15,38 @@ export type ResolvedDateLocales = { export type ResolveActiveDateLocalesOptions = { appLocale: string localizationEnabled: boolean + /** The user's preferred languages, in order, e.g. `navigator.languages`. */ + preferredLocales?: readonly string[] } export function toIntlLocale(appLocale: string): string { return INTL_LOCALE_OVERRIDES[appLocale] ?? appLocale.replace('_', '-') } +/** Compares script too, so a Traditional Chinese preference doesn't format dates next to a Simplified Chinese UI. */ +function toLanguageAndScript(tag: string): string | undefined { + try { + const { language, script } = new Intl.Locale(tag).maximize() + return `${language}-${script}` + } catch { + return undefined + } +} + +/** + * Prefers the user's own regional variant of the app language (e.g. `en-GB` for an `en_US` UI) so dates follow their + * regional conventions, falling back to the app locale when none of their preferred languages match it. + */ +export function resolveIntlLocale(appLocale: string, preferredLocales: readonly string[] = []): string { + const appIntlLocale = toIntlLocale(appLocale) + const appLanguage = toLanguageAndScript(appIntlLocale) + if (!appLanguage) { + return appIntlLocale + } + + return preferredLocales.find((tag) => toLanguageAndScript(tag) === appLanguage) ?? appIntlLocale +} + export function toDayjsLocale(appLocale: string): string { return DAYJS_LOCALE_OVERRIDES[appLocale] ?? appLocale.split(/[-_]/)[0].toLowerCase() } @@ -34,7 +60,7 @@ export function resolveActiveDateLocales(options: ResolveActiveDateLocalesOption } return { - intlLocale: toIntlLocale(options.appLocale), + intlLocale: resolveIntlLocale(options.appLocale, options.preferredLocales), dayjsLocale: toDayjsLocale(options.appLocale), } } diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts index 4e9d25ab6ab..d943fc7a7b7 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts @@ -53,8 +53,6 @@ export const PrefDefaults = { [PrefKey.AddImportsToTag]: true, [PrefKey.AlwaysCreateNewTagForImports]: true, [PrefKey.ExistingTagForImports]: undefined, - [PrefKey.Locale]: undefined, - [PrefKey.HasSeenLocalizationLabsSpotlight]: false, } satisfies { [key in PrefKey]: PrefValue[key] } diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts index 2f0b60a60fc..58970f36ab9 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts @@ -45,8 +45,6 @@ export enum PrefKey { AddImportsToTag = 'addImportsToTag', AlwaysCreateNewTagForImports = 'alwaysCreateNewTagForImports', ExistingTagForImports = 'existingTagForImports', - Locale = 'locale', - HasSeenLocalizationLabsSpotlight = 'hasSeenLocalizationLabsSpotlight', DEPRECATED_ActiveThemes = 'activeThemes', DEPRECATED_UseSystemColorScheme = 'useSystemColorScheme', DEPRECATED_UseTranslucentUI = 'useTranslucentUI', @@ -104,7 +102,5 @@ export type PrefValue = { [PrefKey.AddImportsToTag]: boolean [PrefKey.AlwaysCreateNewTagForImports]: boolean [PrefKey.ExistingTagForImports]: string | undefined - [PrefKey.Locale]: string | undefined - [PrefKey.HasSeenLocalizationLabsSpotlight]: boolean [PrefKey.SuperNoteImageAlignment]: 'left' | 'center' | 'right' } diff --git a/packages/services/src/Domain/Preferences/LocalPrefKey.ts b/packages/services/src/Domain/Preferences/LocalPrefKey.ts index 9e127b8173a..51e89e02fa8 100644 --- a/packages/services/src/Domain/Preferences/LocalPrefKey.ts +++ b/packages/services/src/Domain/Preferences/LocalPrefKey.ts @@ -14,8 +14,6 @@ export enum LocalPrefKey { EditorLineHeight = 'editorLineHeight', EditorLineWidth = 'editorLineWidth', EditorFontSize = 'editorFontSize', - - HasSeenLocalizationLabsSpotlight = 'hasSeenLocalizationLabsSpotlight', } export type LocalPrefValue = { @@ -31,8 +29,6 @@ export type LocalPrefValue = { [LocalPrefKey.EditorLineHeight]: EditorLineHeight [LocalPrefKey.EditorLineWidth]: EditorLineWidth [LocalPrefKey.EditorFontSize]: EditorFontSize - - [LocalPrefKey.HasSeenLocalizationLabsSpotlight]: boolean } export const LocalPrefDefaults = { @@ -48,8 +44,6 @@ export const LocalPrefDefaults = { [LocalPrefKey.EditorLineHeight]: EditorLineHeight.Normal, [LocalPrefKey.EditorLineWidth]: EditorLineWidth.FullWidth, [LocalPrefKey.EditorFontSize]: EditorFontSize.Normal, - - [LocalPrefKey.HasSeenLocalizationLabsSpotlight]: false, } satisfies { [key in LocalPrefKey]: LocalPrefValue[key] } diff --git a/packages/web/src/javascripts/App.tsx b/packages/web/src/javascripts/App.tsx index c9f7300b369..dbd79ef0bce 100644 --- a/packages/web/src/javascripts/App.tsx +++ b/packages/web/src/javascripts/App.tsx @@ -42,8 +42,6 @@ import { ElementIds } from './Constants/ElementIDs' import { setDefaultMonospaceFont } from './setDefaultMonospaceFont' import { RouteParser, RouteType } from '@standardnotes/ui-services' import U2FAuthIframe from './Components/U2FAuthIframe/U2FAuthIframe' -import { activatePersistedLocale } from './Controllers/Localization/WebLocaleService' - let keyCount = 0 const getKey = () => { return keyCount++ @@ -60,8 +58,6 @@ const startApplication: StartApplication = async function startApplication( SNLog.onError = console.error let root: Root - await activatePersistedLocale() - const onDestroy = () => { const rootElement = document.getElementById(ElementIds.RootId) as HTMLElement root.unmount() diff --git a/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts index 69c5b4277f8..0358cffd0b4 100644 --- a/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts +++ b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts @@ -40,7 +40,6 @@ import { ApplicationEventObserver } from '@/Event/ApplicationEventObserver' import { SearchOptionsController } from '@/Controllers/SearchOptionsController' import { LinkingController } from '@/Controllers/LinkingController' import { LocalizationController } from '@/Controllers/Localization/LocalizationController' -import { getSavedLocaleForApplication } from '@/Utils/LocalePersistence' import { SyncStatusController } from '@/Controllers/SyncStatusController' import { ActionsMenuController } from '@/Controllers/ActionsMenuController' import { ItemGroupController } from '@/Components/NoteView/Controller/ItemGroupController' @@ -319,8 +318,7 @@ export class WebDependencies extends DependencyContainer { this.bind(Web_TYPES.LocalizationController, () => { return new LocalizationController( this.get(Web_TYPES.FeaturesController), - () => getSavedLocaleForApplication(application), - application.preferences, + application.identifier, this.get(Web_TYPES.PreferencesController), application.routeService, application.events, diff --git a/packages/web/src/javascripts/Application/WebApplicationGroup.ts b/packages/web/src/javascripts/Application/WebApplicationGroup.ts index 1ec278a0ec2..2de8a862e77 100644 --- a/packages/web/src/javascripts/Application/WebApplicationGroup.ts +++ b/packages/web/src/javascripts/Application/WebApplicationGroup.ts @@ -2,6 +2,7 @@ import { WebApplication } from './WebApplication' import { ApplicationDescriptor, SNApplicationGroup } from '@standardnotes/snjs' import { getPlatform, isDesktopApplication } from '@/Utils' import { WebOrDesktopDevice } from '@/Application/Device/WebOrDesktopDevice' +import { activatePersistedLocale } from '@/Controllers/Localization/WebLocaleService' const createApplication = ( descriptor: ApplicationDescriptor, @@ -38,6 +39,7 @@ export class WebApplicationGroup extends SNApplicationGroup await super.initialize({ applicationCreator: async (descriptor, device) => { + await activatePersistedLocale(descriptor.identifier) return createApplication(descriptor, device, defaultSyncServerHost, device, webSocketUrl) }, }) diff --git a/packages/web/src/javascripts/Components/LocalizationLabsSpotlight/LocalizationLabsSpotlightModal.tsx b/packages/web/src/javascripts/Components/LocalizationLabsSpotlight/LocalizationLabsSpotlightModal.tsx index 57da549e6a5..9b41cd2e143 100644 --- a/packages/web/src/javascripts/Components/LocalizationLabsSpotlight/LocalizationLabsSpotlightModal.tsx +++ b/packages/web/src/javascripts/Components/LocalizationLabsSpotlight/LocalizationLabsSpotlightModal.tsx @@ -32,7 +32,7 @@ const LocalizationLabsSpotlightModal = ({ application }: Props) => {
- {c('B6.Preferences.General.Language.Title').t`Language Settings`} + {c('B6.Preferences.General.Language.Title').t`Language Preferences`} {c('B6.Preferences.General.Label').t`Labs`}
diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/Files.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/Files.tsx index 8ecdc19734c..58227030b3a 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Account/Files.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/Files.tsx @@ -65,9 +65,7 @@ const FilesSection: FunctionComponent = ({ application }) => { ) : ( <> -
- {filesQuotaText} -
+
{filesQuotaText}
= ({ application }) => { const switchAnimation = new Promise((resolve) => { window.setTimeout(resolve, LABS_SWITCH_ANIMATION_MS) }) - void application.localizationController.toggleLocalization(switchAnimation) + void application.localizationController.toggleLocalization(switchAnimation).then((isReloading) => { + if (!isReloading) { + isTogglingRef.current = false + setIsToggling(false) + } + }) }, [application]) const showLanguageDropdown = labsSwitchChecked && localeItems.length > 0 @@ -120,7 +125,7 @@ const Language: FunctionComponent = ({ application }) => {
- {c('B6.Preferences.General.Language.Title').t`Language Settings`} + {c('B6.Preferences.General.Language.Title').t`Language Preferences`} {c('B6.Preferences.General.Label').t`Labs`}
diff --git a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts index 8b0687eacdc..adc16156104 100644 --- a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts +++ b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts @@ -6,6 +6,8 @@ import { localizationStore } from './LocalizationStore' const catalog = { en_US: 'English', fr_FR: 'Français' } +const workspaceIdentifier = 'workspace-1' + let mockCurrentLocale = 'en_US' const mockLocaleService = { getCurrentLocale: (): string => mockCurrentLocale, @@ -28,21 +30,7 @@ describe('LocalizationController', () => { const originalLocation = window.location let reload: jest.Mock let localizationEnabled: boolean - let syncedLocale: string | undefined let localizationAvailable: boolean - let seenOnAccount: boolean - let seenOnDevice: boolean - - const preferences = { - getValue: () => seenOnAccount, - setValue: jest.fn(async (_key: string, value: boolean) => { - seenOnAccount = value - }), - getLocalValue: () => seenOnDevice, - setLocalValue: (_key: string, value: boolean) => { - seenOnDevice = value - }, - } const featuresController = { isLocalizationEnabled: () => localizationEnabled, @@ -55,8 +43,7 @@ describe('LocalizationController', () => { const createController = () => new LocalizationController( featuresController as never, - () => getPersistedLocale() ?? syncedLocale, - preferences as never, + workspaceIdentifier, {} as never, { getRoute: () => ({ type: 'none' }) } as never, { addEventHandler: jest.fn() } as never, @@ -67,6 +54,10 @@ describe('LocalizationController', () => { const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) + const setBrowserLanguages = (languages: string[]) => { + jest.spyOn(window.navigator, 'languages', 'get').mockReturnValue(languages) + } + const bootWithLocale = async (locale: string) => { mockCurrentLocale = locale localizationStore.setCurrentLocale(locale) @@ -85,18 +76,16 @@ describe('LocalizationController', () => { }) localizationEnabled = true - syncedLocale = undefined localizationAvailable = true - seenOnAccount = true - seenOnDevice = false - preferences.setValue.mockClear() + setBrowserLanguages(['en-US']) + localStorage.setItem('sn-has-seen-localization-spotlight', 'true') mockCurrentLocale = 'en_US' mockLocaleService.getAvailableLocales.mockResolvedValue(catalog) mockLocaleService.loadAndActivateLocale.mockImplementation(async (locale: string) => { mockCurrentLocale = locale }) - clearPersistedLocale() + clearPersistedLocale(workspaceIdentifier) sessionStorage.clear() localizationStore.setAvailableLocales({}) localizationStore.setCurrentLocale('en_US') @@ -111,7 +100,7 @@ describe('LocalizationController', () => { expect(await controller.changeLocaleAndReload('fr_FR')).toBe(true) expect(reload).toHaveBeenCalledTimes(1) - expect(getPersistedLocale()).toBe('fr_FR') + expect(getPersistedLocale(workspaceIdentifier)).toBe('fr_FR') expect(addToast).not.toHaveBeenCalled() await bootWithLocale('fr_FR') @@ -122,7 +111,7 @@ describe('LocalizationController', () => { }) it('does not show a toast on a regular reload', async () => { - persistLocale('fr_FR') + persistLocale(workspaceIdentifier, 'fr_FR') await bootWithLocale('fr_FR') @@ -131,17 +120,17 @@ describe('LocalizationController', () => { }) it('keeps the boot language when the saved language failed to load at startup', async () => { - persistLocale('fr_FR') + persistLocale(workspaceIdentifier, 'fr_FR') const controller = await bootWithLocale('en_US') expect(reload).not.toHaveBeenCalled() expect(localizationStore.currentLocale).toBe('en_US') expect(localizationStore.availableLocales).toEqual(catalog) - expect(getPersistedLocale()).toBe('fr_FR') + expect(getPersistedLocale(workspaceIdentifier)).toBe('fr_FR') expect(await controller.changeLocaleAndReload('en_US')).toBe(true) - expect(getPersistedLocale()).toBe('en_US') + expect(getPersistedLocale(workspaceIdentifier)).toBe('en_US') expect(reload).toHaveBeenCalledTimes(1) }) @@ -155,12 +144,12 @@ describe('LocalizationController', () => { }) it('clears the saved language on sign out', async () => { - persistLocale('fr_FR') + persistLocale(workspaceIdentifier, 'fr_FR') const controller = await bootWithLocale('fr_FR') await sendEvent(controller, ApplicationEvent.SignedOut) - expect(getPersistedLocale()).toBeUndefined() + expect(getPersistedLocale(workspaceIdentifier)).toBeUndefined() }) it('loads the language list while localization is still off', async () => { @@ -187,20 +176,20 @@ describe('LocalizationController', () => { }) it('keeps the saved language and reports an error when the catalog fails to load', async () => { - persistLocale('fr_FR') + persistLocale(workspaceIdentifier, 'fr_FR') mockLocaleService.getAvailableLocales.mockRejectedValue(new Error('offline')) jest.spyOn(console, 'error').mockImplementation(() => undefined) await bootWithLocale('fr_FR') - expect(getPersistedLocale()).toBe('fr_FR') + expect(getPersistedLocale(workspaceIdentifier)).toBe('fr_FR') expect(localizationStore.availableLocalesFailedToLoad).toBe(true) expect(reload).not.toHaveBeenCalled() }) it('reloads once, after the switch animation, when turning localization on', async () => { localizationEnabled = false - syncedLocale = 'fr_FR' + setBrowserLanguages(['fr-FR']) const controller = await bootWithLocale('en_US') let finishAnimation: () => void = () => undefined @@ -218,98 +207,73 @@ describe('LocalizationController', () => { await toggling expect(reload).toHaveBeenCalledTimes(1) - expect(getPersistedLocale()).toBe('fr_FR') + expect(getPersistedLocale(workspaceIdentifier)).toBe('fr_FR') expect(sessionStorage.getItem('sn-pending-locale-switch')).toBe('fr_FR') }) - describe('labs spotlight', () => { - beforeEach(() => { - localizationEnabled = false - seenOnAccount = false - }) - - const launch = async () => { - const controller = await bootWithLocale('en_US') - await sendEvent(controller, ApplicationEvent.CompletedFullSync) - return controller - } + it('does not reload when turning localization on while already in the resolved language', async () => { + localizationEnabled = false + const controller = await bootWithLocale('en_US') - const signInAndGainAccess = async (controller: LocalizationController) => { - await sendEvent(controller, ApplicationEvent.SignedIn) - localizationAvailable = true - await sendEvent(controller, ApplicationEvent.FeaturesAvailabilityChanged) - } + expect(await controller.toggleLocalization(Promise.resolve())).toBe(false) - it('opens after the launch sync when it has not been seen', async () => { - const controller = await bootWithLocale('en_US') - expect(controller.labsSpotlightOpen).toBe(false) + expect(reload).not.toHaveBeenCalled() + expect(getPersistedLocale(workspaceIdentifier)).toBe('en_US') + expect(localizationStore.availableLocales).toEqual(catalog) + }) - await sendEvent(controller, ApplicationEvent.CompletedFullSync) + it('does not reload when turning localization off while already in English', async () => { + localizationEnabled = true + const controller = await bootWithLocale('en_US') - expect(controller.labsSpotlightOpen).toBe(true) - }) + expect(await controller.toggleLocalization(Promise.resolve())).toBe(false) - it('does not open on launch when the launch sync brings a pref seen on another device', async () => { - const controller = await bootWithLocale('en_US') - await sendEvent(controller, ApplicationEvent.FeaturesAvailabilityChanged) - expect(controller.labsSpotlightOpen).toBe(false) + expect(reload).not.toHaveBeenCalled() + expect(getPersistedLocale(workspaceIdentifier)).toBeUndefined() + }) - seenOnAccount = true - await sendEvent(controller, ApplicationEvent.PreferencesChanged) - await sendEvent(controller, ApplicationEvent.CompletedFullSync) + it('can toggle localization on and off again without reloading', async () => { + localizationEnabled = false + const controller = await bootWithLocale('en_US') - expect(controller.labsSpotlightOpen).toBe(false) - }) + expect(await controller.toggleLocalization(Promise.resolve())).toBe(false) + expect(await controller.toggleLocalization(Promise.resolve())).toBe(false) - it('does not open on sign in when the synced pref says it was already seen', async () => { - localizationAvailable = false - const controller = await launch() + expect(reload).not.toHaveBeenCalled() + expect(localizationEnabled).toBe(false) + expect(getPersistedLocale(workspaceIdentifier)).toBeUndefined() + }) - await signInAndGainAccess(controller) - expect(controller.labsSpotlightOpen).toBe(false) + describe('labs spotlight', () => { + beforeEach(() => { + localizationEnabled = false + localStorage.removeItem('sn-has-seen-localization-spotlight') + }) - seenOnAccount = true - await sendEvent(controller, ApplicationEvent.PreferencesChanged) - await sendEvent(controller, ApplicationEvent.CompletedFullSync) + it('opens on launch when it has not been seen', async () => { + const controller = await bootWithLocale('en_US') - expect(controller.labsSpotlightOpen).toBe(false) + expect(controller.labsSpotlightOpen).toBe(true) }) - it('opens after the first sync following sign in when it has not been seen', async () => { + it('opens once the feature becomes available, e.g. after signing in', async () => { localizationAvailable = false - const controller = await launch() - - await signInAndGainAccess(controller) + const controller = await bootWithLocale('en_US') expect(controller.labsSpotlightOpen).toBe(false) - await sendEvent(controller, ApplicationEvent.CompletedFullSync) + localizationAvailable = true + await sendEvent(controller, ApplicationEvent.FeaturesAvailabilityChanged) expect(controller.labsSpotlightOpen).toBe(true) }) - it('keeps a signed-out dismissal when the account prefs replace the local ones on sign in', async () => { - const controller = await launch() + it('stays dismissed when the feature availability is checked again', async () => { + const controller = await bootWithLocale('en_US') controller.dismissLabsSpotlight() - await sendEvent(controller, ApplicationEvent.SignedIn) - seenOnAccount = false - await sendEvent(controller, ApplicationEvent.PreferencesChanged) - expect(controller.labsSpotlightOpen).toBe(false) - - preferences.setValue.mockClear() - await sendEvent(controller, ApplicationEvent.CompletedFullSync) + await sendEvent(controller, ApplicationEvent.FeaturesAvailabilityChanged) expect(controller.labsSpotlightOpen).toBe(false) - expect(preferences.setValue).toHaveBeenCalledWith('hasSeenLocalizationLabsSpotlight', true) - }) - - it('does not rewrite the account pref when it already says seen', async () => { - seenOnDevice = true - seenOnAccount = true - - await launch() - - expect(preferences.setValue).not.toHaveBeenCalled() }) }) }) diff --git a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts index ebe86f799ad..c15916953e9 100644 --- a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts +++ b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts @@ -10,6 +10,8 @@ import { syncMobileNativeLocalization } from '@/Application/Device/SyncMobileNat import { clearPersistedLocale, getPersistedLocale, + hasSeenLabsSpotlight, + markLabsSpotlightSeen, persistLocale, setPendingLocaleSwitch, takePendingLocaleSwitch, @@ -24,26 +26,18 @@ import { InternalEventBusInterface, InternalEventHandlerInterface, InternalEventInterface, - LocalPrefDefaults, - LocalPrefKey, - PrefDefaults, - PrefKey, - PreferenceServiceInterface, } from '@standardnotes/snjs' export class LocalizationController extends AbstractViewController implements InternalEventHandlerInterface { labsSpotlightOpen = false - /** The synced "seen" pref is only reliable after a full sync, both at launch and after signing in. */ - private syncedSinceSignIn = false private initializedWithLocalizationDisabled = false private isTogglingLocalization = false private availableLocalesRequested = false constructor( private readonly featuresController: FeaturesController, - private readonly getSavedLocale: () => string | undefined, - private readonly preferences: PreferenceServiceInterface, + private readonly workspaceIdentifier: string, private readonly preferencesController: PreferencesController, private readonly routeService: RouteServiceInterface, eventBus: InternalEventBusInterface, @@ -58,42 +52,25 @@ export class LocalizationController extends AbstractViewController implements In eventBus.addEventHandler(this, ApplicationEvent.FeaturesAvailabilityChanged) eventBus.addEventHandler(this, ApplicationEvent.LocalDataLoaded) - eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged) eventBus.addEventHandler(this, ApplicationEvent.SignedOut) - eventBus.addEventHandler(this, ApplicationEvent.SignedIn) - eventBus.addEventHandler(this, ApplicationEvent.CompletedFullSync) } async handleEvent(event: InternalEventInterface): Promise { switch (event.type) { case ApplicationEvent.LocalDataLoaded: - await this.initializeLocalization() - break case ApplicationEvent.FeaturesAvailabilityChanged: - case ApplicationEvent.PreferencesChanged: await this.initializeLocalization() this.setLabsSpotlightOpen(this.shouldShowLabsSpotlight()) break - case ApplicationEvent.SignedIn: - this.syncedSinceSignIn = false - break - case ApplicationEvent.CompletedFullSync: - if (!this.syncedSinceSignIn) { - this.syncedSinceSignIn = true - this.syncLabsSpotlightPref() - this.setLabsSpotlightOpen(this.shouldShowLabsSpotlight()) - } - break case ApplicationEvent.SignedOut: - clearPersistedLocale() + clearPersistedLocale(this.workspaceIdentifier) break } } dismissLabsSpotlight = (): void => { this.setLabsSpotlightOpen(false) - this.preferences.setLocalValue(LocalPrefKey.HasSeenLocalizationLabsSpotlight, true) - void this.preferences.setValue(PrefKey.HasSeenLocalizationLabsSpotlight, true) + markLabsSpotlightSeen() } openLanguageSettingsFromLabsSpotlight = (): void => { @@ -101,39 +78,13 @@ export class LocalizationController extends AbstractViewController implements In this.preferencesController.openPreferencesAndScrollToSection(ElementIds.LanguagePreferencesSection, 'general') } - private hasSeenLabsSpotlightOnAccount(): boolean { - return this.preferences.getValue( - PrefKey.HasSeenLocalizationLabsSpotlight, - PrefDefaults[PrefKey.HasSeenLocalizationLabsSpotlight], - ) - } - - /** Survives signing in, when the account's synced prefs replace the ones from the signed-out session. */ - private hasSeenLabsSpotlightOnDevice(): boolean { - return this.preferences.getLocalValue( - LocalPrefKey.HasSeenLocalizationLabsSpotlight, - LocalPrefDefaults[LocalPrefKey.HasSeenLocalizationLabsSpotlight], - ) - } - - private syncLabsSpotlightPref(): void { - if (this.hasSeenLabsSpotlightOnDevice() && !this.hasSeenLabsSpotlightOnAccount()) { - void this.preferences.setValue(PrefKey.HasSeenLocalizationLabsSpotlight, true) - } - } - private shouldShowLabsSpotlight(): boolean { - if (!this.syncedSinceSignIn) { - return false - } - const routeType = this.routeService.getRoute().type return ( this.featuresController.isLocalizationFeatureAvailable() && !this.featuresController.isLocalizationEnabled() && - !this.hasSeenLabsSpotlightOnAccount() && - !this.hasSeenLabsSpotlightOnDevice() && + !hasSeenLabsSpotlight() && routeType !== RouteType.Purchase && routeType !== RouteType.Settings ) @@ -146,7 +97,7 @@ export class LocalizationController extends AbstractViewController implements In } async changeLocaleAndReload(locale: string): Promise { - if (locale === localizationStore.currentLocale && locale === getPersistedLocale()) { + if (locale === localizationStore.currentLocale && locale === getPersistedLocale(this.workspaceIdentifier)) { return false } @@ -166,56 +117,32 @@ export class LocalizationController extends AbstractViewController implements In return false } - persistLocale(resolvedLocale) - - try { - await this.preferences.setValue(PrefKey.Locale, resolvedLocale) - } catch (error) { - console.error('Failed to sync locale preference', error) - } - + persistLocale(this.workspaceIdentifier, resolvedLocale) this.reloadAndShowToast(resolvedLocale) return true } - async toggleLocalization(waitBeforeReload: Promise): Promise { + /** + * Resolves to `true` if the page is reloading, or `false` if the toggle was applied in place because the app is + * already displayed in the target language. + */ + async toggleLocalization(waitBeforeReload: Promise): Promise { this.isTogglingLocalization = true + const activeLocale = localizationStore.currentLocale this.featuresController.toggleLocalization() - const locale = this.featuresController.isLocalizationEnabled() - ? await this.enableLocalization() - : await this.disableLocalization() - - await waitBeforeReload - - if (locale) { - this.reloadAndShowToast(locale) - } else { - window.location.reload() - } - } - - private async enableLocalization(): Promise { - try { - const locale = await this.loadLocale(this.getSavedLocale()) - persistLocale(locale) - return locale - } catch (error) { - console.error('Failed to load locale before reload', error) - return undefined + if (this.featuresController.isLocalizationEnabled()) { + try { + await this.loadLocale(getPersistedLocale(this.workspaceIdentifier)) + } catch (error) { + console.error('Failed to load locale before reload', error) + } } - } - - private async disableLocalization(): Promise { - clearPersistedLocale() - try { - await this.preferences.setValue(PrefKey.Locale, PrefDefaults[PrefKey.Locale]) - } catch (error) { - console.error('Failed to clear locale preference', error) - } + await waitBeforeReload - return DEFAULT_LOCALE + this.isTogglingLocalization = false + return this.initializeLocalization(activeLocale) } private reloadAndShowToast(locale: string): void { @@ -253,20 +180,20 @@ export class LocalizationController extends AbstractViewController implements In } private updatePersistedLocale(localizationEnabled: boolean, locale: string): boolean { - const persistedLocale = getPersistedLocale() + const persistedLocale = getPersistedLocale(this.workspaceIdentifier) if (!localizationEnabled) { if (!persistedLocale) { return false } - clearPersistedLocale() + clearPersistedLocale(this.workspaceIdentifier) return true } if (persistedLocale === locale) { return false } - persistLocale(locale) + persistLocale(this.workspaceIdentifier, locale) return true } @@ -287,32 +214,38 @@ export class LocalizationController extends AbstractViewController implements In } } - private async initializeLocalization(): Promise { + /** + * Resolves to `true` if the page is reloading. Pass `activeLocale` when a locale was activated in memory ahead of a + * reload, so it isn't mistaken for the one displayed. + */ + private async initializeLocalization(activeLocale = getWebLocaleService().getCurrentLocale()): Promise { if (this.isTogglingLocalization) { - return + return false } void this.preloadAvailableLocales() const localizationEnabled = this.featuresController.isLocalizationEnabled() if (!localizationEnabled && this.initializedWithLocalizationDisabled) { - return + return false } this.initializedWithLocalizationDisabled = !localizationEnabled const localeService = getWebLocaleService() - const activeLocale = localeService.getCurrentLocale() try { const catalog = localizationEnabled ? await localeService.getAvailableLocales() : undefined const locale = catalog - ? resolveLocale({ savedLocale: this.getSavedLocale(), availableLocales: Object.keys(catalog) }) + ? resolveLocale({ + savedLocale: getPersistedLocale(this.workspaceIdentifier), + availableLocales: Object.keys(catalog), + }) : DEFAULT_LOCALE const persistedLocaleChanged = this.updatePersistedLocale(localizationEnabled, locale) if (locale !== activeLocale && (persistedLocaleChanged || !localizationEnabled)) { this.reloadAndShowToast(locale) - return + return true } if (catalog) { @@ -330,6 +263,7 @@ export class LocalizationController extends AbstractViewController implements In await this.syncDateFormatting(activeLocale) localizationStore.setCurrentLocale(activeLocale) this.syncNativeShellLocalization(localizationEnabled, activeLocale) + return false } private syncNativeShellLocalization(localizationEnabled: boolean, locale: string): void { diff --git a/packages/web/src/javascripts/Controllers/Localization/WebLocaleService.ts b/packages/web/src/javascripts/Controllers/Localization/WebLocaleService.ts index f10f5c9f6a1..aa221ee806c 100644 --- a/packages/web/src/javascripts/Controllers/Localization/WebLocaleService.ts +++ b/packages/web/src/javascripts/Controllers/Localization/WebLocaleService.ts @@ -74,8 +74,8 @@ export function getWebLocaleService(): LocaleService { return localeService } -export async function activatePersistedLocale(): Promise { - const persistedLocale = getPersistedLocale() +export async function activatePersistedLocale(workspaceIdentifier: string): Promise { + const persistedLocale = getPersistedLocale(workspaceIdentifier) if (!persistedLocale) { return } diff --git a/packages/web/src/javascripts/Utils/DateLocale.ts b/packages/web/src/javascripts/Utils/DateLocale.ts index b8dfcdaaa50..a9211a461c4 100644 --- a/packages/web/src/javascripts/Utils/DateLocale.ts +++ b/packages/web/src/javascripts/Utils/DateLocale.ts @@ -52,7 +52,10 @@ export function isDateLocalizationEnabled(): boolean { } export async function configureDateLocale(options: { appLocale: string; localizationEnabled: boolean }): Promise { - const { intlLocale, dayjsLocale } = resolveActiveDateLocales(options) + const { intlLocale, dayjsLocale } = resolveActiveDateLocales({ + ...options, + preferredLocales: typeof navigator !== 'undefined' ? navigator.languages : undefined, + }) activeIntlLocale = intlLocale setSharedItemDateFormattingLocale(intlLocale) diff --git a/packages/web/src/javascripts/Utils/LocalePersistence.spec.ts b/packages/web/src/javascripts/Utils/LocalePersistence.spec.ts index 163adb94ce0..1e243127193 100644 --- a/packages/web/src/javascripts/Utils/LocalePersistence.spec.ts +++ b/packages/web/src/javascripts/Utils/LocalePersistence.spec.ts @@ -1,23 +1,26 @@ -import { clearPersistedLocale, getLocaleCookie, LOCALE_COOKIE_NAME, setLocaleCookie } from './LocalePersistence' +import { clearPersistedLocale, getPersistedLocale, persistLocale } from './LocalePersistence' describe('LocalePersistence', () => { - afterEach(() => { - document.cookie = `${LOCALE_COOKIE_NAME}=; path=/; max-age=0` + const workspaceIdentifier = 'workspace-1' + + beforeEach(() => { + localStorage.clear() }) - it('reads and writes the locale cookie', () => { - setLocaleCookie('es_LA') + it('reads and writes the persisted locale', () => { + persistLocale(workspaceIdentifier, 'es_LA') - expect(getLocaleCookie()).toBe('es_LA') + expect(getPersistedLocale(workspaceIdentifier)).toBe('es_LA') }) - it('returns undefined when the cookie is missing', () => { - expect(getLocaleCookie()).toBeUndefined() + it('returns undefined when no locale is persisted', () => { + expect(getPersistedLocale(workspaceIdentifier)).toBeUndefined() }) - it('clears the locale cookie', () => { - setLocaleCookie('fr_FR') - clearPersistedLocale() - expect(getLocaleCookie()).toBeUndefined() + it('clears the persisted locale', () => { + persistLocale(workspaceIdentifier, 'fr_FR') + clearPersistedLocale(workspaceIdentifier) + + expect(getPersistedLocale(workspaceIdentifier)).toBeUndefined() }) }) diff --git a/packages/web/src/javascripts/Utils/LocalePersistence.ts b/packages/web/src/javascripts/Utils/LocalePersistence.ts index cd40c9daa4e..456f3e0bec6 100644 --- a/packages/web/src/javascripts/Utils/LocalePersistence.ts +++ b/packages/web/src/javascripts/Utils/LocalePersistence.ts @@ -1,98 +1,52 @@ -import { PrefDefaults, PrefKey } from '@standardnotes/snjs' - -import { WebApplication } from '@/Application/WebApplication' - -export const LOCALE_COOKIE_NAME = 'sn_locale' -const LOCALE_LOCAL_STORAGE_KEY = 'sn-locale' const PENDING_LOCALE_SWITCH_KEY = 'sn-pending-locale-switch' +const SEEN_LABS_SPOTLIGHT_KEY = 'sn-has-seen-localization-spotlight' -const LOCALE_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 - -function isFileProtocol(): boolean { - return typeof window !== 'undefined' && window.location.protocol === 'file:' +/** Keyed per workspace, as each workspace has its own Labs setting. */ +function localeStorageKey(workspaceIdentifier: string): string { + return `sn-locale:${workspaceIdentifier}` } -function readLocaleFromLocalStorage(): string | undefined { +/** Persists the workspace's locale on this device for the next page load. */ +export function persistLocale(workspaceIdentifier: string, locale: string): void { try { - const value = localStorage.getItem(LOCALE_LOCAL_STORAGE_KEY) - return value || undefined + localStorage.setItem(localeStorageKey(workspaceIdentifier), locale) } catch { - return undefined + // Ignore quota / privacy errors } } -function writeLocaleToLocalStorage(locale: string): void { +/** Clears the persisted locale so Labs off does not leave a saved language choice. */ +export function clearPersistedLocale(workspaceIdentifier: string): void { try { - localStorage.setItem(LOCALE_LOCAL_STORAGE_KEY, locale) + localStorage.removeItem(localeStorageKey(workspaceIdentifier)) } catch { - // Ignore quota / privacy errors + // Ignore privacy errors } } -export function getLocaleCookie(): string | undefined { - if (typeof document === 'undefined') { - return undefined - } - - const prefix = `${LOCALE_COOKIE_NAME}=` - const entry = document.cookie - .split(';') - .map((part) => part.trim()) - .find((part) => part.startsWith(prefix)) - - if (!entry) { +export function getPersistedLocale(workspaceIdentifier: string): string | undefined { + try { + return localStorage.getItem(localeStorageKey(workspaceIdentifier)) || undefined + } catch { return undefined } - - const value = decodeURIComponent(entry.slice(prefix.length)) - return value || undefined -} - -export function setLocaleCookie(locale: string): void { - if (typeof document === 'undefined') { - return - } - - document.cookie = `${LOCALE_COOKIE_NAME}=${encodeURIComponent( - locale, - )}; path=/; max-age=${LOCALE_COOKIE_MAX_AGE_SECONDS}; samesite=lax` -} - -/** Persists locale for the next page load. Electron `file://` cannot rely on cookies. */ -export function persistLocale(locale: string): void { - if (isFileProtocol()) { - writeLocaleToLocalStorage(locale) - } - - setLocaleCookie(locale) } -/** Clears bootstrapped locale storage so Labs off does not leave a saved language choice. */ -export function clearPersistedLocale(): void { - if (isFileProtocol()) { - try { - localStorage.removeItem(LOCALE_LOCAL_STORAGE_KEY) - } catch { - // Ignore quota / privacy errors - } - } - - if (typeof document === 'undefined') { - return +/** Shared by all workspaces, so dismissing the spotlight once hides it on this device. */ +export function hasSeenLabsSpotlight(): boolean { + try { + return localStorage.getItem(SEEN_LABS_SPOTLIGHT_KEY) === 'true' + } catch { + return false } - - document.cookie = `${LOCALE_COOKIE_NAME}=; path=/; max-age=0; samesite=lax` } -export function getPersistedLocale(): string | undefined { - if (isFileProtocol()) { - const storedLocale = readLocaleFromLocalStorage() - if (storedLocale) { - return storedLocale - } +export function markLabsSpotlightSeen(): void { + try { + localStorage.setItem(SEEN_LABS_SPOTLIGHT_KEY, 'true') + } catch { + // Ignore quota / privacy errors } - - return getLocaleCookie() } export function setPendingLocaleSwitch(locale: string): void { @@ -112,7 +66,3 @@ export function takePendingLocaleSwitch(): string | undefined { return undefined } } - -export function getSavedLocaleForApplication(application: WebApplication): string | undefined { - return getPersistedLocale() ?? application.getPreference(PrefKey.Locale, PrefDefaults[PrefKey.Locale]) -} diff --git a/packages/web/src/javascripts/Utils/LocalizedDateFormat.spec.ts b/packages/web/src/javascripts/Utils/LocalizedDateFormat.spec.ts index 66e89f0bc47..0acc5c6ed99 100644 --- a/packages/web/src/javascripts/Utils/LocalizedDateFormat.spec.ts +++ b/packages/web/src/javascripts/Utils/LocalizedDateFormat.spec.ts @@ -90,6 +90,18 @@ describe('LocalizedDateFormat', () => { }) }) + it("formats dates in the user's regional variant of the app language", async () => { + const languages = jest.spyOn(navigator, 'languages', 'get').mockReturnValue(['fr-CA', 'fr-FR']) + + try { + await configureDateLocale({ appLocale: 'fr_FR', localizationEnabled: true }) + + expect(formatDefaultDateTime(sampleDate)).toBe(sampleDate.toLocaleString('fr-CA')) + } finally { + languages.mockRestore() + } + }) + it('capitalizes the first letter for sentence-start titles', async () => { await configureDateLocale({ appLocale: 'fr_FR', localizationEnabled: true })