diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index d31cd0c84e2..9327d9df859 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -245,6 +245,10 @@ import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; import { SearchService } from "@bitwarden/common/services/search.service"; +import { AchievementService as AchievementServiceAbstraction } from "@bitwarden/common/tools/achievements/achievement.service.abstraction"; +import { DefaultAchievementService } from "@bitwarden/common/tools/achievements/default-achievement.service"; +import { EventStore } from "@bitwarden/common/tools/achievements/event-store"; +import { EventStoreAbstraction } from "@bitwarden/common/tools/achievements/event-store.abstraction.service"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, @@ -329,6 +333,8 @@ import { NoopViewCacheService } from "../platform/services/noop-view-cache.servi import { AngularThemingService } from "../platform/services/theming/angular-theming.service"; import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction"; import { safeProvider, SafeProvider } from "../platform/utils/safe-provider"; +import { AchievementNotifierService as AchievementNotifierServiceAbstraction } from "../tools/achievements/achievement-notifier.abstraction"; +import { AchievementNotifierService } from "../tools/achievements/achievement-notifier.service"; import { LOCALES_DIRECTORY, @@ -1482,6 +1488,27 @@ const safeProviders: SafeProvider[] = [ ToastService, ], }), + safeProvider({ + provide: EventStoreAbstraction, + useClass: EventStore, + deps: [], + }), + safeProvider({ + provide: AchievementServiceAbstraction, + useClass: DefaultAchievementService, + deps: [EventStoreAbstraction], + }), + safeProvider({ + provide: AchievementNotifierServiceAbstraction, + useClass: AchievementNotifierService, + deps: [ + AccountServiceAbstraction, + AchievementServiceAbstraction, + PlatformUtilsServiceAbstraction, + I18nServiceAbstraction, + ToastService, + ], + }), ]; @NgModule({ diff --git a/libs/angular/src/tools/achievements/achievement-notifier.abstraction.ts b/libs/angular/src/tools/achievements/achievement-notifier.abstraction.ts new file mode 100644 index 00000000000..55daf4412c6 --- /dev/null +++ b/libs/angular/src/tools/achievements/achievement-notifier.abstraction.ts @@ -0,0 +1,3 @@ +export abstract class AchievementNotifierService { + abstract init(): Promise; +} diff --git a/libs/angular/src/tools/achievements/achievement-notifier.service.ts b/libs/angular/src/tools/achievements/achievement-notifier.service.ts new file mode 100644 index 00000000000..652d3614f6e --- /dev/null +++ b/libs/angular/src/tools/achievements/achievement-notifier.service.ts @@ -0,0 +1,56 @@ +import { firstValueFrom, switchMap, tap } 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 { ToastService } from "@bitwarden/components"; + +import { AchievementNotifierService as AchievementNotifierServiceAbstraction } from "./achievement-notifier.abstraction"; + +export class AchievementNotifierService implements AchievementNotifierServiceAbstraction { + constructor( + private accountService: AccountService, + private achievementService: AchievementService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private toastService: ToastService, + ) {} + + async init() { + await this.setupListeners(); + } + + 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) + .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, + }); + }); + + // FIXME Migrate to use achievementHub.earned$() instead of achievementService.achievementsEarned$ + } +} diff --git a/libs/common/src/tools/achievements/achievement.service.abstraction.ts b/libs/common/src/tools/achievements/achievement.service.abstraction.ts new file mode 100644 index 00000000000..f1e67ea3a01 --- /dev/null +++ b/libs/common/src/tools/achievements/achievement.service.abstraction.ts @@ -0,0 +1,12 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { Achievement, AchievementEarnedEvent, AchievementProgressEvent } from "./types"; + +export abstract class AchievementService { + abstract achievementById$: (achievementId: string) => Observable; + + abstract achievementsEarned$: (userId: UserId) => Observable; + abstract achievementsInProgress$: (userId: UserId) => Observable; +} diff --git a/libs/common/src/tools/achievements/default-achievement.service.ts b/libs/common/src/tools/achievements/default-achievement.service.ts new file mode 100644 index 00000000000..c4e25bce87a --- /dev/null +++ b/libs/common/src/tools/achievements/default-achievement.service.ts @@ -0,0 +1,60 @@ +import { filter, find, from, Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +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 DefaultAchievementService implements AchievementServiceAbstraction { + private _achievements: Achievement[] = [ + VaultItems_1_Added_Achievement, + VaultItems_10_Added_Achievement, + ]; + + private _achievementsSubject = from(this._achievements); + + achievementById$: (achievementId: string) => Observable; + + // Provided by the AchievementHub + achievementsEarned$: (userId: UserId) => Observable; + + // Provided by the AchievementHub + achievementsInProgress$: (userId: UserId) => Observable; + + constructor(protected eventStore: EventStoreAbstraction) { + this.achievementById$ = (achievementId: AchievementId) => + this._achievementsSubject.pipe(find((item: Achievement) => item.name === achievementId)); + + this.achievementsEarned$ = (userId: UserId) => { + return this.eventStore.events$.pipe( + filter( + (event): event is AchievementEarnedEvent => + isEarnedEvent(event as AchievementEvent) && event.user.id === userId, + ), + ); + }; + + this.achievementsInProgress$ = (userId: UserId) => { + return this.eventStore.events$.pipe( + filter( + (event): event is AchievementProgressEvent => + isProgressEvent(event as AchievementEvent) && event.user.id === userId, + ), + ); + }; + } +} diff --git a/libs/common/src/tools/achievements/event-store.abstraction.service.ts b/libs/common/src/tools/achievements/event-store.abstraction.service.ts new file mode 100644 index 00000000000..03252f390f0 --- /dev/null +++ b/libs/common/src/tools/achievements/event-store.abstraction.service.ts @@ -0,0 +1,10 @@ +import { Observable } from "rxjs"; + +import { AchievementEarnedEvent, AchievementProgressEvent, UserActionEvent } from "./types"; + +export abstract class EventStoreAbstraction { + abstract events$: Observable; + abstract addEvent( + event: UserActionEvent | AchievementProgressEvent | AchievementEarnedEvent, + ): boolean; +} diff --git a/libs/common/src/tools/achievements/event-store.ts b/libs/common/src/tools/achievements/event-store.ts new file mode 100644 index 00000000000..5fcbf7b3a43 --- /dev/null +++ b/libs/common/src/tools/achievements/event-store.ts @@ -0,0 +1,22 @@ +import { Observable, Subject } from "rxjs"; + +import { EventStoreAbstraction } from "./event-store.abstraction.service"; +import { AchievementEarnedEvent, AchievementProgressEvent } from "./types"; + +// Will be replaced by the achievementHub +export class EventStore implements EventStoreAbstraction { + private _events = new Subject(); + + events$: Observable = + this._events.asObservable(); + + constructor() {} + + addEvent(event: AchievementProgressEvent | AchievementEarnedEvent): boolean { + // FIXME Collapse existing of same metric/higher count AchievementProgressEvents + //eslint-disable-next-line no-console + console.log("EventStore.addEvent", event); + this._events.next(event); + return true; + } +}