import { ExtractRequestBody } from "@noya-app/noya-multiplayer-react"; import { uuid } from "@noya-app/noya-utils"; import { Observable } from "@noya-app/observable"; import { RPCManager } from "./rpcManager"; export type FilePropertyManagerOptions = { initialName?: string; }; type UpdateNameResponse = { name: string; }; type PendingChange = { id: string; name: string; }; /** * Manages synchronization of the file name between the local client and * the multiplayer durable object. */ export class FilePropertyManager { name$ = new Observable(undefined); private pendingChanges$ = new Observable([]); optimisticValue$ = Observable.combine( [ this.name$, this.pendingChanges$.map((pending) => pending.at(-1)?.name), ], ([name, optimistic]) => optimistic ?? name ); isInitialized$ = new Observable(false); isUpdating$ = this.pendingChanges$.map( (pending) => pending.length > 0 ); constructor( private rpcManager: RPCManager, options: FilePropertyManagerOptions = {} ) { if (options.initialName !== undefined) { this.setName(options.initialName); } } setName(name: string) { this.name$.set(name); this.isInitialized$.set(true); } applyServerUpdate({ name, changeId }: { name: string; changeId?: string }) { this.setName(name); if (changeId) { this.removePendingChange(changeId); } } /** * Optimistically update the file name and persist the change through the * durable object. */ updateName = async (name: string): Promise => { if (!name) { name = ""; } const changeId = uuid(); const pending = this.pendingChanges$.get(); this.pendingChanges$.set([...pending, { id: changeId, name }]); try { const payload: ExtractRequestBody<"PUT /api/file"> = { name, changeId }; const response = await this.rpcManager.requestRoute("PUT /api/file", { body: JSON.stringify(payload), headers: { "Content-Type": "application/json" }, }); const parsed = this.rpcManager.getResponseBody(response) as | UpdateNameResponse | undefined; const resolvedName = typeof parsed?.name === "string" && parsed.name.length > 0 ? parsed.name : name; this.setName(resolvedName); return resolvedName; } catch (error) { this.removePendingChange(changeId); throw error; } }; acknowledgeNameChange(changeId: string) { this.removePendingChange(changeId); } private removePendingChange(changeId: string) { const pending = this.pendingChanges$.get(); const next = pending.filter((change) => change.id !== changeId); if (next.length !== pending.length) { this.pendingChanges$.set(next); } } }