1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00

[PM-23384] - Browser extension spotlight directing to Premium signup in web (#17343)

* premium upgrade nudge

* add specs

* clean up vault template and specs

* fix date comparison. add more specs for date

* fix spec

* fix specs

* make prop private
This commit is contained in:
Jordan Aasen
2025-11-17 12:36:37 -08:00
committed by GitHub
parent 610537535a
commit 670f3514ba
6 changed files with 636 additions and 0 deletions

View File

@@ -5809,6 +5809,15 @@
"upgradeToPremium": { "upgradeToPremium": {
"message": "Upgrade to Premium" "message": "Upgrade to Premium"
}, },
"upgradeCompleteSecurity": {
"message": "Upgrade for complete security"
},
"premiumGivesMoreTools": {
"message": "Premium gives you more tools to stay secure, work efficiently, and stay in control."
},
"explorePremium": {
"message": "Explore Premium"
},
"loadingVault": { "loadingVault": {
"message": "Loading vault" "message": "Loading vault"
}, },

View File

@@ -30,6 +30,15 @@
<!-- Show search & filters outside of the scroll area of the page --> <!-- Show search & filters outside of the scroll area of the page -->
<ng-container slot="above-scroll-area"> <ng-container slot="above-scroll-area">
<ng-container *ngIf="showPremiumSpotlight$ | async">
<bit-spotlight
[title]="'upgradeCompleteSecurity' | i18n"
[subtitle]="'premiumGivesMoreTools' | i18n"
[buttonText]="'explorePremium' | i18n"
(onButtonClick)="showPremiumDialog()"
(onDismiss)="dismissVaultNudgeSpotlight(NudgeType.PremiumUpgrade)"
></bit-spotlight>
</ng-container>
<ng-container *ngIf="vaultState === VaultStateEnum.Empty && showEmptyVaultSpotlight$ | async"> <ng-container *ngIf="vaultState === VaultStateEnum.Empty && showEmptyVaultSpotlight$ | async">
<bit-spotlight <bit-spotlight
[title]="'emptyVaultNudgeTitle' | i18n" [title]="'emptyVaultNudgeTitle' | i18n"

View File

@@ -0,0 +1,564 @@
import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
import { ChangeDetectionStrategy, Component, input, NO_ERRORS_SCHEMA } from "@angular/core";
import { TestBed, fakeAsync, flush, tick } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute, Router } from "@angular/router";
import { RouterTestingModule } from "@angular/router/testing";
import { mock } from "jest-mock-extended";
import { BehaviorSubject, Observable, Subject, of } from "rxjs";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { NudgeType, NudgesService } from "@bitwarden/angular/vault";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { CurrentAccountComponent } from "@bitwarden/browser/auth/popup/account-switching/current-account.component";
import AutofillService from "@bitwarden/browser/autofill/services/autofill.service";
import { PopOutComponent } from "@bitwarden/browser/platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "@bitwarden/browser/platform/popup/layout/popup-header.component";
import { PopupRouterCacheService } from "@bitwarden/browser/platform/popup/view-cache/popup-router-cache.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { TaskService } from "@bitwarden/common/vault/tasks";
import { DialogService } from "@bitwarden/components";
import { StateProvider } from "@bitwarden/state";
import { DecryptionFailureDialogComponent } from "@bitwarden/vault";
import { BrowserApi } from "../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../platform/browser/browser-popup-utils";
import { IntroCarouselService } from "../../services/intro-carousel.service";
import { VaultPopupAutofillService } from "../../services/vault-popup-autofill.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 { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component";
import { AutofillVaultListItemsComponent } from "./autofill-vault-list-items/autofill-vault-list-items.component";
import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component";
import { NewItemDropdownV2Component } from "./new-item-dropdown/new-item-dropdown-v2.component";
import { VaultHeaderV2Component } from "./vault-header/vault-header-v2.component";
import { VaultListItemsContainerComponent } from "./vault-list-items-container/vault-list-items-container.component";
import { VaultV2Component } from "./vault-v2.component";
@Component({
selector: "popup-header",
standalone: true,
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PopupHeaderStubComponent {
readonly pageTitle = input("");
}
@Component({
selector: "app-vault-header-v2",
standalone: true,
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VaultHeaderV2StubComponent {}
@Component({
selector: "app-current-account",
standalone: true,
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class CurrentAccountStubComponent {}
@Component({
selector: "app-new-item-dropdown",
standalone: true,
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class NewItemDropdownStubComponent {
readonly initialValues = input();
}
@Component({
selector: "app-pop-out",
standalone: true,
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class PopOutStubComponent {}
@Component({
selector: "blocked-injection-banner",
standalone: true,
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class BlockedInjectionBannerStubComponent {}
@Component({
selector: "vault-at-risk-password-callout",
standalone: true,
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class VaultAtRiskCalloutStubComponent {}
@Component({
selector: "app-autofill-vault-list-items",
standalone: true,
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class AutofillVaultListItemsStubComponent {}
@Component({
selector: "app-vault-list-items-container",
standalone: true,
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class VaultListItemsContainerStubComponent {
readonly title = input<string>();
readonly ciphers = input<any[]>();
readonly id = input<string>();
readonly disableSectionMargin = input<boolean>();
readonly collapsibleKey = input<string>();
}
const mockDialogRef = {
close: jest.fn(),
afterClosed: jest.fn().mockReturnValue(of(undefined)),
} as unknown as import("@bitwarden/components").DialogRef<any, any>;
jest
.spyOn(PremiumUpgradeDialogComponent, "open")
.mockImplementation((_: DialogService) => mockDialogRef as any);
jest
.spyOn(DecryptionFailureDialogComponent, "open")
.mockImplementation((_: DialogService, _params: any) => mockDialogRef as any);
jest.spyOn(BrowserApi, "isPopupOpen").mockResolvedValue(false);
jest.spyOn(BrowserPopupUtils, "openCurrentPagePopout").mockResolvedValue();
describe("VaultV2Component", () => {
let component: VaultV2Component;
interface FakeAccount {
id: string;
}
function queryAllSpotlights(fixture: any): HTMLElement[] {
return Array.from(fixture.nativeElement.querySelectorAll("bit-spotlight")) as HTMLElement[];
}
const itemsSvc: any = {
emptyVault$: new BehaviorSubject<boolean>(false),
noFilteredResults$: new BehaviorSubject<boolean>(false),
showDeactivatedOrg$: new BehaviorSubject<boolean>(false),
favoriteCiphers$: new BehaviorSubject<any[]>([]),
remainingCiphers$: new BehaviorSubject<any[]>([]),
cipherCount$: new BehaviorSubject<number>(0),
loading$: new BehaviorSubject<boolean>(true),
} as Partial<VaultPopupItemsService>;
const filtersSvc = {
allFilters$: new Subject<any>(),
filters$: new BehaviorSubject<any>({}),
filterVisibilityState$: new BehaviorSubject<any>({}),
} as Partial<VaultPopupListFiltersService>;
const accountActive$ = new BehaviorSubject<FakeAccount | null>({ id: "user-1" });
const cipherSvc = {
failedToDecryptCiphers$: jest.fn().mockReturnValue(of([])),
} as Partial<CipherService>;
const nudgesSvc = {
showNudgeSpotlight$: jest.fn().mockImplementation((_type: NudgeType) => of(false)),
dismissNudge: jest.fn().mockResolvedValue(undefined),
} as Partial<NudgesService>;
const dialogSvc = {} as Partial<DialogService>;
const introSvc = {
setIntroCarouselDismissed: jest.fn().mockResolvedValue(undefined),
} as Partial<IntroCarouselService>;
const scrollSvc = {
start: jest.fn(),
stop: jest.fn(),
} as Partial<VaultPopupScrollPositionService>;
function getObs<T = unknown>(cmp: any, key: string): Observable<T> {
return cmp[key] as Observable<T>;
}
const hasPremiumFromAnySource$ = new BehaviorSubject<boolean>(false);
const billingSvc = {
hasPremiumFromAnySource$: (_: string) => hasPremiumFromAnySource$,
};
const vaultProfileSvc = {
getProfileCreationDate: jest
.fn()
.mockResolvedValue(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)), // 8 days ago
};
beforeEach(async () => {
jest.clearAllMocks();
await TestBed.configureTestingModule({
imports: [VaultV2Component, RouterTestingModule],
providers: [
{ provide: VaultPopupItemsService, useValue: itemsSvc },
{ provide: VaultPopupListFiltersService, useValue: filtersSvc },
{ provide: VaultPopupScrollPositionService, useValue: scrollSvc },
{
provide: AccountService,
useValue: { activeAccount$: accountActive$ },
},
{ provide: CipherService, useValue: cipherSvc },
{ provide: DialogService, useValue: dialogSvc },
{ provide: IntroCarouselService, useValue: introSvc },
{ provide: NudgesService, useValue: nudgesSvc },
{
provide: VaultProfileService,
useValue: vaultProfileSvc,
},
{
provide: VaultPopupCopyButtonsService,
useValue: { showQuickCopyActions$: new BehaviorSubject<boolean>(false) },
},
{
provide: BillingAccountProfileStateService,
useValue: billingSvc,
},
{
provide: I18nService,
useValue: { translate: (key: string) => key, t: (key: string) => key },
},
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
{ provide: RestrictedItemTypesService, useValue: { restricted$: new BehaviorSubject([]) } },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: AvatarService, useValue: mock<AvatarService>() },
{ provide: ActivatedRoute, useValue: mock<ActivatedRoute>() },
{ provide: AuthService, useValue: mock<AuthService>() },
{ provide: AutofillService, useValue: mock<AutofillService>() },
{
provide: VaultPopupAutofillService,
useValue: mock<VaultPopupAutofillService>(),
},
{ provide: TaskService, useValue: mock<TaskService>() },
{ provide: StateProvider, useValue: mock<StateProvider>() },
{
provide: ConfigService,
useValue: {
getFeatureFlag$: (_: string) => of(false),
},
},
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
TestBed.overrideComponent(VaultV2Component, {
remove: {
imports: [
PopupHeaderComponent,
VaultHeaderV2Component,
CurrentAccountComponent,
NewItemDropdownV2Component,
PopOutComponent,
BlockedInjectionBanner,
AtRiskPasswordCalloutComponent,
AutofillVaultListItemsComponent,
VaultListItemsContainerComponent,
],
},
add: {
imports: [
PopupHeaderStubComponent,
VaultHeaderV2StubComponent,
CurrentAccountStubComponent,
NewItemDropdownStubComponent,
PopOutStubComponent,
BlockedInjectionBannerStubComponent,
VaultAtRiskCalloutStubComponent,
AutofillVaultListItemsStubComponent,
VaultListItemsContainerStubComponent,
],
},
});
const fixture = TestBed.createComponent(VaultV2Component);
component = fixture.componentInstance;
});
describe("vaultState", () => {
type ExpectedKey = "Empty" | "DeactivatedOrg" | "NoResults" | null;
const cases: [string, boolean, boolean, boolean, ExpectedKey][] = [
["null when none true", false, false, false, null],
["Empty when empty true only", true, false, false, "Empty"],
["DeactivatedOrg when only deactivated true", false, false, true, "DeactivatedOrg"],
["NoResults when only noResults true", false, true, false, "NoResults"],
];
it.each(cases)(
"%s",
fakeAsync(
(
_label: string,
empty: boolean,
noResults: boolean,
deactivated: boolean,
expectedKey: ExpectedKey,
) => {
const empty$ = itemsSvc.emptyVault$ as BehaviorSubject<boolean>;
const noResults$ = itemsSvc.noFilteredResults$ as BehaviorSubject<boolean>;
const deactivated$ = itemsSvc.showDeactivatedOrg$ as BehaviorSubject<boolean>;
empty$.next(empty);
noResults$.next(noResults);
deactivated$.next(deactivated);
tick();
const expectedValue =
expectedKey === null ? null : (component as any).VaultStateEnum[expectedKey];
expect((component as any).vaultState).toBe(expectedValue);
},
),
);
});
it("loading$ is true when items loading or filters missing; false when both ready", () => {
const itemsLoading$ = itemsSvc.loading$ as unknown as BehaviorSubject<boolean>;
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
const values: boolean[] = [];
getObs<boolean>(component, "loading$").subscribe((v) => values.push(!!v));
itemsLoading$.next(true);
allFilters$.next({});
itemsLoading$.next(false);
expect(values[values.length - 1]).toBe(false);
});
it("ngAfterViewInit waits for allFilters$ then starts scroll position service", fakeAsync(() => {
const allFilters$ = filtersSvc.allFilters$ as unknown as Subject<any>;
(component as any).virtualScrollElement = {} as CdkVirtualScrollableElement;
component.ngAfterViewInit();
expect(scrollSvc.start).not.toHaveBeenCalled();
allFilters$.next({ any: true });
tick();
expect(scrollSvc.start).toHaveBeenCalledTimes(1);
expect(scrollSvc.start).toHaveBeenCalledWith((component as any).virtualScrollElement);
flush();
}));
it("showPremiumDialog opens PremiumUpgradeDialogComponent", () => {
component["showPremiumDialog"]();
expect(PremiumUpgradeDialogComponent.open).toHaveBeenCalledTimes(1);
});
it("navigateToImport navigates and opens popout if popup is open", fakeAsync(async () => {
(BrowserApi.isPopupOpen as jest.Mock).mockResolvedValueOnce(true);
const ngRouter = TestBed.inject(Router);
jest.spyOn(ngRouter, "navigate").mockResolvedValue(true as any);
await component["navigateToImport"]();
expect(ngRouter.navigate).toHaveBeenCalledWith(["/import"]);
expect(BrowserPopupUtils.openCurrentPagePopout).toHaveBeenCalled();
}));
it("navigateToImport does not popout when popup is not open", fakeAsync(async () => {
(BrowserApi.isPopupOpen as jest.Mock).mockResolvedValueOnce(false);
const ngRouter = TestBed.inject(Router);
jest.spyOn(ngRouter, "navigate").mockResolvedValue(true as any);
await component["navigateToImport"]();
expect(ngRouter.navigate).toHaveBeenCalledWith(["/import"]);
expect(BrowserPopupUtils.openCurrentPagePopout).not.toHaveBeenCalled();
}));
it("ngOnInit dismisses intro carousel and opens decryption dialog for non-deleted failures", fakeAsync(() => {
(cipherSvc.failedToDecryptCiphers$ as any).mockReturnValue(
of([
{ id: "a", isDeleted: false },
{ id: "b", isDeleted: true },
{ id: "c", isDeleted: false },
]),
);
void component.ngOnInit();
tick();
expect(introSvc.setIntroCarouselDismissed).toHaveBeenCalled();
expect(DecryptionFailureDialogComponent.open).toHaveBeenCalledWith(expect.any(Object), {
cipherIds: ["a", "c"],
});
flush();
}));
it("dismissVaultNudgeSpotlight forwards to NudgesService with active user id", fakeAsync(() => {
const spy = jest.spyOn(nudgesSvc, "dismissNudge").mockResolvedValue(undefined);
accountActive$.next({ id: "user-xyz" });
void component.ngOnInit();
tick();
void component["dismissVaultNudgeSpotlight"](NudgeType.HasVaultItems);
tick();
expect(spy).toHaveBeenCalledWith(NudgeType.HasVaultItems, "user-xyz");
}));
it("accountAgeInDays$ computes integer days since creation", (done) => {
getObs<number | null>(component, "accountAgeInDays$").subscribe((days) => {
if (days !== null) {
expect(days).toBeGreaterThanOrEqual(7);
done();
}
});
void component.ngOnInit();
});
it("renders Premium spotlight when eligible and opens dialog on click", fakeAsync(() => {
itemsSvc.cipherCount$.next(10);
hasPremiumFromAnySource$.next(false);
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) =>
of(type === NudgeType.PremiumUpgrade),
);
const fixture = TestBed.createComponent(VaultV2Component);
const component = fixture.componentInstance;
void component.ngOnInit();
fixture.detectChanges();
tick();
fixture.detectChanges();
const spotlights = Array.from(
fixture.nativeElement.querySelectorAll("bit-spotlight"),
) as HTMLElement[];
expect(spotlights.length).toBe(1);
const spotDe = fixture.debugElement.query(By.css("bit-spotlight"));
expect(spotDe).toBeTruthy();
spotDe.triggerEventHandler("onButtonClick", undefined);
fixture.detectChanges();
expect(PremiumUpgradeDialogComponent.open).toHaveBeenCalledTimes(1);
}));
it("renders Empty-Vault spotlight when vaultState is Empty and nudge is on", fakeAsync(() => {
itemsSvc.emptyVault$.next(true);
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => {
return of(type === NudgeType.EmptyVaultNudge);
});
const fixture = TestBed.createComponent(VaultV2Component);
fixture.detectChanges();
tick();
const spotlights = queryAllSpotlights(fixture);
expect(spotlights.length).toBe(1);
expect(fixture.nativeElement.textContent).toContain("emptyVaultNudgeTitle");
}));
it("renders Has-Items spotlight when vault has items and nudge is on", fakeAsync(() => {
itemsSvc.emptyVault$.next(false);
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => {
return of(type === NudgeType.HasVaultItems);
});
const fixture = TestBed.createComponent(VaultV2Component);
fixture.detectChanges();
tick();
const spotlights = queryAllSpotlights(fixture);
expect(spotlights.length).toBe(1);
expect(fixture.nativeElement.textContent).toContain("hasItemsVaultNudgeTitle");
}));
it("does not render Premium spotlight when account is less than a week old", fakeAsync(() => {
itemsSvc.cipherCount$.next(10);
hasPremiumFromAnySource$.next(false);
vaultProfileSvc.getProfileCreationDate = jest
.fn()
.mockResolvedValue(new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)); // 3 days ago
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => {
return of(type === NudgeType.PremiumUpgrade);
});
const fixture = TestBed.createComponent(VaultV2Component);
fixture.detectChanges();
tick();
const spotlights = queryAllSpotlights(fixture);
expect(spotlights.length).toBe(0);
}));
it("does not render Premium spotlight when vault has less than 5 items", fakeAsync(() => {
itemsSvc.cipherCount$.next(3);
hasPremiumFromAnySource$.next(false);
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => {
return of(type === NudgeType.PremiumUpgrade);
});
const fixture = TestBed.createComponent(VaultV2Component);
fixture.detectChanges();
tick();
const spotlights = queryAllSpotlights(fixture);
expect(spotlights.length).toBe(0);
}));
it("does not render Premium spotlight when user already has premium", fakeAsync(() => {
itemsSvc.cipherCount$.next(10);
hasPremiumFromAnySource$.next(true);
(nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => {
return of(type === NudgeType.PremiumUpgrade);
});
const fixture = TestBed.createComponent(VaultV2Component);
fixture.detectChanges();
tick();
const spotlights = queryAllSpotlights(fixture);
expect(spotlights.length).toBe(0);
}));
});

View File

@@ -9,6 +9,7 @@ import {
distinctUntilChanged, distinctUntilChanged,
filter, filter,
firstValueFrom, firstValueFrom,
from,
map, map,
Observable, Observable,
shareReplay, shareReplay,
@@ -17,12 +18,15 @@ import {
tap, tap,
} from "rxjs"; } from "rxjs";
import { PremiumUpgradeDialogComponent } from "@bitwarden/angular/billing/components";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service";
import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg"; import { DeactivatedOrg, NoResults, VaultOpen } from "@bitwarden/assets/svg";
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 { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -124,13 +128,55 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
void this.liveAnnouncer.announce(this.i18nService.translate(key), "polite"); void this.liveAnnouncer.announce(this.i18nService.translate(key), "polite");
}), }),
); );
private skeletonFeatureFlag$ = this.configService.getFeatureFlag$( private skeletonFeatureFlag$ = this.configService.getFeatureFlag$(
FeatureFlag.VaultLoadingSkeletons, FeatureFlag.VaultLoadingSkeletons,
); );
private showPremiumNudgeSpotlight$ = this.activeUserId$.pipe(
switchMap((userId) => this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId)),
);
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$;
protected allFilters$ = this.vaultPopupListFiltersService.allFilters$; protected allFilters$ = this.vaultPopupListFiltersService.allFilters$;
protected cipherCount$ = this.vaultPopupItemsService.cipherCount$;
protected hasPremium$ = this.activeUserId$.pipe(
switchMap((userId) => this.billingAccountService.hasPremiumFromAnySource$(userId)),
);
protected accountAgeInDays$ = this.activeUserId$.pipe(
switchMap((userId) => {
const creationDate$ = from(this.vaultProfileService.getProfileCreationDate(userId));
return creationDate$.pipe(
map((creationDate) => {
if (!creationDate) {
return 0;
}
const ageInMilliseconds = Date.now() - creationDate.getTime();
return Math.floor(ageInMilliseconds / (1000 * 60 * 60 * 24));
}),
);
}),
);
protected showPremiumSpotlight$ = combineLatest([
this.showPremiumNudgeSpotlight$,
this.showEmptyVaultSpotlight$,
this.showHasItemsVaultSpotlight$,
this.hasPremium$,
this.cipherCount$,
this.accountAgeInDays$,
]).pipe(
map(
([showNudge, emptyVault, hasItems, hasPremium, count, age]) =>
showNudge && !emptyVault && !hasItems && !hasPremium && count >= 5 && age >= 7,
),
shareReplay({ bufferSize: 1, refCount: true }),
);
showPremiumDialog() {
PremiumUpgradeDialogComponent.open(this.dialogService);
}
/** When true, show spinner loading state */ /** When true, show spinner loading state */
protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe( protected showSpinnerLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe(
@@ -177,6 +223,8 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
private introCarouselService: IntroCarouselService, private introCarouselService: IntroCarouselService,
private nudgesService: NudgesService, private nudgesService: NudgesService,
private router: Router, private router: Router,
private vaultProfileService: VaultProfileService,
private billingAccountService: BillingAccountProfileStateService,
private liveAnnouncer: LiveAnnouncer, private liveAnnouncer: LiveAnnouncer,
private i18nService: I18nService, private i18nService: I18nService,
private configService: ConfigService, private configService: ConfigService,

View File

@@ -288,6 +288,11 @@ export class VaultPopupItemsService {
map((ciphers) => !ciphers.length), map((ciphers) => !ciphers.length),
); );
/**
* Observable that contains the count of ciphers in the active filtered list.
*/
cipherCount$: Observable<number> = this._activeCipherList$.pipe(map((ciphers) => ciphers.length));
/** /**
* Observable that indicates whether there are no ciphers to show with the current filter. * Observable that indicates whether there are no ciphers to show with the current filter.
*/ */

View File

@@ -37,6 +37,7 @@ export const NudgeType = {
NewNoteItemStatus: "new-note-item-status", NewNoteItemStatus: "new-note-item-status",
NewSshItemStatus: "new-ssh-item-status", NewSshItemStatus: "new-ssh-item-status",
GeneratorNudgeStatus: "generator-nudge-status", GeneratorNudgeStatus: "generator-nudge-status",
PremiumUpgrade: "premium-upgrade",
} as const; } as const;
export type NudgeType = UnionOfValues<typeof NudgeType>; export type NudgeType = UnionOfValues<typeof NudgeType>;