SkateSpots/src/hooks/useDeleteEntity.ts
shakespeare.diy 79e8376f58 Make admin delete non-blocking + polish notification bell
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>
2026-09-03 22:59:16 +07:00

148 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
);
});
}
}
}