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:
parent
36845b122d
commit
79e8376f58
5 changed files with 197 additions and 32 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
src/lib/localModeration.ts
Normal file
61
src/lib/localModeration.ts
Normal 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) };
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue