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>
This commit is contained in:
shakespeare.diy 2026-09-03 22:59:16 +07:00
parent 36845b122d
commit 79e8376f58
5 changed files with 197 additions and 32 deletions

View file

@ -252,13 +252,29 @@ export function NotificationBell({ className }: { className?: string }) {
<Button
variant="ghost"
size="icon"
className={cn('relative text-gray-400 hover:text-teal-400 hover:bg-teal-500/10', className)}
className={cn(
'group relative text-gray-300 transition-colors hover:text-[#ccff00] hover:bg-[#ccff00]/10',
badgeTotal > 0 && 'text-[#ccff00]',
className,
)}
title="Notifications"
aria-label={`Notifications${badgeTotal > 0 ? ` (${badgeTotal} new)` : ''}`}
>
<Bell className="h-5 w-5" />
{/* Soft glow behind the bell when there's something waiting */}
{badgeTotal > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex min-w-[18px] items-center justify-center rounded-full bg-teal-500 px-1 text-[10px] font-black leading-[18px] text-black">
<span
aria-hidden
className="absolute inset-0 rounded-full bg-[#ccff00]/20 blur-md animate-pulse"
/>
)}
<Bell
className={cn(
'relative h-5 w-5 transition-transform',
badgeTotal > 0 && 'animate-[wiggle_1.5s_ease-in-out_infinite] origin-top',
)}
/>
{badgeTotal > 0 && (
<span className="absolute -right-1 -top-1 flex min-w-[18px] items-center justify-center rounded-full bg-[#ccff00] px-1 text-[10px] font-black leading-[18px] text-black shadow-[0_0_8px_rgba(204,255,0,0.6)] ring-2 ring-black">
{badgeTotal > 9 ? '9+' : badgeTotal}
</span>
)}
@ -270,17 +286,31 @@ export function NotificationBell({ className }: { className?: string }) {
className="flex max-h-[min(70vh,var(--radix-popover-content-available-height))] w-[380px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden p-0"
sideOffset={8}
>
<div className="flex shrink-0 items-center justify-between border-b border-white/10 px-4 py-3">
<div className="flex shrink-0 items-center justify-between border-b border-white/10 bg-gradient-to-r from-teal-500/[0.08] via-transparent to-[#ccff00]/[0.06] px-4 py-3">
<h3 className="flex items-center gap-2 text-sm font-black uppercase tracking-wide text-white">
<Bell className="h-4 w-4 text-teal-400" /> Notifications
<span className="relative flex h-6 w-6 items-center justify-center rounded-lg bg-teal-500/15">
<Bell className="h-3.5 w-3.5 text-teal-400" />
</span>
Notifications
{badgeTotal > 0 && (
<span className="rounded-full bg-[#ccff00] px-1.5 text-[10px] font-black leading-4 text-black">
{badgeTotal > 99 ? '99+' : badgeTotal}
</span>
)}
</h3>
<button
type="button"
onClick={markAllRead}
className="flex items-center gap-1 text-[10px] font-black uppercase tracking-wide text-gray-500 transition-colors hover:text-teal-400"
>
<CheckCheck className="h-3.5 w-3.5" /> Mark read
</button>
{badgeTotal > 0 ? (
<button
type="button"
onClick={markAllRead}
className="flex items-center gap-1 rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] font-black uppercase tracking-wide text-gray-300 transition-all active:scale-95 hover:border-teal-400/40 hover:bg-teal-500/10 hover:text-teal-300"
>
<CheckCheck className="h-3 w-3" /> Mark read
</button>
) : (
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-600">
All clear
</span>
)}
</div>
<Tabs defaultValue="all" className="flex min-h-0 w-full flex-1 flex-col">

View file

@ -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;
}

View file

@ -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<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.
@ -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<void> {
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();
},
});
}
}

View file

@ -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<LocalHidden>;
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<string>; ids: Set<string> } {
const data = read();
return { coords: new Set(data.coords), ids: new Set(data.ids) };
}

View file

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