DELETE HANG (real fix): The admin takedown required TWO sequential Amber approvals (kind:5 deletion + kind:1985 hide label) and awaited both. On mobile the second Amber prompt often doesn't resurface, so the button sat on "Deleting…" until the 90s signer timeout. Now: - Only the kind:5 deletion is awaited (the required action). - The target is recorded in a local optimistic-hide store immediately, so the app hides it on this device the instant kind:5 succeeds — no waiting on a second signature. - The cross-device kind:1985 label is fired in the BACKGROUND (not awaited); a second approval, if needed, happens after the dialog closes. - The whole mutation is wrapped in a 40s hard timeout so the spinner can never hang indefinitely, whatever the signer/relay does. - useAdminModeration merges the local optimistic set so hides are instant and survive reload. NOTIFICATION BELL UI: - Brand-lime (#ccff00) glowing badge with ring, replacing the flat teal dot. - Bell wiggles + soft glow pulse when there are unread items (new `wiggle` keyframe). - Gradient header with an icon chip, inline count, and a pill "Mark read" button / "All clear" state. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
148 lines
5.7 KiB
TypeScript
148 lines
5.7 KiB
TypeScript
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<T>(promise: Promise<T>, ms: number): 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.',
|
||
),
|
||
);
|
||
}, 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<void> {
|
||
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,
|
||
);
|
||
});
|
||
}
|
||
}
|
||
}
|