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:
parent
52fd6f7a81
commit
850c37dbaa
2 changed files with 98 additions and 82 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.12",
|
"version": "1.2.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,33 @@
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostr } from '@nostrify/react';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { isAdminPubkey } from '@/lib/admins';
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
import { addLocalHidden } from '@/lib/localModeration';
|
import { addLocalHidden } from '@/lib/localModeration';
|
||||||
|
import { toast } from '@/hooks/useToast';
|
||||||
import type { NostrEvent } from '@nostrify/nostrify';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hard ceiling on how long the AWAITED part of the delete can run before we give
|
* DIAGNOSTIC DELETE
|
||||||
* 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
|
* We could not identify WHERE the delete hangs (signing? relay publish?), so
|
||||||
* fumble. Too short a ceiling was surfacing "taking longer than expected" even
|
* this version narrates every step on-screen via toasts and splits the flow into
|
||||||
* when Amber was about to succeed. The signer has its own 90s cap underneath, so
|
* explicit, independently-timed phases:
|
||||||
* we align to that plus headroom.
|
*
|
||||||
|
* 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) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
reject(
|
reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s.`));
|
||||||
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);
|
}, ms);
|
||||||
promise.then(
|
promise.then(
|
||||||
(v) => {
|
(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() {
|
export function useDeleteEntity() {
|
||||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
const { nostr } = useNostr();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -70,62 +52,96 @@ export function useDeleteEntity() {
|
||||||
event,
|
event,
|
||||||
reason,
|
reason,
|
||||||
}: {
|
}: {
|
||||||
/** The full addressable event to delete. */
|
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
/** Optional human-readable reason, stored in the deletion request content. */
|
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}) => withHardTimeout(runDelete(event, reason), DELETE_TIMEOUT_MS),
|
}) => runDelete(event, reason),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Nudge every list/detail query to refetch so the deleted entity drops out.
|
|
||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async function runDelete(event: NostrEvent, reason?: string): Promise<void> {
|
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 isOwner = user.pubkey === event.pubkey;
|
||||||
const isAdmin = isAdminPubkey(user.pubkey);
|
const isAdmin = isAdminPubkey(user.pubkey);
|
||||||
if (!isOwner && !isAdmin) {
|
if (!isOwner && !isAdmin) {
|
||||||
throw new Error('You can only delete your own listing.');
|
throw new Error('You can only delete your own listing.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
|
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
|
||||||
const coord = `${event.kind}:${event.pubkey}:${dTag}`;
|
const coord = `${event.kind}:${event.pubkey}:${dTag}`;
|
||||||
|
|
||||||
// NIP-09: reference by both the addressable coordinate (`a`) and the
|
const deletionTags: string[][] = [
|
||||||
// concrete event id (`e`), plus the kind (`k`), for maximum relay support.
|
['a', coord],
|
||||||
const deletionTags: string[][] = [
|
['e', event.id],
|
||||||
['a', coord],
|
['k', String(event.kind)],
|
||||||
['e', event.id],
|
];
|
||||||
['k', String(event.kind)],
|
|
||||||
];
|
|
||||||
|
|
||||||
// STEP 1 (required): publish the NIP-09 kind:5 deletion request. This is
|
const template = {
|
||||||
// the ONE signature the UI waits on. useNostrPublish has its own internal
|
kind: 5,
|
||||||
// sign + relay timeouts, and we wrap the whole thing in a hard ceiling
|
content: reason?.trim() || '',
|
||||||
// (below) so the spinner can never hang forever.
|
tags: deletionTags,
|
||||||
await publishEvent({
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
kind: 5,
|
};
|
||||||
content: reason?.trim() || '',
|
|
||||||
tags: deletionTags,
|
// ── 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
|
// ── PHASE 2: PUBLISH ─────────────────────────────────────────────────────
|
||||||
// disappears from this device immediately. This needs NO signature.
|
toast({
|
||||||
//
|
title: 'Step 2/2: Publishing…',
|
||||||
// CRITICAL UX/BUG FIX: we deliberately do NOT fire additional signed
|
description: 'Sending the deletion to relays.',
|
||||||
// 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
|
try {
|
||||||
// flight COLLIDES on Android and makes the whole delete hang ("taking
|
await withTimeout(
|
||||||
// longer than expected", Amber juggling multiple prompts). The single
|
nostr.event(signed),
|
||||||
// kind:5 deletion (step 1) already tells relays to drop the event, and the
|
PUBLISH_TIMEOUT_MS,
|
||||||
// local hide covers this device instantly. The optional label / relay-ban
|
'Publishing (relays)',
|
||||||
// can be performed separately from the relay moderator tools without
|
);
|
||||||
// blocking or breaking the interactive delete.
|
} catch (e) {
|
||||||
if (isAdmin && !isOwner) {
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
addLocalHidden({ coord, id: event.id });
|
// 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.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue