Skip to content
Source
Sign inSign up

threadUndo.ts

82 lines2.6 KB

1// Shared across hook instances so sidebar, header and menu actions invalidate each other.
2const currentActions = new Map<string, symbol>();
3const listeners = new Set<() => void>();
4​
5export function subscribe(listener: () => void) {
6 listeners.add(listener);
7 return () => {
8 listeners.delete(listener);
9 };
11​
12function notify() {
13 for (const listener of listeners) listener();
15​
16export function begin(kind: string, threadKey: string) {
17 const key = JSON.stringify([kind, threadKey]);
18 const token = Symbol();
19 currentActions.set(key, token);
20 notify();
21 const isCurrent = () => currentActions.get(key) === token;
22 return {
23 isCurrent,
24 finish: () => {
25 if (isCurrent()) {
26 currentActions.delete(key);
27 notify();
28 }
29 },
30 };
32​
33export function invalidate(kind: string, threadKey: string) {
34 currentActions.delete(JSON.stringify([kind, threadKey]));
35 notify();
37​
38export type ThreadUndoStep =
39 | { readonly action: "unsettle" }
40 | { readonly action: "unsnooze" }
41 | { readonly action: "pin"; readonly orderKey?: string }
42 | { readonly action: "snooze"; readonly snoozedUntil: string };
43​
44export type ThreadUndoPlan = readonly [ThreadUndoStep, ...ThreadUndoStep[]];
45​
46export function planThreadUndo(
47 action: "settle" | "snooze" | "unpin",
48 thread?: {
49 readonly pinnedAt?: string | null;
50 readonly pinOrderKey?: string | null;
51 readonly snoozedUntil?: string | null;
52 } | null,
53): ThreadUndoPlan {
54 if (action === "snooze") return [{ action: "unsnooze" }];
55 const pin: ThreadUndoStep = {
56 action: "pin",
57 ...(thread?.pinOrderKey != null ? { orderKey: thread.pinOrderKey } : {}),
58 };
59 // Pinning clears snooze on the server; restore it after restoring pin placement.
60 const snooze =
61 thread?.snoozedUntil != null
62 ? [{ action: "snooze" as const, snoozedUntil: thread.snoozedUntil }]
63 : [];
64 if (action === "unpin") return [pin, ...snooze];
65 return [{ action: "unsettle" }, ...(thread?.pinnedAt != null ? [pin] : []), ...snooze];
67​
68export async function executeThreadUndo<Result>(
69 plan: ThreadUndoPlan,
70 execute: (step: ThreadUndoStep) => Promise<Result>,
71 succeeded: (result: Result) => boolean,
72): Promise<Result> {
73 let result = await execute(plan[0]);
74 for (const step of plan.slice(1)) {
75 if (!succeeded(result)) return result;
76 // A snooze can expire before Undo runs, and the server rejects an expired restore.
77 // @effect-diagnostics-next-line globalDate:off
78 if (step.action === "snooze" && Date.parse(step.snoozedUntil) <= Date.now()) continue;
79 result = await execute(step);
80 }
81 return result;