1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +00:00

[PM-18946] Improve Vault loading experience (#13714)

* [PM-18946] Refactor loading$ in vault-v2. Update icon-component, and build-cipher-icon
This commit is contained in:
Shane Melton
2025-03-13 11:38:29 -07:00
committed by GitHub
parent 81335978d8
commit 4687120618
10 changed files with 88 additions and 61 deletions

View File

@@ -969,7 +969,7 @@ describe("OverlayBackground", () => {
icon: { icon: {
fallbackImage: "", fallbackImage: "",
icon: "bwi-credit-card", icon: "bwi-credit-card",
image: undefined, image: null,
imageEnabled: true, imageEnabled: true,
}, },
id: "inline-menu-cipher-0", id: "inline-menu-cipher-0",
@@ -1007,7 +1007,7 @@ describe("OverlayBackground", () => {
icon: { icon: {
fallbackImage: "", fallbackImage: "",
icon: "bwi-id-card", icon: "bwi-id-card",
image: undefined, image: null,
imageEnabled: true, imageEnabled: true,
}, },
id: "inline-menu-cipher-1", id: "inline-menu-cipher-1",
@@ -1048,7 +1048,7 @@ describe("OverlayBackground", () => {
icon: { icon: {
fallbackImage: "", fallbackImage: "",
icon: "bwi-id-card", icon: "bwi-id-card",
image: undefined, image: null,
imageEnabled: true, imageEnabled: true,
}, },
id: "inline-menu-cipher-0", id: "inline-menu-cipher-0",
@@ -1120,7 +1120,7 @@ describe("OverlayBackground", () => {
icon: { icon: {
fallbackImage: "", fallbackImage: "",
icon: "bwi-id-card", icon: "bwi-id-card",
image: undefined, image: null,
imageEnabled: true, imageEnabled: true,
}, },
id: "inline-menu-cipher-1", id: "inline-menu-cipher-1",

View File

@@ -355,7 +355,7 @@ describe("OverlayBackground", () => {
icon: { icon: {
fallbackImage: "", fallbackImage: "",
icon: "bwi-credit-card", icon: "bwi-credit-card",
image: undefined, image: null,
imageEnabled: true, imageEnabled: true,
}, },
id: "overlay-cipher-2", id: "overlay-cipher-2",
@@ -370,7 +370,7 @@ describe("OverlayBackground", () => {
icon: { icon: {
fallbackImage: "", fallbackImage: "",
icon: "bwi-credit-card", icon: "bwi-credit-card",
image: undefined, image: null,
imageEnabled: true, imageEnabled: true,
}, },
id: "overlay-cipher-3", id: "overlay-cipher-3",

View File

@@ -28,10 +28,7 @@
></blocked-injection-banner> ></blocked-injection-banner>
<!-- Show search & filters outside of the scroll area of the page --> <!-- Show search & filters outside of the scroll area of the page -->
<ng-container <ng-container slot="above-scroll-area" *ngIf="vaultState !== VaultStateEnum.Empty">
slot="above-scroll-area"
*ngIf="vaultState !== VaultStateEnum.Empty && !(loading$ | async)"
>
<vault-at-risk-password-callout <vault-at-risk-password-callout
*appIfFeature="FeatureFlag.SecurityTasks" *appIfFeature="FeatureFlag.SecurityTasks"
></vault-at-risk-password-callout> ></vault-at-risk-password-callout>

View File

@@ -2,7 +2,6 @@ import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrol
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { RouterLink } from "@angular/router";
import { import {
combineLatest, combineLatest,
filter, filter,
@@ -12,29 +11,24 @@ import {
shareReplay, shareReplay,
switchMap, switchMap,
take, take,
startWith,
} from "rxjs"; } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { import { ButtonModule, DialogService, Icons, NoItemsModule } from "@bitwarden/components";
BannerComponent,
ButtonModule,
DialogService,
Icons,
NoItemsModule,
} from "@bitwarden/components";
import { DecryptionFailureDialogComponent, VaultIcons } from "@bitwarden/vault"; import { DecryptionFailureDialogComponent, VaultIcons } from "@bitwarden/vault";
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component"; import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component"; import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component"; import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
import { VaultPopupCopyButtonsService } from "../../services/vault-popup-copy-buttons.service";
import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service"; import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service"; import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
@@ -73,12 +67,9 @@ enum VaultState {
AutofillVaultListItemsComponent, AutofillVaultListItemsComponent,
VaultListItemsContainerComponent, VaultListItemsContainerComponent,
ButtonModule, ButtonModule,
RouterLink,
NewItemDropdownV2Component, NewItemDropdownV2Component,
ScrollingModule, ScrollingModule,
VaultHeaderV2Component, VaultHeaderV2Component,
DecryptionFailureDialogComponent,
BannerComponent,
AtRiskPasswordCalloutComponent, AtRiskPasswordCalloutComponent,
NewSettingsCalloutComponent, NewSettingsCalloutComponent,
], ],
@@ -93,9 +84,15 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
protected allFilters$ = this.vaultPopupListFiltersService.allFilters$; protected allFilters$ = this.vaultPopupListFiltersService.allFilters$;
protected loading$ = combineLatest([this.vaultPopupItemsService.loading$, this.allFilters$]).pipe( 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), map(([itemsLoading, filters]) => itemsLoading || !filters),
shareReplay({ bufferSize: 1, refCount: true }), shareReplay({ bufferSize: 1, refCount: true }),
startWith(true),
); );
protected newItemItemValues$: Observable<NewItemInitialValues> = protected newItemItemValues$: Observable<NewItemInitialValues> =
@@ -130,8 +127,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
private cipherService: CipherService, private cipherService: CipherService,
private dialogService: DialogService, private dialogService: DialogService,
private vaultProfileService: VaultProfileService, private vaultCopyButtonsService: VaultPopupCopyButtonsService,
private vaultPageService: VaultPageService,
) { ) {
combineLatest([ combineLatest([
this.vaultPopupItemsService.emptyVault$, this.vaultPopupItemsService.emptyVault$,

View File

@@ -1,5 +1,5 @@
import { inject, Injectable } from "@angular/core"; import { inject, Injectable } from "@angular/core";
import { map, Observable } from "rxjs"; import { map, Observable, shareReplay } from "rxjs";
import { import {
GlobalStateProvider, GlobalStateProvider,
@@ -31,6 +31,7 @@ export class VaultPopupCopyButtonsService {
showQuickCopyActions$: Observable<boolean> = this.displayMode$.pipe( showQuickCopyActions$: Observable<boolean> = this.displayMode$.pipe(
map((displayMode) => displayMode === "quick"), map((displayMode) => displayMode === "quick"),
shareReplay({ bufferSize: 1, refCount: true }),
); );
async setShowQuickCopyActions(value: boolean) { async setShowQuickCopyActions(value: boolean) {

View File

@@ -17,7 +17,10 @@ import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.servic
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import {
Environment,
EnvironmentService,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
@@ -53,6 +56,11 @@ export default {
getIconsUrl() { getIconsUrl() {
return ""; return "";
}, },
environment$: new BehaviorSubject({
getIconsUrl() {
return "";
},
} as Environment).asObservable(),
} as Partial<EnvironmentService>, } as Partial<EnvironmentService>,
}, },
{ {

View File

@@ -2,16 +2,18 @@
<ng-container *ngIf="data$ | async as data"> <ng-container *ngIf="data$ | async as data">
<img <img
[src]="data.image" [src]="data.image"
[appFallbackSrc]="data.fallbackImage"
*ngIf="data.imageEnabled && data.image" *ngIf="data.imageEnabled && data.image"
class="tw-size-6 tw-rounded-md" class="tw-size-6 tw-rounded-md"
alt="" alt=""
decoding="async" decoding="async"
loading="lazy" loading="lazy"
[ngClass]="{ 'tw-invisible tw-absolute': !imageLoaded() }"
(load)="imageLoaded.set(true)"
(error)="imageLoaded.set(false)"
/> />
<i <i
class="tw-w-6 tw-text-muted bwi bwi-lg {{ data.icon }}" class="tw-w-6 tw-text-muted bwi bwi-lg {{ data.icon }}"
*ngIf="!data.imageEnabled || !data.image" *ngIf="!data.imageEnabled || !data.image || !imageLoaded()"
></i> ></i>
</ng-container> </ng-container>
</div> </div>

View File

@@ -1,18 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line import { ChangeDetectionStrategy, Component, input, signal } from "@angular/core";
// @ts-strict-ignore import { toObservable } from "@angular/core/rxjs-interop";
import { ChangeDetectionStrategy, Component, Input, OnInit } from "@angular/core";
import { import {
BehaviorSubject,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
filter,
map, map,
tap,
Observable, Observable,
startWith,
pairwise,
} from "rxjs"; } from "rxjs";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { buildCipherIcon } from "@bitwarden/common/vault/icon/build-cipher-icon"; import { buildCipherIcon, CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
@Component({ @Component({
@@ -20,33 +20,40 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
templateUrl: "icon.component.html", templateUrl: "icon.component.html",
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class IconComponent implements OnInit { export class IconComponent {
@Input() /**
set cipher(value: CipherView) { * The cipher to display the icon for.
this.cipher$.next(value); */
} cipher = input.required<CipherView>();
protected data$: Observable<{ imageLoaded = signal(false);
imageEnabled: boolean;
image?: string;
fallbackImage: string;
icon?: string;
}>;
private cipher$ = new BehaviorSubject<CipherView>(undefined); protected data$: Observable<CipherIconDetails>;
constructor( constructor(
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private domainSettingsService: DomainSettingsService, private domainSettingsService: DomainSettingsService,
) {} ) {
const iconSettings$ = combineLatest([
async ngOnInit() {
this.data$ = combineLatest([
this.environmentService.environment$.pipe(map((e) => e.getIconsUrl())), this.environmentService.environment$.pipe(map((e) => e.getIconsUrl())),
this.domainSettingsService.showFavicons$.pipe(distinctUntilChanged()), this.domainSettingsService.showFavicons$.pipe(distinctUntilChanged()),
this.cipher$.pipe(filter((c) => c !== undefined)),
]).pipe( ]).pipe(
map(([iconsUrl, showFavicon, cipher]) => buildCipherIcon(iconsUrl, cipher, showFavicon)), map(([iconsUrl, showFavicon]) => ({ iconsUrl, showFavicon })),
startWith({ iconsUrl: null, showFavicon: false }), // Start with a safe default to avoid flickering icons
distinctUntilChanged(),
);
this.data$ = combineLatest([iconSettings$, toObservable(this.cipher)]).pipe(
map(([{ iconsUrl, showFavicon }, cipher]) => buildCipherIcon(iconsUrl, cipher, showFavicon)),
startWith(null),
pairwise(),
tap(([prev, next]) => {
if (prev?.image !== next?.image) {
// The image changed, reset the loaded state to not show an empty icon
this.imageLoaded.set(false);
}
}),
map(([_, next]) => next!),
); );
} }
} }

View File

@@ -89,7 +89,7 @@ describe("buildCipherIcon", () => {
expect(iconDetails).toEqual({ expect(iconDetails).toEqual({
icon: "bwi-globe", icon: "bwi-globe",
image: undefined, image: null,
fallbackImage: "", fallbackImage: "",
imageEnabled: false, imageEnabled: false,
}); });
@@ -102,7 +102,7 @@ describe("buildCipherIcon", () => {
expect(iconDetails).toEqual({ expect(iconDetails).toEqual({
icon: "bwi-globe", icon: "bwi-globe",
image: undefined, image: null,
fallbackImage: "", fallbackImage: "",
imageEnabled: true, imageEnabled: true,
}); });

View File

@@ -2,9 +2,23 @@ import { Utils } from "../../platform/misc/utils";
import { CipherType } from "../enums/cipher-type"; import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view"; import { CipherView } from "../models/view/cipher.view";
export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, showFavicon: boolean) { export interface CipherIconDetails {
let icon; imageEnabled: boolean;
let image; image: string | null;
/**
* @deprecated Fallback to `icon` instead which will default to "bwi-globe" if no other icon is applicable.
*/
fallbackImage: string;
icon: string;
}
export function buildCipherIcon(
iconsServerUrl: string | null,
cipher: CipherView,
showFavicon: boolean,
): CipherIconDetails {
let icon: string = "bwi-globe";
let image: string | null = null;
let fallbackImage = ""; let fallbackImage = "";
const cardIcons: Record<string, string> = { const cardIcons: Record<string, string> = {
Visa: "card-visa", Visa: "card-visa",
@@ -18,6 +32,10 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show
RuPay: "card-ru-pay", RuPay: "card-ru-pay",
}; };
if (iconsServerUrl == null) {
showFavicon = false;
}
switch (cipher.type) { switch (cipher.type) {
case CipherType.Login: case CipherType.Login:
icon = "bwi-globe"; icon = "bwi-globe";
@@ -53,9 +71,7 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show
try { try {
image = `${iconsServerUrl}/${Utils.getHostname(hostnameUri)}/icon.png`; image = `${iconsServerUrl}/${Utils.getHostname(hostnameUri)}/icon.png`;
fallbackImage = "images/bwi-globe.png"; fallbackImage = "images/bwi-globe.png";
// FIXME: Remove when updating file. Eslint update } catch {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
// Ignore error since the fallback icon will be shown if image is null. // Ignore error since the fallback icon will be shown if image is null.
} }
} }