// FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { combineLatestWith, map, distinctUntilChanged, shareReplay, combineLatest, Observable, } from "rxjs"; import { Account, AccountInfo, InternalAccountService, accountInfoEqual, } from "../../auth/abstractions/account.service"; import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { Utils } from "../../platform/misc/utils"; import { ACCOUNT_DISK, GlobalState, GlobalStateProvider, KeyDefinition, } from "../../platform/state"; import { UserId } from "../../types/guid"; export const ACCOUNT_ACCOUNTS = KeyDefinition.record( ACCOUNT_DISK, "accounts", { deserializer: (accountInfo) => accountInfo, }, ); export const ACCOUNT_ACTIVE_ACCOUNT_ID = new KeyDefinition(ACCOUNT_DISK, "activeAccountId", { deserializer: (id: UserId) => id, }); export const ACCOUNT_ACTIVITY = KeyDefinition.record(ACCOUNT_DISK, "activity", { deserializer: (activity) => new Date(activity), }); const LOGGED_OUT_INFO: AccountInfo = { email: "", emailVerified: false, name: undefined, }; /** * An rxjs map operator that extracts the UserId from an account, or throws if the account or UserId are null. */ export const getUserId = map((account) => { if (account == null) { throw new Error("Null or undefined account"); } return account.id; }); /** * An rxjs map operator that extracts the UserId from an account, or returns undefined if the account or UserId are null. */ export const getOptionalUserId = map( (account) => account?.id ?? null, ); export class AccountServiceImplementation implements InternalAccountService { private accountsState: GlobalState>; private activeAccountIdState: GlobalState; accounts$: Observable>; activeAccount$: Observable; accountActivity$: Observable>; sortedUserIds$: Observable; nextUpAccount$: Observable; constructor( private messagingService: MessagingService, private logService: LogService, private globalStateProvider: GlobalStateProvider, ) { this.accountsState = this.globalStateProvider.get(ACCOUNT_ACCOUNTS); this.activeAccountIdState = this.globalStateProvider.get(ACCOUNT_ACTIVE_ACCOUNT_ID); this.accounts$ = this.accountsState.state$.pipe( map((accounts) => (accounts == null ? {} : accounts)), ); this.activeAccount$ = this.activeAccountIdState.state$.pipe( combineLatestWith(this.accounts$), map(([id, accounts]) => (id ? ({ id, ...(accounts[id] as AccountInfo) } as Account) : null)), distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)), shareReplay({ bufferSize: 1, refCount: false }), ); this.accountActivity$ = this.globalStateProvider .get(ACCOUNT_ACTIVITY) .state$.pipe(map((activity) => activity ?? {})); this.sortedUserIds$ = this.accountActivity$.pipe( map((activity) => { return Object.entries(activity) .map(([userId, lastActive]: [UserId, Date]) => ({ userId, lastActive })) .sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime()) // later dates first .map((a) => a.userId); }), ); this.nextUpAccount$ = combineLatest([ this.accounts$, this.activeAccount$, this.sortedUserIds$, ]).pipe( map(([accounts, activeAccount, sortedUserIds]) => { const nextId = sortedUserIds.find((id) => id !== activeAccount?.id && accounts[id] != null); return nextId ? { id: nextId, ...accounts[nextId] } : null; }), ); } async addAccount(userId: UserId, accountData: AccountInfo): Promise { if (!Utils.isGuid(userId)) { throw new Error("userId is required"); } await this.accountsState.update((accounts) => { accounts ||= {}; accounts[userId] = accountData; return accounts; }); await this.setAccountActivity(userId, new Date()); } async setAccountName(userId: UserId, name: string): Promise { await this.setAccountInfo(userId, { name }); } async setAccountEmail(userId: UserId, email: string): Promise { await this.setAccountInfo(userId, { email }); } async setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise { await this.setAccountInfo(userId, { emailVerified }); } async clean(userId: UserId) { await this.setAccountInfo(userId, LOGGED_OUT_INFO); await this.removeAccountActivity(userId); } async switchAccount(userId: UserId | null): Promise { let updateActivity = false; await this.activeAccountIdState.update( (_, accounts) => { if (userId == null) { // indicates no account is active return null; } if (accounts?.[userId] == null) { throw new Error("Account does not exist"); } updateActivity = true; return userId; }, { combineLatestWith: this.accounts$, shouldUpdate: (id) => { // update only if userId changes return id !== userId; }, }, ); if (updateActivity) { await this.setAccountActivity(userId, new Date()); } } async setAccountActivity(userId: UserId, lastActivity: Date): Promise { if (!Utils.isGuid(userId)) { // only store for valid userIds return; } await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update( (activity) => { activity ||= {}; activity[userId] = lastActivity; return activity; }, { shouldUpdate: (oldActivity) => oldActivity?.[userId]?.getTime() !== lastActivity?.getTime(), }, ); } async removeAccountActivity(userId: UserId): Promise { await this.globalStateProvider.get(ACCOUNT_ACTIVITY).update( (activity) => { if (activity == null) { return activity; } delete activity[userId]; return activity; }, { shouldUpdate: (oldActivity) => oldActivity?.[userId] != null }, ); } // TODO: update to use our own account status settings. Requires inverting direction of state service accounts flow async delete(): Promise { try { this.messagingService?.send("logout"); } catch (e) { this.logService.error(e); throw e; } } private async setAccountInfo(userId: UserId, update: Partial): Promise { function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo { return { ...oldAccountInfo, ...update }; } await this.accountsState.update( (accounts) => { accounts[userId] = newAccountInfo(accounts[userId]); return accounts; }, { // Avoid unnecessary updates // TODO: Faster comparison, maybe include a hash on the objects? shouldUpdate: (accounts) => { if (accounts?.[userId] == null) { throw new Error("Account does not exist"); } return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId])); }, }, ); } }