1import {
2 type AtomCommandResult,
3 isAtomCommandInterrupted,
4 squashAtomCommandFailure,
5} from "@t3tools/client-runtime/state/runtime";
6import { create } from "zustand";
7
8import { stackedThreadToast, toastManager } from "../components/ui/toast";
9import * as ThreadUndo from "./threadUndo";
10
11type UndoOptions = {
12 action: "Settled" | "Snoozed" | "Unpinned" | "Archived" | "Discarded";
13 undo: () => Promise<AtomCommandResult<unknown, unknown>>;
14 failureTitle: string;
15 claim: ReturnType<typeof ThreadUndo.begin>;
16 /** Runs once the action can no longer be undone. */
17 commit?: () => void;
18};
19
20type UndoNotice = {
21 action: UndoOptions["action"];
22 count: number;
23 undo: () => Promise<void>;
24};
25
26export const useThreadUndoNotice = create<{ notice: UndoNotice | null }>(() => ({ notice: null }));
27
28// Shared across sidebar, header and menu actions. Consecutive actions of the
29// same kind share one notice and can be restored together.
30let liveUndos: UndoOptions[] = [];
31let expiry: ReturnType<typeof setTimeout> | undefined;
32
33function refreshNotice() {
34 const stale = liveUndos.filter(({ claim }) => !claim.isCurrent());
35 liveUndos = liveUndos.filter((entry) => !stale.includes(entry));
36 for (const entry of stale) entry.commit?.();
37 const latest = liveUndos.at(-1);
38 if (!latest) {
39 clearTimeout(expiry);
40 useThreadUndoNotice.setState({ notice: null });
41 return;
42 }
43 const group: UndoOptions[] = [];
44 for (let index = liveUndos.length - 1; index >= 0; index--) {
45 const entry = liveUndos[index]!;
46 if (entry.action !== latest.action) break;
47 group.push(entry);
48 }
49 useThreadUndoNotice.setState({
50 notice: {
51 action: latest.action,
52 count: group.length,
53 undo: async () => {
54 const current = group.filter(
55 (entry) => liveUndos.includes(entry) && entry.claim.isCurrent(),
56 );
57 liveUndos = liveUndos.filter((entry) => !group.includes(entry));
58 // Consume every claim before awaiting, so repeated clicks or shortcuts
59 // cannot restore the same group twice.
60 for (const entry of current) entry.claim.finish();
61 refreshNotice();
62 await Promise.all(
63 current.map(async ({ undo, failureTitle }) => {
64 const reportFailure = (error: unknown) => {
65 toastManager.add(
66 stackedThreadToast({
67 type: "error",
68 title: failureTitle,
69 description: error instanceof Error ? error.message : "An error occurred.",
70 }),
71 );
72 };
73 try {
74 const result = await undo();
75 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
76 reportFailure(squashAtomCommandFailure(result));
77 }
78 } catch (error) {
79 reportFailure(error);
80 }
81 }),
82 );
83 },
84 },
85 });
86}
87
88ThreadUndo.subscribe(refreshNotice);
89
90/** Runs the group displayed in the sidebar; false when nothing is left to undo. */
91export function undoLatestThreadAction(): boolean {
92 const notice = useThreadUndoNotice.getState().notice;
93 if (!notice) return false;
94 void notice.undo();
95 return true;
96}
97
98/** Shows one compact confirmation for the currently undoable thread actions. */
99export function showThreadUndoNotice(options: UndoOptions) {
100 if (!options.claim.isCurrent()) return;
101 liveUndos.push(options);
102 refreshNotice();
103 clearTimeout(expiry);
104 expiry = setTimeout(() => {
105 const expired = liveUndos;
106 liveUndos = [];
107 for (const { claim, commit } of expired) {
108 claim.finish();
109 commit?.();
110 }
111 refreshNotice();
112 }, 5_000);
113}