From 106dd33ef4db9ed38caf2ac9b3c9661b8776f1b9 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 30 Apr 2025 12:16:09 -0400 Subject: [PATCH] [PM-18800] vault onboarding nudges and badge (#14278) * added empty vault nudge service and has items vault nudge service with spotlight and settings badge to vault v2 in browser * Refactor Vault Nudge Service for clarity between spotlight and badge dismissals --- apps/browser/src/_locales/en/messages.json | 21 ++++++ apps/browser/src/popup/tabs-v2.component.ts | 6 +- .../popup/settings/settings-v2.component.html | 21 +++++- .../popup/settings/settings-v2.component.ts | 34 +++++++++- .../vault-v2/vault-v2.component.html | 34 ++++++++-- .../components/vault-v2/vault-v2.component.ts | 59 +++++++++++++---- .../popup/services/intro-carousel.service.ts | 16 ++++- libs/vault/src/index.ts | 2 + .../empty-vault-nudge.service.ts | 64 +++++++++++++++++++ .../has-items-nudge.service.ts | 43 +++++++++---- .../has-nudge.service.ts | 18 ++++-- .../services/custom-nudges-services/index.ts | 1 + .../services/default-single-nudge.service.ts | 33 ++++++---- .../src/services/vault-nudges.service.spec.ts | 40 +++++++----- .../src/services/vault-nudges.service.ts | 43 +++++++++---- 15 files changed, 345 insertions(+), 90 deletions(-) create mode 100644 libs/vault/src/services/custom-nudges-services/empty-vault-nudge.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 4f83b07506..5c9e829e82 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5201,6 +5201,12 @@ "changeAtRiskPassword": { "message": "Change at-risk password" }, + "settingsVaultOptions": { + "message": "Vault options" + }, + "emptyVaultDescription": { + "message": "The vault protects more than just your passwords. Store secure logins, IDs, cards and notes securely here." + }, "introCarouselLabel": { "message": "Welcome to Bitwarden" }, @@ -5227,5 +5233,20 @@ }, "secureDevicesBody": { "message": "Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps." + }, + "emptyVaultNudgeTitle": { + "message": "Import existing passwords" + }, + "emptyVaultNudgeBody": { + "message": "Use the importer to quickly transfer logins to Bitwarden without manually adding them." + }, + "emptyVaultNudgeButton": { + "message": "Import now" + }, + "hasItemsVaultNudgeTitle": { + "message": "Welcome to your vault!" + }, + "hasItemsVaultNudgeBody": { + "message": "Autofill items for the current page\nFavorite items for easy access\nSearch your vault for something else" } } \ No newline at end of file diff --git a/apps/browser/src/popup/tabs-v2.component.ts b/apps/browser/src/popup/tabs-v2.component.ts index 1392dc565a..24ce9d8cb1 100644 --- a/apps/browser/src/popup/tabs-v2.component.ts +++ b/apps/browser/src/popup/tabs-v2.component.ts @@ -18,9 +18,9 @@ export class TabsV2Component { protected navButtons$ = combineLatest([ this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge), - this.hasNudgeService.shouldShowNudge$(), + this.hasNudgeService.nudgeStatus$(), ]).pipe( - map(([onboardingFeatureEnabled, showNudge]) => { + map(([onboardingFeatureEnabled, nudgeStatus]) => { return [ { label: "vault", @@ -45,7 +45,7 @@ export class TabsV2Component { page: "/tabs/settings", iconKey: "cog", iconKeyActive: "cog-f", - showBerry: onboardingFeatureEnabled && showNudge, + showBerry: onboardingFeatureEnabled && !nudgeStatus.hasSpotlightDismissed, }, ]; }), diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.html b/apps/browser/src/tools/popup/settings/settings-v2.component.html index 26aeea4f20..b6f98b649f 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.html +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.html @@ -29,9 +29,26 @@ - + - {{ "vault" | i18n }} +
+

{{ "settingsVaultOptions" | i18n }}

+ + 1 +
+
diff --git a/apps/browser/src/tools/popup/settings/settings-v2.component.ts b/apps/browser/src/tools/popup/settings/settings-v2.component.ts index 5f3eb1c8f1..737d79ea4c 100644 --- a/apps/browser/src/tools/popup/settings/settings-v2.component.ts +++ b/apps/browser/src/tools/popup/settings/settings-v2.component.ts @@ -1,9 +1,14 @@ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { RouterModule } from "@angular/router"; +import { firstValueFrom, Observable } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ItemModule } from "@bitwarden/components"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BadgeComponent, ItemModule } from "@bitwarden/components"; +import { NudgeStatus, VaultNudgesService, VaultNudgeType } from "@bitwarden/vault"; import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component"; import { PopOutComponent } from "../../../platform/popup/components/pop-out.component"; @@ -22,6 +27,29 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co PopOutComponent, ItemModule, CurrentAccountComponent, + BadgeComponent, ], }) -export class SettingsV2Component {} +export class SettingsV2Component implements OnInit { + VaultNudgeType = VaultNudgeType; + showVaultBadge$: Observable = new Observable(); + activeUserId: UserId | null = null; + + constructor( + private readonly vaultNudgesService: VaultNudgesService, + private readonly accountService: AccountService, + ) {} + async ngOnInit() { + this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + this.showVaultBadge$ = this.vaultNudgesService.showNudge$( + VaultNudgeType.EmptyVaultNudge, + this.activeUserId, + ); + } + + async dismissBadge(type: VaultNudgeType) { + if (!(await firstValueFrom(this.showVaultBadge$)).hasBadgeDismissed) { + await this.vaultNudgesService.dismissNudge(type, this.activeUserId as UserId, true); + } + } +} 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 2a50eb4396..7d04f23795 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 @@ -14,7 +14,9 @@ > {{ "yourVaultIsEmpty" | i18n }} - {{ "autofillSuggestionsTip" | i18n }} + +

