diff --git a/package.json b/package.json index 2c0e3b2..8a63852 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.2.12", + "version": "1.2.13", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/hooks/useDeleteEntity.ts b/src/hooks/useDeleteEntity.ts index f04ba21..0ebbf52 100644 --- a/src/hooks/useDeleteEntity.ts +++ b/src/hooks/useDeleteEntity.ts @@ -1,29 +1,33 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { isAdminPubkey } from '@/lib/admins'; import { addLocalHidden } from '@/lib/localModeration'; +import { toast } from '@/hooks/useToast'; import type { NostrEvent } from '@nostrify/nostrify'; /** - * Hard ceiling on how long the AWAITED part of the delete can run before we give - * up. This must be generous enough for an Amber round-trip: the user has to - * switch to Amber, approve, and switch back — which easily exceeds 40s if they - * fumble. Too short a ceiling was surfacing "taking longer than expected" even - * when Amber was about to succeed. The signer has its own 90s cap underneath, so - * we align to that plus headroom. + * DIAGNOSTIC DELETE + * ================= + * We could not identify WHERE the delete hangs (signing? relay publish?), so + * this version narrates every step on-screen via toasts and splits the flow into + * explicit, independently-timed phases: + * + * 1. SIGN — call the signer (Amber). Own timeout. If it hangs here, Amber + * never returned (app↔Amber problem). + * 2. PUBLISH — send the signed event to relays. Own timeout. If it hangs here, + * the relay is the problem, not Amber. + * + * Whatever step is on screen when it freezes tells us EXACTLY what to fix. */ -const DELETE_TIMEOUT_MS = 120_000; -function withHardTimeout(promise: Promise, ms: number): Promise { +const SIGN_TIMEOUT_MS = 90_000; // matches the signer's own cap +const PUBLISH_TIMEOUT_MS = 20_000; // relays should answer well within this + +function withTimeout(promise: Promise, ms: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { - reject( - new Error( - 'This is taking longer than expected. If you approved the request in ' + - 'your signer, the takedown may still go through — check back in a moment.', - ), - ); + reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s.`)); }, ms); promise.then( (v) => { @@ -38,30 +42,8 @@ function withHardTimeout(promise: Promise, ms: number): Promise { }); } -/** - * Deletion / takedown for an ADDRESSABLE event (kinds 30000–39999) such as a - * skate crew, a Lab profile, a spot, a tour, an event, etc. - * - * Two callers, two mechanisms: - * - * 1. The ORIGINAL AUTHOR deleting their own listing → NIP-09 kind:5 deletion - * request. Relays that honor NIP-09 stop serving the event. A deletion - * request is only valid when signed by the pubkey that created the target, - * so an author's request is respected network-wide. - * - * 2. A SkateSpots ADMIN (see src/lib/admins.ts) taking down someone else's - * content → we ALSO publish a kind:5 (honored by the community relay for - * its own stored events) AND a NIP-32 kind:1985 `hidden` label referencing - * the target coordinate. The label is authored by the admin, so the app's - * client-side moderation layer (useAdminModeration) can hide the content - * everywhere regardless of whether a given relay honors a cross-author - * NIP-09 request. This makes admin takedowns reliable inside the app. - * - * In all cases deletion on a decentralized network is best-effort: a relay that - * ignores NIP-09 may keep a cached copy, and other users' relays may retain it. - */ export function useDeleteEntity() { - const { mutateAsync: publishEvent } = useNostrPublish(); + const { nostr } = useNostr(); const { user } = useCurrentUser(); const queryClient = useQueryClient(); @@ -70,62 +52,96 @@ export function useDeleteEntity() { event, reason, }: { - /** The full addressable event to delete. */ event: NostrEvent; - /** Optional human-readable reason, stored in the deletion request content. */ reason?: string; - }) => withHardTimeout(runDelete(event, reason), DELETE_TIMEOUT_MS), + }) => runDelete(event, reason), onSuccess: () => { - // Nudge every list/detail query to refetch so the deleted entity drops out. queryClient.invalidateQueries(); }, }); async function runDelete(event: NostrEvent, reason?: string): Promise { - if (!user) throw new Error('You must be logged in to delete.'); + if (!user) throw new Error('You must be logged in to delete.'); - const isOwner = user.pubkey === event.pubkey; - const isAdmin = isAdminPubkey(user.pubkey); - if (!isOwner && !isAdmin) { - throw new Error('You can only delete your own listing.'); - } + const isOwner = user.pubkey === event.pubkey; + const isAdmin = isAdminPubkey(user.pubkey); + if (!isOwner && !isAdmin) { + throw new Error('You can only delete your own listing.'); + } - const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? ''; - const coord = `${event.kind}:${event.pubkey}:${dTag}`; + const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? ''; + const coord = `${event.kind}:${event.pubkey}:${dTag}`; - // NIP-09: reference by both the addressable coordinate (`a`) and the - // concrete event id (`e`), plus the kind (`k`), for maximum relay support. - const deletionTags: string[][] = [ - ['a', coord], - ['e', event.id], - ['k', String(event.kind)], - ]; + const deletionTags: string[][] = [ + ['a', coord], + ['e', event.id], + ['k', String(event.kind)], + ]; - // STEP 1 (required): publish the NIP-09 kind:5 deletion request. This is - // the ONE signature the UI waits on. useNostrPublish has its own internal - // sign + relay timeouts, and we wrap the whole thing in a hard ceiling - // (below) so the spinner can never hang forever. - await publishEvent({ - kind: 5, - content: reason?.trim() || '', - tags: deletionTags, + const template = { + kind: 5, + content: reason?.trim() || '', + tags: deletionTags, + created_at: Math.floor(Date.now() / 1000), + }; + + // ── PHASE 1: SIGN ──────────────────────────────────────────────────────── + toast({ + title: 'Step 1/2: Signing…', + description: 'Approve the request in Amber, then come back to the app.', + }); + + if (!user.signer) { + throw new Error('No signer available. Please log in again.'); + } + + let signed: NostrEvent; + try { + signed = await withTimeout( + user.signer.signEvent(template), + SIGN_TIMEOUT_MS, + 'Signing (Amber)', + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + toast({ + title: 'Stuck at SIGNING', + description: `Amber step failed: ${msg}`, + variant: 'destructive', }); + throw new Error(`SIGN step failed: ${msg}`); + } - // STEP 2 (admin takedown only): hide the target LOCALLY right now so it - // disappears from this device immediately. This needs NO signature. - // - // CRITICAL UX/BUG FIX: we deliberately do NOT fire additional signed - // events here (the kind:1985 moderation label or the NIP-86 relay ban). - // Each of those calls Amber, and Amber can only handle ONE approval at a - // time — firing a second/third signer request while the first is still in - // flight COLLIDES on Android and makes the whole delete hang ("taking - // longer than expected", Amber juggling multiple prompts). The single - // kind:5 deletion (step 1) already tells relays to drop the event, and the - // local hide covers this device instantly. The optional label / relay-ban - // can be performed separately from the relay moderator tools without - // blocking or breaking the interactive delete. - if (isAdmin && !isOwner) { - addLocalHidden({ coord, id: event.id }); - } + // ── PHASE 2: PUBLISH ───────────────────────────────────────────────────── + toast({ + title: 'Step 2/2: Publishing…', + description: 'Sending the deletion to relays.', + }); + + try { + await withTimeout( + nostr.event(signed), + PUBLISH_TIMEOUT_MS, + 'Publishing (relays)', + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + // The event was SIGNED fine — the relay is the slow/failing part. Hide it + // locally so the user's action still takes visible effect on this device. + if (isAdmin && !isOwner) addLocalHidden({ coord, id: event.id }); + toast({ + title: 'Stuck at PUBLISHING', + description: `Relay step failed: ${msg}. It was signed OK; the relay didn't confirm.`, + variant: 'destructive', + }); + throw new Error(`PUBLISH step failed: ${msg}`); + } + + // Admin: hide locally too (no signature needed). + if (isAdmin && !isOwner) { + addLocalHidden({ coord, id: event.id }); + } + + toast({ title: 'Deleted', description: 'The deletion was signed and published.' }); } }