diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 5f7fbc1fad7..f009ad064c4 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -1,11 +1,20 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, inject } from "@angular/core"; +import { + ChangeDetectorRef, + Component, + DestroyRef, + NgZone, + OnDestroy, + OnInit, + inject, +} from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs"; import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; +import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; import { LogoutReason } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; @@ -73,9 +82,14 @@ export class AppComponent implements OnInit, OnDestroy { private biometricStateService: BiometricStateService, private biometricsService: BiometricsService, private deviceTrustToastService: DeviceTrustToastService, + private readonly destoryRef: DestroyRef, + private readonly documentLangSetter: DocumentLangSetter, private popupSizeService: PopupSizeService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); + + const langSubscription = this.documentLangSetter.start(); + this.destoryRef.onDestroy(() => langSubscription.unsubscribe()); } async ngOnInit() { diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 77ac783ac9f..b578be6ad5b 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Component, + DestroyRef, NgZone, OnDestroy, OnInit, @@ -25,6 +26,7 @@ import { import { CollectionService } from "@bitwarden/admin-console/common"; import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; +import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular"; import { DESKTOP_SSO_CALLBACK, LogoutReason } from "@bitwarden/auth/common"; @@ -163,8 +165,13 @@ export class AppComponent implements OnInit, OnDestroy { private accountService: AccountService, private organizationService: OrganizationService, private deviceTrustToastService: DeviceTrustToastService, + private readonly destroyRef: DestroyRef, + private readonly documentLangSetter: DocumentLangSetter, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); + + const langSubscription = this.documentLangSetter.start(); + this.destroyRef.onDestroy(() => langSubscription.unsubscribe()); } ngOnInit() { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 3de9bf0a8c8..15436f3097a 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -1,13 +1,13 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { DOCUMENT } from "@angular/common"; -import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core"; +import { Component, DestroyRef, NgZone, OnDestroy, OnInit } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { Subject, filter, firstValueFrom, map, takeUntil, timeout } from "rxjs"; +import { Subject, filter, firstValueFrom, map, timeout } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; +import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; @@ -60,7 +60,6 @@ export class AppComponent implements OnDestroy, OnInit { loading = false; constructor( - @Inject(DOCUMENT) private document: Document, private broadcasterService: BroadcasterService, private folderService: InternalFolderService, private cipherService: CipherService, @@ -86,15 +85,16 @@ export class AppComponent implements OnDestroy, OnInit { private accountService: AccountService, private processReloadService: ProcessReloadServiceAbstraction, private deviceTrustToastService: DeviceTrustToastService, + private readonly destoryRef: DestroyRef, + private readonly documentLangSetter: DocumentLangSetter, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); + + const langSubscription = this.documentLangSetter.start(); + this.destoryRef.onDestroy(() => langSubscription.unsubscribe()); } ngOnInit() { - this.i18nService.locale$.pipe(takeUntil(this.destroy$)).subscribe((locale) => { - this.document.documentElement.lang = locale; - }); - this.ngZone.runOutsideAngular(() => { window.onmousemove = () => this.recordActivity(); window.onmousedown = () => this.recordActivity(); diff --git a/libs/angular/src/platform/i18n/document-lang.setter.spec.ts b/libs/angular/src/platform/i18n/document-lang.setter.spec.ts new file mode 100644 index 00000000000..84a046ac8bf --- /dev/null +++ b/libs/angular/src/platform/i18n/document-lang.setter.spec.ts @@ -0,0 +1,36 @@ +import { mock } from "jest-mock-extended"; +import { Subject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { DocumentLangSetter } from "./document-lang.setter"; + +describe("DocumentLangSetter", () => { + const document = mock(); + const i18nService = mock(); + + const sut = new DocumentLangSetter(document, i18nService); + + describe("start", () => { + it("reacts to locale changes while start called with a non-closed subscription", async () => { + const localeSubject = new Subject(); + i18nService.locale$ = localeSubject; + + localeSubject.next("en"); + + expect(document.documentElement.lang).toBeFalsy(); + + const sub = sut.start(); + + localeSubject.next("es"); + + expect(document.documentElement.lang).toBe("es"); + + sub.unsubscribe(); + + localeSubject.next("ar"); + + expect(document.documentElement.lang).toBe("es"); + }); + }); +}); diff --git a/libs/angular/src/platform/i18n/document-lang.setter.ts b/libs/angular/src/platform/i18n/document-lang.setter.ts new file mode 100644 index 00000000000..f576e72d082 --- /dev/null +++ b/libs/angular/src/platform/i18n/document-lang.setter.ts @@ -0,0 +1,26 @@ +import { Subscription } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +/** + * A service for managing the setting of the `lang="" attribute on the + * main document for the application. + */ +export class DocumentLangSetter { + constructor( + private readonly document: Document, + private readonly i18nService: I18nService, + ) {} + + /** + * Starts listening to an upstream source for the best locale for the user + * and applies it to the application document. + * @returns A subscription that can be unsubscribed if you wish to stop + * applying lang attribute updates to the application document. + */ + start(): Subscription { + return this.i18nService.locale$.subscribe((locale) => { + this.document.documentElement.lang = locale; + }); + } +} diff --git a/libs/angular/src/platform/i18n/index.ts b/libs/angular/src/platform/i18n/index.ts new file mode 100644 index 00000000000..259bdca65d0 --- /dev/null +++ b/libs/angular/src/platform/i18n/index.ts @@ -0,0 +1 @@ +export { DocumentLangSetter } from "./document-lang.setter"; diff --git a/libs/angular/src/services/injection-tokens.ts b/libs/angular/src/services/injection-tokens.ts index 4c29abe680a..2122506890a 100644 --- a/libs/angular/src/services/injection-tokens.ts +++ b/libs/angular/src/services/injection-tokens.ts @@ -21,6 +21,7 @@ import { SafeInjectionToken } from "@bitwarden/ui-common"; export { SafeInjectionToken } from "@bitwarden/ui-common"; export const WINDOW = new SafeInjectionToken("WINDOW"); +export const DOCUMENT = new SafeInjectionToken("DOCUMENT"); export const OBSERVABLE_MEMORY_STORAGE = new SafeInjectionToken< AbstractStorageService & ObservableStorageService >("OBSERVABLE_MEMORY_STORAGE"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 1f5adb6260e..565a9dc0ac5 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -337,6 +337,7 @@ import { import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction"; import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; +import { DocumentLangSetter } from "../platform/i18n"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; import { LoggingErrorHandler } from "../platform/services/logging-error-handler"; import { AngularThemingService } from "../platform/services/theming/angular-theming.service"; @@ -349,6 +350,7 @@ import { NoopViewCacheService } from "../platform/view-cache/internal"; import { CLIENT_TYPE, DEFAULT_VAULT_TIMEOUT, + DOCUMENT, ENV_ADDITIONAL_REGIONS, HTTP_OPERATIONS, INTRAPROCESS_MESSAGING_SUBJECT, @@ -378,6 +380,7 @@ const safeProviders: SafeProvider[] = [ safeProvider(ModalService), safeProvider(PasswordRepromptService), safeProvider({ provide: WINDOW, useValue: window }), + safeProvider({ provide: DOCUMENT, useValue: document }), safeProvider({ provide: LOCALE_ID as SafeInjectionToken, useFactory: (i18nService: I18nServiceAbstraction) => i18nService.translationLocale, @@ -1542,6 +1545,11 @@ const safeProviders: SafeProvider[] = [ useClass: MasterPasswordApiService, deps: [ApiServiceAbstraction, LogService], }), + safeProvider({ + provide: DocumentLangSetter, + useClass: DocumentLangSetter, + deps: [DOCUMENT, I18nServiceAbstraction], + }), safeProvider({ provide: CipherEncryptionService, useClass: DefaultCipherEncryptionService,