From 62339142a0596036db164b01008ae97869cd3ab1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Thu, 20 Mar 2025 18:51:14 -0400 Subject: [PATCH] wire next achievement service to notifier service --- .../achievement-notifier.service.ts | 64 ++++------- .../src/tools/achievements/achievement-hub.ts | 6 +- .../tools/achievements/achievement-manager.ts | 13 ++- .../achievements/hub-achievement.service.ts | 52 --------- .../achievements/next-achievement.service.ts | 100 ++++++++++++++++++ libs/common/src/tools/log/logger.ts | 5 +- 6 files changed, 134 insertions(+), 106 deletions(-) delete mode 100644 libs/common/src/tools/achievements/hub-achievement.service.ts create mode 100644 libs/common/src/tools/achievements/next-achievement.service.ts diff --git a/libs/angular/src/tools/achievements/achievement-notifier.service.ts b/libs/angular/src/tools/achievements/achievement-notifier.service.ts index 8db9c06f086..a43198f8815 100644 --- a/libs/angular/src/tools/achievements/achievement-notifier.service.ts +++ b/libs/angular/src/tools/achievements/achievement-notifier.service.ts @@ -1,9 +1,11 @@ -import { firstValueFrom, switchMap, tap } from "rxjs"; +import { concat, filter, map, mergeAll, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { AchievementService } from "@bitwarden/common/tools/achievements/achievement.service.abstraction"; +import { NextAchievementService } from "@bitwarden/common/tools/achievements/next-achievement.service"; +import { Achievement } from "@bitwarden/common/tools/achievements/types"; +import { UserId } from "@bitwarden/common/types/guid"; import { Icon, ToastService } from "@bitwarden/components"; import { AchievementNotifierService as AchievementNotifierServiceAbstraction } from "./achievement-notifier.abstraction"; @@ -13,7 +15,7 @@ import { iconMap } from "./icons/iconMap"; export class AchievementNotifierService implements AchievementNotifierServiceAbstraction { constructor( private accountService: AccountService, - private achievementService: AchievementService, + private achievementService: NextAchievementService, private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private toastService: ToastService, @@ -24,56 +26,32 @@ export class AchievementNotifierService implements AchievementNotifierServiceAbs } private async setupListeners() { - // FIXME Implement achievements earned filter and notififer - /* Get the userId from the accountService - * Subscribe to achievementService.achievementsEarned$(userId) - * Retrieve current device and filter out messages that are not for this client/device (achievements should be only shown on the device that earned them) - * Retrieve Achievement by AchievementId via the achievementService - * Use information from Achievement to fill out the options for the notification (toast) - * Invoke showing toast - */ - // FIXME getClientType browswer and achievementEarned.service.name.extension won't match - const account = await firstValueFrom(this.accountService.activeAccount$); - this.achievementService - .achievementsEarned$(account.id) + this.accountService.accounts$ .pipe( - // Removing filter for testing purposes - // filter(achievementEarned => achievementEarned.service.name == this.platformUtilsService.getClientType())).pipe( - switchMap((earned) => this.achievementService.achievementById$(earned.achievement.name)), - tap((achievement) => { - //eslint-disable-next-line no-console - console.log(achievement); + switchMap((accounts) => { + const earned$ = Array.from(Object.entries(accounts), ([id, value]) => { + const account = { ...value, id: id as UserId }; + const metadata = this.achievementService.achievementMap(); + const achievements = this.achievementService.earnedStream$(account).pipe( + map((earned) => metadata.get(earned.achievement.name)), + // FIXME: exclude achievements earned on another device + filter((earned): earned is Achievement => !!earned), + ); + + return achievements; + }); + return concat(earned$); }), + mergeAll(), ) .subscribe((achievement) => { this.toastService.showToast({ variant: "info", title: achievement.name, - message: achievement.description, + message: achievement.description ?? "", icon: this.lookupIcon(achievement.achievement), }); }); - - // FIXME Migrate to use achievementHub.earned$() instead of achievementService.achievementsEarned$ - // this.achievementService - // .earned$ - // .pipe( - // // Removing filter for testing purposes - // // filter(achievementEarned => achievementEarned.service.name == this.platformUtilsService.getClientType())).pipe( - // switchMap((earned) => this.achievementService.achievementById$(earned.achievement.name)), - // tap((achievement) => { - // //eslint-disable-next-line no-console - // console.log(achievement); - // }), - // ) - // .subscribe((achievement) => { - // this.toastService.showToast({ - // variant: "info", - // title: achievement.name, - // message: achievement.description, - // icon: this.lookupIcon(achievement.name), - // }); - // }); } lookupIcon(achievementName: string): Icon { diff --git a/libs/common/src/tools/achievements/achievement-hub.ts b/libs/common/src/tools/achievements/achievement-hub.ts index 6207ed2505d..53e822a2459 100644 --- a/libs/common/src/tools/achievements/achievement-hub.ts +++ b/libs/common/src/tools/achievements/achievement-hub.ts @@ -82,8 +82,7 @@ export class AchievementHub { earned$(): Observable> { return this.achievementLog.pipe( - filter((e) => isEarnedEvent(e)), - map((e) => e as AchievementEarnedEvent), + filter(isEarnedEvent), latestEarnedMetrics(), tap((m) => this.log.debug(m, "earned achievements update")), startWith(new Map()), @@ -92,8 +91,7 @@ export class AchievementHub { metrics$(): Observable> { return this.achievementLog.pipe( - filter((e) => isProgressEvent(e)), - map((e) => e as AchievementProgressEvent), + filter(isProgressEvent), latestProgressMetrics(), tap((m) => this.log.debug(m, "achievement metrics update")), startWith(new Map()), diff --git a/libs/common/src/tools/achievements/achievement-manager.ts b/libs/common/src/tools/achievements/achievement-manager.ts index afc44c8754f..af88017e640 100644 --- a/libs/common/src/tools/achievements/achievement-manager.ts +++ b/libs/common/src/tools/achievements/achievement-manager.ts @@ -1,4 +1,4 @@ -import { Observable, OperatorFunction, map, pipe, withLatestFrom } from "rxjs"; +import { Observable, OperatorFunction, combineLatestWith, map, pipe, withLatestFrom } from "rxjs"; import { AchievementId, AchievementValidator, MetricId } from "./types"; @@ -7,12 +7,15 @@ import { AchievementId, AchievementValidator, MetricId } from "./types"; function active( metrics$: Observable>, earned$: Observable>, + // TODO: accept a configuration observable that completes without + // emission when the user has opted out of achievements ): OperatorFunction { return pipe( - // TODO: accept a configuration observable that completes without - // emission when the user has opted out of achievements - withLatestFrom(metrics$, earned$), - map(([monitors, metrics, earned]) => { + // refresh when an achievement is earned, but not when metrics + // update; this may cause metrics to overrun + withLatestFrom(metrics$), + combineLatestWith(earned$), + map(([[monitors, metrics], earned]) => { // compute list of active achievements const active = monitors.filter((m) => { // 🧠 the filters could be lifted into a function argument & delivered diff --git a/libs/common/src/tools/achievements/hub-achievement.service.ts b/libs/common/src/tools/achievements/hub-achievement.service.ts deleted file mode 100644 index d2daaad55db..00000000000 --- a/libs/common/src/tools/achievements/hub-achievement.service.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { filter, find, from, map, Observable } from "rxjs"; - -import { UserId } from "@bitwarden/common/types/guid"; - -import { AchievementHub } from "./achievement-hub"; -import { AchievementService as AchievementServiceAbstraction } from "./achievement.service.abstraction"; -import { EventStoreAbstraction } from "./event-store.abstraction.service"; -import { - VaultItems_1_Added_Achievement, - VaultItems_10_Added_Achievement, -} from "./examples/achievements"; -import { isEarnedEvent, isProgressEvent } from "./meta"; -import { - Achievement, - AchievementEarnedEvent, - AchievementEvent, - AchievementId, - AchievementProgressEvent, -} from "./types"; - -// Service might be deprecated in favor of the AchievmentHub -// The hub is currently missing a way of listing all achievements, finding by id, but that could be possibly done via the AchievementManager -export class HubAchievementService implements AchievementServiceAbstraction { - private _achievements: Achievement[] = [ - VaultItems_1_Added_Achievement, - VaultItems_10_Added_Achievement, - ]; - - private _achievementsSubject = from(this._achievements); - - earned$: Observable; - inProgress$: Observable; - - achievementById$: (achievementId: string) => Observable; - achievementsEarned$ = (userId: UserId) => { return this.earned$ }; - achievementsInProgress$ = (userId: UserId) => { return this.inProgress$ } - - private achievementHub = new AchievementHub(); - - constructor() { - this.achievementById$ = (achievementId: AchievementId) => - this._achievementsSubject.pipe(find((item: Achievement) => item.name === achievementId)); - - this.earned$ = this.achievementHub.new$().pipe(filter((event) => isEarnedEvent(event)), map((event) => { - return event as AchievementEarnedEvent; - })); - - this.inProgress$ = this.achievementHub.new$().pipe(filter((event) => isProgressEvent(event)), map((event) => { - return event as AchievementProgressEvent; - })); - } -} diff --git a/libs/common/src/tools/achievements/next-achievement.service.ts b/libs/common/src/tools/achievements/next-achievement.service.ts new file mode 100644 index 00000000000..461293d83ba --- /dev/null +++ b/libs/common/src/tools/achievements/next-achievement.service.ts @@ -0,0 +1,100 @@ +import { BehaviorSubject, EMPTY, filter, find, from, Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { Account } from "../../auth/abstractions/account.service"; +import { UserEventLogProvider } from "../log/logger"; + +import { AchievementHub } from "./achievement-hub"; +import { AchievementService as AchievementServiceAbstraction } from "./achievement.service.abstraction"; +import { isEarnedEvent, isProgressEvent } from "./meta"; +import { + Achievement, + AchievementEarnedEvent, + AchievementEvent, + AchievementProgressEvent, + AchievementValidator, +} from "./types"; +import { ItemCreatedCountConfig } from "./validators/config/item-created-count-config"; +import { SendItemCreatedCountConfig } from "./validators/config/send-created-count-config"; +import { SendItemCreatedCountValidator } from "./validators/send-item-created-count-validator"; +import { VaultItemCreatedCountValidator } from "./validators/vault-item-created-count-validator"; + +export class NextAchievementService implements AchievementServiceAbstraction { + constructor(private readonly eventLogs: UserEventLogProvider) {} + + private hubs = new Map(); + + private getHub(account: Account) { + if (!this.hubs.has(account.id)) { + // FIXME: sync these from the server and load them + const validators$ = new BehaviorSubject([ + ...VaultItemCreatedCountValidator.createValidators(ItemCreatedCountConfig.AllConfigs), + ...SendItemCreatedCountValidator.createValidators(SendItemCreatedCountConfig.AllConfigs), + ]); + + // FIXME: load stored achievements + const achievements$ = from([] as AchievementEvent[]); + const events$ = this.eventLogs.monitor$(account); + const hub = new AchievementHub(validators$, events$, achievements$); + + this.hubs.set(account.id, hub); + } + + return this.hubs.get(account.id)!; + } + + private _achievements: Achievement[] = [ + ...ItemCreatedCountConfig.AllConfigs, + ...SendItemCreatedCountConfig.AllConfigs, + ]; + + private _achievementsSubject = from(this._achievements); + + achievementMap() { + return new Map(this._achievements.map((a) => [a.achievement, a] as const)); + } + + earnedStream$(account: Account, all: boolean = false) { + const hub = this.getHub(account); + if (all) { + return hub.all$().pipe(filter(isEarnedEvent)); + } else { + return hub.new$().pipe(filter(isEarnedEvent)); + } + } + + earnedMap$(account: Account) { + return this.getHub(account).metrics$(); + } + + progressStream$(account: Account, all: boolean = false) { + const hub = this.getHub(account); + if (all) { + return hub.all$().pipe(filter(isProgressEvent)); + } else { + return hub.new$().pipe(filter(isProgressEvent)); + } + } + + metricsMap$(account: Account) { + return this.getHub(account).metrics$(); + } + + achievementById$(achievementId: string): Observable { + return this._achievementsSubject.pipe( + find((item: Achievement) => item.name === achievementId), + filter((f): f is Achievement => !!f), + ); + } + + earned$: Observable = EMPTY; + inProgress$: Observable = EMPTY; + achievementsEarned$(userId: UserId): Observable { + return EMPTY; + } + + achievementsInProgress$(userId: UserId): Observable { + return EMPTY; + } +} diff --git a/libs/common/src/tools/log/logger.ts b/libs/common/src/tools/log/logger.ts index 2494b8edce0..6ce0d645b2e 100644 --- a/libs/common/src/tools/log/logger.ts +++ b/libs/common/src/tools/log/logger.ts @@ -1,4 +1,4 @@ -import { BehaviorSubject, SubjectLike, from, map, zip } from "rxjs"; +import { BehaviorSubject, Observable, SubjectLike, from, map, zip } from "rxjs"; import { Primitive } from "type-fest"; import { Account } from "../../auth/abstractions/account.service"; @@ -11,7 +11,8 @@ import { disabledSemanticLoggerProvider } from "./factory"; import { SemanticLogger } from "./semantic-logger.abstraction"; export abstract class UserEventLogProvider { - abstract create: (account: Account) => UserEventLogger; + abstract capture: (account: Account) => UserEventLogger; + abstract monitor$: (account: Account) => Observable; } type BaselineType = Omit;