diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index b37aeb442ed..26e324b73c2 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -206,3 +206,4 @@ export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk"); export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk"); export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk"); export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk"); +export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk"); diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 12f3ddbf805..0ab85f47252 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -24,6 +24,7 @@ export * from "./components/carousel"; export * as VaultIcons from "./icons"; export * from "./notifications"; +export * from "./services/vault-nudges.service"; export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service"; export { SshImportPromptService } from "./services/ssh-import-prompt.service"; 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 new file mode 100644 index 00000000000..144b15d61f4 --- /dev/null +++ b/libs/vault/src/services/custom-nudges-services/has-items-nudge.service.ts @@ -0,0 +1,30 @@ +import { inject, Injectable } from "@angular/core"; +import { map, Observable, of, switchMap } from "rxjs"; + +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"; + +/** + * Custom Nudge Service to use for the Onboarding Nudges in the Vault + */ +@Injectable({ + providedIn: "root", +}) +export class HasItemsNudgeService extends DefaultSingleNudgeService { + cipherService = inject(CipherService); + + 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)), + ), + ); + } +} diff --git a/libs/vault/src/services/custom-nudges-services/index.ts b/libs/vault/src/services/custom-nudges-services/index.ts new file mode 100644 index 00000000000..84409eb35ae --- /dev/null +++ b/libs/vault/src/services/custom-nudges-services/index.ts @@ -0,0 +1 @@ +export * from "./has-items-nudge.service"; diff --git a/libs/vault/src/services/default-single-nudge.service.ts b/libs/vault/src/services/default-single-nudge.service.ts new file mode 100644 index 00000000000..0fd48b63c8d --- /dev/null +++ b/libs/vault/src/services/default-single-nudge.service.ts @@ -0,0 +1,52 @@ +import { inject, Injectable } from "@angular/core"; +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"; + +/** + * Base interface for handling a nudge's status + */ +export interface SingleNudgeService { + shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable; + + setNudgeStatus(nudgeType: VaultNudgeType, dismissed: boolean, userId: UserId): Promise; +} + +/** + * Default implementation for nudges. Set and Show Nudge dismissed state + */ +@Injectable({ + providedIn: "root", +}) +export class DefaultSingleNudgeService implements SingleNudgeService { + stateProvider = inject(StateProvider); + + protected isDismissed$(nudgeType: VaultNudgeType, userId: UserId): Observable { + return this.stateProvider + .getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY) + .state$.pipe(map((nudges) => nudges?.includes(nudgeType) ?? false)); + } + + shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable { + return this.isDismissed$(nudgeType, userId).pipe(map((dismissed) => !dismissed)); + } + + async setNudgeStatus( + nudgeType: VaultNudgeType, + dismissed: boolean, + 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); + } + return nudges; + }); + } +} diff --git a/libs/vault/src/services/vault-nudges.service.spec.ts b/libs/vault/src/services/vault-nudges.service.spec.ts new file mode 100644 index 00000000000..0d376f37cf9 --- /dev/null +++ b/libs/vault/src/services/vault-nudges.service.spec.ts @@ -0,0 +1,102 @@ +import { TestBed } from "@angular/core/testing"; +import { mock } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +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 { DefaultSingleNudgeService } from "./default-single-nudge.service"; +import { VaultNudgesService, VaultNudgeType } from "./vault-nudges.service"; + +describe("Vault Nudges Service", () => { + let fakeStateProvider: FakeStateProvider; + + let testBed: TestBed; + + beforeEach(async () => { + fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId)); + + testBed = TestBed.configureTestingModule({ + imports: [], + providers: [ + { + provide: VaultNudgesService, + }, + { + provide: DefaultSingleNudgeService, + }, + { + provide: StateProvider, + useValue: fakeStateProvider, + }, + { + provide: HasItemsNudgeService, + useValue: mock(), + }, + ], + }); + }); + + describe("DefaultSingleNudgeService", () => { + it("should return shouldShowNudge === false when IntroCaourselDismissal dismissed is true", async () => { + const service = testBed.inject(DefaultSingleNudgeService); + + await service.setNudgeStatus( + VaultNudgeType.IntroCarouselDismissal, + true, + "user-id" as UserId, + ); + + const result = await firstValueFrom( + service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId), + ); + expect(result).toBe(false); + }); + + it("should return shouldShowNudge === true when IntroCaourselDismissal dismissed is false", async () => { + const service = testBed.inject(DefaultSingleNudgeService); + + await service.setNudgeStatus( + VaultNudgeType.IntroCarouselDismissal, + false, + "user-id" as UserId, + ); + + const result = await firstValueFrom( + service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId), + ); + expect(result).toBe(true); + }); + }); + + describe("VaultNudgesService", () => { + it("should return true, the proper value from the custom nudge service shouldShowNudge$", async () => { + TestBed.overrideProvider(HasItemsNudgeService, { + useValue: { shouldShowNudge$: () => of(true) }, + }); + const service = testBed.inject(VaultNudgesService); + + const result = await firstValueFrom( + service.showNudge$(VaultNudgeType.HasVaultItems, "user-id" as UserId), + ); + + expect(result).toBe(true); + }); + + it("should return false, the proper value for the custom nudge service shouldShowNudge$", async () => { + TestBed.overrideProvider(HasItemsNudgeService, { + useValue: { shouldShowNudge$: () => of(false) }, + }); + const service = testBed.inject(VaultNudgesService); + + const result = await firstValueFrom( + service.showNudge$(VaultNudgeType.HasVaultItems, "user-id" as UserId), + ); + + expect(result).toBe(false); + }); + }); +}); diff --git a/libs/vault/src/services/vault-nudges.service.ts b/libs/vault/src/services/vault-nudges.service.ts new file mode 100644 index 00000000000..0a031f8c092 --- /dev/null +++ b/libs/vault/src/services/vault-nudges.service.ts @@ -0,0 +1,70 @@ +import { inject, Injectable } from "@angular/core"; + +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 { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service"; + +/** + * Enum to list the various nudge types, to be used by components/badges to show/hide the nudge + */ +export enum VaultNudgeType { + /** Nudge to show when user has no items in their vault + * Add future nudges here + */ + 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 + }, +); + +@Injectable({ + providedIn: "root", +}) +export class VaultNudgesService { + /** + * Custom nudge services to use for specific nudge types + * Each nudge type can have its own service to determine when to show the nudge + * @private + */ + private customNudgeServices: any = { + [VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService), + }; + + /** + * Default nudge service to use when no custom service is available + * Simply stores the dismissed state in the user's state + * @private + */ + private defaultNudgeService = inject(DefaultSingleNudgeService); + + private getNudgeService(nudge: VaultNudgeType): SingleNudgeService { + return this.customNudgeServices[nudge] ?? this.defaultNudgeService; + } + + /** + * Check if a nudge should be shown to the user + * @param nudge + * @param userId + */ + showNudge$(nudge: VaultNudgeType, userId: UserId) { + return this.getNudgeService(nudge).shouldShowNudge$(nudge, userId); + } + + /** + * Dismiss a nudge for the user so that it is not shown again + * @param nudge + * @param userId + */ + dismissNudge(nudge: VaultNudgeType, userId: UserId) { + return this.getNudgeService(nudge).setNudgeStatus(nudge, true, userId); + } +}