From eb8d6ca52a177f6d9b4a0f167dcdbce7b645ad21 Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Wed, 28 Oct 2026 14:26:04 -0300 Subject: [PATCH 1/7] fix: prevent reloading app if preferred language is the same as default --- .../Preferences/Panes/General/Language.tsx | 7 +++++- .../LocalizationController.spec.ts | 21 +++++++++++++++++ .../Localization/LocalizationController.ts | 23 +++++++++++++++---- 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Language.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Language.tsx index 369d5fc962f..9b2dfcecde1 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Language.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Language.tsx @@ -46,7 +46,12 @@ const Language: FunctionComponent = ({ 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 diff --git a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts index 8b0687eacdc..a5821c469d8 100644 --- a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts +++ b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts @@ -222,6 +222,27 @@ describe('LocalizationController', () => { expect(sessionStorage.getItem('sn-pending-locale-switch')).toBe('fr_FR') }) + it('does not reload when turning localization on while already in the resolved language', async () => { + localizationEnabled = false + const controller = await bootWithLocale('en_US') + + expect(await controller.toggleLocalization(Promise.resolve())).toBe(false) + + expect(reload).not.toHaveBeenCalled() + expect(getPersistedLocale()).toBe('en_US') + expect(localizationStore.availableLocales).toEqual(catalog) + }) + + it('does not reload when turning localization off while already in English', async () => { + localizationEnabled = true + const controller = await bootWithLocale('en_US') + + expect(await controller.toggleLocalization(Promise.resolve())).toBe(false) + + expect(reload).not.toHaveBeenCalled() + expect(getPersistedLocale()).toBeUndefined() + }) + describe('labs spotlight', () => { beforeEach(() => { localizationEnabled = false diff --git a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts index ebe86f799ad..015b05999bf 100644 --- a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts +++ b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts @@ -178,8 +178,13 @@ export class LocalizationController extends AbstractViewController implements In 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() @@ -188,11 +193,21 @@ export class LocalizationController extends AbstractViewController implements In await waitBeforeReload - if (locale) { - this.reloadAndShowToast(locale) - } else { + if (!locale) { window.location.reload() + return true + } + + if (locale === activeLocale) { + this.isTogglingLocalization = false + this.initializedWithLocalizationDisabled = false + await this.initializeLocalization() + this.setLabsSpotlightOpen(this.shouldShowLabsSpotlight()) + return false } + + this.reloadAndShowToast(locale) + return true } private async enableLocalization(): Promise { From c296acc9d89e715898501c4938b9f20a22a81f39 Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Wed, 28 Oct 2026 14:27:23 -0300 Subject: [PATCH 2/7] chore: update copy --- .../LocalizationLabsSpotlightModal.tsx | 8 ++++---- .../Components/Preferences/Panes/General/Language.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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/General/Language.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Language.tsx index 9b2dfcecde1..c80446bc528 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/General/Language.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Language.tsx @@ -125,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`}
From 2680d21204c4cad6f40cb7759c7578d6afd4d4c5 Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Wed, 28 Oct 2026 16:29:25 -0300 Subject: [PATCH 3/7] chore: use browser pref for date formatting --- packages/i18n/src/DateLocaleResolver.spec.ts | 56 ++++++++++++++++++- packages/i18n/src/DateLocaleResolver.ts | 28 +++++++++- .../LocalizationController.spec.ts | 12 ++++ .../Localization/LocalizationController.ts | 2 - .../web/src/javascripts/Utils/DateLocale.ts | 5 +- .../Utils/LocalizedDateFormat.spec.ts | 12 ++++ 6 files changed, 110 insertions(+), 5 deletions(-) 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/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts index a5821c469d8..5820b2cc559 100644 --- a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts +++ b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts @@ -243,6 +243,18 @@ describe('LocalizationController', () => { expect(getPersistedLocale()).toBeUndefined() }) + it('can toggle localization on and off again without reloading', async () => { + localizationEnabled = false + const controller = await bootWithLocale('en_US') + + expect(await controller.toggleLocalization(Promise.resolve())).toBe(false) + expect(await controller.toggleLocalization(Promise.resolve())).toBe(false) + + expect(reload).not.toHaveBeenCalled() + expect(localizationEnabled).toBe(false) + expect(getPersistedLocale()).toBeUndefined() + }) + describe('labs spotlight', () => { beforeEach(() => { localizationEnabled = false diff --git a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts index 015b05999bf..0a142213c8d 100644 --- a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts +++ b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts @@ -200,9 +200,7 @@ export class LocalizationController extends AbstractViewController implements In if (locale === activeLocale) { this.isTogglingLocalization = false - this.initializedWithLocalizationDisabled = false await this.initializeLocalization() - this.setLabsSpotlightOpen(this.shouldShowLabsSpotlight()) return false } 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/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 }) From 10fb61c0cf4358dbe1cd1a573e077ef11e3ad8e9 Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Thu, 29 Oct 2026 09:49:31 -0300 Subject: [PATCH 4/7] chore: remove synced pref and cookie, simplify reload logic --- .../Domain/Syncable/UserPrefs/PrefDefaults.ts | 1 - .../src/Domain/Syncable/UserPrefs/PrefKey.ts | 2 - .../Dependencies/WebDependencies.ts | 2 - .../LocalizationController.spec.ts | 10 +- .../Localization/LocalizationController.ts | 72 ++++---------- .../Utils/LocalePersistence.spec.ts | 23 ++--- .../javascripts/Utils/LocalePersistence.ts | 94 +++---------------- 7 files changed, 48 insertions(+), 156 deletions(-) diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts index 4e9d25ab6ab..f705d07784b 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts @@ -53,7 +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..66c069765db 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts @@ -45,7 +45,6 @@ export enum PrefKey { AddImportsToTag = 'addImportsToTag', AlwaysCreateNewTagForImports = 'alwaysCreateNewTagForImports', ExistingTagForImports = 'existingTagForImports', - Locale = 'locale', HasSeenLocalizationLabsSpotlight = 'hasSeenLocalizationLabsSpotlight', DEPRECATED_ActiveThemes = 'activeThemes', DEPRECATED_UseSystemColorScheme = 'useSystemColorScheme', @@ -104,7 +103,6 @@ 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/web/src/javascripts/Application/Dependencies/WebDependencies.ts b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts index 69c5b4277f8..544c60a833b 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,7 +318,6 @@ export class WebDependencies extends DependencyContainer { this.bind(Web_TYPES.LocalizationController, () => { return new LocalizationController( this.get(Web_TYPES.FeaturesController), - () => getSavedLocaleForApplication(application), application.preferences, this.get(Web_TYPES.PreferencesController), application.routeService, diff --git a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts index 5820b2cc559..201ec39cd4d 100644 --- a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts +++ b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts @@ -28,7 +28,6 @@ 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 @@ -55,7 +54,6 @@ describe('LocalizationController', () => { const createController = () => new LocalizationController( featuresController as never, - () => getPersistedLocale() ?? syncedLocale, preferences as never, {} as never, { getRoute: () => ({ type: 'none' }) } as never, @@ -67,6 +65,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,8 +87,8 @@ describe('LocalizationController', () => { }) localizationEnabled = true - syncedLocale = undefined localizationAvailable = true + setBrowserLanguages(['en-US']) seenOnAccount = true seenOnDevice = false preferences.setValue.mockClear() @@ -200,7 +202,7 @@ describe('LocalizationController', () => { 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 diff --git a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts index 0a142213c8d..3e3749f8c65 100644 --- a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts +++ b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts @@ -42,7 +42,6 @@ export class LocalizationController extends AbstractViewController implements In constructor( private readonly featuresController: FeaturesController, - private readonly getSavedLocale: () => string | undefined, private readonly preferences: PreferenceServiceInterface, private readonly preferencesController: PreferencesController, private readonly routeService: RouteServiceInterface, @@ -167,13 +166,6 @@ export class LocalizationController extends AbstractViewController implements In } persistLocale(resolvedLocale) - - try { - await this.preferences.setValue(PrefKey.Locale, resolvedLocale) - } catch (error) { - console.error('Failed to sync locale preference', error) - } - this.reloadAndShowToast(resolvedLocale) return true } @@ -187,48 +179,18 @@ export class LocalizationController extends AbstractViewController implements In const activeLocale = localizationStore.currentLocale this.featuresController.toggleLocalization() - const locale = this.featuresController.isLocalizationEnabled() - ? await this.enableLocalization() - : await this.disableLocalization() - - await waitBeforeReload - - if (!locale) { - window.location.reload() - return true - } - - if (locale === activeLocale) { - this.isTogglingLocalization = false - await this.initializeLocalization() - return false - } - - this.reloadAndShowToast(locale) - return true - } - - 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()) + } 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 { @@ -300,32 +262,35 @@ 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(), 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) { @@ -343,6 +308,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/Utils/LocalePersistence.spec.ts b/packages/web/src/javascripts/Utils/LocalePersistence.spec.ts index 163adb94ce0..043ba0a2d97 100644 --- a/packages/web/src/javascripts/Utils/LocalePersistence.spec.ts +++ b/packages/web/src/javascripts/Utils/LocalePersistence.spec.ts @@ -1,23 +1,24 @@ -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` + beforeEach(() => { + localStorage.clear() }) - it('reads and writes the locale cookie', () => { - setLocaleCookie('es_LA') + it('reads and writes the persisted locale', () => { + persistLocale('es_LA') - expect(getLocaleCookie()).toBe('es_LA') + expect(getPersistedLocale()).toBe('es_LA') }) - it('returns undefined when the cookie is missing', () => { - expect(getLocaleCookie()).toBeUndefined() + it('returns undefined when no locale is persisted', () => { + expect(getPersistedLocale()).toBeUndefined() }) - it('clears the locale cookie', () => { - setLocaleCookie('fr_FR') + it('clears the persisted locale', () => { + persistLocale('fr_FR') clearPersistedLocale() - expect(getLocaleCookie()).toBeUndefined() + + expect(getPersistedLocale()).toBeUndefined() }) }) diff --git a/packages/web/src/javascripts/Utils/LocalePersistence.ts b/packages/web/src/javascripts/Utils/LocalePersistence.ts index cd40c9daa4e..1e25b04defb 100644 --- a/packages/web/src/javascripts/Utils/LocalePersistence.ts +++ b/packages/web/src/javascripts/Utils/LocalePersistence.ts @@ -1,27 +1,8 @@ -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 LOCALE_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 - -function isFileProtocol(): boolean { - return typeof window !== 'undefined' && window.location.protocol === 'file:' -} - -function readLocaleFromLocalStorage(): string | undefined { - try { - const value = localStorage.getItem(LOCALE_LOCAL_STORAGE_KEY) - return value || undefined - } catch { - return undefined - } -} - -function writeLocaleToLocalStorage(locale: string): void { +/** Persists the locale on this device for the next page load. */ +export function persistLocale(locale: string): void { try { localStorage.setItem(LOCALE_LOCAL_STORAGE_KEY, locale) } catch { @@ -29,70 +10,21 @@ function writeLocaleToLocalStorage(locale: string): void { } } -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) { - 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. */ +/** Clears the persisted locale 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 + try { + localStorage.removeItem(LOCALE_LOCAL_STORAGE_KEY) + } catch { + // Ignore privacy errors } - - 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 - } + try { + return localStorage.getItem(LOCALE_LOCAL_STORAGE_KEY) || undefined + } catch { + return undefined } - - return getLocaleCookie() } export function setPendingLocaleSwitch(locale: string): void { @@ -112,7 +44,3 @@ export function takePendingLocaleSwitch(): string | undefined { return undefined } } - -export function getSavedLocaleForApplication(application: WebApplication): string | undefined { - return getPersistedLocale() ?? application.getPreference(PrefKey.Locale, PrefDefaults[PrefKey.Locale]) -} From ec6c2c63da10eaf2aa6ebd32830073bc053b7d01 Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Thu, 29 Oct 2026 10:09:27 -0300 Subject: [PATCH 5/7] chore: eslint fix --- .../Components/Preferences/Panes/Account/Files.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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}
Date: Thu, 29 Oct 2026 23:06:13 -0300 Subject: [PATCH 6/7] fix: keep each workspace's language when switching workspaces --- packages/web/src/javascripts/App.tsx | 4 --- .../Dependencies/WebDependencies.ts | 1 + .../Application/WebApplicationGroup.ts | 2 ++ .../LocalizationController.spec.ts | 31 ++++++++++--------- .../Localization/LocalizationController.ts | 20 +++++++----- .../Localization/WebLocaleService.ts | 4 +-- .../Utils/LocalePersistence.spec.ts | 14 +++++---- .../javascripts/Utils/LocalePersistence.ts | 20 +++++++----- 8 files changed, 54 insertions(+), 42 deletions(-) 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 544c60a833b..b81b5566574 100644 --- a/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts +++ b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts @@ -318,6 +318,7 @@ export class WebDependencies extends DependencyContainer { this.bind(Web_TYPES.LocalizationController, () => { return new LocalizationController( this.get(Web_TYPES.FeaturesController), + application.identifier, application.preferences, this.get(Web_TYPES.PreferencesController), application.routeService, 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/Controllers/Localization/LocalizationController.spec.ts b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts index 201ec39cd4d..47beefdc177 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, @@ -54,6 +56,7 @@ describe('LocalizationController', () => { const createController = () => new LocalizationController( featuresController as never, + workspaceIdentifier, preferences as never, {} as never, { getRoute: () => ({ type: 'none' }) } as never, @@ -98,7 +101,7 @@ describe('LocalizationController', () => { mockCurrentLocale = locale }) - clearPersistedLocale() + clearPersistedLocale(workspaceIdentifier) sessionStorage.clear() localizationStore.setAvailableLocales({}) localizationStore.setCurrentLocale('en_US') @@ -113,7 +116,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') @@ -124,7 +127,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') @@ -133,17 +136,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) }) @@ -157,12 +160,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 () => { @@ -189,13 +192,13 @@ 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() }) @@ -220,7 +223,7 @@ 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') }) @@ -231,7 +234,7 @@ describe('LocalizationController', () => { expect(await controller.toggleLocalization(Promise.resolve())).toBe(false) expect(reload).not.toHaveBeenCalled() - expect(getPersistedLocale()).toBe('en_US') + expect(getPersistedLocale(workspaceIdentifier)).toBe('en_US') expect(localizationStore.availableLocales).toEqual(catalog) }) @@ -242,7 +245,7 @@ describe('LocalizationController', () => { expect(await controller.toggleLocalization(Promise.resolve())).toBe(false) expect(reload).not.toHaveBeenCalled() - expect(getPersistedLocale()).toBeUndefined() + expect(getPersistedLocale(workspaceIdentifier)).toBeUndefined() }) it('can toggle localization on and off again without reloading', async () => { @@ -254,7 +257,7 @@ describe('LocalizationController', () => { expect(reload).not.toHaveBeenCalled() expect(localizationEnabled).toBe(false) - expect(getPersistedLocale()).toBeUndefined() + expect(getPersistedLocale(workspaceIdentifier)).toBeUndefined() }) describe('labs spotlight', () => { diff --git a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts index 3e3749f8c65..b10c6740b80 100644 --- a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts +++ b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.ts @@ -42,6 +42,7 @@ export class LocalizationController extends AbstractViewController implements In constructor( private readonly featuresController: FeaturesController, + private readonly workspaceIdentifier: string, private readonly preferences: PreferenceServiceInterface, private readonly preferencesController: PreferencesController, private readonly routeService: RouteServiceInterface, @@ -84,7 +85,7 @@ export class LocalizationController extends AbstractViewController implements In } break case ApplicationEvent.SignedOut: - clearPersistedLocale() + clearPersistedLocale(this.workspaceIdentifier) break } } @@ -145,7 +146,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 } @@ -165,7 +166,7 @@ export class LocalizationController extends AbstractViewController implements In return false } - persistLocale(resolvedLocale) + persistLocale(this.workspaceIdentifier, resolvedLocale) this.reloadAndShowToast(resolvedLocale) return true } @@ -181,7 +182,7 @@ export class LocalizationController extends AbstractViewController implements In if (this.featuresController.isLocalizationEnabled()) { try { - await this.loadLocale(getPersistedLocale()) + await this.loadLocale(getPersistedLocale(this.workspaceIdentifier)) } catch (error) { console.error('Failed to load locale before reload', error) } @@ -228,20 +229,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 } @@ -284,7 +285,10 @@ export class LocalizationController extends AbstractViewController implements In try { const catalog = localizationEnabled ? await localeService.getAvailableLocales() : undefined const locale = catalog - ? resolveLocale({ savedLocale: getPersistedLocale(), availableLocales: Object.keys(catalog) }) + ? resolveLocale({ + savedLocale: getPersistedLocale(this.workspaceIdentifier), + availableLocales: Object.keys(catalog), + }) : DEFAULT_LOCALE const persistedLocaleChanged = this.updatePersistedLocale(localizationEnabled, locale) 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/LocalePersistence.spec.ts b/packages/web/src/javascripts/Utils/LocalePersistence.spec.ts index 043ba0a2d97..1e243127193 100644 --- a/packages/web/src/javascripts/Utils/LocalePersistence.spec.ts +++ b/packages/web/src/javascripts/Utils/LocalePersistence.spec.ts @@ -1,24 +1,26 @@ import { clearPersistedLocale, getPersistedLocale, persistLocale } from './LocalePersistence' describe('LocalePersistence', () => { + const workspaceIdentifier = 'workspace-1' + beforeEach(() => { localStorage.clear() }) it('reads and writes the persisted locale', () => { - persistLocale('es_LA') + persistLocale(workspaceIdentifier, 'es_LA') - expect(getPersistedLocale()).toBe('es_LA') + expect(getPersistedLocale(workspaceIdentifier)).toBe('es_LA') }) it('returns undefined when no locale is persisted', () => { - expect(getPersistedLocale()).toBeUndefined() + expect(getPersistedLocale(workspaceIdentifier)).toBeUndefined() }) it('clears the persisted locale', () => { - persistLocale('fr_FR') - clearPersistedLocale() + persistLocale(workspaceIdentifier, 'fr_FR') + clearPersistedLocale(workspaceIdentifier) - expect(getPersistedLocale()).toBeUndefined() + expect(getPersistedLocale(workspaceIdentifier)).toBeUndefined() }) }) diff --git a/packages/web/src/javascripts/Utils/LocalePersistence.ts b/packages/web/src/javascripts/Utils/LocalePersistence.ts index 1e25b04defb..c5497d9b3d8 100644 --- a/packages/web/src/javascripts/Utils/LocalePersistence.ts +++ b/packages/web/src/javascripts/Utils/LocalePersistence.ts @@ -1,27 +1,31 @@ -const LOCALE_LOCAL_STORAGE_KEY = 'sn-locale' const PENDING_LOCALE_SWITCH_KEY = 'sn-pending-locale-switch' -/** Persists the locale on this device for the next page load. */ -export function persistLocale(locale: string): void { +/** Keyed per workspace, as each workspace has its own Labs setting. */ +function localeStorageKey(workspaceIdentifier: string): string { + return `sn-locale:${workspaceIdentifier}` +} + +/** Persists the workspace's locale on this device for the next page load. */ +export function persistLocale(workspaceIdentifier: string, locale: string): void { try { - localStorage.setItem(LOCALE_LOCAL_STORAGE_KEY, locale) + localStorage.setItem(localeStorageKey(workspaceIdentifier), locale) } catch { // Ignore quota / privacy errors } } /** Clears the persisted locale so Labs off does not leave a saved language choice. */ -export function clearPersistedLocale(): void { +export function clearPersistedLocale(workspaceIdentifier: string): void { try { - localStorage.removeItem(LOCALE_LOCAL_STORAGE_KEY) + localStorage.removeItem(localeStorageKey(workspaceIdentifier)) } catch { // Ignore privacy errors } } -export function getPersistedLocale(): string | undefined { +export function getPersistedLocale(workspaceIdentifier: string): string | undefined { try { - return localStorage.getItem(LOCALE_LOCAL_STORAGE_KEY) || undefined + return localStorage.getItem(localeStorageKey(workspaceIdentifier)) || undefined } catch { return undefined } From 626484e29990fe981467d9003eb46eeb48de44b6 Mon Sep 17 00:00:00 2001 From: Antonella Sgarlatta Date: Thu, 29 Oct 2026 23:09:28 -0300 Subject: [PATCH 7/7] fix: store localization spotlight dismissal in localStorage --- .../Domain/Syncable/UserPrefs/PrefDefaults.ts | 1 - .../src/Domain/Syncable/UserPrefs/PrefKey.ts | 2 - .../src/Domain/Preferences/LocalPrefKey.ts | 6 -- .../Dependencies/WebDependencies.ts | 1 - .../LocalizationController.spec.ts | 94 ++----------------- .../Localization/LocalizationController.ts | 57 +---------- .../javascripts/Utils/LocalePersistence.ts | 18 ++++ 7 files changed, 32 insertions(+), 147 deletions(-) diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts index f705d07784b..d943fc7a7b7 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts @@ -53,7 +53,6 @@ export const PrefDefaults = { [PrefKey.AddImportsToTag]: true, [PrefKey.AlwaysCreateNewTagForImports]: true, [PrefKey.ExistingTagForImports]: 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 66c069765db..58970f36ab9 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts @@ -45,7 +45,6 @@ export enum PrefKey { AddImportsToTag = 'addImportsToTag', AlwaysCreateNewTagForImports = 'alwaysCreateNewTagForImports', ExistingTagForImports = 'existingTagForImports', - HasSeenLocalizationLabsSpotlight = 'hasSeenLocalizationLabsSpotlight', DEPRECATED_ActiveThemes = 'activeThemes', DEPRECATED_UseSystemColorScheme = 'useSystemColorScheme', DEPRECATED_UseTranslucentUI = 'useTranslucentUI', @@ -103,6 +102,5 @@ export type PrefValue = { [PrefKey.AddImportsToTag]: boolean [PrefKey.AlwaysCreateNewTagForImports]: boolean [PrefKey.ExistingTagForImports]: 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/Application/Dependencies/WebDependencies.ts b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts index b81b5566574..0358cffd0b4 100644 --- a/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts +++ b/packages/web/src/javascripts/Application/Dependencies/WebDependencies.ts @@ -319,7 +319,6 @@ export class WebDependencies extends DependencyContainer { return new LocalizationController( this.get(Web_TYPES.FeaturesController), application.identifier, - application.preferences, this.get(Web_TYPES.PreferencesController), application.routeService, application.events, diff --git a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts index 47beefdc177..adc16156104 100644 --- a/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts +++ b/packages/web/src/javascripts/Controllers/Localization/LocalizationController.spec.ts @@ -31,19 +31,6 @@ describe('LocalizationController', () => { let reload: jest.Mock let localizationEnabled: boolean 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, @@ -57,7 +44,6 @@ describe('LocalizationController', () => { new LocalizationController( featuresController as never, workspaceIdentifier, - preferences as never, {} as never, { getRoute: () => ({ type: 'none' }) } as never, { addEventHandler: jest.fn() } as never, @@ -92,9 +78,7 @@ describe('LocalizationController', () => { localizationEnabled = true localizationAvailable = true setBrowserLanguages(['en-US']) - seenOnAccount = true - seenOnDevice = false - preferences.setValue.mockClear() + localStorage.setItem('sn-has-seen-localization-spotlight', 'true') mockCurrentLocale = 'en_US' mockLocaleService.getAvailableLocales.mockResolvedValue(catalog) mockLocaleService.loadAndActivateLocale.mockImplementation(async (locale: string) => { @@ -263,91 +247,33 @@ describe('LocalizationController', () => { describe('labs spotlight', () => { beforeEach(() => { localizationEnabled = false - seenOnAccount = false + localStorage.removeItem('sn-has-seen-localization-spotlight') }) - const launch = async () => { - const controller = await bootWithLocale('en_US') - await sendEvent(controller, ApplicationEvent.CompletedFullSync) - return controller - } - - const signInAndGainAccess = async (controller: LocalizationController) => { - await sendEvent(controller, ApplicationEvent.SignedIn) - localizationAvailable = true - await sendEvent(controller, ApplicationEvent.FeaturesAvailabilityChanged) - } - - it('opens after the launch sync when it has not been seen', async () => { + it('opens on launch when it has not been seen', async () => { const controller = await bootWithLocale('en_US') - expect(controller.labsSpotlightOpen).toBe(false) - - await sendEvent(controller, ApplicationEvent.CompletedFullSync) expect(controller.labsSpotlightOpen).toBe(true) }) - 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) - - seenOnAccount = true - await sendEvent(controller, ApplicationEvent.PreferencesChanged) - await sendEvent(controller, ApplicationEvent.CompletedFullSync) - - expect(controller.labsSpotlightOpen).toBe(false) - }) - - it('does not open on sign in when the synced pref says it was already seen', async () => { + it('opens once the feature becomes available, e.g. after signing in', async () => { localizationAvailable = false - const controller = await launch() - - await signInAndGainAccess(controller) - expect(controller.labsSpotlightOpen).toBe(false) - - seenOnAccount = true - await sendEvent(controller, ApplicationEvent.PreferencesChanged) - await sendEvent(controller, ApplicationEvent.CompletedFullSync) - - expect(controller.labsSpotlightOpen).toBe(false) - }) - - it('opens after the first sync following sign in when it has not been seen', 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 b10c6740b80..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,18 +26,11 @@ 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 @@ -43,7 +38,6 @@ export class LocalizationController extends AbstractViewController implements In constructor( private readonly featuresController: FeaturesController, private readonly workspaceIdentifier: string, - private readonly preferences: PreferenceServiceInterface, private readonly preferencesController: PreferencesController, private readonly routeService: RouteServiceInterface, eventBus: InternalEventBusInterface, @@ -58,32 +52,16 @@ 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(this.workspaceIdentifier) break @@ -92,8 +70,7 @@ export class LocalizationController extends AbstractViewController implements In 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 ) diff --git a/packages/web/src/javascripts/Utils/LocalePersistence.ts b/packages/web/src/javascripts/Utils/LocalePersistence.ts index c5497d9b3d8..456f3e0bec6 100644 --- a/packages/web/src/javascripts/Utils/LocalePersistence.ts +++ b/packages/web/src/javascripts/Utils/LocalePersistence.ts @@ -1,4 +1,5 @@ const PENDING_LOCALE_SWITCH_KEY = 'sn-pending-locale-switch' +const SEEN_LABS_SPOTLIGHT_KEY = 'sn-has-seen-localization-spotlight' /** Keyed per workspace, as each workspace has its own Labs setting. */ function localeStorageKey(workspaceIdentifier: string): string { @@ -31,6 +32,23 @@ export function getPersistedLocale(workspaceIdentifier: string): string | undefi } } +/** 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 + } +} + +export function markLabsSpotlightSeen(): void { + try { + localStorage.setItem(SEEN_LABS_SPOTLIGHT_KEY, 'true') + } catch { + // Ignore quota / privacy errors + } +} + export function setPendingLocaleSwitch(locale: string): void { try { sessionStorage.setItem(PENDING_LOCALE_SWITCH_KEY, locale)