diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 4ea69404024..0ff2db480c1 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5806,6 +5806,12 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "loadingVault": { + "message": "Loading vault" + }, + "vaultLoaded": { + "message": "Vault loaded" + }, "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." diff --git a/apps/browser/src/platform/popup/layout/popup-page.component.html b/apps/browser/src/platform/popup/layout/popup-page.component.html index a9184a9dd54..828d9947373 100644 --- a/apps/browser/src/platform/popup/layout/popup-page.component.html +++ b/apps/browser/src/platform/popup/layout/popup-page.component.html @@ -27,10 +27,10 @@ data-testid="popup-layout-scroll-region" (scroll)="handleScroll($event)" [ngClass]="{ - 'tw-overflow-hidden': hideOverflow(), + '!tw-overflow-hidden': hideOverflow(), 'tw-overflow-y-auto': !hideOverflow(), 'tw-invisible': loading(), - 'tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]': + 'tw-relative tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]': !disablePadding(), }" bitScrollLayoutHost diff --git a/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.html b/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.html new file mode 100644 index 00000000000..c83c1ab85c4 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.html @@ -0,0 +1,6 @@ + +
+ +
diff --git a/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.ts b/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.ts new file mode 100644 index 00000000000..2426153ad68 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component.ts @@ -0,0 +1,20 @@ +import { animate, style, transition, trigger } from "@angular/animations"; +import { ChangeDetectionStrategy, Component, HostBinding } from "@angular/core"; + +@Component({ + selector: "vault-fade-in-out-skeleton", + templateUrl: "./vault-fade-in-out-skeleton.component.html", + animations: [ + trigger("fadeInOut", [ + transition(":enter", [ + style({ opacity: 0 }), + animate("100ms ease-in", style({ opacity: 1 })), + ]), + transition(":leave", [animate("300ms ease-out", style({ opacity: 0 }))]), + ]), + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VaultFadeInOutSkeletonComponent { + @HostBinding("@fadeInOut") fadeInOut = true; +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts index 9564aeadc09..e6afc69b56a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts @@ -28,6 +28,7 @@ import { PopupListFilter, VaultPopupListFiltersService, } from "../../../../../vault/popup/services/vault-popup-list-filters.service"; +import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service"; import { VaultHeaderV2Component } from "./vault-header-v2.component"; @@ -99,6 +100,10 @@ describe("VaultHeaderV2Component", () => { provide: StateProvider, useValue: { getGlobal: () => ({ state$, update }) }, }, + { + provide: VaultPopupLoadingService, + useValue: { loading$: new BehaviorSubject(false) }, + }, ], }).compileComponents(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html index 224eaccd93c..68e5baac5f3 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html @@ -4,5 +4,6 @@ [(ngModel)]="searchText" (ngModelChange)="onSearchTextChanged()" appAutofocus + [disabled]="loading$ | async" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts index c254c290915..afe71404717 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts @@ -9,6 +9,7 @@ import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/sea import { SearchModule } from "@bitwarden/components"; import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -22,8 +23,10 @@ export class VaultV2SearchComponent { private searchText$ = new Subject(); + protected loading$ = this.vaultPopupLoadingService.loading$; constructor( private vaultPopupItemsService: VaultPopupItemsService, + private vaultPopupLoadingService: VaultPopupLoadingService, private ngZone: NgZone, ) { this.subscribeToLatestSearchText(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index 07d3f042e60..5bca9cddd4f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -1,4 +1,4 @@ - + @@ -103,4 +103,10 @@ > + + @if (showSkeletonsLoaders$ | async) { + + + + } diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 2dd6c1a0ce1..e55a702d350 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -1,3 +1,4 @@ +import { LiveAnnouncer } from "@angular/cdk/a11y"; import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling"; import { CommonModule } from "@angular/common"; import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core"; @@ -5,14 +6,15 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router, RouterModule } from "@angular/router"; import { combineLatest, + distinctUntilChanged, filter, firstValueFrom, map, Observable, shareReplay, - startWith, switchMap, take, + tap, } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; @@ -22,6 +24,8 @@ import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -41,11 +45,13 @@ import { PopOutComponent } from "../../../../platform/popup/components/pop-out.c import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; import { IntroCarouselService } from "../../services/intro-carousel.service"; -import { VaultPopupCopyButtonsService } from "../../services/vault-popup-copy-buttons.service"; import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service"; +import { VaultPopupLoadingService } from "../../services/vault-popup-loading.service"; import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service"; import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component"; +import { VaultFadeInOutSkeletonComponent } from "../vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component"; +import { VaultLoadingSkeletonComponent } from "../vault-loading-skeleton/vault-loading-skeleton.component"; import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component"; import { @@ -88,6 +94,8 @@ type VaultState = UnionOfValues; SpotlightComponent, RouterModule, TypographyModule, + VaultLoadingSkeletonComponent, + VaultFadeInOutSkeletonComponent, ], }) export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { @@ -108,19 +116,30 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { ); activeUserId: UserId | null = null; + + private loading$ = this.vaultPopupLoadingService.loading$.pipe( + distinctUntilChanged(), + tap((loading) => { + const key = loading ? "loadingVault" : "vaultLoaded"; + void this.liveAnnouncer.announce(this.i18nService.translate(key), "polite"); + }), + ); + private skeletonFeatureFlag$ = this.configService.getFeatureFlag$( + FeatureFlag.VaultLoadingSkeletons, + ); + protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; protected allFilters$ = this.vaultPopupListFiltersService.allFilters$; - protected loading$ = combineLatest([ - this.vaultPopupItemsService.loading$, - this.allFilters$, - // Added as a dependency to avoid flashing the copyActions on slower devices - this.vaultCopyButtonsService.showQuickCopyActions$, - ]).pipe( - map(([itemsLoading, filters]) => itemsLoading || !filters), - shareReplay({ bufferSize: 1, refCount: true }), - startWith(true), + /** When true, show spinner loading state */ + protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe( + map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled), + ); + + /** When true, show skeleton loading state */ + protected showSkeletonsLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe( + map(([loading, skeletonsEnabled]) => loading && skeletonsEnabled), ); protected newItemItemValues$: Observable = @@ -150,14 +169,17 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupListFiltersService: VaultPopupListFiltersService, private vaultScrollPositionService: VaultPopupScrollPositionService, + private vaultPopupLoadingService: VaultPopupLoadingService, private accountService: AccountService, private destroyRef: DestroyRef, private cipherService: CipherService, private dialogService: DialogService, - private vaultCopyButtonsService: VaultPopupCopyButtonsService, private introCarouselService: IntroCarouselService, private nudgesService: NudgesService, private router: Router, + private liveAnnouncer: LiveAnnouncer, + private i18nService: I18nService, + private configService: ConfigService, ) { combineLatest([ this.vaultPopupItemsService.emptyVault$, diff --git a/apps/browser/src/vault/popup/services/vault-popup-loading.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-loading.service.spec.ts new file mode 100644 index 00000000000..4b9c284b3b7 --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-loading.service.spec.ts @@ -0,0 +1,72 @@ +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom, skip, Subject } from "rxjs"; + +import { VaultPopupCopyButtonsService } from "./vault-popup-copy-buttons.service"; +import { VaultPopupItemsService } from "./vault-popup-items.service"; +import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; +import { VaultPopupLoadingService } from "./vault-popup-loading.service"; + +describe("VaultPopupLoadingService", () => { + let service: VaultPopupLoadingService; + let itemsLoading$: Subject; + let allFilters$: Subject; + let showQuickCopyActions$: Subject; + + beforeEach(() => { + itemsLoading$ = new Subject(); + allFilters$ = new Subject(); + showQuickCopyActions$ = new Subject(); + + TestBed.configureTestingModule({ + providers: [ + VaultPopupLoadingService, + { provide: VaultPopupItemsService, useValue: { loading$: itemsLoading$ } }, + { provide: VaultPopupListFiltersService, useValue: { allFilters$: allFilters$ } }, + { + provide: VaultPopupCopyButtonsService, + useValue: { showQuickCopyActions$: showQuickCopyActions$ }, + }, + ], + }); + + service = TestBed.inject(VaultPopupLoadingService); + }); + + it("emits true initially", async () => { + const loading = await firstValueFrom(service.loading$); + + expect(loading).toBe(true); + }); + + it("emits false when items are loaded and filters are available", async () => { + const loadingPromise = firstValueFrom(service.loading$.pipe(skip(1))); + + itemsLoading$.next(false); + allFilters$.next({}); + showQuickCopyActions$.next(true); + + expect(await loadingPromise).toBe(false); + }); + + it("emits true when filters are not available", async () => { + const loadingPromise = firstValueFrom(service.loading$.pipe(skip(2))); + + itemsLoading$.next(false); + allFilters$.next({}); + showQuickCopyActions$.next(true); + allFilters$.next(null); + + expect(await loadingPromise).toBe(true); + }); + + it("emits true when items are loading", async () => { + const loadingPromise = firstValueFrom(service.loading$.pipe(skip(2))); + + itemsLoading$.next(false); + allFilters$.next({}); + showQuickCopyActions$.next(true); + itemsLoading$.next(true); + + expect(await loadingPromise).toBe(true); + }); +}); diff --git a/apps/browser/src/vault/popup/services/vault-popup-loading.service.ts b/apps/browser/src/vault/popup/services/vault-popup-loading.service.ts new file mode 100644 index 00000000000..f56f2b8d8ee --- /dev/null +++ b/apps/browser/src/vault/popup/services/vault-popup-loading.service.ts @@ -0,0 +1,27 @@ +import { inject, Injectable } from "@angular/core"; +import { combineLatest, map, shareReplay, startWith } from "rxjs"; + +import { VaultPopupCopyButtonsService } from "./vault-popup-copy-buttons.service"; +import { VaultPopupItemsService } from "./vault-popup-items.service"; +import { VaultPopupListFiltersService } from "./vault-popup-list-filters.service"; + +@Injectable({ + providedIn: "root", +}) +export class VaultPopupLoadingService { + private vaultPopupItemsService = inject(VaultPopupItemsService); + private vaultPopupListFiltersService = inject(VaultPopupListFiltersService); + private vaultCopyButtonsService = inject(VaultPopupCopyButtonsService); + + /** Loading state of the vault */ + loading$ = combineLatest([ + this.vaultPopupItemsService.loading$, + this.vaultPopupListFiltersService.allFilters$, + // Added as a dependency to avoid flashing the copyActions on slower devices + this.vaultCopyButtonsService.showQuickCopyActions$, + ]).pipe( + map(([itemsLoading, filters]) => itemsLoading || !filters), + shareReplay({ bufferSize: 1, refCount: true }), + startWith(true), + ); +}