From 79e8376f5884a8d125c0f1b7fbe49e28d86f64d9 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 22:59:16 +0700 Subject: [PATCH] Make admin delete non-blocking + polish notification bell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/NotificationBell.tsx | 54 ++++++++++++++----- src/hooks/useAdminModeration.ts | 22 +++++++- src/hooks/useDeleteEntity.ts | 81 ++++++++++++++++++++++------- src/lib/localModeration.ts | 61 ++++++++++++++++++++++ tailwind.config.ts | 11 ++++ 5 files changed, 197 insertions(+), 32 deletions(-) create mode 100644 src/lib/localModeration.ts diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx index 8e0af54..d0e1804 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/NotificationBell.tsx @@ -252,13 +252,29 @@ export function NotificationBell({ className }: { className?: string }) { + {badgeTotal > 0 ? ( + + ) : ( + + All clear + + )} diff --git a/src/hooks/useAdminModeration.ts b/src/hooks/useAdminModeration.ts index 4ff2b80..330f7d1 100644 --- a/src/hooks/useAdminModeration.ts +++ b/src/hooks/useAdminModeration.ts @@ -5,6 +5,7 @@ import { MODERATION_LABEL_NAMESPACE, MODERATION_LABEL_HIDDEN, } from '@/lib/types'; +import { getLocalHidden } from '@/lib/localModeration'; /** * App-wide admin moderation layer. @@ -114,6 +115,12 @@ export function useAdminModeration() { } } + // Merge in this device's OPTIMISTIC local hides so admin-removed content + // disappears instantly, before the background kind:1985 label propagates. + const local = getLocalHidden(); + for (const c of local.coords) coords.add(c); + for (const i of local.ids) ids.add(i); + return { coords, ids, @@ -126,5 +133,18 @@ export function useAdminModeration() { refetchOnMount: true, }); - return query.data ?? EMPTY; + // Even if the relay query is still loading/empty, honor local optimistic + // hides so the takedown is reflected immediately on this device. + if (!query.data) { + const local = getLocalHidden(); + if (local.coords.size === 0 && local.ids.size === 0) return EMPTY; + return { + coords: local.coords, + ids: local.ids, + has: ({ coord, id }) => + (!!coord && local.coords.has(coord)) || (!!id && local.ids.has(id)), + }; + } + + return query.data; } diff --git a/src/hooks/useDeleteEntity.ts b/src/hooks/useDeleteEntity.ts index c2492d8..fdcde5c 100644 --- a/src/hooks/useDeleteEntity.ts +++ b/src/hooks/useDeleteEntity.ts @@ -6,8 +6,39 @@ 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. @@ -36,7 +67,7 @@ export function useDeleteEntity() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ + mutationFn: ({ event, reason, }: { @@ -44,7 +75,14 @@ export function useDeleteEntity() { 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; @@ -64,23 +102,26 @@ export function useDeleteEntity() { ['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, }); - // Admin takedown of someone else's content: also publish a client-side - // moderation label so the app hides it even if relays won't honor a - // cross-author NIP-09 deletion. This label is what actually hides the - // content across SkateSpots (see useAdminModeration), so it MUST succeed — - // we await it and let a failure surface to the caller as an error. - // - // NOTE: this requires a SECOND Amber approval. Both signatures now compute - // their event id locally (see CapacitorAmberSigner.signEvent), so a - // signature-only Amber response no longer produces an empty id that stalls - // the publish forever — the previous "stuck on Deleting…" cause. + // 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], @@ -90,16 +131,18 @@ export function useDeleteEntity() { ['p', event.pubkey], ['alt', `SkateSpots admin takedown${reason?.trim() ? `: ${reason.trim()}` : ''}`], ]; - await publishEvent({ + // 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, + ); }); } - }, - onSuccess: () => { - // Nudge every list/detail query to refetch so the deleted entity drops out. - queryClient.invalidateQueries(); - }, - }); + } } diff --git a/src/lib/localModeration.ts b/src/lib/localModeration.ts new file mode 100644 index 0000000..78a6c85 --- /dev/null +++ b/src/lib/localModeration.ts @@ -0,0 +1,61 @@ +/** + * Local (optimistic) admin-moderation store. + * + * WHY THIS EXISTS + * --------------- + * An admin takedown publishes a kind:1985 `hidden` label so the content is + * hidden across devices (see useAdminModeration). But publishing that label + * requires a SECOND Amber approval, which on mobile can be slow or require a + * second app switch. We must NOT make the delete UI wait on it. + * + * So the moment the required kind:5 deletion succeeds, we record the target in + * this local store. useAdminModeration merges it, so the content disappears + * from the app immediately on THIS device. The kind:1985 label still publishes + * in the background for cross-device / other-user persistence. + * + * Persisted to localStorage so an optimistic hide survives a reload even if the + * background label publish hasn't propagated yet. + */ + +const STORAGE_KEY = 'skatespots_local_admin_hidden_v1'; + +interface LocalHidden { + coords: string[]; + ids: string[]; +} + +function read(): LocalHidden { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { coords: [], ids: [] }; + const parsed = JSON.parse(raw) as Partial; + return { + coords: Array.isArray(parsed.coords) ? parsed.coords : [], + ids: Array.isArray(parsed.ids) ? parsed.ids : [], + }; + } catch { + return { coords: [], ids: [] }; + } +} + +function write(data: LocalHidden): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch { + /* storage full / unavailable — optimistic hide is best-effort */ + } +} + +/** Record a coordinate + event id as locally hidden by the admin. */ +export function addLocalHidden(opts: { coord?: string; id?: string }): void { + const data = read(); + if (opts.coord && !data.coords.includes(opts.coord)) data.coords.push(opts.coord); + if (opts.id && !data.ids.includes(opts.id)) data.ids.push(opts.id); + write(data); +} + +/** Read the current local hidden sets. */ +export function getLocalHidden(): { coords: Set; ids: Set } { + const data = read(); + return { coords: new Set(data.coords), ids: new Set(data.ids) }; +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 5f3eb5e..2e3bb6b 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -96,6 +96,17 @@ export default { to: { height: '0' } + }, + wiggle: { + '0%, 90%, 100%': { + transform: 'rotate(0deg)' + }, + '92%, 96%': { + transform: 'rotate(-12deg)' + }, + '94%, 98%': { + transform: 'rotate(12deg)' + } } }, animation: {