{{ "emptyVaultDescription" | i18n }}

+
- - - + + + + + + +
+ + +
+ + +
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 7f5242dcf1..64805a0239 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 @@ -2,30 +2,38 @@ import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrol import { CommonModule } from "@angular/common"; import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Router, RouterModule } from "@angular/router"; import { combineLatest, filter, - map, firstValueFrom, + map, Observable, shareReplay, + startWith, switchMap, take, - startWith, } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; 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 { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +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"; import { ButtonModule, DialogService, Icons, NoItemsModule } from "@bitwarden/components"; -import { DecryptionFailureDialogComponent, VaultIcons } from "@bitwarden/vault"; +import { + DecryptionFailureDialogComponent, + SpotlightComponent, + VaultIcons, + VaultNudgesService, + VaultNudgeType, +} from "@bitwarden/vault"; import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component"; +import { BrowserApi } from "../../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; 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"; @@ -74,14 +82,29 @@ enum VaultState { VaultHeaderV2Component, AtRiskPasswordCalloutComponent, NewSettingsCalloutComponent, + SpotlightComponent, + RouterModule, ], providers: [VaultPageService], }) export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { @ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement; + VaultNudgeType = VaultNudgeType; cipherType = CipherType; + private activeUserId$ = this.accountService.activeAccount$.pipe(getUserId); + showEmptyVaultSpotlight$: Observable = this.activeUserId$.pipe( + switchMap((userId) => + this.vaultNudgesService.showNudge$(VaultNudgeType.EmptyVaultNudge, userId), + ), + map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed), + ); + showHasItemsVaultSpotlight$: Observable = this.activeUserId$.pipe( + switchMap((userId) => this.vaultNudgesService.showNudge$(VaultNudgeType.HasVaultItems, userId)), + map((nudgeStatus) => !nudgeStatus.hasSpotlightDismissed), + ); + activeUserId: UserId | null = null; protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; protected allFilters$ = this.vaultPopupListFiltersService.allFilters$; @@ -131,7 +154,8 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { private dialogService: DialogService, private vaultCopyButtonsService: VaultPopupCopyButtonsService, private introCarouselService: IntroCarouselService, - private configService: ConfigService, + private vaultNudgesService: VaultNudgesService, + private router: Router, ) { combineLatest([ this.vaultPopupItemsService.emptyVault$, @@ -169,16 +193,12 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { } async ngOnInit() { - const hasVaultNudgeFlag = await this.configService.getFeatureFlag( - FeatureFlag.PM8851_BrowserOnboardingNudge, - ); - if (hasVaultNudgeFlag) { - await this.introCarouselService.setIntroCarouselDismissed(); - } - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + + await this.introCarouselService.setIntroCarouselDismissed(); this.cipherService - .failedToDecryptCiphers$(activeUserId) + .failedToDecryptCiphers$(this.activeUserId) .pipe( map((ciphers) => (ciphers ? ciphers.filter((c) => !c.isDeleted) : [])), filter((ciphers) => ciphers.length > 0), @@ -196,5 +216,16 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { this.vaultScrollPositionService.stop(); } + async navigateToImport() { + await this.router.navigate(["/import"]); + if (await BrowserApi.isPopupOpen()) { + await BrowserPopupUtils.openCurrentPagePopout(window); + } + } + + async dismissVaultNudgeSpotlight(type: VaultNudgeType) { + await this.vaultNudgesService.dismissNudge(type, this.activeUserId as UserId); + } + protected readonly FeatureFlag = FeatureFlag; } diff --git a/apps/browser/src/vault/popup/services/intro-carousel.service.ts b/apps/browser/src/vault/popup/services/intro-carousel.service.ts index 2c523c5a93..7d2bb7dedb 100644 --- a/apps/browser/src/vault/popup/services/intro-carousel.service.ts +++ b/apps/browser/src/vault/popup/services/intro-carousel.service.ts @@ -1,6 +1,8 @@ import { Injectable } from "@angular/core"; -import { map, Observable } from "rxjs"; +import { firstValueFrom, map, Observable } from "rxjs"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { GlobalState, KeyDefinition, @@ -26,9 +28,17 @@ export class IntroCarouselService { map((x) => x ?? false), ); - constructor(private stateProvider: StateProvider) {} + constructor( + private stateProvider: StateProvider, + private configService: ConfigService, + ) {} async setIntroCarouselDismissed(): Promise { - await this.introCarouselState.update(() => true); + const hasVaultNudgeFlag = await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge), + ); + if (hasVaultNudgeFlag) { + await this.introCarouselState.update(() => true); + } } } diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 6e5a452ec8..87e15b1867 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -32,3 +32,5 @@ export { SshImportPromptService } from "./services/ssh-import-prompt.service"; export * from "./abstractions/change-login-password.service"; export * from "./services/default-change-login-password.service"; + +export { SpotlightComponent } from "./components/spotlight/spotlight.component"; diff --git a/libs/vault/src/services/custom-nudges-services/empty-vault-nudge.service.ts b/libs/vault/src/services/custom-nudges-services/empty-vault-nudge.service.ts new file mode 100644 index 0000000000..556e389b28 --- /dev/null +++ b/libs/vault/src/services/custom-nudges-services/empty-vault-nudge.service.ts @@ -0,0 +1,64 @@ +import { inject, Injectable } from "@angular/core"; +import { combineLatest, Observable, of, switchMap } from "rxjs"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; + +import { DefaultSingleNudgeService } from "../default-single-nudge.service"; +import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service"; + +/** + * Custom Nudge Service Checking Nudge Status For Empty Vault + */ +@Injectable({ + providedIn: "root", +}) +export class EmptyVaultNudgeService extends DefaultSingleNudgeService { + cipherService = inject(CipherService); + organizationService = inject(OrganizationService); + collectionService = inject(CollectionService); + + nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable { + return combineLatest([ + this.getNudgeStatus$(nudgeType, userId), + this.cipherService.cipherViews$(userId), + this.organizationService.organizations$(userId), + this.collectionService.decryptedCollections$, + ]).pipe( + switchMap(([nudgeStatus, ciphers, orgs, collections]) => { + const emptyVault = ciphers == null || ciphers.length === 0; + if (orgs == null || orgs.length === 0) { + return nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed + ? of(nudgeStatus) + : of({ + hasSpotlightDismissed: emptyVault, + hasBadgeDismissed: emptyVault, + }); + } + const orgIds = new Set(orgs.map((org) => org.id)); + const canCreateCollections = orgs.some((org) => org.canCreateNewCollections); + const hasManageCollections = collections.some( + (c) => c.manage && orgIds.has(c.organizationId), + ); + // Do not show nudge when + // user has previously dismissed nudge + // OR + // user belongs to an organization and cannot create collections || manage collections + if ( + nudgeStatus.hasBadgeDismissed || + nudgeStatus.hasSpotlightDismissed || + hasManageCollections || + canCreateCollections + ) { + return of(nudgeStatus); + } + return of({ + hasSpotlightDismissed: emptyVault, + hasBadgeDismissed: emptyVault, + }); + }), + ); + } +} diff --git a/libs/vault/src/services/custom-nudges-services/has-items-nudge.service.ts b/libs/vault/src/services/custom-nudges-services/has-items-nudge.service.ts index 144b15d61f..6b5ac7eba0 100644 --- a/libs/vault/src/services/custom-nudges-services/has-items-nudge.service.ts +++ b/libs/vault/src/services/custom-nudges-services/has-items-nudge.service.ts @@ -1,30 +1,49 @@ import { inject, Injectable } from "@angular/core"; -import { map, Observable, of, switchMap } from "rxjs"; +import { combineLatest, Observable, switchMap } from "rxjs"; +import { VaultProfileService } from "@bitwarden/angular/vault/services/vault-profile.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { DefaultSingleNudgeService } from "../default-single-nudge.service"; -import { VaultNudgeType } from "../vault-nudges.service"; +import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service"; /** - * Custom Nudge Service to use for the Onboarding Nudges in the Vault + * Custom Nudge Service Checking Nudge Status For Welcome Nudge With Populated Vault */ @Injectable({ providedIn: "root", }) export class HasItemsNudgeService extends DefaultSingleNudgeService { cipherService = inject(CipherService); + vaultProfileService = inject(VaultProfileService); + logService = inject(LogService); - shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable { - return this.isDismissed$(nudgeType, userId).pipe( - switchMap((dismissed) => - dismissed - ? of(false) - : this.cipherService - .cipherViews$(userId) - .pipe(map((ciphers) => ciphers == null || ciphers.length === 0)), - ), + nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable { + return combineLatest([ + this.cipherService.cipherViews$(userId), + this.getNudgeStatus$(nudgeType, userId), + ]).pipe( + switchMap(async ([ciphers, nudgeStatus]) => { + try { + const creationDate = await this.vaultProfileService.getProfileCreationDate(userId); + const thirtyDays = new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000); + const isRecentAcct = creationDate >= thirtyDays; + + if (!isRecentAcct || nudgeStatus.hasSpotlightDismissed) { + return nudgeStatus; + } else { + return { + hasBadgeDismissed: ciphers == null || ciphers.length === 0, + hasSpotlightDismissed: ciphers == null || ciphers.length === 0, + }; + } + } catch (error) { + this.logService.error("Failed to fetch profile creation date: ", error); + return nudgeStatus; + } + }), ); } } diff --git a/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts b/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts index b1f319451e..0c14cff002 100644 --- a/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts +++ b/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts @@ -5,7 +5,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { UserId } from "@bitwarden/common/types/guid"; import { DefaultSingleNudgeService } from "../default-single-nudge.service"; -import { VaultNudgeType } from "../vault-nudges.service"; +import { NudgeStatus, VaultNudgeType } from "../vault-nudges.service"; /** * Custom Nudge Service used for showing if the user has any existing nudge in the Vault. @@ -17,6 +17,7 @@ export class HasNudgeService extends DefaultSingleNudgeService { private accountService = inject(AccountService); private nudgeTypes: VaultNudgeType[] = [ + VaultNudgeType.EmptyVaultNudge, VaultNudgeType.HasVaultItems, VaultNudgeType.IntroCarouselDismissal, // add additional nudge types here as needed @@ -25,20 +26,25 @@ export class HasNudgeService extends DefaultSingleNudgeService { /** * Returns an observable that emits true if any of the provided nudge types are present */ - shouldShowNudge$(): Observable { + nudgeStatus$(): Observable { return this.accountService.activeAccount$.pipe( switchMap((activeAccount) => { const userId: UserId | undefined = activeAccount?.id; if (!userId) { - return of(false); + return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true }); } - const nudgeObservables: Observable[] = this.nudgeTypes.map((nudge) => - super.shouldShowNudge$(nudge, userId), + const nudgeObservables: Observable[] = this.nudgeTypes.map((nudge) => + super.nudgeStatus$(nudge, userId), ); return combineLatest(nudgeObservables).pipe( - map((nudgeStates) => nudgeStates.some((state) => state)), + map((nudgeStates) => { + return { + hasBadgeDismissed: true, + hasSpotlightDismissed: nudgeStates.some((state) => state.hasSpotlightDismissed), + }; + }), distinctUntilChanged(), ); }), diff --git a/libs/vault/src/services/custom-nudges-services/index.ts b/libs/vault/src/services/custom-nudges-services/index.ts index dd343e47d7..9a1f0acd42 100644 --- a/libs/vault/src/services/custom-nudges-services/index.ts +++ b/libs/vault/src/services/custom-nudges-services/index.ts @@ -1,2 +1,3 @@ export * from "./has-items-nudge.service"; +export * from "./empty-vault-nudge.service"; export * from "./has-nudge.service"; diff --git a/libs/vault/src/services/default-single-nudge.service.ts b/libs/vault/src/services/default-single-nudge.service.ts index 0fd48b63c8..9a1759cab3 100644 --- a/libs/vault/src/services/default-single-nudge.service.ts +++ b/libs/vault/src/services/default-single-nudge.service.ts @@ -4,15 +4,19 @@ import { map, Observable } from "rxjs"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { VAULT_NUDGE_DISMISSED_DISK_KEY, VaultNudgeType } from "./vault-nudges.service"; +import { + NudgeStatus, + VAULT_NUDGE_DISMISSED_DISK_KEY, + VaultNudgeType, +} from "./vault-nudges.service"; /** * Base interface for handling a nudge's status */ export interface SingleNudgeService { - shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable; + nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable; - setNudgeStatus(nudgeType: VaultNudgeType, dismissed: boolean, userId: UserId): Promise; + setNudgeStatus(nudgeType: VaultNudgeType, newStatus: NudgeStatus, userId: UserId): Promise; } /** @@ -24,28 +28,29 @@ export interface SingleNudgeService { export class DefaultSingleNudgeService implements SingleNudgeService { stateProvider = inject(StateProvider); - protected isDismissed$(nudgeType: VaultNudgeType, userId: UserId): Observable { + protected getNudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable { return this.stateProvider .getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY) - .state$.pipe(map((nudges) => nudges?.includes(nudgeType) ?? false)); + .state$.pipe( + map( + (nudges) => + nudges?.[nudgeType] ?? { hasBadgeDismissed: false, hasSpotlightDismissed: false }, + ), + ); } - shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable { - return this.isDismissed$(nudgeType, userId).pipe(map((dismissed) => !dismissed)); + nudgeStatus$(nudgeType: VaultNudgeType, userId: UserId): Observable { + return this.getNudgeStatus$(nudgeType, userId); } async setNudgeStatus( nudgeType: VaultNudgeType, - dismissed: boolean, + status: NudgeStatus, userId: UserId, ): Promise { await this.stateProvider.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY).update((nudges) => { - nudges ??= []; - if (dismissed) { - nudges.push(nudgeType); - } else { - nudges = nudges.filter((n) => n !== nudgeType); - } + nudges ??= {}; + nudges[nudgeType] = status; return nudges; }); } diff --git a/libs/vault/src/services/vault-nudges.service.spec.ts b/libs/vault/src/services/vault-nudges.service.spec.ts index 0d376f37cf..a01cac94fb 100644 --- a/libs/vault/src/services/vault-nudges.service.spec.ts +++ b/libs/vault/src/services/vault-nudges.service.spec.ts @@ -2,12 +2,13 @@ import { TestBed } from "@angular/core/testing"; import { mock } from "jest-mock-extended"; import { firstValueFrom, of } from "rxjs"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; import { FakeStateProvider, mockAccountServiceWith } from "../../../common/spec"; -import { HasItemsNudgeService } from "./custom-nudges-services/has-items-nudge.service"; +import { HasItemsNudgeService, EmptyVaultNudgeService } from "./custom-nudges-services"; import { DefaultSingleNudgeService } from "./default-single-nudge.service"; import { VaultNudgesService, VaultNudgeType } from "./vault-nudges.service"; @@ -15,6 +16,10 @@ describe("Vault Nudges Service", () => { let fakeStateProvider: FakeStateProvider; let testBed: TestBed; + const mockConfigService = { + getFeatureFlag$: jest.fn().mockReturnValue(of(true)), + getFeatureFlag: jest.fn().mockReturnValue(true), + }; beforeEach(async () => { fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId)); @@ -32,50 +37,55 @@ describe("Vault Nudges Service", () => { provide: StateProvider, useValue: fakeStateProvider, }, + { provide: ConfigService, useValue: mockConfigService }, { provide: HasItemsNudgeService, useValue: mock(), }, + { + provide: EmptyVaultNudgeService, + useValue: mock(), + }, ], }); }); describe("DefaultSingleNudgeService", () => { - it("should return shouldShowNudge === false when IntroCaourselDismissal dismissed is true", async () => { + it("should return hasSpotlightDismissed === true when EmptyVaultNudge dismissed is true", async () => { const service = testBed.inject(DefaultSingleNudgeService); await service.setNudgeStatus( - VaultNudgeType.IntroCarouselDismissal, - true, + VaultNudgeType.EmptyVaultNudge, + { hasBadgeDismissed: true, hasSpotlightDismissed: true }, "user-id" as UserId, ); const result = await firstValueFrom( - service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId), + service.nudgeStatus$(VaultNudgeType.EmptyVaultNudge, "user-id" as UserId), ); - expect(result).toBe(false); + expect(result).toEqual({ hasBadgeDismissed: true, hasSpotlightDismissed: true }); }); - it("should return shouldShowNudge === true when IntroCaourselDismissal dismissed is false", async () => { + it("should return hasSpotlightDismissed === true when EmptyVaultNudge dismissed is false", async () => { const service = testBed.inject(DefaultSingleNudgeService); await service.setNudgeStatus( - VaultNudgeType.IntroCarouselDismissal, - false, + VaultNudgeType.EmptyVaultNudge, + { hasBadgeDismissed: false, hasSpotlightDismissed: false }, "user-id" as UserId, ); const result = await firstValueFrom( - service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId), + service.nudgeStatus$(VaultNudgeType.EmptyVaultNudge, "user-id" as UserId), ); - expect(result).toBe(true); + expect(result).toEqual({ hasBadgeDismissed: false, hasSpotlightDismissed: false }); }); }); describe("VaultNudgesService", () => { - it("should return true, the proper value from the custom nudge service shouldShowNudge$", async () => { + it("should return true, the proper value from the custom nudge service nudgeStatus$", async () => { TestBed.overrideProvider(HasItemsNudgeService, { - useValue: { shouldShowNudge$: () => of(true) }, + useValue: { nudgeStatus$: () => of(true) }, }); const service = testBed.inject(VaultNudgesService); @@ -86,9 +96,9 @@ describe("Vault Nudges Service", () => { expect(result).toBe(true); }); - it("should return false, the proper value for the custom nudge service shouldShowNudge$", async () => { + it("should return false, the proper value for the custom nudge service nudgeStatus$", async () => { TestBed.overrideProvider(HasItemsNudgeService, { - useValue: { shouldShowNudge$: () => of(false) }, + useValue: { nudgeStatus$: () => of(false) }, }); const service = testBed.inject(VaultNudgesService); diff --git a/libs/vault/src/services/vault-nudges.service.ts b/libs/vault/src/services/vault-nudges.service.ts index 0a031f8c09..28198d1706 100644 --- a/libs/vault/src/services/vault-nudges.service.ts +++ b/libs/vault/src/services/vault-nudges.service.ts @@ -1,11 +1,19 @@ import { inject, Injectable } from "@angular/core"; +import { of, switchMap } from "rxjs"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { UserKeyDefinition, VAULT_NUDGES_DISK } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; -import { HasItemsNudgeService } from "./custom-nudges-services/has-items-nudge.service"; +import { HasItemsNudgeService, EmptyVaultNudgeService } from "./custom-nudges-services"; import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service"; +export type NudgeStatus = { + hasBadgeDismissed: boolean; + hasSpotlightDismissed: boolean; +}; + /** * Enum to list the various nudge types, to be used by components/badges to show/hide the nudge */ @@ -13,18 +21,17 @@ export enum VaultNudgeType { /** Nudge to show when user has no items in their vault * Add future nudges here */ + EmptyVaultNudge = "empty-vault-nudge", HasVaultItems = "has-vault-items", IntroCarouselDismissal = "intro-carousel-dismissal", } -export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition( - VAULT_NUDGES_DISK, - "vaultNudgeDismissed", - { - deserializer: (nudgeDismissed) => nudgeDismissed, - clearOn: [], // Do not clear dismissals - }, -); +export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition< + Partial> +>(VAULT_NUDGES_DISK, "vaultNudgeDismissed", { + deserializer: (nudge) => nudge, + clearOn: [], // Do not clear dismissals +}); @Injectable({ providedIn: "root", @@ -37,6 +44,7 @@ export class VaultNudgesService { */ private customNudgeServices: any = { [VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService), + [VaultNudgeType.EmptyVaultNudge]: inject(EmptyVaultNudgeService), }; /** @@ -45,6 +53,7 @@ export class VaultNudgesService { * @private */ private defaultNudgeService = inject(DefaultSingleNudgeService); + private configService = inject(ConfigService); private getNudgeService(nudge: VaultNudgeType): SingleNudgeService { return this.customNudgeServices[nudge] ?? this.defaultNudgeService; @@ -56,7 +65,14 @@ export class VaultNudgesService { * @param userId */ showNudge$(nudge: VaultNudgeType, userId: UserId) { - return this.getNudgeService(nudge).shouldShowNudge$(nudge, userId); + return this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge).pipe( + switchMap((hasVaultNudgeFlag) => { + if (!hasVaultNudgeFlag) { + return of({ hasBadgeDismissed: true, hasSpotlightDismissed: true } as NudgeStatus); + } + return this.getNudgeService(nudge).nudgeStatus$(nudge, userId); + }), + ); } /** @@ -64,7 +80,10 @@ export class VaultNudgesService { * @param nudge * @param userId */ - dismissNudge(nudge: VaultNudgeType, userId: UserId) { - return this.getNudgeService(nudge).setNudgeStatus(nudge, true, userId); + async dismissNudge(nudge: VaultNudgeType, userId: UserId, onlyBadge: boolean = false) { + const dismissedStatus = onlyBadge + ? { hasBadgeDismissed: true, hasSpotlightDismissed: false } + : { hasBadgeDismissed: true, hasSpotlightDismissed: true }; + await this.getNudgeService(nudge).setNudgeStatus(nudge, dismissedStatus, userId); } }