Admins can delete/take down any listing (spot, crew, tour, event, shop)
The two SkateSpots admin pubkeys (already in src/lib/admins.ts) can now remove any listing, not just their own. Because a NIP-09 deletion signed by a non-author isn't honored by most relays, admin takedowns are made reliable with a client-side moderation layer: - useDeleteEntity: owners still publish a plain NIP-09 kind:5. Admins acting on someone else's content publish the kind:5 AND a NIP-32 kind:1985 `hidden` label referencing the target coordinate/id, authored by the admin. - useAdminModeration (new): fetches admin-authored `hidden` labels app-wide and exposes a fast lookup + filterAdminHidden() helper. Only labels whose author is in the hardcoded ADMIN_PUBKEYS are honored (author = trust boundary, re-verified client-side; never derived from a d-tag). - Wired the hide filter into the list + detail hooks for spots, crews (incl. by-focus), tours, events, and shops via React Query `select`, so removed content disappears from lists and 404s on its detail page. UI: - DeleteEntityDialog now renders for owner OR admin, with distinct "Remove (admin)" framing and copy (used on Crew, Coach, Tour, Event pages). - SpotDetailPage & ShopDetailPage: routed their inline delete through useDeleteEntity, show the delete button for admins, and relabel the confirm dialog for admin takedowns. - Added the delete control to Tour and Event detail pages (previously none). SECURITY.md: documented the admin moderation trust boundary and extended the auditor file map. Verified build + no console errors. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
538ef67b0b
commit
13eece3a1e
13 changed files with 363 additions and 98 deletions
14
SECURITY.md
14
SECURITY.md
|
|
@ -213,6 +213,19 @@ intentionally *not* author-filtered.
|
|||
worst-case loss from a compromised connection.
|
||||
- Prefer wallets/flows that let you set a spending budget.
|
||||
|
||||
### Admin moderation / takedowns
|
||||
|
||||
Two hardcoded admin pubkeys (`src/lib/admins.ts`, `ADMIN_PUBKEYS`) can take
|
||||
down any listing (spot, crew, tour, event, shop, …). A takedown publishes both
|
||||
a NIP-09 kind:5 deletion request **and** a NIP-32 kind:1985 `hidden` moderation
|
||||
label authored by the admin. The client-side layer (`useAdminModeration`) then
|
||||
hides that content across the app, so takedowns are reliable even when a relay
|
||||
won't honor a cross-author NIP-09 request. **Trust boundary:** only labels whose
|
||||
`pubkey` is in the hardcoded admin list are honored — this is re-verified
|
||||
client-side in `useAdminModeration` and is never derived from a `d` tag or any
|
||||
attacker-controllable value. Auditors should confirm the admin list is the only
|
||||
source of moderation authority.
|
||||
|
||||
### Tooling
|
||||
|
||||
- A custom ESLint rule (`eslint-rules/no-inline-script.js`) forbids inline
|
||||
|
|
@ -251,6 +264,7 @@ oversights:
|
|||
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
|
||||
| Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` |
|
||||
| Cashu ecash (native only): proofs in IndexedDB `cashu-wallet`, seed in `cashu-mnemonic` | `src/hooks/wallet/useCashu.ts`, `src/lib/platform.ts` |
|
||||
| Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts` |
|
||||
| CSP & security headers | `index.html` (`<meta>` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` |
|
||||
| Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) |
|
||||
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
|
|
@ -53,15 +54,29 @@ export function DeleteEntityDialog({
|
|||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Only the owner can delete their own event.
|
||||
if (!user || user.pubkey !== event.pubkey) return null;
|
||||
if (!user) return null;
|
||||
|
||||
const isOwner = user.pubkey === event.pubkey;
|
||||
const isAdmin = isAdminPubkey(user.pubkey);
|
||||
// The owner can delete their own event; a SkateSpots admin can take down any.
|
||||
if (!isOwner && !isAdmin) return null;
|
||||
|
||||
// An admin acting on someone else's content is a moderation takedown.
|
||||
const adminTakedown = isAdmin && !isOwner;
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteEntity({ event, reason: `Deleted ${entityLabel} "${entityName}"` });
|
||||
await deleteEntity({
|
||||
event,
|
||||
reason: adminTakedown
|
||||
? `SkateSpots admin removed ${entityLabel} "${entityName}"`
|
||||
: `Deleted ${entityLabel} "${entityName}"`,
|
||||
});
|
||||
toast({
|
||||
title: 'Deletion requested',
|
||||
description: `Your ${entityLabel} has been removed. Relays that honor deletions will stop serving it.`,
|
||||
title: adminTakedown ? 'Content removed' : 'Deletion requested',
|
||||
description: adminTakedown
|
||||
? `This ${entityLabel} has been taken down and will be hidden across SkateSpots.`
|
||||
: `Your ${entityLabel} has been removed. Relays that honor deletions will stop serving it.`,
|
||||
});
|
||||
setOpen(false);
|
||||
if (redirectTo) navigate(redirectTo);
|
||||
|
|
@ -89,7 +104,7 @@ export function DeleteEntityDialog({
|
|||
className,
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> {triggerText}
|
||||
<Trash2 className="h-3.5 w-3.5" /> {adminTakedown ? `Remove (admin)` : triggerText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -102,15 +117,24 @@ export function DeleteEntityDialog({
|
|||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d
|
||||
have to create a new one.
|
||||
</p>
|
||||
{adminTakedown ? (
|
||||
<p>
|
||||
As a SkateSpots admin you’re taking down this {entityLabel}. It will be hidden
|
||||
across the app for everyone and a deletion request will be sent to relays. The
|
||||
original author can’t restore it from here.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d
|
||||
have to create a new one.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-sm">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" />
|
||||
<p className="text-yellow-400/90">
|
||||
Nostr is decentralized, so deletion is best-effort: relays that honor NIP-09 will
|
||||
drop it, but a relay that doesn’t may keep a copy.
|
||||
drop it, but a relay that doesn’t may keep a copy. SkateSpots hides admin-removed
|
||||
content in the app regardless.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
107
src/hooks/useAdminModeration.ts
Normal file
107
src/hooks/useAdminModeration.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { ADMIN_PUBKEYS } from '@/lib/admins';
|
||||
import {
|
||||
MODERATION_LABEL_NAMESPACE,
|
||||
MODERATION_LABEL_HIDDEN,
|
||||
} from '@/lib/types';
|
||||
|
||||
/**
|
||||
* App-wide admin moderation layer.
|
||||
*
|
||||
* SkateSpots admins (see src/lib/admins.ts) can take down ANY listing (spot,
|
||||
* crew, tour, event, shop, Lab profile, …). Because a NIP-09 deletion request
|
||||
* signed by a non-author is not honored by most relays, admin takedowns also
|
||||
* publish a NIP-32 kind:1985 `hidden` label referencing the target. This hook
|
||||
* fetches those admin labels so the client can hide the content everywhere,
|
||||
* reliably, regardless of relay NIP-09 behavior.
|
||||
*
|
||||
* Security: only labels authored by a hardcoded ADMIN pubkey are honored, so a
|
||||
* random user cannot hide someone else's listing. The label authorship is the
|
||||
* trust boundary (see .agents/skills/nostr-security §3).
|
||||
*/
|
||||
export interface AdminHiddenSet {
|
||||
/** Hidden addressable coordinates `kind:pubkey:d`. */
|
||||
coords: Set<string>;
|
||||
/** Hidden concrete event ids. */
|
||||
ids: Set<string>;
|
||||
/** True if either set contains anything (cheap guard for callers). */
|
||||
has: (opts: { coord?: string; id?: string }) => boolean;
|
||||
}
|
||||
|
||||
const EMPTY: AdminHiddenSet = {
|
||||
coords: new Set(),
|
||||
ids: new Set(),
|
||||
has: () => false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop admin-hidden entities from a parsed list. Works for any entity that
|
||||
* carries `{ id, pubkey, identifier }` (spots, crews, tours, shops, events, …).
|
||||
* `kind` is the entity's Nostr kind, used to build the `a` coordinate.
|
||||
*/
|
||||
export function filterAdminHidden<
|
||||
T extends { id: string; pubkey: string; identifier: string },
|
||||
>(items: T[], kind: number, hidden: AdminHiddenSet): T[] {
|
||||
if (hidden.coords.size === 0 && hidden.ids.size === 0) return items;
|
||||
return items.filter(
|
||||
(it) =>
|
||||
!hidden.has({ coord: `${kind}:${it.pubkey}:${it.identifier}`, id: it.id }),
|
||||
);
|
||||
}
|
||||
|
||||
export function useAdminModeration() {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-moderation', getQueryKeySuffix()],
|
||||
queryFn: async (): Promise<AdminHiddenSet> => {
|
||||
const signal = AbortSignal.timeout(8000);
|
||||
|
||||
const events = await querySkateContent(
|
||||
[
|
||||
{
|
||||
kinds: [1985],
|
||||
authors: ADMIN_PUBKEYS,
|
||||
'#l': [MODERATION_LABEL_HIDDEN],
|
||||
limit: 1000,
|
||||
},
|
||||
],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase()));
|
||||
const coords = new Set<string>();
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const ev of events) {
|
||||
// Defense in depth: re-verify author + namespace + label client-side.
|
||||
if (!adminSet.has(ev.pubkey.toLowerCase())) continue;
|
||||
const hasNamespace = ev.tags.some(
|
||||
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
|
||||
);
|
||||
const hasLabel = ev.tags.some(
|
||||
([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN,
|
||||
);
|
||||
if (!hasNamespace || !hasLabel) continue;
|
||||
|
||||
for (const [n, v] of ev.tags) {
|
||||
if (n === 'a' && v) coords.add(v);
|
||||
if (n === 'e' && v) ids.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
coords,
|
||||
ids,
|
||||
has: ({ coord, id }) =>
|
||||
(!!coord && coords.has(coord)) || (!!id && ids.has(id)),
|
||||
};
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
return query.data ?? EMPTY;
|
||||
}
|
||||
|
|
@ -8,12 +8,15 @@ import {
|
|||
type CrewFocus,
|
||||
} from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||
|
||||
export function useCrews(limit = 200) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-crews', limit, getQueryKeySuffix()],
|
||||
select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
|
|
@ -50,9 +53,11 @@ export function useCrews(limit = 200) {
|
|||
export function useCrewsByFocus(focus: Exclude<CrewFocus, 'mixed'>, limit = 30) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const focusTag = CREW_FOCUS_TAG[focus];
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-crews-focus', focus, limit, getQueryKeySuffix()],
|
||||
select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
|
|
@ -84,9 +89,14 @@ export function useCrewsByFocus(focus: Exclude<CrewFocus, 'mixed'>, limit = 30)
|
|||
|
||||
export function useCrew(identifier: string, pubkey?: string) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-crew', identifier, pubkey, getQueryKeySuffix()],
|
||||
select: (crew: SkateCrew | null) =>
|
||||
crew && hidden.has({ coord: `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`, id: crew.id })
|
||||
? null
|
||||
: crew,
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,34 @@
|
|||
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 type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* NIP-09 deletion request for an ADDRESSABLE event (kinds 30000–39999) such as
|
||||
* a skate crew, a Lab profile, a spot, etc.
|
||||
* Deletion / takedown for an ADDRESSABLE event (kinds 30000–39999) such as a
|
||||
* skate crew, a Lab profile, a spot, a tour, an event, etc.
|
||||
*
|
||||
* How deletion works on Nostr (important to set expectations):
|
||||
* • We publish a kind:5 "deletion request" that references the target event by
|
||||
* its `a` coordinate (kind:pubkey:d-tag) and its `e` id + `k` kind.
|
||||
* • Relays that honor NIP-09 stop serving the referenced event and won't
|
||||
* return it in future queries. This is how "delete" works in a
|
||||
* decentralized network — there's no central database row to remove.
|
||||
* • A relay that ignores NIP-09 may still serve a cached copy, and other
|
||||
* users' relays may retain it. So deletion is best-effort, not a guarantee.
|
||||
* Two callers, two mechanisms:
|
||||
*
|
||||
* Only the ORIGINAL AUTHOR can delete their own event — a deletion request is
|
||||
* only valid when signed by the same pubkey that created the target.
|
||||
* 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();
|
||||
|
|
@ -35,7 +46,10 @@ export function useDeleteEntity() {
|
|||
reason?: string;
|
||||
}) => {
|
||||
if (!user) throw new Error('You must be logged in to delete.');
|
||||
if (user.pubkey !== event.pubkey) {
|
||||
|
||||
const isOwner = user.pubkey === event.pubkey;
|
||||
const isAdmin = isAdminPubkey(user.pubkey);
|
||||
if (!isOwner && !isAdmin) {
|
||||
throw new Error('You can only delete your own listing.');
|
||||
}
|
||||
|
||||
|
|
@ -44,17 +58,37 @@ export function useDeleteEntity() {
|
|||
|
||||
// NIP-09: reference by both the addressable coordinate (`a`) and the
|
||||
// concrete event id (`e`), plus the kind (`k`), for maximum relay support.
|
||||
const tags: string[][] = [
|
||||
const deletionTags: string[][] = [
|
||||
['a', coord],
|
||||
['e', event.id],
|
||||
['k', String(event.kind)],
|
||||
];
|
||||
|
||||
return publishEvent({
|
||||
await publishEvent({
|
||||
kind: 5,
|
||||
content: reason?.trim() || '',
|
||||
tags,
|
||||
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.
|
||||
if (isAdmin && !isOwner) {
|
||||
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()}` : ''}`],
|
||||
];
|
||||
await publishEvent({
|
||||
kind: 1985,
|
||||
content: reason?.trim() || 'Removed by SkateSpots admin',
|
||||
tags: labelTags,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Nudge every list/detail query to refetch so the deleted entity drops out.
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CALENDAR_EVENT_KIND, parseSkateEvent, isFeaturedActive, type SkateEvent } from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||
|
||||
export function useSkateEvents(limit = 50) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-events', limit, getQueryKeySuffix()],
|
||||
select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(15000);
|
||||
|
||||
|
|
@ -41,10 +44,12 @@ export function useSkateEvents(limit = 50) {
|
|||
|
||||
export function useUpcomingSkateEvents(limit = 20) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['upcoming-skate-events', limit, getQueryKeySuffix()],
|
||||
select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(15000);
|
||||
|
||||
|
|
@ -84,9 +89,14 @@ export function useUpcomingSkateEvents(limit = 20) {
|
|||
|
||||
export function useSkateEvent(identifier: string, pubkey?: string) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-event', identifier, pubkey, getQueryKeySuffix()],
|
||||
select: (evt: SkateEvent | null) =>
|
||||
evt && hidden.has({ coord: `${CALENDAR_EVENT_KIND}:${evt.pubkey}:${evt.identifier}`, id: evt.id })
|
||||
? null
|
||||
: evt,
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { SKATE_SHOP_KIND, parseSkateShop, type SkateShop } from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||
|
||||
/**
|
||||
* Hook to fetch skate shops using the skate relay logic
|
||||
*/
|
||||
export function useSkateShops(limit: number = 100) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery<SkateShop[]>({
|
||||
return useQuery<SkateShop[], Error, SkateShop[]>({
|
||||
queryKey: ['skate-shops', limit, getQueryKeySuffix()],
|
||||
select: (shops: SkateShop[]) => filterAdminHidden(shops, SKATE_SHOP_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
|
|
@ -41,9 +44,14 @@ export function useSkateShops(limit: number = 100) {
|
|||
*/
|
||||
export function useSkateShop(pubkey: string | undefined, identifier: string | undefined) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery<SkateShop | null>({
|
||||
return useQuery<SkateShop | null, Error, SkateShop | null>({
|
||||
queryKey: ['skate-shop', pubkey, identifier, getQueryKeySuffix()],
|
||||
select: (shop: SkateShop | null) =>
|
||||
shop && hidden.has({ coord: `${SKATE_SHOP_KIND}:${shop.pubkey}:${shop.identifier}`, id: shop.id })
|
||||
? null
|
||||
: shop,
|
||||
queryFn: async () => {
|
||||
if (!pubkey || !identifier) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||
import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache';
|
||||
|
||||
// Spots change infrequently — cache for 5 minutes, keep for 30 minutes
|
||||
|
|
@ -10,9 +11,11 @@ const QUERY_TIMEOUT = 15000; // 15s — mobile cold-start needs mo
|
|||
|
||||
export function useSkateSpots(limit = 100) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-spots', limit, getQueryKeySuffix()],
|
||||
select: (spots: SkateSpot[]) => filterAdminHidden(spots, SKATE_SPOT_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
// If offline, return cached spots
|
||||
if (isOffline()) {
|
||||
|
|
@ -60,9 +63,14 @@ export function useSkateSpots(limit = 100) {
|
|||
|
||||
export function useSkateSpot(identifier: string, pubkey?: string) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()],
|
||||
select: (spot: SkateSpot | null) =>
|
||||
spot && hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })
|
||||
? null
|
||||
: spot,
|
||||
queryFn: async () => {
|
||||
// If offline, try to get from cache first
|
||||
if (isOffline() && pubkey) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { SKATE_TOUR_KIND, parseSkateTour, isFeaturedActive, type SkateTour } from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||
|
||||
/** Sort featured (Pro) tours first, then newest. */
|
||||
function sortTours(a: SkateTour, b: SkateTour): number {
|
||||
|
|
@ -16,9 +17,11 @@ const TIMEOUT = 8000;
|
|||
|
||||
export function useTours(limit = 100) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-tours', limit, getQueryKeySuffix()],
|
||||
select: (tours: SkateTour[]) => filterAdminHidden(tours, SKATE_TOUR_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(TIMEOUT);
|
||||
|
||||
|
|
@ -49,9 +52,14 @@ export function useTours(limit = 100) {
|
|||
|
||||
export function useTour(identifier: string, pubkey?: string) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-tour', identifier, pubkey, getQueryKeySuffix()],
|
||||
select: (tour: SkateTour | null) =>
|
||||
tour && hidden.has({ coord: `${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`, id: tour.id })
|
||||
? null
|
||||
: tour,
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(TIMEOUT);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
|
@ -194,14 +195,22 @@ export default function EventDetailPage() {
|
|||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-8">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
to="/events"
|
||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Events
|
||||
</Link>
|
||||
{/* Back link + owner/admin controls */}
|
||||
<div className="mb-6 flex items-center justify-between gap-2">
|
||||
<Link
|
||||
to="/events"
|
||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Events
|
||||
</Link>
|
||||
<DeleteEntityDialog
|
||||
event={event.event}
|
||||
entityLabel="event"
|
||||
entityName={event.title}
|
||||
redirectTo="/events"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Main Content */}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/saniti
|
|||
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||
import { useShop } from '@/hooks/useShops';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import NotFound from './NotFound';
|
||||
|
|
@ -78,13 +80,17 @@ export default function ShopDetailPage() {
|
|||
const { data: shop, isLoading, error } = useShop(identifier || '', pubkey);
|
||||
const author = useAuthor(shop?.pubkey);
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { mutateAsync: deleteEntity } = useDeleteEntity();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const isOwner = user && shop && user.pubkey === shop.pubkey;
|
||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||
// Owner can delete their own shop; a SkateSpots admin can take down any shop.
|
||||
const canDelete = !!user && !!shop && (isOwner || isAdmin);
|
||||
const adminTakedown = isAdmin && !isOwner;
|
||||
const hasNip05 = user?.metadata?.nip05;
|
||||
|
||||
useSeoMeta({
|
||||
|
|
@ -144,19 +150,19 @@ export default function ShopDetailPage() {
|
|||
if (!shop || !user) return;
|
||||
|
||||
try {
|
||||
await publishEvent({
|
||||
kind: 5,
|
||||
content: 'Deleted shop',
|
||||
tags: [
|
||||
['e', shop.id],
|
||||
['a', `${shop.event.kind}:${shop.pubkey}:${shop.identifier}`],
|
||||
],
|
||||
// Routes through useDeleteEntity: owner → NIP-09 delete; admin → NIP-09
|
||||
// delete + a moderation label so the shop is hidden across the app.
|
||||
await deleteEntity({
|
||||
event: shop.event,
|
||||
reason: adminTakedown ? `SkateSpots admin removed shop "${shop.name}"` : 'Deleted shop',
|
||||
});
|
||||
|
||||
setShowDeleteDialog(false);
|
||||
toast({
|
||||
title: 'Shop deleted',
|
||||
description: 'Deletion request sent to relays. Most relays will remove this content.',
|
||||
title: adminTakedown ? 'Shop removed' : 'Shop deleted',
|
||||
description: adminTakedown
|
||||
? 'This shop has been taken down and will be hidden across SkateSpots.'
|
||||
: 'Deletion request sent to relays. Most relays will remove this content.',
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['skate-shops'] });
|
||||
|
|
@ -257,21 +263,25 @@ export default function ShopDetailPage() {
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOwner && (
|
||||
<>
|
||||
<Link to={`/edit-shop/${shop.pubkey}/${shop.identifier}`}>
|
||||
<Button variant="outline" size="icon">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Link to={`/edit-shop/${shop.pubkey}/${shop.identifier}`}>
|
||||
<Button variant="outline" size="icon">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
</Link>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
title={adminTakedown ? 'Remove (admin)' : 'Delete'}
|
||||
className={cn(
|
||||
'hover:bg-destructive hover:text-destructive-foreground',
|
||||
adminTakedown && 'border-red-500/50 text-red-400',
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="icon" onClick={handleShare}>
|
||||
<Share2 className="h-4 w-4" />
|
||||
|
|
@ -478,10 +488,12 @@ export default function ShopDetailPage() {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
Delete Shop?
|
||||
{adminTakedown ? 'Remove this shop? (admin)' : 'Delete Shop?'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-300">
|
||||
This will permanently delete "{shop.name}" from all relays. This action cannot be undone.
|
||||
{adminTakedown
|
||||
? `As a SkateSpots admin you're taking down "${shop.name}". It will be hidden across the app for everyone and a deletion request will be sent to relays.`
|
||||
: `This will permanently delete "${shop.name}" from all relays. This action cannot be undone.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
@ -490,7 +502,7 @@ export default function ShopDetailPage() {
|
|||
onClick={handleDelete}
|
||||
className="bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
Delete Shop
|
||||
{adminTakedown ? 'Remove Shop' : 'Delete Shop'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import { SpotChat } from '@/components/SpotChat';
|
|||
import { CrewsForSpotCard } from '@/components/CrewsForSpotCard';
|
||||
import { useSkateSpot } from '@/hooks/useSkateSpots';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useSpotWeather } from '@/hooks/useSpotWeather';
|
||||
import { useMarketplaceAtSpot } from '@/hooks/useMarketplace';
|
||||
|
|
@ -121,7 +123,7 @@ export default function SpotDetailPage() {
|
|||
const { data: spotServices, isLoading: isLoadingServices } = useSpotServices(pubkey, identifier);
|
||||
const author = useAuthor(spot?.pubkey);
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { mutateAsync: deleteEntity } = useDeleteEntity();
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -131,6 +133,10 @@ export default function SpotDetailPage() {
|
|||
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
||||
|
||||
const isOwner = user && spot && user.pubkey === spot.pubkey;
|
||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||
// Owner can delete their own spot; a SkateSpots admin can take down any spot.
|
||||
const canDelete = !!user && !!spot && (isOwner || isAdmin);
|
||||
const adminTakedown = isAdmin && !isOwner;
|
||||
const isShop = spot?.types.includes('shop');
|
||||
const hasNip05 = user?.metadata?.nip05;
|
||||
|
||||
|
|
@ -192,19 +198,19 @@ export default function SpotDetailPage() {
|
|||
if (!spot || !user) return;
|
||||
|
||||
try {
|
||||
await publishEvent({
|
||||
kind: 5,
|
||||
content: 'Deleted spot',
|
||||
tags: [
|
||||
['e', spot.id],
|
||||
['a', `${spot.event.kind}:${spot.pubkey}:${spot.identifier}`],
|
||||
],
|
||||
// Routes through useDeleteEntity: owner → NIP-09 delete; admin → NIP-09
|
||||
// delete + a moderation label so the spot is hidden across the app.
|
||||
await deleteEntity({
|
||||
event: spot.event,
|
||||
reason: adminTakedown ? `SkateSpots admin removed spot "${spot.title}"` : 'Deleted spot',
|
||||
});
|
||||
|
||||
setShowDeleteDialog(false);
|
||||
toast({
|
||||
title: 'Spot deleted',
|
||||
description: 'Deletion request sent to relays. Most relays will remove this content.',
|
||||
title: adminTakedown ? 'Spot removed' : 'Spot deleted',
|
||||
description: adminTakedown
|
||||
? 'This spot has been taken down and will be hidden across SkateSpots.'
|
||||
: 'Deletion request sent to relays. Most relays will remove this content.',
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['skate-spots'] });
|
||||
|
|
@ -310,21 +316,25 @@ export default function SpotDetailPage() {
|
|||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isOwner && (
|
||||
<>
|
||||
<Link to={`/edit-spot/${spot.pubkey}/${spot.identifier}`}>
|
||||
<Button variant="outline" size="icon">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Link to={`/edit-spot/${spot.pubkey}/${spot.identifier}`}>
|
||||
<Button variant="outline" size="icon">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
</Link>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
title={adminTakedown ? 'Remove (admin)' : 'Delete'}
|
||||
className={cn(
|
||||
'hover:bg-destructive hover:text-destructive-foreground',
|
||||
adminTakedown && 'border-red-500/50 text-red-400',
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user && !isShop && (
|
||||
<>
|
||||
|
|
@ -971,10 +981,12 @@ export default function SpotDetailPage() {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
Delete Spot?
|
||||
{adminTakedown ? 'Remove this spot? (admin)' : 'Delete Spot?'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-300">
|
||||
This will permanently delete "{spot.title}" from all relays. This action cannot be undone.
|
||||
{adminTakedown
|
||||
? `As a SkateSpots admin you're taking down "${spot.title}". It will be hidden across the app for everyone and a deletion request will be sent to relays.`
|
||||
: `This will permanently delete "${spot.title}" from all relays. This action cannot be undone.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
@ -983,7 +995,7 @@ export default function SpotDetailPage() {
|
|||
onClick={handleDelete}
|
||||
className="bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
Delete Spot
|
||||
{adminTakedown ? 'Remove Spot' : 'Delete Spot'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { genUserName } from '@/lib/genUserName';
|
|||
import { TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, SKATE_TOUR_KIND } from '@/lib/types';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { ProBadge } from '@/components/ProBadge';
|
||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Route,
|
||||
|
|
@ -124,11 +125,19 @@ export default function TourDetailPage() {
|
|||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-6 max-w-3xl">
|
||||
{/* Back link */}
|
||||
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-6">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Map
|
||||
</Link>
|
||||
{/* Back link + owner/admin controls */}
|
||||
<div className="mb-6 flex items-center justify-between gap-2">
|
||||
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Map
|
||||
</Link>
|
||||
<DeleteEntityDialog
|
||||
event={tour.event}
|
||||
entityLabel="tour"
|
||||
entityName={tour.title}
|
||||
redirectTo="/map"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main card */}
|
||||
<Card className="bg-black/90 border-2 border-cyan-500/30 overflow-hidden">
|
||||
|
|
|
|||
Loading…
Reference in a new issue