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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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"
|
title="Notifications"
|
||||||
aria-label={`Notifications${badgeTotal > 0 ? ` (${badgeTotal} new)` : ''}`}
|
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 && (
|
{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}
|
{badgeTotal > 9 ? '9+' : badgeTotal}
|
||||||
</span>
|
</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"
|
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}
|
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">
|
<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>
|
</h3>
|
||||||
<button
|
{badgeTotal > 0 ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={markAllRead}
|
type="button"
|
||||||
className="flex items-center gap-1 text-[10px] font-black uppercase tracking-wide text-gray-500 transition-colors hover:text-teal-400"
|
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.5 w-3.5" /> Mark read
|
>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="all" className="flex min-h-0 w-full flex-1 flex-col">
|
<Tabs defaultValue="all" className="flex min-h-0 w-full flex-1 flex-col">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
MODERATION_LABEL_NAMESPACE,
|
MODERATION_LABEL_NAMESPACE,
|
||||||
MODERATION_LABEL_HIDDEN,
|
MODERATION_LABEL_HIDDEN,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
import { getLocalHidden } from '@/lib/localModeration';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide admin moderation layer.
|
* 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 {
|
return {
|
||||||
coords,
|
coords,
|
||||||
ids,
|
ids,
|
||||||
|
|
@ -126,5 +133,18 @@ export function useAdminModeration() {
|
||||||
refetchOnMount: true,
|
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_NAMESPACE,
|
||||||
MODERATION_LABEL_HIDDEN,
|
MODERATION_LABEL_HIDDEN,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
import { addLocalHidden } from '@/lib/localModeration';
|
||||||
import type { NostrEvent } from '@nostrify/nostrify';
|
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
|
* Deletion / takedown for an ADDRESSABLE event (kinds 30000–39999) such as a
|
||||||
* skate crew, a Lab profile, a spot, a tour, an event, etc.
|
* skate crew, a Lab profile, a spot, a tour, an event, etc.
|
||||||
|
|
@ -36,7 +67,7 @@ export function useDeleteEntity() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: ({
|
||||||
event,
|
event,
|
||||||
reason,
|
reason,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -44,7 +75,14 @@ export function useDeleteEntity() {
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
/** Optional human-readable reason, stored in the deletion request content. */
|
/** Optional human-readable reason, stored in the deletion request content. */
|
||||||
reason?: string;
|
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.');
|
if (!user) throw new Error('You must be logged in to delete.');
|
||||||
|
|
||||||
const isOwner = user.pubkey === event.pubkey;
|
const isOwner = user.pubkey === event.pubkey;
|
||||||
|
|
@ -64,23 +102,26 @@ export function useDeleteEntity() {
|
||||||
['k', String(event.kind)],
|
['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({
|
await publishEvent({
|
||||||
kind: 5,
|
kind: 5,
|
||||||
content: reason?.trim() || '',
|
content: reason?.trim() || '',
|
||||||
tags: deletionTags,
|
tags: deletionTags,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin takedown of someone else's content: also publish a client-side
|
// STEP 2 (admin takedown only): record the target locally RIGHT NOW so the
|
||||||
// moderation label so the app hides it even if relays won't honor a
|
// app hides it on this device immediately — no waiting on a second signer
|
||||||
// cross-author NIP-09 deletion. This label is what actually hides the
|
// round-trip. Then fire the cross-device kind:1985 moderation label in the
|
||||||
// content across SkateSpots (see useAdminModeration), so it MUST succeed —
|
// BACKGROUND (not awaited): a second Amber approval, if needed, happens
|
||||||
// we await it and let a failure surface to the caller as an error.
|
// 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
|
||||||
// NOTE: this requires a SECOND Amber approval. Both signatures now compute
|
// published, regardless of the label.
|
||||||
// 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.
|
|
||||||
if (isAdmin && !isOwner) {
|
if (isAdmin && !isOwner) {
|
||||||
|
addLocalHidden({ coord, id: event.id });
|
||||||
|
|
||||||
const labelTags: string[][] = [
|
const labelTags: string[][] = [
|
||||||
['L', MODERATION_LABEL_NAMESPACE],
|
['L', MODERATION_LABEL_NAMESPACE],
|
||||||
['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE],
|
['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE],
|
||||||
|
|
@ -90,16 +131,18 @@ export function useDeleteEntity() {
|
||||||
['p', event.pubkey],
|
['p', event.pubkey],
|
||||||
['alt', `SkateSpots admin takedown${reason?.trim() ? `: ${reason.trim()}` : ''}`],
|
['alt', `SkateSpots admin takedown${reason?.trim() ? `: ${reason.trim()}` : ''}`],
|
||||||
];
|
];
|
||||||
await publishEvent({
|
// Intentionally NOT awaited — background best-effort.
|
||||||
|
void publishEvent({
|
||||||
kind: 1985,
|
kind: 1985,
|
||||||
content: reason?.trim() || 'Removed by SkateSpots admin',
|
content: reason?.trim() || 'Removed by SkateSpots admin',
|
||||||
tags: labelTags,
|
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: {
|
to: {
|
||||||
height: '0'
|
height: '0'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
wiggle: {
|
||||||
|
'0%, 90%, 100%': {
|
||||||
|
transform: 'rotate(0deg)'
|
||||||
|
},
|
||||||
|
'92%, 96%': {
|
||||||
|
transform: 'rotate(-12deg)'
|
||||||
|
},
|
||||||
|
'94%, 98%': {
|
||||||
|
transform: 'rotate(12deg)'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue