1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 14:23:32 +00:00

Add lang attr on desktop and browser (#14691)

This commit is contained in:
Justin Baur
2025-06-09 06:54:00 -04:00
committed by GitHub
parent 685f7a0fd8
commit b1f090e054
8 changed files with 102 additions and 9 deletions

View File

@@ -1,11 +1,20 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @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 { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs"; import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; 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 { LogoutReason } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
@@ -73,9 +82,14 @@ export class AppComponent implements OnInit, OnDestroy {
private biometricStateService: BiometricStateService, private biometricStateService: BiometricStateService,
private biometricsService: BiometricsService, private biometricsService: BiometricsService,
private deviceTrustToastService: DeviceTrustToastService, private deviceTrustToastService: DeviceTrustToastService,
private readonly destoryRef: DestroyRef,
private readonly documentLangSetter: DocumentLangSetter,
private popupSizeService: PopupSizeService, private popupSizeService: PopupSizeService,
) { ) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
const langSubscription = this.documentLangSetter.start();
this.destoryRef.onDestroy(() => langSubscription.unsubscribe());
} }
async ngOnInit() { async ngOnInit() {

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore // @ts-strict-ignore
import { import {
Component, Component,
DestroyRef,
NgZone, NgZone,
OnDestroy, OnDestroy,
OnInit, OnInit,
@@ -25,6 +26,7 @@ import {
import { CollectionService } from "@bitwarden/admin-console/common"; import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; 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 { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular"; import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular";
import { DESKTOP_SSO_CALLBACK, LogoutReason } from "@bitwarden/auth/common"; import { DESKTOP_SSO_CALLBACK, LogoutReason } from "@bitwarden/auth/common";
@@ -163,8 +165,13 @@ export class AppComponent implements OnInit, OnDestroy {
private accountService: AccountService, private accountService: AccountService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private deviceTrustToastService: DeviceTrustToastService, private deviceTrustToastService: DeviceTrustToastService,
private readonly destroyRef: DestroyRef,
private readonly documentLangSetter: DocumentLangSetter,
) { ) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
const langSubscription = this.documentLangSetter.start();
this.destroyRef.onDestroy(() => langSubscription.unsubscribe());
} }
ngOnInit() { ngOnInit() {

View File

@@ -1,13 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line // FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore // @ts-strict-ignore
import { DOCUMENT } from "@angular/common"; import { Component, DestroyRef, NgZone, OnDestroy, OnInit } from "@angular/core";
import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router"; 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 { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; 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 { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
@@ -60,7 +60,6 @@ export class AppComponent implements OnDestroy, OnInit {
loading = false; loading = false;
constructor( constructor(
@Inject(DOCUMENT) private document: Document,
private broadcasterService: BroadcasterService, private broadcasterService: BroadcasterService,
private folderService: InternalFolderService, private folderService: InternalFolderService,
private cipherService: CipherService, private cipherService: CipherService,
@@ -86,15 +85,16 @@ export class AppComponent implements OnDestroy, OnInit {
private accountService: AccountService, private accountService: AccountService,
private processReloadService: ProcessReloadServiceAbstraction, private processReloadService: ProcessReloadServiceAbstraction,
private deviceTrustToastService: DeviceTrustToastService, private deviceTrustToastService: DeviceTrustToastService,
private readonly destoryRef: DestroyRef,
private readonly documentLangSetter: DocumentLangSetter,
) { ) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
const langSubscription = this.documentLangSetter.start();
this.destoryRef.onDestroy(() => langSubscription.unsubscribe());
} }
ngOnInit() { ngOnInit() {
this.i18nService.locale$.pipe(takeUntil(this.destroy$)).subscribe((locale) => {
this.document.documentElement.lang = locale;
});
this.ngZone.runOutsideAngular(() => { this.ngZone.runOutsideAngular(() => {
window.onmousemove = () => this.recordActivity(); window.onmousemove = () => this.recordActivity();
window.onmousedown = () => this.recordActivity(); window.onmousedown = () => this.recordActivity();

View File

@@ -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<Document>();
const i18nService = mock<I18nService>();
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<string>();
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");
});
});
});

View File

@@ -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="<locale>" 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;
});
}
}

View File

@@ -0,0 +1 @@
export { DocumentLangSetter } from "./document-lang.setter";

View File

@@ -21,6 +21,7 @@ import { SafeInjectionToken } from "@bitwarden/ui-common";
export { SafeInjectionToken } from "@bitwarden/ui-common"; export { SafeInjectionToken } from "@bitwarden/ui-common";
export const WINDOW = new SafeInjectionToken<Window>("WINDOW"); export const WINDOW = new SafeInjectionToken<Window>("WINDOW");
export const DOCUMENT = new SafeInjectionToken<Document>("DOCUMENT");
export const OBSERVABLE_MEMORY_STORAGE = new SafeInjectionToken< export const OBSERVABLE_MEMORY_STORAGE = new SafeInjectionToken<
AbstractStorageService & ObservableStorageService AbstractStorageService & ObservableStorageService
>("OBSERVABLE_MEMORY_STORAGE"); >("OBSERVABLE_MEMORY_STORAGE");

View File

@@ -337,6 +337,7 @@ import {
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction"; import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation"; import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; 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 { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
import { LoggingErrorHandler } from "../platform/services/logging-error-handler"; import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
import { AngularThemingService } from "../platform/services/theming/angular-theming.service"; import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
@@ -349,6 +350,7 @@ import { NoopViewCacheService } from "../platform/view-cache/internal";
import { import {
CLIENT_TYPE, CLIENT_TYPE,
DEFAULT_VAULT_TIMEOUT, DEFAULT_VAULT_TIMEOUT,
DOCUMENT,
ENV_ADDITIONAL_REGIONS, ENV_ADDITIONAL_REGIONS,
HTTP_OPERATIONS, HTTP_OPERATIONS,
INTRAPROCESS_MESSAGING_SUBJECT, INTRAPROCESS_MESSAGING_SUBJECT,
@@ -378,6 +380,7 @@ const safeProviders: SafeProvider[] = [
safeProvider(ModalService), safeProvider(ModalService),
safeProvider(PasswordRepromptService), safeProvider(PasswordRepromptService),
safeProvider({ provide: WINDOW, useValue: window }), safeProvider({ provide: WINDOW, useValue: window }),
safeProvider({ provide: DOCUMENT, useValue: document }),
safeProvider({ safeProvider({
provide: LOCALE_ID as SafeInjectionToken<string>, provide: LOCALE_ID as SafeInjectionToken<string>,
useFactory: (i18nService: I18nServiceAbstraction) => i18nService.translationLocale, useFactory: (i18nService: I18nServiceAbstraction) => i18nService.translationLocale,
@@ -1542,6 +1545,11 @@ const safeProviders: SafeProvider[] = [
useClass: MasterPasswordApiService, useClass: MasterPasswordApiService,
deps: [ApiServiceAbstraction, LogService], deps: [ApiServiceAbstraction, LogService],
}), }),
safeProvider({
provide: DocumentLangSetter,
useClass: DocumentLangSetter,
deps: [DOCUMENT, I18nServiceAbstraction],
}),
safeProvider({ safeProvider({
provide: CipherEncryptionService, provide: CipherEncryptionService,
useClass: DefaultCipherEncryptionService, useClass: DefaultCipherEncryptionService,