Skip to content
6 changes: 6 additions & 0 deletions apps/browser/src/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -5812,6 +5812,12 @@
"vaultLoaded": {
"message": "Vault loaded"
},
"loadingSendPage": {
"message": "Loading send page"
},
"sendPageLoaded": {
"message": "Send page 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."
Expand Down
11 changes: 8 additions & 3 deletions apps/browser/src/tools/popup/send-v2/send-v2.component.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
<popup-page [loading]="sendsLoading$ | async">
<popup-page [loading]="showSpinnerLoaders$ | async" [hideOverflow]="showSkeletonsLoaders$ | async">
<popup-header slot="header" [pageTitle]="'send' | i18n">
<ng-container slot="end">
<tools-new-send-dropdown *ngIf="!sendsDisabled"></tools-new-send-dropdown>
<app-pop-out></app-pop-out>
<app-current-account></app-current-account>
</ng-container>
</popup-header>
<ng-container slot="above-scroll-area" *ngIf="!(sendsLoading$ | async)">
<ng-container slot="above-scroll-area">
<bit-callout *ngIf="sendsDisabled" [title]="'sendDisabled' | i18n">
{{ "sendDisabledWarning" | i18n }}
</bit-callout>
Expand Down Expand Up @@ -34,7 +34,7 @@
</bit-no-items>
</div>

<ng-container *ngIf="listState !== sendState.Empty">
<ng-container *ngIf="listState !== sendState.Empty && !(showSkeletonsLoaders$ | async)">
<div
*ngIf="listState === sendState.NoResults"
class="tw-flex tw-flex-col tw-justify-center tw-h-auto tw-pt-12"
Expand All @@ -46,4 +46,9 @@
</div>
<app-send-list-items-container [headerText]="title | i18n" [sends]="sends$ | async" />
</ng-container>
@if (showSkeletonsLoaders$ | async) {
<vault-fade-in-skeleton>
<vault-loading-skeleton></vault-loading-skeleton>
</vault-fade-in-skeleton>
}
</popup-page>
38 changes: 36 additions & 2 deletions apps/browser/src/tools/popup/send-v2/send-v2.component.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { Component, OnDestroy } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { combineLatest, switchMap } from "rxjs";
import { combineLatest, distinctUntilChanged, map, shareReplay, switchMap, tap } from "rxjs";

import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NoResults, NoSendsIcon } from "@bitwarden/assets/svg";
import { VaultLoadingSkeletonComponent } from "@bitwarden/browser/vault/popup/components/vault-loading-skeleton/vault-loading-skeleton.component";
import { BrowserPremiumUpgradePromptService } from "@bitwarden/browser/vault/popup/services/browser-premium-upgrade-prompt.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
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 { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import {
Expand All @@ -31,6 +36,7 @@ import { CurrentAccountComponent } from "../../../auth/popup/account-switching/c
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { VaultFadeInOutSkeletonComponent } from "../../../vault/popup/components/vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component";

// FIXME: update to use a const object instead of a typescript enum
// eslint-disable-next-line @bitwarden/platform/no-enums
Expand Down Expand Up @@ -64,6 +70,8 @@ export enum SendState {
SendListFiltersComponent,
SendSearchComponent,
TypographyModule,
VaultFadeInOutSkeletonComponent,
VaultLoadingSkeletonComponent,
],
})
export class SendV2Component implements OnDestroy {
Expand All @@ -72,7 +80,30 @@ export class SendV2Component implements OnDestroy {

protected listState: SendState | null = null;
protected sends$ = this.sendItemsService.filteredAndSortedSends$;
protected sendsLoading$ = this.sendItemsService.loading$;
private skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
FeatureFlag.VaultLoadingSkeletons,
);
protected sendsLoading$ = this.sendItemsService.loading$.pipe(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ’ญ Consider extracting to a dedicated loading service

The vault implementation uses VaultPopupLoadingService (vault-popup-loading.service.ts:11-27) to centralize loading logic, making it easier to test and maintain.

Consider creating a SendPopupLoadingService that:

  • Combines sendItemsService.loading$ with any future dependencies
  • Includes startWith(true) for initial state
  • Can be easily mocked in tests

This would improve consistency across the codebase and make the component logic cleaner.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Missing startWith(true) for initial skeleton display

The observable doesn't include startWith(true), which means the skeleton won't display on initial component load before the first emission from sendItemsService.loading$.

Recommended addition:

protected sendsLoading$ = this.sendItemsService.loading$.pipe(
  startWith(true), // Add this
  distinctUntilChanged(),
  tap((loading) => {
    const key = loading ? "loadingSendPage" : "sendPageLoaded";
    void this.liveAnnouncer.announce(this.i18nService.translate(key), "polite");
  }),
  shareReplay({ bufferSize: 1, refCount: true }),
);

Reference: vault-popup-loading.service.ts:25 uses startWith(true) to ensure skeleton displays immediately on init.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐ŸŽจ Consider extracting to dedicated loading service

The vault implementation uses VaultPopupLoadingService (vault-popup-loading.service.ts:11-27) to centralize loading logic, improving testability and maintainability.

Consider creating a SendPopupLoadingService that:

  • Combines sendItemsService.loading$ with any future dependencies
  • Includes startWith(true) and shareReplay() for proper initialization and sharing
  • Can be easily mocked in tests (current tests don't verify loading behavior - see send-v2.component.spec.ts)

This would improve consistency with the vault implementation and make the component logic cleaner. However, this is an optional enhancement.

distinctUntilChanged(),
tap((loading) => {
const key = loading ? "loadingSendPage" : "sendPageLoaded";
void this.liveAnnouncer.announce(this.i18nService.translate(key), "polite");
}),
shareReplay({ bufferSize: 1, refCount: true }),
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โŒ Missing test coverage for loading state transitions

The codecov report indicates 2 lines of missing coverage in this component. These loading pipeline operations (distinctUntilChanged, tap with liveAnnouncer) should have tests verifying:

  1. Initial loading state (true)
  2. Transition from loading to loaded
  3. LiveAnnouncer calls with correct i18n keys

Consider adding a test file similar to vault-popup-loading.service.spec.ts:35-71 that validates these behaviors.

Comment on lines 84 to 87
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โŒ Missing startWith(true) for initial loading state

Without startWith(true), the skeleton won't display during initial component load before the first emission from sendItemsService.loading$. This can cause a flash of empty content.

Add this line after line 86:

protected sendsLoading$ = this.sendItemsService.loading$.pipe(
  startWith(true), // Add this
  distinctUntilChanged(),
  tap((loading) => {
    const key = loading ? "loadingSendPage" : "sendPageLoaded";
    void this.liveAnnouncer.announce(this.i18nService.translate(key), "polite");
  }),
  shareReplay({ bufferSize: 1, refCount: true }),
);

Also import startWith on line 5.

Reference: vault-popup-loading.service.ts:25

Comment on lines 84 to 87
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Will this actually provide value? In most cases this will be very quick and might be quite annoying.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a fair point. I was following the skeleton loading patterns we're implementing in vault. But sends should be way quicker to load. I'll bring this up to design.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems the skeleton components have a 1s delay, the announce should probably mirror this.

Copy link
Contributor Author

@Jingo88 Jingo88 Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed. Thanks!

Edit (did not see previous comment) - We're going to keep it removed for now. We have another ticket for avoiding a flicker where we can revisit this if needed.


/** Spinner Loading State */
protected showSpinnerLoaders$ = combineLatest([
this.sendsLoading$,
this.skeletonFeatureFlag$,
]).pipe(map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled));

/** Skeleton Loading State */
protected showSkeletonsLoaders$ = combineLatest([
this.sendsLoading$,
this.skeletonFeatureFlag$,
]).pipe(map(([loading, skeletonsEnabled]) => loading && skeletonsEnabled));

protected title: string = "allSends";
protected noItemIcon = NoSendsIcon;
protected noResultsIcon = NoResults;
Expand All @@ -84,6 +115,9 @@ export class SendV2Component implements OnDestroy {
protected sendListFiltersService: SendListFiltersService,
private policyService: PolicyService,
private accountService: AccountService,
private liveAnnouncer: LiveAnnouncer,
private i18nService: I18nService,
private configService: ConfigService,
) {
combineLatest([
this.sendItemsService.emptyList$,
Expand Down
Loading