diff --git a/src/app/chain/page.tsx b/src/app/chain/page.tsx index 7355797f..e32f006e 100644 --- a/src/app/chain/page.tsx +++ b/src/app/chain/page.tsx @@ -12,6 +12,7 @@ import { supabase, type Storyline } from "../../../lib/supabase"; import { STORY_FACTORY } from "../../../lib/contracts/constants"; import { useChainPlot } from "../../hooks/useChainPlot"; import type { PublishState } from "../../hooks/usePublish"; +import { PublishRecovery } from "../../components/PublishRecovery"; import Link from "next/link"; import { ConnectWallet } from "../../components/ConnectWallet"; import { Select } from "../../components/Select"; @@ -101,6 +102,9 @@ export default function ChainPlotPage() { return (
+ {/* Recovery banner for failed indexing */} + +

Chain Plot

diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index e1a4536b..eb70ad04 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -8,6 +8,7 @@ import { MAX_CONTENT_LENGTH, } from "../../../lib/content"; import { usePublish, type PublishState } from "../../hooks/usePublish"; +import { PublishRecovery } from "../../components/PublishRecovery"; import { storyFactoryAbi, storylineCreatedEvent } from "../../../lib/contracts/abi"; import { STORY_FACTORY } from "../../../lib/contracts/constants"; import { decodeEventLog, encodeEventTopics } from "viem"; @@ -123,6 +124,9 @@ export default function CreateStorylinePage() { return (
+ {/* Recovery banner for failed indexing */} + + {/* Manuscript header */}

diff --git a/src/components/PublishRecovery.tsx b/src/components/PublishRecovery.tsx new file mode 100644 index 00000000..ff6586dc --- /dev/null +++ b/src/components/PublishRecovery.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; +import { + usePublishIntent, + MAX_RETRY_COUNT, +} from "../hooks/usePublishIntent"; +import { EXPLORER_URL } from "../../lib/contracts/constants"; + +/** + * Recovery banner for failed indexing after successful on-chain tx. + * Mount on any page where publishing can occur (create, chain). + * Renders nothing if no pending intent exists. + */ +export function PublishRecovery() { + const { pendingIntent, retryIndexing, clearIntent } = usePublishIntent(); + const [retrying, setRetrying] = useState(false); + + if (!pendingIntent) return null; + + const maxRetriesExceeded = pendingIntent.retryCount >= MAX_RETRY_COUNT; + + const handleRetry = async () => { + setRetrying(true); + await retryIndexing(); + setRetrying(false); + }; + + return ( +
+
+ ! +
+

+ Previous publish needs indexing +

+

+ Your transaction was confirmed on-chain but indexing failed. + {maxRetriesExceeded + ? " Maximum retries reached — the backfill process will handle this automatically." + : " You can retry indexing or dismiss this notice."} +

+ + {/* Tx hash link */} + {pendingIntent.txHash && ( +

+ TX: + + {pendingIntent.txHash.slice(0, 10)}... + {pendingIntent.txHash.slice(-8)} + +

+ )} + + {/* Last error */} + {pendingIntent.lastError && ( +

+ {pendingIntent.lastError} + {pendingIntent.retryCount > 0 && ( + + {" "} + ({pendingIntent.retryCount}/{MAX_RETRY_COUNT} retries) + + )} +

+ )} + + {/* Actions */} +
+ {!maxRetriesExceeded && ( + + )} + +
+
+
+
+ ); +} diff --git a/src/hooks/usePublish.ts b/src/hooks/usePublish.ts index 11af6646..7e6684d7 100644 --- a/src/hooks/usePublish.ts +++ b/src/hooks/usePublish.ts @@ -5,6 +5,7 @@ import { useWriteContract } from "wagmi"; import { hashContent } from "../../lib/content"; import { publicClient } from "../../lib/rpc"; import type { Hex, Abi, TransactionReceipt } from "viem"; +import { usePublishIntent } from "./usePublishIntent"; export type PublishState = | "idle" @@ -36,15 +37,19 @@ interface PublishOptions { * * Manages the 5-state flow: uploading -> confirming -> pending -> indexing -> published. * Caches CID keyed by content hash for retry (skips re-upload if content unchanged). + * Integrates with usePublishIntent for crash-safe recovery. */ export function usePublish() { const [state, setState] = useState("idle"); const [error, setError] = useState(null); const [txHash, setTxHash] = useState(undefined); - const [receipt, setReceipt] = useState(undefined); + const [receipt, setReceipt] = useState( + undefined, + ); const cachedCid = useRef<{ cid: string; contentHash: string } | null>(null); const { writeContractAsync } = useWriteContract(); + const { saveIntent, persistTxHash, clearIntent } = usePublishIntent(); const execute = useCallback( async (opts: PublishOptions) => { @@ -75,28 +80,60 @@ export function usePublish() { cachedCid.current = { cid, contentHash }; } + // Save intent before wallet confirmation + saveIntent({ + content: opts.content, + metadata: opts.metadata ?? {}, + indexerRoute: opts.indexerRoute, + uploadKeyPrefix: opts.uploadKeyPrefix, + }); + // 2. Submit tx to wallet setState("confirming"); const writeCall = opts.buildWriteCall(cid, contentHash); - const hash = await writeContractAsync(writeCall); + let hash: Hex; + try { + hash = await writeContractAsync(writeCall); + } catch (err) { + // User rejected tx — clear intent, no recovery needed + clearIntent(); + throw err; + } setTxHash(hash); + // Persist tx hash after wallet confirms + persistTxHash(hash); + // 3. Wait for tx confirmation setState("pending"); - const receipt = await publicClient.waitForTransactionReceipt({ hash }); + const txReceipt = await publicClient.waitForTransactionReceipt({ + hash, + }); - setReceipt(receipt); + setReceipt(txReceipt); // 4. Trigger indexer setState("indexing"); await new Promise((r) => setTimeout(r, 5000)); - await fetch(opts.indexerRoute, { + const indexRes = await fetch(opts.indexerRoute, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ txHash: hash, content: opts.content, ...opts.metadata }), + body: JSON.stringify({ + txHash: hash, + content: opts.content, + ...opts.metadata, + }), }); + // 409 = already indexed, treat as success + if (!indexRes.ok && indexRes.status !== 409) { + throw new Error(`Indexing failed (${indexRes.status})`); + } + + // Clear intent on success + clearIntent(); + // 5. Done setState("published"); cachedCid.current = null; @@ -107,7 +144,7 @@ export function usePublish() { setState("error"); } }, - [writeContractAsync], + [writeContractAsync, saveIntent, persistTxHash, clearIntent], ); const reset = useCallback(() => { diff --git a/src/hooks/usePublishIntent.ts b/src/hooks/usePublishIntent.ts new file mode 100644 index 00000000..e8be2e08 --- /dev/null +++ b/src/hooks/usePublishIntent.ts @@ -0,0 +1,164 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; + +export interface PublishIntent { + txHash: string | null; + content: string; + metadata: Record; + indexerRoute: string; + uploadKeyPrefix: string; + createdAt: number; + retryCount: number; + lastError: string | null; +} + +const STORAGE_KEY = "plotlink_publish_intent_v1"; +const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours +export const MAX_RETRY_COUNT = 5; + +function readIntent(): PublishIntent | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw) as PublishIntent; + } catch { + return null; + } +} + +function writeIntent(intent: PublishIntent): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(intent)); + } catch { + // localStorage full or unavailable + } +} + +function removeIntent(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // silent + } +} + +/** + * localStorage-backed publish intent for crash-safe recovery. + * + * Stores intent before wallet confirmation. If the browser crashes after + * on-chain tx but before indexing completes, the intent survives and can + * be retried on next page load. + */ +export function usePublishIntent() { + const [pendingIntent, setPendingIntent] = useState( + () => { + if (typeof window === "undefined") return null; + const intent = readIntent(); + if (!intent) return null; + // Discard intents without txHash that are older than 24h + if ( + !intent.txHash && + Date.now() - intent.createdAt > STALE_THRESHOLD_MS + ) { + removeIntent(); + return null; + } + // Only surface intents that have a txHash (tx confirmed) but indexing never succeeded + return intent.txHash ? intent : null; + }, + ); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const saveIntent = useCallback( + (data: Omit) => { + const intent: PublishIntent = { + ...data, + txHash: null, + createdAt: Date.now(), + retryCount: 0, + lastError: null, + }; + writeIntent(intent); + }, + [], + ); + + const persistTxHash = useCallback((hash: string) => { + const intent = readIntent(); + if (!intent) return; + const updated = { ...intent, txHash: hash }; + writeIntent(updated); + // Don't setPendingIntent here — avoids recovery UI flash during active session + }, []); + + const clearIntent = useCallback(() => { + removeIntent(); + if (mountedRef.current) setPendingIntent(null); + }, []); + + const retryIndexing = useCallback(async (): Promise<{ + success: boolean; + error?: string; + }> => { + const intent = readIntent(); + if (!intent?.txHash) { + return { success: false, error: "No transaction found" }; + } + + try { + const response = await fetch(intent.indexerRoute, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + txHash: intent.txHash, + content: intent.content, + ...intent.metadata, + }), + }); + + // 409 = already indexed (idempotent success) + if (response.ok || response.status === 409) { + removeIntent(); + if (mountedRef.current) setPendingIntent(null); + return { success: true }; + } + + const errorMessage = `Indexer error (${response.status})`; + const updated: PublishIntent = { + ...intent, + retryCount: intent.retryCount + 1, + lastError: errorMessage, + }; + writeIntent(updated); + if (mountedRef.current) setPendingIntent(updated); + return { success: false, error: errorMessage }; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Network error"; + const updated: PublishIntent = { + ...intent, + retryCount: intent.retryCount + 1, + lastError: errorMessage, + }; + writeIntent(updated); + if (mountedRef.current) setPendingIntent(updated); + return { success: false, error: errorMessage }; + } + }, []); + + return { + pendingIntent, + saveIntent, + persistTxHash, + clearIntent, + retryIndexing, + }; +}