import { useActorRef, useSelector } from "@xstate/react"; import { useMemo, type ReactNode } from "react"; import { Actor, fromCallback, fromPromise } from "xstate"; import { useAppConfig } from "../AppConfig/AppConfig.tsx"; import { Failure, Success } from "../async-op.ts"; import type { IndieTabletopClient } from "../client.ts"; import { createStrictContext } from "../createStrictContext.ts"; import type { ModernIDB } from "../ModernIDB/ModernIDB.ts"; import type { ModernIDBIndexes, ModernIDBSchema } from "../ModernIDB/types.ts"; import type { CurrentUser } from "../types.ts"; import { machine, type AppMachine, type PullChangesInput, type PushChangesInput, } from "./store.ts"; import type { MachineEvent, PullResult, PushResult } from "./types.ts"; import { toSyncedItems, type UserGameDataShape } from "./utils.ts"; export type AppActions = ReturnType; type AppActor = Actor; export const [AppMachineContext, useAppMachineContext] = createStrictContext<{ actorRef: AppActor; actions: AppActions; }>("App Machine"); export function useCurrentUser() { const { actorRef } = useAppMachineContext(); return useSelector(actorRef, (s) => s.context.currentUser); } export function useSessionInfo() { const { actorRef } = useAppMachineContext(); return useSelector(actorRef, (s) => s.context.sessionInfo); } /** * Checks if the app is in an authenticated state. * * This means that the user that might have been provided in local storage * has been checked with the server and they a) have a valid session and * b) are matching the current user. */ export function useIsAuthenticated() { const { actorRef } = useAppMachineContext(); return useSelector(actorRef, (s) => s.matches("authenticated")); } export function useSyncLog() { const { actorRef } = useAppMachineContext(); return useSelector(actorRef, (s) => s.context.syncLog); } export function useLastSuccessfulSyncTs() { const { actorRef } = useAppMachineContext(); return useSelector(actorRef, (s) => s.context.lastSuccessfulSyncTs); } export type SyncState = ReturnType; /** * Returns the app's current {@link SyncState}. * * Note that this is not a property within the app's machine context, it is a * derived value based on several factors. * */ export function useSyncState() { const { actorRef } = useAppMachineContext(); return useSelector(actorRef, (s) => { switch (true) { case s.matches({ authenticated: { sync: "syncing" } }): { return "SYNCING"; } case s.matches({ authenticated: { sync: "waiting" } }): { return "WAITING"; } case s.matches({ authenticated: { sync: "idle" } }): { const lastSync = s.context.syncLog[0]; if ( lastSync?.pull?.type === "FAILURE" || lastSync?.push?.type === "FAILURE" ) { return "ERROR"; } return "IDLE"; } case s.matches("unauthenticated") && !!s.context.currentUser: { return "AUTHENTICATING"; } default: { return "INACTIVE"; } } }); } export function useAppActions() { return useAppMachineContext().actions; } export type DatabaseAppMachineMethods = { upsertGameData(data: GameDataPayload): Promise>; getUpdatedGameDataSince(props: { sinceTs: number | null; exclude: Set; }): Promise>; clearAll(): Promise | Failure>; }; export function createMachine< Schema extends ModernIDBSchema, Indexes extends ModernIDBIndexes, SnapshotPayload, GameDataPayload extends UserGameDataShape, RulesetPayload, >(options: { database: ModernIDB & DatabaseAppMachineMethods; client: IndieTabletopClient; /** * Which games should the machine pull game data for? * * If null is provided, no data will be pulled. * * @remarks Honestly this is a bit hacky and we should have a better way to * disable sync entirely for apps that don't need it (e.g. the Creators App). */ pullGameDataFor: | (string & keyof GameDataPayload) | (string & keyof GameDataPayload)[] | null; }) { const { client, database } = options; // Concrete implementations of actors that our machine requires. const auth = fromCallback(({ sendBack }) => { const controller = new AbortController(); client.addEventListener( "currentUser", ({ detail }) => void sendBack({ type: "currentUser", ...detail }), controller, ); client.addEventListener( "sessionInfo", ({ detail }) => void sendBack({ type: "sessionInfo", ...detail }), controller, ); client.addEventListener( "sessionExpired", () => void sendBack({ type: "sessionExpired" }), controller, ); return () => { controller.abort(); }; }); const sync = fromCallback(({ sendBack }) => { const controller = new AbortController(); // As long as sync is active, make sure to `pull` after every window focus. window.addEventListener( "focus", () => void sendBack({ type: "pull" }), controller, ); // If there is a `readwrite` transaction in IndexedDB trigger a `push`. database.addEventListener( "readwrite", () => void sendBack({ type: "push" }), controller, ); return () => { controller.abort(); }; }); const pullChanges = fromPromise( async ({ input }) => { if (!options.pullGameDataFor) { return new Success({ pulled: [] }); } const result = await client.pullUserData({ include: options.pullGameDataFor, sinceTs: input.sinceTs, expectCurrentUserId: input.currentUser.id, }); const userGameData = result.valueOrThrow(); await database.upsertGameData(userGameData); return new Success({ pulled: toSyncedItems(userGameData) }); }, ); const pushChanges = fromPromise( async ({ input }) => { const { value: changedData } = await database.getUpdatedGameDataSince({ sinceTs: input.sinceTs, exclude: new Set(input.ignoredItems.map((i) => i.id)), }); const changed = toSyncedItems(changedData); if (changed.length === 0) { return new Success({ pushed: [], pulled: [] }); } const result = await client.pushUserData({ data: changedData, pullSinceTs: input.currentSyncTs, currentSyncTs: input.currentSyncTs, expectCurrentUserId: input.currentUser.id, }); const userGameData = result.valueOrThrow(); await database.upsertGameData(userGameData); return new Success({ pushed: changed, pulled: toSyncedItems(userGameData), }); }, ); return machine.provide({ actors: { auth, sync, pullChanges, pushChanges }, }); } function useCreateActions(app: AppActor) { const { database, client } = useAppConfig(); return useMemo(() => { async function clientLogout(params: { serverUser: CurrentUser }) { await database.clearAll(); app.send({ type: "serverUser", ...params }); // Get new session info await client.refreshTokens(); } async function serverLogout() { await client.logout(); } async function logout() { await client.logout(); await database.clearAll(); app.send({ type: "reset" }); } function push() { app.send({ type: "push" }); } function pull() { app.send({ type: "pull" }); } return { pull, push, logout, serverLogout, clientLogout, }; }, [app]); } export function AppMachineProvider(props: { value: typeof machine; children: ReactNode; }) { const actorRef = useActorRef(props.value); const actions = useCreateActions(actorRef); const context = useMemo(() => ({ actorRef, actions }), [actorRef, actions]); return ( {props.children} ); }