diag(delete): narrate SIGN vs PUBLISH phases on-screen to locate the hang (1.2.13)

We can't identify where delete freezes. Split the flow into two independently
timed, on-screen-narrated phases: Step 1 SIGN (Amber) and Step 2 PUBLISH
(relays). Whichever toast is on screen when it hangs tells us exactly which layer
is at fault — Amber vs relay — so we fix the right thing. Bump to 1.2.13.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-04 23:07:13 +07:00
parent 52fd6f7a81
commit 850c37dbaa
2 changed files with 98 additions and 82 deletions

View file

@ -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": {

View file

@ -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<T>(promise: Promise<T>, ms: number): Promise<T> {
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<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((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<T>(promise: Promise<T>, ms: number): Promise<T> {
});
}
/**
* 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<void> {
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.' });
}
}