diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 9028a7cfffb..a28afa33398 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -195,12 +195,12 @@ import { BrowserStateService as StateServiceAbstraction } from "../platform/serv import { BrowserConfigService } from "../platform/services/browser-config.service"; import { BrowserCryptoService } from "../platform/services/browser-crypto.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; -import { BrowserI18nService } from "../platform/services/browser-i18n.service"; import BrowserLocalStorageService from "../platform/services/browser-local-storage.service"; import BrowserMessagingPrivateModeBackgroundService from "../platform/services/browser-messaging-private-mode-background.service"; import BrowserMessagingService from "../platform/services/browser-messaging.service"; import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service"; import { BrowserStateService } from "../platform/services/browser-state.service"; +import I18nService from "../platform/services/i18n.service"; import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; import { BackgroundDerivedStateProvider } from "../platform/state/background-derived-state.provider"; import { BackgroundMemoryStorageService } from "../platform/storage/background-memory-storage.service"; @@ -462,7 +462,7 @@ export default class MainBackground { }, self, ); - this.i18nService = new BrowserI18nService(BrowserApi.getUILanguage(), this.stateService); + this.i18nService = new I18nService(BrowserApi.getUILanguage(), this.globalStateProvider); this.cryptoService = new BrowserCryptoService( this.keyGenerationService, this.cryptoFunctionService, @@ -969,7 +969,7 @@ export default class MainBackground { await this.stateService.init(); await this.vaultTimeoutService.init(true); - await (this.i18nService as BrowserI18nService).init(); + await (this.i18nService as I18nService).init(); await (this.eventUploadService as EventUploadService).init(true); await this.runtimeBackground.init(); await this.notificationBackground.init(); diff --git a/apps/browser/src/platform/background/service-factories/i18n-service.factory.ts b/apps/browser/src/platform/background/service-factories/i18n-service.factory.ts index 86ec82784b1..9f9580df843 100644 --- a/apps/browser/src/platform/background/service-factories/i18n-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/i18n-service.factory.ts @@ -4,6 +4,10 @@ import { I18nService as BaseI18nService } from "@bitwarden/common/platform/servi import I18nService from "../../services/i18n.service"; import { FactoryOptions, CachedServices, factory } from "./factory-options"; +import { + GlobalStateProviderInitOptions, + globalStateProviderFactory, +} from "./global-state-provider.factory"; type I18nServiceFactoryOptions = FactoryOptions & { i18nServiceOptions: { @@ -11,7 +15,7 @@ type I18nServiceFactoryOptions = FactoryOptions & { }; }; -export type I18nServiceInitOptions = I18nServiceFactoryOptions; +export type I18nServiceInitOptions = I18nServiceFactoryOptions & GlobalStateProviderInitOptions; export async function i18nServiceFactory( cache: { i18nService?: AbstractI18nService } & CachedServices, @@ -21,7 +25,11 @@ export async function i18nServiceFactory( cache, "i18nService", opts, - () => new I18nService(opts.i18nServiceOptions.systemLanguage), + async () => + new I18nService( + opts.i18nServiceOptions.systemLanguage, + await globalStateProviderFactory(cache, opts), + ), ); if (!(service as BaseI18nService as any).inited) { await (service as BaseI18nService).init(); diff --git a/apps/browser/src/platform/services/browser-i18n.service.ts b/apps/browser/src/platform/services/browser-i18n.service.ts deleted file mode 100644 index 66821bc1582..00000000000 --- a/apps/browser/src/platform/services/browser-i18n.service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ReplaySubject } from "rxjs"; - -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; - -import { browserSession, sessionSync } from "../decorators/session-sync-observable"; - -import I18nService from "./i18n.service"; - -@browserSession -export class BrowserI18nService extends I18nService { - @sessionSync({ initializer: (s: string) => s }) - protected _locale: ReplaySubject; - - constructor( - systemLanguage: string, - private stateService: StateService, - ) { - super(systemLanguage); - } -} diff --git a/apps/browser/src/platform/services/i18n.service.ts b/apps/browser/src/platform/services/i18n.service.ts index 1badfdb7cb2..334ad8dc6cf 100644 --- a/apps/browser/src/platform/services/i18n.service.ts +++ b/apps/browser/src/platform/services/i18n.service.ts @@ -1,12 +1,18 @@ import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; export default class I18nService extends BaseI18nService { - constructor(systemLanguage: string) { - super(systemLanguage, null, async (formattedLocale: string) => { - // Deprecated - const file = await fetch(this.localesDirectory + formattedLocale + "/messages.json"); - return await file.json(); - }); + constructor(systemLanguage: string, globalStateProvider: GlobalStateProvider) { + super( + systemLanguage, + null, + async (formattedLocale: string) => { + // Deprecated + const file = await fetch(this.localesDirectory + formattedLocale + "/messages.json"); + return await file.json(); + }, + globalStateProvider, + ); // Please leave 'en' where it is, as it's our fallback language in case no translation can be found this.supportedTranslationLocales = [ diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index b5138274d36..00dee196b5c 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -1,4 +1,4 @@ -import { APP_INITIALIZER, LOCALE_ID, NgModule, NgZone } from "@angular/core"; +import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/auth/guards"; import { ThemingService } from "@bitwarden/angular/platform/services/theming/theming.service"; @@ -60,10 +60,7 @@ import { } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { - StateService as BaseStateServiceAbstraction, - StateService, -} from "@bitwarden/common/platform/abstractions/state.service"; +import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractMemoryStorageService, AbstractStorageService, @@ -75,7 +72,11 @@ import { ConsoleLogService } from "@bitwarden/common/platform/services/console-l import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service"; -import { DerivedStateProvider, StateProvider } from "@bitwarden/common/platform/state"; +import { + DerivedStateProvider, + GlobalStateProvider, + StateProvider, +} from "@bitwarden/common/platform/state"; import { SearchService } from "@bitwarden/common/services/search.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; @@ -112,11 +113,11 @@ import { BrowserStateService as StateServiceAbstraction } from "../../platform/s import { BrowserConfigService } from "../../platform/services/browser-config.service"; import { BrowserEnvironmentService } from "../../platform/services/browser-environment.service"; import { BrowserFileDownloadService } from "../../platform/services/browser-file-download.service"; -import { BrowserI18nService } from "../../platform/services/browser-i18n.service"; import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service"; import BrowserMessagingPrivateModePopupService from "../../platform/services/browser-messaging-private-mode-popup.service"; import BrowserMessagingService from "../../platform/services/browser-messaging.service"; import { BrowserStateService } from "../../platform/services/browser-state.service"; +import I18nService from "../../platform/services/i18n.service"; import { ForegroundDerivedStateProvider } from "../../platform/state/foreground-derived-state.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; import { BrowserSendService } from "../../services/browser-send.service"; @@ -158,11 +159,6 @@ function getBgService(service: keyof MainBackground) { DebounceNavigationService, DialogService, PopupCloseWarningService, - { - provide: LOCALE_ID, - useFactory: () => getBgService("i18nService")().translationLocale, - deps: [], - }, { provide: APP_INITIALIZER, useFactory: (initService: InitService) => initService.init(), @@ -254,10 +250,10 @@ function getBgService(service: keyof MainBackground) { { provide: TokenService, useFactory: getBgService("tokenService"), deps: [] }, { provide: I18nServiceAbstraction, - useFactory: (stateService: BrowserStateService) => { - return new BrowserI18nService(BrowserApi.getUILanguage(), stateService); + useFactory: (globalStateProvider: GlobalStateProvider) => { + return new I18nService(BrowserApi.getUILanguage(), globalStateProvider); }, - deps: [StateService], + deps: [GlobalStateProvider], }, { provide: CryptoService, diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 581d37c9e6a..9718bb8f100 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -231,7 +231,6 @@ export class Main { p = path.join(process.env.HOME, ".config/Bitwarden CLI"); } - this.i18nService = new I18nService("en", "./locales"); this.platformUtilsService = new CliPlatformUtilsService(ClientType.Cli, packageJson); this.logService = new ConsoleLogService( this.platformUtilsService.isDev(), @@ -270,6 +269,8 @@ export class Main { storageServiceProvider, ); + this.i18nService = new I18nService("en", "./locales", this.globalStateProvider); + this.singleUserStateProvider = new DefaultSingleUserStateProvider( storageServiceProvider, stateEventRegistrarService, @@ -665,8 +666,7 @@ export class Main { await this.stateService.init(); this.containerService.attachToGlobal(global); await this.environmentService.setUrlsFromStorage(); - const locale = await this.stateService.getLocale(); - await this.i18nService.init(locale); + await this.i18nService.init(); this.twoFactorService.init(); this.configService.init(); diff --git a/apps/cli/src/platform/services/i18n.service.ts b/apps/cli/src/platform/services/i18n.service.ts index 0a52aba41e9..61e1ed6b09a 100644 --- a/apps/cli/src/platform/services/i18n.service.ts +++ b/apps/cli/src/platform/services/i18n.service.ts @@ -2,18 +2,28 @@ import * as fs from "fs"; import * as path from "path"; import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; export class I18nService extends BaseI18nService { - constructor(systemLanguage: string, localesDirectory: string) { - super(systemLanguage, localesDirectory, (formattedLocale: string) => { - const filePath = path.join( - __dirname, - this.localesDirectory + "/" + formattedLocale + "/messages.json", - ); - const localesJson = fs.readFileSync(filePath, "utf8"); - const locales = JSON.parse(localesJson.replace(/^\uFEFF/, "")); // strip the BOM - return Promise.resolve(locales); - }); + constructor( + systemLanguage: string, + localesDirectory: string, + globalStateProvider: GlobalStateProvider, + ) { + super( + systemLanguage, + localesDirectory, + (formattedLocale: string) => { + const filePath = path.join( + __dirname, + this.localesDirectory + "/" + formattedLocale + "/messages.json", + ); + const localesJson = fs.readFileSync(filePath, "utf8"); + const locales = JSON.parse(localesJson.replace(/^\uFEFF/, "")); // strip the BOM + return Promise.resolve(locales); + }, + globalStateProvider, + ); this.supportedTranslationLocales = ["en"]; } diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 32aad980f02..c594d5acedf 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -264,7 +264,7 @@ export class SettingsComponent implements OnInit { enableDuckDuckGoBrowserIntegration: await this.stateService.getEnableDuckDuckGoBrowserIntegration(), theme: await this.stateService.getTheme(), - locale: (await this.stateService.getLocale()) ?? null, + locale: await firstValueFrom(this.i18nService.locale$), }; this.form.setValue(initialValues, { emitEvent: false }); @@ -553,7 +553,7 @@ export class SettingsComponent implements OnInit { } async saveLocale() { - await this.stateService.setLocale(this.form.value.locale); + await this.i18nService.setLocale(this.form.value.locale); } async saveTheme() { diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index 9bbf0b2e37c..56361499ea1 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -52,8 +52,7 @@ export class InitService { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.syncService.fullSync(true); await this.vaultTimeoutService.init(true); - const locale = await this.stateService.getLocale(); - await (this.i18nService as I18nRendererService).init(locale); + await (this.i18nService as I18nRendererService).init(); (this.eventUploadService as EventUploadService).init(true); this.twoFactorService.init(); setTimeout(() => this.notificationsService.init(), 3000); diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 3b50b6ced7a..efc93d698b8 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -43,7 +43,7 @@ import { GlobalState } from "@bitwarden/common/platform/models/domain/global-sta import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { SystemService } from "@bitwarden/common/platform/services/system.service"; -import { StateProvider } from "@bitwarden/common/platform/state"; +import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state"; // eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; @@ -104,7 +104,7 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); { provide: I18nServiceAbstraction, useClass: I18nRendererService, - deps: [SYSTEM_LANGUAGE, LOCALES_DIRECTORY], + deps: [SYSTEM_LANGUAGE, LOCALES_DIRECTORY, GlobalStateProvider], }, { provide: MessagingServiceAbstraction, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index dbcb85bacd8..e2c8f9c0ad3 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -97,7 +97,6 @@ export class Main { } this.logService = new ElectronLogMainService(null, app.getPath("userData")); - this.i18nService = new I18nMainService("en", "./locales/"); const storageDefaults: any = {}; // Default vault timeout to "on restart", and action to "lock" @@ -112,6 +111,8 @@ export class Main { ); const globalStateProvider = new DefaultGlobalStateProvider(storageServiceProvider); + this.i18nService = new I18nMainService("en", "./locales/", globalStateProvider); + const accountService = new AccountServiceImplementation( new NoopMessagingService(), this.logService, @@ -218,8 +219,7 @@ export class Main { this.migrationRunner.run().then( async () => { await this.windowMain.init(); - const locale = await this.stateService.getLocale(); - await this.i18nService.init(locale != null ? locale : app.getLocale()); + await this.i18nService.init(); this.messagingMain.init(); // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises diff --git a/apps/desktop/src/platform/services/i18n.main.service.ts b/apps/desktop/src/platform/services/i18n.main.service.ts index 0170c934fd8..edf79eccf00 100644 --- a/apps/desktop/src/platform/services/i18n.main.service.ts +++ b/apps/desktop/src/platform/services/i18n.main.service.ts @@ -1,14 +1,22 @@ import * as fs from "fs"; import * as path from "path"; -import { ipcMain } from "electron"; +import { app, ipcMain } from "electron"; import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; export class I18nMainService extends BaseI18nService { - constructor(systemLanguage: string, localesDirectory: string) { - super(systemLanguage, localesDirectory, (formattedLocale: string) => - this.readLanguageFile(formattedLocale), + constructor( + systemLanguage: string, + localesDirectory: string, + globalStateProvider: GlobalStateProvider, + ) { + super( + systemLanguage, + localesDirectory, + (formattedLocale: string) => this.readLanguageFile(formattedLocale), + globalStateProvider, ); ipcMain.handle("getLanguageFile", async (event, formattedLocale: string) => @@ -76,6 +84,12 @@ export class I18nMainService extends BaseI18nService { ]; } + override async init(): Promise { + // Set system language to electron language + this.systemLanguage = app.getLocale(); + await super.init(); + } + private readLanguageFile(formattedLocale: string): Promise { // Check that the provided locale only contains letters and dashes and underscores to avoid possible path traversal if (!/^[a-zA-Z_-]+$/.test(formattedLocale)) { diff --git a/apps/desktop/src/platform/services/i18n.renderer.service.ts b/apps/desktop/src/platform/services/i18n.renderer.service.ts index 906f53566e2..87ad8b40183 100644 --- a/apps/desktop/src/platform/services/i18n.renderer.service.ts +++ b/apps/desktop/src/platform/services/i18n.renderer.service.ts @@ -1,10 +1,20 @@ import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; export class I18nRendererService extends BaseI18nService { - constructor(systemLanguage: string, localesDirectory: string) { - super(systemLanguage, localesDirectory, (formattedLocale: string) => { - return ipc.platform.getLanguageFile(formattedLocale); - }); + constructor( + systemLanguage: string, + localesDirectory: string, + globalStateProvider: GlobalStateProvider, + ) { + super( + systemLanguage, + localesDirectory, + (formattedLocale: string) => { + return ipc.platform.getLanguageFile(formattedLocale); + }, + globalStateProvider, + ); // Please leave 'en' where it is, as it's our fallback language in case no translation can be found this.supportedTranslationLocales = [ diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 6aded4875eb..556cfadd35f 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -29,6 +29,7 @@ import { MigrationBuilderService } from "@bitwarden/common/platform/services/mig import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; /* eslint-disable import/no-restricted-paths -- Implementation for memory storage */ import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service"; /* eslint-enable import/no-restricted-paths -- Implementation for memory storage */ @@ -74,7 +75,7 @@ import { WebPlatformUtilsService } from "./web-platform-utils.service"; { provide: I18nServiceAbstraction, useClass: I18nService, - deps: [SYSTEM_LANGUAGE, LOCALES_DIRECTORY], + deps: [SYSTEM_LANGUAGE, LOCALES_DIRECTORY, GlobalStateProvider], }, { provide: AbstractStorageService, useClass: HtmlStorageService }, { diff --git a/apps/web/src/app/core/i18n.service.ts b/apps/web/src/app/core/i18n.service.ts index c1580c4bf17..744b11d56e6 100644 --- a/apps/web/src/app/core/i18n.service.ts +++ b/apps/web/src/app/core/i18n.service.ts @@ -1,20 +1,30 @@ import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service"; +import { GlobalStateProvider } from "@bitwarden/common/platform/state"; import { SupportedTranslationLocales } from "../../translation-constants"; export class I18nService extends BaseI18nService { - constructor(systemLanguage: string, localesDirectory: string) { - super(systemLanguage || "en-US", localesDirectory, async (formattedLocale: string) => { - const filePath = - this.localesDirectory + - "/" + - formattedLocale + - "/messages.json?cache=" + - process.env.CACHE_TAG; - const localesResult = await fetch(filePath); - const locales = await localesResult.json(); - return locales; - }); + constructor( + systemLanguage: string, + localesDirectory: string, + globalStateProvider: GlobalStateProvider, + ) { + super( + systemLanguage || "en-US", + localesDirectory, + async (formattedLocale: string) => { + const filePath = + this.localesDirectory + + "/" + + formattedLocale + + "/messages.json?cache=" + + process.env.CACHE_TAG; + const localesResult = await fetch(filePath); + const locales = await localesResult.json(); + return locales; + }, + globalStateProvider, + ); this.supportedTranslationLocales = SupportedTranslationLocales; } diff --git a/apps/web/src/app/core/init.service.ts b/apps/web/src/app/core/init.service.ts index 899a1684796..8a834e25b4a 100644 --- a/apps/web/src/app/core/init.service.ts +++ b/apps/web/src/app/core/init.service.ts @@ -50,8 +50,7 @@ export class InitService { setTimeout(() => this.notificationsService.init(), 3000); await this.vaultTimeoutService.init(true); - const locale = await this.stateService.getLocale(); - await (this.i18nService as I18nService).init(locale); + await (this.i18nService as I18nService).init(); (this.eventUploadService as EventUploadService).init(true); this.twoFactorService.init(); const htmlEl = this.win.document.documentElement; diff --git a/apps/web/src/app/core/tests/preloaded-english-i18n.module.ts b/apps/web/src/app/core/tests/preloaded-english-i18n.module.ts index 49e8475bf1a..95d8404f1f8 100644 --- a/apps/web/src/app/core/tests/preloaded-english-i18n.module.ts +++ b/apps/web/src/app/core/tests/preloaded-english-i18n.module.ts @@ -1,16 +1,23 @@ import { APP_INITIALIZER, NgModule } from "@angular/core"; +import { Observable, of } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { I18nService as BaseI18nService } from "@bitwarden/common/platform/services/i18n.service"; +import { TranslationService } from "@bitwarden/common/platform/services/translation.service"; import eng from "../../../locales/en/messages.json"; -class PreloadedEnglishI18nService extends BaseI18nService { +class PreloadedEnglishI18nService extends TranslationService implements I18nService { + translationLocale = "en"; + locale$: Observable = of("en"); constructor() { super("en", "", () => { return Promise.resolve(eng); }); } + + setLocale(): Promise { + throw new Error("Method not implemented."); + } } function i18nInitializer(i18nService: I18nService): () => Promise { diff --git a/apps/web/src/app/settings/preferences.component.ts b/apps/web/src/app/settings/preferences.component.ts index bf9a1421f42..7047430dff9 100644 --- a/apps/web/src/app/settings/preferences.component.ts +++ b/apps/web/src/app/settings/preferences.component.ts @@ -142,7 +142,7 @@ export class PreferencesComponent implements OnInit { ), enableFavicons: !(await this.settingsService.getDisableFavicon()), theme: await this.stateService.getTheme(), - locale: (await this.stateService.getLocale()) ?? null, + locale: (await firstValueFrom(this.i18nService.locale$)) ?? null, }; this.startingLocale = initialFormValues.locale; this.startingTheme = initialFormValues.theme; @@ -169,7 +169,7 @@ export class PreferencesComponent implements OnInit { await this.themingService.updateConfiguredTheme(values.theme); this.startingTheme = values.theme; } - await this.stateService.setLocale(values.locale); + await this.i18nService.setLocale(values.locale); if (values.locale !== this.startingLocale) { window.location.reload(); } else { diff --git a/libs/common/src/platform/abstractions/i18n.service.ts b/libs/common/src/platform/abstractions/i18n.service.ts index f2c8117e36d..46fe1cd1028 100644 --- a/libs/common/src/platform/abstractions/i18n.service.ts +++ b/libs/common/src/platform/abstractions/i18n.service.ts @@ -4,4 +4,5 @@ import { TranslationService } from "./translation.service"; export abstract class I18nService extends TranslationService { locale$: Observable; + abstract setLocale(locale: string): Promise; } diff --git a/libs/common/src/platform/services/i18n.service.ts b/libs/common/src/platform/services/i18n.service.ts index 8e1a2d679ad..642d19155b3 100644 --- a/libs/common/src/platform/services/i18n.service.ts +++ b/libs/common/src/platform/services/i18n.service.ts @@ -1,28 +1,36 @@ -import { Observable, ReplaySubject } from "rxjs"; +import { Observable, firstValueFrom, map } from "rxjs"; import { I18nService as I18nServiceAbstraction } from "../abstractions/i18n.service"; +import { GlobalState, GlobalStateProvider, KeyDefinition, TRANSLATION_DISK } from "../state"; import { TranslationService } from "./translation.service"; +const LOCALE_KEY = new KeyDefinition(TRANSLATION_DISK, "locale", { + deserializer: (value) => value, +}); + export class I18nService extends TranslationService implements I18nServiceAbstraction { - protected _locale = new ReplaySubject(1); - private _translationLocale: string; - locale$: Observable = this._locale.asObservable(); + translationLocale: string; + protected translationLocaleState: GlobalState; + locale$: Observable; constructor( protected systemLanguage: string, protected localesDirectory: string, protected getLocalesJson: (formattedLocale: string) => Promise, + globalStateProvider: GlobalStateProvider, ) { super(systemLanguage, localesDirectory, getLocalesJson); + this.translationLocaleState = globalStateProvider.get(LOCALE_KEY); + this.locale$ = this.translationLocaleState.state$.pipe(map((locale) => locale ?? null)); } - get translationLocale(): string { - return this._translationLocale; + async setLocale(locale: string): Promise { + await this.translationLocaleState.update(() => locale); } - set translationLocale(locale: string) { - this._translationLocale = locale; - this._locale.next(locale); + override async init() { + const storedLocale = await firstValueFrom(this.translationLocaleState.state$); + await super.init(storedLocale); } } diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index e446fc7cbf6..baede535c4d 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -57,6 +57,7 @@ export const CLEAR_EVENT_DISK = new StateDefinition("clearEvent", "disk"); export const CRYPTO_DISK = new StateDefinition("crypto", "disk"); export const CRYPTO_MEMORY = new StateDefinition("crypto", "memory"); export const ENVIRONMENT_DISK = new StateDefinition("environment", "disk"); +export const TRANSLATION_DISK = new StateDefinition("translation", "disk"); // Secrets Manager diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 61e849f0ba5..29bf5fb8d50 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -27,6 +27,7 @@ import { UserNotificationSettingsKeyMigrator } from "./migrations/29-move-user-n import { FixPremiumMigrator } from "./migrations/3-fix-premium"; import { PolicyMigrator } from "./migrations/30-move-policy-state-to-state-provider"; import { EnableContextMenuMigrator } from "./migrations/31-move-enable-context-menu-to-autofill-settings-state-provider"; +import { PreferredLanguageMigrator } from "./migrations/32-move-preferred-language"; import { RemoveEverBeenUnlockedMigrator } from "./migrations/4-remove-ever-been-unlocked"; import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; @@ -36,7 +37,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 2; -export const CURRENT_VERSION = 31; +export const CURRENT_VERSION = 32; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -70,7 +71,8 @@ export function createMigrationBuilder() { .with(MoveBiometricUnlockToStateProviders, 27, 28) .with(UserNotificationSettingsKeyMigrator, 28, 29) .with(PolicyMigrator, 29, 30) - .with(EnableContextMenuMigrator, 30, CURRENT_VERSION); + .with(EnableContextMenuMigrator, 30, 31) + .with(PreferredLanguageMigrator, 31, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/32-move-preferred-language.spec.ts b/libs/common/src/state-migrations/migrations/32-move-preferred-language.spec.ts new file mode 100644 index 00000000000..15d272012ae --- /dev/null +++ b/libs/common/src/state-migrations/migrations/32-move-preferred-language.spec.ts @@ -0,0 +1,77 @@ +import { MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { LOCALE_KEY, PreferredLanguageMigrator } from "./32-move-preferred-language"; + +function exampleJSON() { + return { + global: { + locale: "en", + otherStuff: "otherStuff1", + }, + otherStuff: "otherStuff2", + }; +} + +function rollbackJSON() { + return { + global_translation_locale: "en", + global: { + otherStuff: "otherStuff1", + }, + otherStuff: "otherStuff2", + }; +} + +describe("PreferredLanguageMigrator", () => { + let helper: MockProxy; + let sut: PreferredLanguageMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 31); + sut = new PreferredLanguageMigrator(31, 32); + }); + + it("should remove locale setting from global", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + otherStuff: "otherStuff1", + }); + }); + + it("should set locale for global state provider", async () => { + await sut.migrate(helper); + + expect(helper.setToGlobal).toHaveBeenCalledTimes(1); + expect(helper.setToGlobal).toHaveBeenCalledWith(LOCALE_KEY, "en"); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 32); + sut = new PreferredLanguageMigrator(31, 32); + }); + + it("should null out new values for global", async () => { + await sut.rollback(helper); + + expect(helper.setToGlobal).toHaveBeenCalledTimes(1); + expect(helper.setToGlobal).toHaveBeenCalledWith(LOCALE_KEY, null); + }); + + it("should add locale back to the old global object", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).toHaveBeenCalledWith("global", { + locale: "en", + otherStuff: "otherStuff1", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/32-move-preferred-language.ts b/libs/common/src/state-migrations/migrations/32-move-preferred-language.ts new file mode 100644 index 00000000000..0ce689c43f9 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/32-move-preferred-language.ts @@ -0,0 +1,39 @@ +import { MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedGlobal = { + locale?: string; +}; + +export const LOCALE_KEY = { + key: "locale", + stateDefinition: { + name: "translation", + }, +}; + +export class PreferredLanguageMigrator extends Migrator<31, 32> { + async migrate(helper: MigrationHelper): Promise { + // global state + const global = await helper.get("global"); + if (!global?.locale) { + return; + } + + await helper.setToGlobal(LOCALE_KEY, global.locale); + delete global.locale; + await helper.set("global", global); + } + + async rollback(helper: MigrationHelper): Promise { + const locale = await helper.getFromGlobal(LOCALE_KEY); + + if (!locale) { + return; + } + const global = (await helper.get("global")) ?? {}; + global.locale = locale; + await helper.set("global", global); + await helper.setToGlobal(LOCALE_KEY, null); + } +} diff --git a/libs/components/src/utils/i18n-mock.service.ts b/libs/components/src/utils/i18n-mock.service.ts index 1dcc84b4a99..2c4149bf402 100644 --- a/libs/components/src/utils/i18n-mock.service.ts +++ b/libs/components/src/utils/i18n-mock.service.ts @@ -34,4 +34,8 @@ export class I18nMockService implements I18nService { translate(id: string, p1?: string, p2?: string, p3?: string) { return this.t(id, p1, p2, p3); } + + async setLocale(locale: string): Promise { + throw new Error("Method not implemented."); + } }