import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { isAdminPubkey } from '@/lib/admins'; import { MODERATION_LABEL_NAMESPACE, MODERATION_LABEL_HIDDEN, } from '@/lib/types'; import { addLocalHidden } from '@/lib/localModeration'; import type { NostrEvent } from '@nostrify/nostrify'; /** * Hard ceiling on how long the whole delete can run before we give up and * surface an error. Guarantees the UI spinner can NEVER hang indefinitely, * whatever the signer/relay does underneath. */ const DELETE_TIMEOUT_MS = 40_000; function withHardTimeout(promise: Promise, ms: number): 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.', ), ); }, ms); promise.then( (v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); }, ); }); } /** * 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 { user } = useCurrentUser(); const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ 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), 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.'); 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}`; // 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)], ]; // 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, }); // STEP 2 (admin takedown only): record the target locally RIGHT NOW so the // app hides it on this device immediately — no waiting on a second signer // round-trip. Then fire the cross-device kind:1985 moderation label in the // BACKGROUND (not awaited): a second Amber approval, if needed, happens // after the dialog has already closed and never blocks the UI. This is the // fix for "stuck on Deleting…": the mutation resolves as soon as kind:5 is // published, regardless of the label. if (isAdmin && !isOwner) { addLocalHidden({ coord, id: event.id }); const labelTags: string[][] = [ ['L', MODERATION_LABEL_NAMESPACE], ['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE], ['a', coord], ['e', event.id], ['k', String(event.kind)], ['p', event.pubkey], ['alt', `SkateSpots admin takedown${reason?.trim() ? `: ${reason.trim()}` : ''}`], ]; // Intentionally NOT awaited — background best-effort. void publishEvent({ kind: 1985, content: reason?.trim() || 'Removed by SkateSpots admin', tags: labelTags, }).catch((labelError) => { console.warn( '[useDeleteEntity] background moderation label failed (content is ' + 'already hidden locally; kind:5 was published):', labelError, ); }); } } }