1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

wire next achievement service to notifier service

This commit is contained in:
✨ Audrey ✨
2025-03-20 18:51:14 -04:00
parent d9edf1149c
commit 62339142a0
6 changed files with 134 additions and 106 deletions

View File

@@ -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 {

View File

@@ -82,8 +82,7 @@ export class AchievementHub {
earned$(): Observable<Map<AchievementId, AchievementEarnedEvent>> {
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<AchievementId, AchievementEarnedEvent>()),
@@ -92,8 +91,7 @@ export class AchievementHub {
metrics$(): Observable<Map<MetricId, AchievementProgressEvent>> {
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<MetricId, AchievementProgressEvent>()),

View File

@@ -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<ReadonlyMap<MetricId, number>>,
earned$: Observable<ReadonlySet<AchievementId>>,
// TODO: accept a configuration observable that completes without
// emission when the user has opted out of achievements
): OperatorFunction<AchievementValidator[], AchievementValidator[]> {
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

View File

@@ -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<AchievementEarnedEvent>;
inProgress$: Observable<AchievementProgressEvent>;
achievementById$: (achievementId: string) => Observable<Achievement>;
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;
}));
}
}

View File

@@ -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<string, AchievementHub>();
private getHub(account: Account) {
if (!this.hubs.has(account.id)) {
// FIXME: sync these from the server and load them
const validators$ = new BehaviorSubject<AchievementValidator[]>([
...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<Achievement> {
return this._achievementsSubject.pipe(
find((item: Achievement) => item.name === achievementId),
filter((f): f is Achievement => !!f),
);
}
earned$: Observable<AchievementEarnedEvent> = EMPTY;
inProgress$: Observable<AchievementProgressEvent> = EMPTY;
achievementsEarned$(userId: UserId): Observable<AchievementEarnedEvent> {
return EMPTY;
}
achievementsInProgress$(userId: UserId): Observable<AchievementProgressEvent> {
return EMPTY;
}
}

View File

@@ -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<UserActionEvent>;
}
type BaselineType = Omit<ServiceFormat & UserFormat, "@timestamp">;