| 1 | import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; |
| 2 | import { app } from "./bridge"; |
| 3 | import { inboxQueueDrafts, type QueueEditDraft } from "./inboxQueueDrafts"; |
| 4 | import type { InboxQueueRequest, InboxQueueResult } from "./inboxQueueCommands"; |
| 5 | import type { InboxSnapshotLike } from "./composerInboxQueue"; |
| 6 | import type { InboxTarget } from "./pendingFollowup"; |
| 7 | |
| 8 | export function useInboxQueueEditor(scope: string, tabId: string, sessionPath: string, apply: (snapshot: InboxSnapshotLike) => unknown, refresh: () => void) { |
| 9 | const drafts = useSyncExternalStore(inboxQueueDrafts.subscribe, () => inboxQueueDrafts.get(scope)); |
| 10 | const [open, setOpen] = useState(Boolean(drafts.edit)); |
| 11 | const [busy, setBusy] = useState(false); |
| 12 | const [notice, setNotice] = useState(""); |
| 13 | const owner = useRef<object | null>(null); |
| 14 | const gate = useRef<{ owner: object } | null>(null); |
| 15 | useLayoutEffect(() => { |
| 16 | owner.current = {}; |
| 17 | gate.current = null; |
| 18 | setBusy(false); |
| 19 | return () => { owner.current = null; gate.current = null; }; |
| 20 | }, [scope, tabId, sessionPath]); |
| 21 | // Component lifetime, not just session path: leaving and returning to the |
| 22 | // same session creates a new editor that an old reply must never clear. |
| 23 | const operation = async (run: (check: () => void) => Promise<void>) => { |
| 24 | if (gate.current) return; |
| 25 | if (!owner.current) return; |
| 26 | const ticket = { owner: owner.current }; |
| 27 | gate.current = ticket; setBusy(true); |
| 28 | const current = () => owner.current === ticket.owner && gate.current === ticket; |
| 29 | const check = () => { if (!current()) throw new Error("queue editor operation expired"); }; |
| 30 | try { await run(check); } catch { if (current()) setNotice("error"); } |
| 31 | finally { if (current()) { gate.current = null; setBusy(false); } } |
| 32 | }; |
| 33 | const capture = async (check: () => void) => { |
| 34 | check(); |
| 35 | if (!app.CaptureInboxTarget || !app.InboxQueueForTarget) throw new Error("unsupported"); |
| 36 | const target = await app.CaptureInboxTarget(tabId, sessionPath); |
| 37 | check(); |
| 38 | return target; |
| 39 | }; |
| 40 | const call = async (target: InboxTarget, request: InboxQueueRequest, check: () => void): Promise<InboxQueueResult> => { |
| 41 | check(); |
| 42 | if (!app.InboxQueueForTarget) throw new Error("unsupported"); |
| 43 | const result = await app.InboxQueueForTarget(target, request); |
| 44 | check(); |
| 45 | if (result.snapshot?.sessionPath === sessionPath) apply(result.snapshot); |
| 46 | return result; |
| 47 | }; |
| 48 | const edit = (id: string) => operation(async check => { |
| 49 | const retained = inboxQueueDrafts.get(scope).edit; |
| 50 | if (retained?.id === id) { setOpen(true); return; } |
| 51 | const target = await capture(check); |
| 52 | const result = await call(target, { kind: "read", itemId: id }, check); |
| 53 | if (!result.edit) { setNotice(result.reason || "error"); return; } |
| 54 | const state = inboxQueueDrafts.get(scope); |
| 55 | if (state.edit !== retained) return; |
| 56 | inboxQueueDrafts.set(scope, { edit: { ...result.edit, target, value: result.edit.text }, recovered: state.edit && (state.edit.value !== state.edit.text || state.edit.reason) ? [...state.recovered, state.edit] : state.recovered }); |
| 57 | setNotice(""); setOpen(true); |
| 58 | }); |
| 59 | const update = (value: string) => { |
| 60 | const state = inboxQueueDrafts.get(scope); |
| 61 | if (state.edit) inboxQueueDrafts.set(scope, { ...state, edit: { ...state.edit, value } }); |
| 62 | }; |
| 63 | const save = () => operation(async check => { |
| 64 | const state = inboxQueueDrafts.get(scope), draft = state.edit; |
| 65 | if (!draft || !draft.value.trim()) return; |
| 66 | let result: InboxQueueResult; |
| 67 | try { |
| 68 | result = await call(draft.target, { kind: "edit", itemId: draft.id, text: draft.value, contentVersion: draft.contentVersion }, check); |
| 69 | } catch { |
| 70 | check(); |
| 71 | // Reconcile without resubmitting. A missing/executing item is not proof |
| 72 | // that the attempted save succeeded. |
| 73 | try { await call(draft.target, { kind: "snapshot" }, check); } catch { /* Keep draft. */ } |
| 74 | check(); |
| 75 | if (inboxQueueDrafts.get(scope).edit !== draft) return; |
| 76 | inboxQueueDrafts.set(scope, { ...inboxQueueDrafts.get(scope), edit: { ...draft, reason: "unconfirmed" } }); |
| 77 | setNotice("unconfirmed"); return; |
| 78 | } |
| 79 | if (inboxQueueDrafts.get(scope).edit !== draft) return; |
| 80 | if (result.outcome === "applied" || result.outcome === "unchanged") { |
| 81 | inboxQueueDrafts.set(scope, { ...inboxQueueDrafts.get(scope), edit: undefined }); |
| 82 | setOpen(false); setNotice("saved"); |
| 83 | } else { |
| 84 | inboxQueueDrafts.set(scope, { ...inboxQueueDrafts.get(scope), edit: { ...draft, reason: result.reason || "error" } }); |
| 85 | setNotice(result.reason || "error"); |
| 86 | } |
| 87 | refresh(); |
| 88 | }); |
| 89 | const mutate = (request: InboxQueueRequest) => operation(async check => { |
| 90 | const result = await call(await capture(check), request, check); |
| 91 | setNotice(result.reason || ""); refresh(); |
| 92 | }); |
| 93 | const recover = (): string | undefined => { |
| 94 | if (gate.current) return; |
| 95 | const state = inboxQueueDrafts.get(scope); |
| 96 | if (!state.edit) return; |
| 97 | inboxQueueDrafts.set(scope, { edit: undefined, recovered: [...state.recovered, state.edit] }); |
| 98 | setOpen(false); setNotice("retained"); |
| 99 | return state.edit.value; |
| 100 | }; |
| 101 | const reopen = (draft: QueueEditDraft) => { |
| 102 | if (gate.current) return; |
| 103 | const state = inboxQueueDrafts.get(scope); |
| 104 | inboxQueueDrafts.set(scope, { edit: draft, recovered: [...state.recovered.filter(d => d !== draft), ...(state.edit ? [state.edit] : [])] }); |
| 105 | setOpen(true); |
| 106 | }; |
| 107 | const reload = () => operation(async check => { |
| 108 | const state = inboxQueueDrafts.get(scope); |
| 109 | if (!state.edit) return; |
| 110 | // Reload is an explicit rebase onto the current session selection. Saving |
| 111 | // still uses the originally captured target and content version. |
| 112 | const target = await capture(check); |
| 113 | const result = await call(target, { kind: "read", itemId: state.edit.id }, check); |
| 114 | if (!result.edit) { setNotice(result.reason || "error"); return; } |
| 115 | const latest = inboxQueueDrafts.get(scope); |
| 116 | if (latest.edit !== state.edit) return; |
| 117 | inboxQueueDrafts.set(scope, { recovered: [...latest.recovered, state.edit], edit: { ...result.edit, target, value: result.edit.text } }); |
| 118 | setNotice(""); |
| 119 | }); |
| 120 | const close = () => { |
| 121 | if (gate.current) return; |
| 122 | const state = inboxQueueDrafts.get(scope); |
| 123 | if (state.edit && state.edit.value === state.edit.text && !state.edit.reason) { |
| 124 | inboxQueueDrafts.set(scope, { ...state, edit: undefined }); |
| 125 | } |
| 126 | setOpen(false); setNotice(""); |
| 127 | }; |
| 128 | return { drafts, open, busy, notice, edit, update, save, mutate, recover, reopen, reload, close, show: () => { if (!gate.current) setOpen(true); } }; |
| 129 | } |
| 130 |