// src/hooks/useArchive.ts // Delete Archive (Phase 1b). Owns the archive-confirm modal's open/submit state around an // injected archiveFn, so every delete-affordance site wires the same three lines. See // docs/_CLAUDE/2026-07-16-delete-to-archive-phase1b-design.md. import { useState, useCallback, useRef } from 'react'; import type { ArchiveReason } from '../utils/archivedResource'; import { describeFetchFailure, ownFailureSentence } from '../utils/fetchFailureMessage'; /** Thrown by an archive function to say, in its own words, why nothing (or not everything) was archived. */ export class ArchiveNotice extends Error { // A protected member makes the class nominal: no other error, whatever its fields, passes as this one. protected readonly ownedNotice!: true; constructor(message: string) { super(message); this.name = 'ArchiveNotice'; } } /** Returned by an archive function that decided, before writing, that this attempt no longer belongs here. */ export const ARCHIVE_ABANDONED = Symbol('archive abandoned'); export const useArchive = ( // `signal` aborts when this attempt no longer owns the dialog (closed, reopened, or submitted again), so the // caller can stop preparing or sending its write rather than only dropping its answer. archiveFn: (reason: ArchiveReason, note: string | undefined, signal: AbortSignal) => Promise, ) => { const [isOpen, setIsOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(); // The current operation. Opening, closing and submitting each start a new one, and the dialog, its error and // its busy state belong to it: a submit finishing late, after its dialog was closed (and perhaps opened // again), touches none of them. The write itself is not undone; only its local effects are dropped. const operation = useRef(0); const attempt = useRef(); const abandon = () => { attempt.current?.abort(); attempt.current = undefined; }; const open = useCallback(() => { abandon(); operation.current += 1; setSubmitting(false); setError(undefined); setIsOpen(true); }, []); const close = useCallback(() => { abandon(); operation.current += 1; setSubmitting(false); setIsOpen(false); }, []); // Returns true only when the archive PUT succeeded. Callers that navigate away on archive // MUST gate the navigation on this (navigate only when true) - otherwise a failed archive // silently navigates as if it worked and the `error` below can never render. A submit // overtaken by a close, an opening or another submit returns false: what is on screen is not its own. const submit = useCallback( async (reason: ArchiveReason, note?: string): Promise => { abandon(); const mine = ++operation.current; const controller = new AbortController(); attempt.current = controller; setSubmitting(true); setError(undefined); try { // Abandoned before writing: nothing was archived, and this dialog (or its replacement) is left as it is. if (await archiveFn(reason, note, controller.signal) === ARCHIVE_ABANDONED) return false; if (operation.current !== mine) return false; setIsOpen(false); return true; } catch (e) { console.error('Archive failed:', e); if (operation.current === mine) setError(e instanceof ArchiveNotice ? ownFailureSentence(e) : describeFetchFailure(e, 'Failed to archive')); return false; } finally { if (operation.current === mine) setSubmitting(false); } }, [archiveFn], ); return { isOpen, open, close, submit, submitting, error }; };