Open History to see the last commit
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 { shortcutLabelForCommand } from "../keybindings";
10import { announce, cancelAnnouncement } from "../lib/announce";
11import { appAtomRegistry } from "../rpc/atomRegistry";
12import { primaryServerKeybindingsAtom } from "../state/server";
13import * as ThreadUndo from "@t3tools/client-runtime/state/thread-undo";
14
15type UndoOptions = {
16 action: "Settled" | "Snoozed" | "Unpinned" | "Archived" | "Discarded";
17 undo: () => Promise<AtomCommandResult<unknown, unknown>>;
18 failureTitle: string;
19 claim: ReturnType<typeof ThreadUndo.begin>;
20 commit?: () => void;
21};
22
23type UndoNotice = {
24 action: UndoOptions["action"];
25 count: number;
26 undo: () => Promise<void>;
27};
28
29export const useThreadUndoNotice = create<{ notice: UndoNotice | null }>(() => ({ notice: null }));
30
31const UNDONE_ACTION = {
32 Settled: "settle",
33 Snoozed: "snooze",
34 Unpinned: "unpin",
35 Archived: "archive",
36 Discarded: "discard",
37} satisfies Record<UndoOptions["action"], string>;
38
39function countLabel(action: UndoOptions["action"], count: number) {
40 const noun = action === "Discarded" ? "draft" : "thread";
41 return `${count} ${noun}${count === 1 ? "" : "s"}`;
42}
43
44function threadUndoNoticeSummary(notice: Pick<UndoNotice, "action" | "count">): string {
45 return `${notice.action} ${countLabel(notice.action, notice.count)}`;
46}
47
48export function threadUndoLabel(keybindings: Parameters<typeof shortcutLabelForCommand>[0]) {
49 const shortcut = shortcutLabelForCommand(keybindings, "thread.undo");
50 return shortcut ? `${shortcut} to undo` : "Undo";
51}
52
53let announcedSummary: string | null = null;
54
55// A live region that mounts already filled is skipped by screen readers, and settling
56// usually moves focus to the next thread at the same moment, so the notice is announced
57// separately from the alert that shows it.
58function setNotice(notice: UndoNotice | null) {
59 useThreadUndoNotice.setState({ notice });
60 const summary = notice ? threadUndoNoticeSummary(notice) : null;
61 if (summary === announcedSummary) return;
62 announcedSummary = summary;
63 if (summary) {
64 const label = threadUndoLabel(appAtomRegistry.get(primaryServerKeybindingsAtom));
65 announce(`${summary}, ${label}`, "thread-undo-notice");
66 } else {
67 cancelAnnouncement("thread-undo-notice");
68 }
69}
70
71let liveUndos: UndoOptions[] = [];
72let expiry: ReturnType<typeof setTimeout> | undefined;
73// A new notice owns mod+z even inside a text field, until the user presses
74// another key or edits text. After that, mod+z is the field's own undo again.
75let shortcutClaimed = false;
76
77function refreshNotice() {
78 const stale = liveUndos.filter(({ claim }) => !claim.isCurrent());
79 liveUndos = liveUndos.filter((entry) => !stale.includes(entry));
80 for (const entry of stale) entry.commit?.();
81 const latest = liveUndos.at(-1);
82 if (!latest) {
83 clearTimeout(expiry);
84 setNotice(null);
85 return;
86 }
87 const group: UndoOptions[] = [];
88 for (let index = liveUndos.length - 1; index >= 0; index--) {
89 const entry = liveUndos[index]!;
90 if (entry.action !== latest.action) break;
91 group.push(entry);
92 }
93 setNotice({
94 action: latest.action,
95 count: group.length,
96 undo: async () => {
97 const current = group.filter((entry) => liveUndos.includes(entry) && entry.claim.isCurrent());
98 liveUndos = liveUndos.filter((entry) => !group.includes(entry));
99 // Claim before awaiting to prevent repeated actions restoring a group twice.
100 for (const entry of current) entry.claim.finish();
101 refreshNotice();
102 let undone = 0;
103 await Promise.all(
104 current.map(async ({ undo, failureTitle }) => {
105 const reportFailure = (error: unknown) => {
106 toastManager.add(
107 stackedThreadToast({
108 type: "error",
109 title: failureTitle,
110 description: error instanceof Error ? error.message : "An error occurred.",
111 }),
112 );
113 };
114 try {
115 const result = await undo();
116 if (result._tag === "Success") undone++;
117 else if (!isAtomCommandInterrupted(result)) {
118 reportFailure(squashAtomCommandFailure(result));
119 }
120 } catch (error) {
121 reportFailure(error);
122 }
123 }),
124 );
125 // Failures already raise a toast, so only what was actually restored is spoken.
126 if (undone > 0) {
127 announce(`Undid ${UNDONE_ACTION[latest.action]} of ${countLabel(latest.action, undone)}`);
128 }
129 },
130 });
131}
132
133ThreadUndo.subscribe(refreshNotice);
134
135export function undoLatestThreadAction(): boolean {
136 const notice = useThreadUndoNotice.getState().notice;
137 if (!notice) return false;
138 void notice.undo();
139 return true;
140}
141
142/** Whether mod+z should undo the notice's actions even while a text field is focused. */
143export function threadUndoClaimsShortcut(): boolean {
144 return shortcutClaimed && useThreadUndoNotice.getState().notice !== null;
145}
146
147/** Called on any other keypress or text edit; hands mod+z back to text fields. */
148export function releaseThreadUndoShortcut() {
149 shortcutClaimed = false;
150}
151
152/** Shows one compact confirmation for the currently undoable thread actions. */
153export function showThreadUndoNotice(options: UndoOptions) {
154 if (!options.claim.isCurrent()) return;
155 liveUndos.push(options);
156 shortcutClaimed = true;
157 refreshNotice();
158 clearTimeout(expiry);
159 expiry = setTimeout(() => {
160 const expired = liveUndos;
161 liveUndos = [];
162 for (const { claim, commit } of expired) {
163 claim.finish();
164 commit?.();
165 }
166 refreshNotice();
167 }, 5_000);
168}