fix(trust): tier 2 — moderation, labels & data integrity (H2–H17)

- H2 useAdminModeration now fetches the 'cleared' label too, so an admin Undo
  actually propagates (newest-verdict-wins) instead of only working on-device.
- H3 admin takedowns publish the NIP-32 'hidden' label (useAdminHide) instead of
  an author-only kind-5 relays ignore; the cross-app hide is real now.
- H5 the /stats outage banner works: queryCanonical reports relay failures via
  onError, so 'partial' can be true and zeros are never presented as truth.
- H6 useShops got its own cache namespace (was colliding with useSkateShops).
- H7 listing NIP-05 is verified against the user's key, not merely self-asserted.
- H8 NIP-22 spot comments/transfer now use uppercase root tags (A/K/P); readers
  accept legacy #a; NIP.md corrected.
- H9 Lab profile edits preserve foreign  spot refs (crew link = 35006 only).
- H10 collection edits preserve foreign NIP-51 tags.
- H11 LAB_KINDS = all 13 provider kinds (was 35001-35005).
- H16 skip-to-content is a button that focuses <main> (was href=#main, which
  HashRouter treated as a route → 404).
- H17 CustomizeNavSheet is translated; useLocalStorage re-reads on key change and
  keeps stateRef in sync cross-tab.
- Backup 'confirmed' flag is now per-account (backup-confirmed:<pubkey>).
This commit is contained in:
SkateSpots 2026-10-02 21:30:01 +07:00
parent a8b4b9fdd0
commit 9189fc6584
24 changed files with 231 additions and 55 deletions

12
NIP.md
View file

@ -849,15 +849,20 @@ Community photos use NIP-94 File Metadata (kind 1063) with an `a` tag referencin
### Tag Suggestions (kind 1111) ### Tag Suggestions (kind 1111)
Tag suggestions use NIP-22 Comments (kind 1111) with a special marker tag. Tag suggestions use NIP-22 Comments (kind 1111) with a special marker tag. The
comment target is an **addressable** event (the spot), so the NIP-22 **root
scope uses UPPERCASE tags** — `A` (root coordinate), `K` (root kind) and `P`
(root author). Lowercase `A`/`a` is only for a *parent* reference and is not
written for a top-level comment.
```json ```json
{ {
"kind": 1111, "kind": 1111,
"content": "Suggested tags for Hollywood High: #marble #ledge #10-stair", "content": "Suggested tags for Hollywood High: #marble #ledge #10-stair",
"tags": [ "tags": [
["a", "30818:<spot-pubkey>:<spot-d-tag>"], ["A", "30818:<spot-pubkey>:<spot-d-tag>"],
["K", "30818"], ["K", "30818"],
["P", "<spot-pubkey>"],
["t", "skatespot-tags"], ["t", "skatespot-tags"],
["t", "marble"], ["t", "marble"],
["t", "ledge"], ["t", "ledge"],
@ -867,6 +872,9 @@ Tag suggestions use NIP-22 Comments (kind 1111) with a special marker tag.
``` ```
The `skatespot-tags` marker distinguishes tag suggestions from regular comments. The `skatespot-tags` marker distinguishes tag suggestions from regular comments.
Readers query **both** `#A` (new) and `#a` (legacy) so suggestions published
before this correction still resolve. `spot-transfer` notices use the same
uppercase root scope plus `["p", "<recipient>", "", "new-owner"]`.
### Querying Contributions ### Querying Contributions

View file

@ -50,10 +50,12 @@ export function AddTagsToSpot({ spotPubkey, spotIdentifier, spotTitle, existingT
try { try {
// Create kind 1111 comment with tag suggestions // Create kind 1111 comment with tag suggestions
const eventTags: string[][] = [ const eventTags: string[][] = [
// Reference the spot with 'a' tag (NIP-22 comment target) // NIP-22 ROOT scope for an addressable target uses UPPERCASE tags
['a', spotRef], // (A = root coordinate, K = root kind, P = root author). Lowercase `a`
// Root marker for comment threading // is the *parent* reference and is not valid for a top-level comment.
['A', spotRef],
['K', String(SKATE_SPOT_KIND)], ['K', String(SKATE_SPOT_KIND)],
['P', spotPubkey],
// Mark this as a tag contribution // Mark this as a tag contribution
['t', 'skatespot-tags'], ['t', 'skatespot-tags'],
]; ];

View file

@ -4,6 +4,7 @@ import { AlertTriangle, X, Download, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { backupConfirmedKey } from '@/lib/backup';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import { import {
Sheet, Sheet,
@ -13,12 +14,12 @@ import {
SheetTitle, SheetTitle,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
const BACKUP_CONFIRMED_KEY = 'backup-confirmed';
// Session-scoped snooze key. "Remind me later" used to set a PERMANENT // Session-scoped snooze key. "Remind me later" used to set a PERMANENT
// localStorage flag, which silently turned into "never remind again" — a real // localStorage flag, which silently turned into "never remind again" — a real
// safety hole. We now snooze only for the current browser session, so the // safety hole. We now snooze only for the current browser session, so the
// reminder returns on the next visit until the user actually saves their login. // reminder returns on the next visit until the user actually saves their login.
// Only `backup-confirmed` (set when they back up) stops it for good. // Only the per-account `backup-confirmed:<pubkey>` flag (set when they back up)
// stops it for good.
const BACKUP_SNOOZE_KEY = 'backup-reminder-snoozed-session'; const BACKUP_SNOOZE_KEY = 'backup-reminder-snoozed-session';
/** /**
@ -30,7 +31,7 @@ const BACKUP_SNOOZE_KEY = 'backup-reminder-snoozed-session';
*/ */
export function BackupReminder() { export function BackupReminder() {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [confirmed, setConfirmed] = useLocalStorage(BACKUP_CONFIRMED_KEY, false); const [confirmed, setConfirmed] = useLocalStorage(backupConfirmedKey(user?.pubkey), false);
const [showReminder, setShowReminder] = useState(false); const [showReminder, setShowReminder] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();

View file

@ -149,7 +149,9 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
setNoffer(tag('noffer') ?? ''); setNoffer(tag('noffer') ?? '');
setEntityType((tag('entity') as EntityType) ?? 'individual'); setEntityType((tag('entity') as EntityType) ?? 'individual');
setMembers(existingProfile.tags.filter(([t]) => t === 'p').map(([, v]) => v)); setMembers(existingProfile.tags.filter(([t]) => t === 'p').map(([, v]) => v));
setCrewRef(existingProfile.tags.find(([t]) => t === 'a')?.[1]); // Only a 35006 crew coordinate is the crew link; other `a` tags (spot refs)
// are foreign and must be preserved on re-publish, not read as a crew.
setCrewRef(existingProfile.tags.find(([t, v]) => t === 'a' && v?.startsWith('35006:'))?.[1]);
const ex: Record<string, string> = {}; const ex: Record<string, string> = {};
for (const f of extraFields) { for (const f of extraFields) {
const v = tag(f.tag); const v = tag(f.tag);
@ -281,6 +283,12 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
} }
tags.push(['entity', entityType]); tags.push(['entity', entityType]);
// Preserve foreign `a` tags (spot references) — this form doesn't manage
// them, and an addressable re-publish replaces the entire tag set.
for (const tag of existingProfile?.tags ?? []) {
const [n, v] = tag;
if (n === 'a' && !v?.startsWith('35006:')) tags.push(tag);
}
if (allowCollective && entityType === 'collective') { if (allowCollective && entityType === 'collective') {
for (const pk of members) tags.push(['p', pk]); for (const pk of members) tags.push(['p', pk]);
if (crewRef) tags.push(['a', crewRef]); if (crewRef) tags.push(['a', crewRef]);

View file

@ -85,16 +85,16 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
> >
<SheetHeader className="text-left"> <SheetHeader className="text-left">
<SheetTitle className="flex items-center gap-2 text-xl font-black uppercase text-[#ff0040]"> <SheetTitle className="flex items-center gap-2 text-xl font-black uppercase text-[#ff0040]">
<Sparkles className="h-5 w-5" /> Customize your bar <Sparkles className="h-5 w-5" /> {t('navSheet.title')}
</SheetTitle> </SheetTitle>
<SheetDescription className="text-gray-400"> <SheetDescription className="text-gray-400">
Pick the {MIDDLE_SLOTS} tabs you use most. Everything else stays in the menu. {t('navSheet.desc', { count: MIDDLE_SLOTS })}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
{/* Live preview */} {/* Live preview */}
<div className="mt-4"> <div className="mt-4">
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">Preview</p> <p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">{t('navSheet.preview')}</p>
<div className="glass flex items-stretch justify-around gap-1 rounded-2xl border border-white/10 p-2"> <div className="glass flex items-stretch justify-around gap-1 rounded-2xl border border-white/10 p-2">
{/* Fixed Map */} {/* Fixed Map */}
<PreviewSlot icon={MapPin} label={t('nav.map')} locked accent="#ff0040" /> <PreviewSlot icon={MapPin} label={t('nav.map')} locked accent="#ff0040" />
@ -108,14 +108,14 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
className="flex flex-1 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-white/10 py-1.5 text-gray-700" className="flex flex-1 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-white/10 py-1.5 text-gray-700"
> >
<span className="text-lg leading-none">+</span> <span className="text-lg leading-none">+</span>
<span className="text-[9px] font-semibold uppercase">Empty</span> <span className="text-[9px] font-semibold uppercase">{t('navSheet.empty')}</span>
</div> </div>
); );
} }
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />; return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
})} })}
{/* Fixed Profile */} {/* Fixed Profile */}
<PreviewSlot icon={User} label={isLoggedIn ? 'Profile' : 'Log In'} locked accent="#ff0040" /> <PreviewSlot icon={User} label={isLoggedIn ? t('navSheet.profile') : t('navSheet.login')} locked accent="#ff0040" />
</div> </div>
</div> </div>
@ -123,7 +123,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
{draftTabs.length > 0 && ( {draftTabs.length > 0 && (
<div className="mt-5"> <div className="mt-5">
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-500"> <p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
Order ({draft.length}/{MIDDLE_SLOTS}) {t('navSheet.order', { done: draft.length, total: MIDDLE_SLOTS })}
</p> </p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{draftTabs.map((tab, i) => ( {draftTabs.map((tab, i) => (
@ -137,7 +137,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
type="button" type="button"
onClick={() => move(tab.id, -1)} onClick={() => move(tab.id, -1)}
disabled={i === 0} disabled={i === 0}
aria-label={`Move ${label(tab)} up`} aria-label={t('navSheet.moveUp', { name: label(tab) })}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30" className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30"
> >
<ArrowUp className="h-4 w-4" /> <ArrowUp className="h-4 w-4" />
@ -146,7 +146,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
type="button" type="button"
onClick={() => move(tab.id, 1)} onClick={() => move(tab.id, 1)}
disabled={i === draftTabs.length - 1} disabled={i === draftTabs.length - 1}
aria-label={`Move ${label(tab)} down`} aria-label={t('navSheet.moveDown', { name: label(tab) })}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30" className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-white disabled:opacity-30"
> >
<ArrowDown className="h-4 w-4" /> <ArrowDown className="h-4 w-4" />
@ -160,10 +160,10 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
{/* Catalog picker */} {/* Catalog picker */}
<div className="mt-5"> <div className="mt-5">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">Available tabs</p> <p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">{t('navSheet.available')}</p>
{full && ( {full && (
<span className="text-[10px] font-bold text-amber-400"> <span className="text-[10px] font-bold text-amber-400">
Bar full — remove one to swap {t('navSheet.full')}
</span> </span>
)} )}
</div> </div>
@ -197,7 +197,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
<span className="text-[11px] font-bold uppercase leading-tight">{label(tab)}</span> <span className="text-[11px] font-bold uppercase leading-tight">{label(tab)}</span>
{gatedOut && ( {gatedOut && (
<span className="flex items-center gap-0.5 text-[8px] font-bold text-gray-500"> <span className="flex items-center gap-0.5 text-[8px] font-bold text-gray-500">
<Lock className="h-2 w-2" /> Log in <Lock className="h-2 w-2" /> {t('navSheet.login')}
</span> </span>
)} )}
</button> </button>
@ -213,13 +213,13 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
onClick={handleReset} onClick={handleReset}
className="gap-2 border-white/15 text-gray-300 hover:text-white" className="gap-2 border-white/15 text-gray-300 hover:text-white"
> >
<RotateCcw className="h-4 w-4" /> Reset <RotateCcw className="h-4 w-4" /> {t('navSheet.reset')}
</Button> </Button>
<Button <Button
onClick={save} onClick={save}
className="flex-1 gap-2 bg-[#ff0040] font-black uppercase text-white hover:bg-[#ff0040]/90" className="flex-1 gap-2 bg-[#ff0040] font-black uppercase text-white hover:bg-[#ff0040]/90"
> >
<Check className="h-4 w-4" /> Done <Check className="h-4 w-4" /> {t('navSheet.done')}
</Button> </Button>
</div> </div>
</SheetContent> </SheetContent>

View file

@ -535,16 +535,20 @@ export function Layout({ children, noPadding }: LayoutProps) {
return location.pathname.startsWith(path); return location.pathname.startsWith(path);
}; };
const mainRef = useRef<HTMLElement>(null);
return ( return (
<div className="min-h-screen bg-black text-white"> <div className="min-h-screen bg-black text-white">
{/* Skip-to-content — visually hidden until focused, so keyboard and {/* Skip-to-content — visually hidden until focused. A real <button> (not
screen-reader users can jump past the nav on every page. */} href="#main") because HashRouter treats the hash AS the route, so an
<a anchor would navigate away instead of focusing the content. */}
href="#main" <button
type="button"
onClick={() => mainRef.current?.focus()}
className="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-[100] focus:rounded-lg focus:bg-[#ccff00] focus:px-4 focus:py-2 focus:text-sm focus:font-black focus:uppercase focus:tracking-wide focus:text-black focus:shadow-lg" className="sr-only focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:z-[100] focus:rounded-lg focus:bg-[#ccff00] focus:px-4 focus:py-2 focus:text-sm focus:font-black focus:uppercase focus:tracking-wide focus:text-black focus:shadow-lg"
> >
{t('chromeUi.skipToContent')} {t('chromeUi.skipToContent')}
</a> </button>
{/* Desktop Header */} {/* Desktop Header */}
<header className="hidden md:flex sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur"> <header className="hidden md:flex sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur">
<div className="mx-auto flex w-full max-w-[1700px] items-center justify-between h-16 px-4 gap-4 xl:gap-8"> <div className="mx-auto flex w-full max-w-[1700px] items-center justify-between h-16 px-4 gap-4 xl:gap-8">
@ -1047,7 +1051,12 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */} {/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */}
{/* noPadding: full-bleed pages (map) manage their own height */} {/* noPadding: full-bleed pages (map) manage their own height */}
<main id="main" className={isFullBleed ? undefined : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6"}> <main
id="main"
ref={mainRef}
tabIndex={-1}
className={isFullBleed ? "outline-none" : "pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:pb-6 outline-none"}
>
{children ?? <Outlet />} {children ?? <Outlet />}
</main> </main>

View file

@ -6,6 +6,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { QRCodeCanvas } from '@/components/ui/qrcode'; import { QRCodeCanvas } from '@/components/ui/qrcode';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { backupConfirmedKey } from '@/lib/backup';
import { useNostrLogin } from '@nostrify/react/login'; import { useNostrLogin } from '@nostrify/react/login';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -16,7 +17,7 @@ export function SecuritySettings() {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { logins } = useNostrLogin(); const { logins } = useNostrLogin();
const { t } = useTranslation(); const { t } = useTranslation();
const [confirmed, setConfirmed] = useLocalStorage('backup-confirmed', false); const [confirmed, setConfirmed] = useLocalStorage(backupConfirmedKey(user?.pubkey), false);
const [showWarning, setShowWarning] = useState(!confirmed); const [showWarning, setShowWarning] = useState(!confirmed);
// Recovery reveal state // Recovery reveal state

View file

@ -139,8 +139,10 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
kind: 1111, kind: 1111,
content: t('spotUi.transferRequestContent', { title: spot.title }), content: t('spotUi.transferRequestContent', { title: spot.title }),
tags: [ tags: [
['a', coord], // NIP-22 ROOT scope for an addressable target uses uppercase tags.
['A', coord],
['K', String(SKATE_SPOT_KIND)], ['K', String(SKATE_SPOT_KIND)],
['P', spot.pubkey],
['t', 'spot-transfer'], ['t', 'spot-transfer'],
['p', recipientPubkey, '', 'new-owner'], ['p', recipientPubkey, '', 'new-owner'],
], ],

View file

@ -15,6 +15,8 @@ import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { backupConfirmedKey } from '@/lib/backup';
import { generateSecretKey, nip19 } from 'nostr-tools'; import { generateSecretKey, nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { SkateNip05Input } from '@/components/SkateNip05Input'; import { SkateNip05Input } from '@/components/SkateNip05Input';
@ -80,7 +82,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
const avatarFileInputRef = useRef<HTMLInputElement>(null); const avatarFileInputRef = useRef<HTMLInputElement>(null);
// Shared source of truth: once the recovery code is saved here, mark backup // Shared source of truth: once the recovery code is saved here, mark backup
// confirmed so the gentle post-signup reminder never nags a safe user. // confirmed so the gentle post-signup reminder never nags a safe user.
const [, setBackupConfirmed] = useLocalStorage('backup-confirmed', false); const { user } = useCurrentUser();
const [, setBackupConfirmed] = useLocalStorage(backupConfirmedKey(user?.pubkey), false);
// Silently generate a key and log the user in immediately. // Silently generate a key and log the user in immediately.
// No forced download, no crypto vocabulary — the account "just works". // No forced download, no crypto vocabulary — the account "just works".

View file

@ -99,8 +99,10 @@ export function useAdminModeration() {
{ {
kinds: [1985], kinds: [1985],
authors: ADMIN_PUBKEYS, authors: ADMIN_PUBKEYS,
// One request for BOTH admin verdicts: hidden and verified. // One request for ALL admin verdicts: hidden, verified and cleared.
'#l': [MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED], // `cleared` (undo) MUST be included or a hidden item can never be
// un-hidden on another device — it is the newest-wins retraction.
'#l': [MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED, MODERATION_LABEL_CLEARED],
limit: 1000, limit: 1000,
}, },
], ],

View file

@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react'; import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdminPubkey } from '@/lib/admins'; import { isAdminPubkey } from '@/lib/admins';
import { addLocalHidden } from '@/lib/localModeration'; import { useAdminHide } from '@/hooks/useAdminHide';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
@ -46,6 +46,7 @@ export function useDeleteEntity() {
const { nostr } = useNostr(); const { nostr } = useNostr();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { hideContent } = useAdminHide();
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
@ -72,6 +73,20 @@ export function useDeleteEntity() {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? ''; const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
const coord = `${event.kind}:${event.pubkey}:${dTag}`; const coord = `${event.kind}:${event.pubkey}:${dTag}`;
// ADMIN TAKEDOWN: relays only honour a NIP-09 deletion from the event's
// AUTHOR, so a kind 5 signed by an admin is ignored. The cross-device
// mechanism is the NIP-32 `hidden` label (useAdminHide), which every
// SkateSpots client reads, filters on and can later `cleared`-undo.
if (isAdmin && !isOwner) {
await hideContent({
coord,
id: event.id,
reason: reason?.trim() || 'Moderator takedown',
});
toast({ title: 'Hidden', description: 'Hidden across SkateSpots (soft and reversible).' });
return;
}
const deletionTags: string[][] = [ const deletionTags: string[][] = [
['a', coord], ['a', coord],
['e', event.id], ['e', event.id],
@ -134,9 +149,7 @@ export function useDeleteEntity() {
); );
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
// The event was SIGNED fine — the relay is the slow/failing part. Hide it // The event was SIGNED fine — the relay is the slow/failing part.
// locally so the user's action still takes visible effect on this device.
if (isAdmin && !isOwner) addLocalHidden({ coord, id: event.id });
toast({ toast({
title: 'Stuck at PUBLISHING', title: 'Stuck at PUBLISHING',
description: `Relay step failed: ${msg}. It was signed OK; the relay didn't confirm.`, description: `Relay step failed: ${msg}. It was signed OK; the relay didn't confirm.`,
@ -145,11 +158,6 @@ export function useDeleteEntity() {
throw new Error(`PUBLISH step failed: ${msg}`); throw new Error(`PUBLISH step failed: ${msg}`);
} }
// Admin: hide locally too (no signature needed).
if (isAdmin && !isOwner) {
addLocalHidden({ coord, id: event.id });
}
toast({ title: 'Deleted', description: 'The deletion was signed and published.' }); toast({ title: 'Deleted', description: 'The deletion was signed and published.' });
} }
} }

View file

@ -189,7 +189,7 @@ export function useGrowthStats() {
const querySkateContent = ( const querySkateContent = (
filters: Parameters<typeof queryCanonical>[0], filters: Parameters<typeof queryCanonical>[0],
opts: Parameters<typeof queryCanonical>[1], opts: Parameters<typeof queryCanonical>[1],
) => queryCanonical(filters, opts).catch((err) => { failedQueries++; throw err; }); ) => queryCanonical(filters, { ...opts, onError: () => { failedQueries++; } });
// Query all content types in parallel from the community relay. // Query all content types in parallel from the community relay.
// High limits to get full history — this is a stats page, not a feed. // High limits to get full history — this is a stats page, not a feed.

View file

@ -4,6 +4,7 @@ import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { MARKETPLACE_ITEM_KIND, SKATE_SPOT_KIND, SKATE_RELAY } from '@/lib/types'; import { MARKETPLACE_ITEM_KIND, SKATE_SPOT_KIND, SKATE_RELAY } from '@/lib/types';
import { verifyNip05 } from '@/lib/nip05';
// Platform fee destination (single source of truth: src/lib/platform.ts) // Platform fee destination (single source of truth: src/lib/platform.ts)
@ -68,8 +69,22 @@ export function useListingRestrictions(linkedSpotPubkey?: string): ListingRestri
null null
); );
// Check if user has NIP-05 verification // SECURITY: NIP-05 must be VERIFIED against the user's own key, not merely
const hasNip05 = !!metadata?.nip05; // present in their self-authored kind-0. Otherwise anyone can type a fake
// handle and skip the new-account listing fee / limit.
const { data: nip05Status } = useQuery({
queryKey: ['nip05-verify', user?.pubkey, metadata?.nip05],
enabled: !!user?.pubkey && !!metadata?.nip05,
staleTime: 10 * 60 * 1000,
queryFn: async (c) => {
if (!user?.pubkey || !metadata?.nip05) return { status: 'none' as const };
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
return verifyNip05(metadata.nip05, user.pubkey, signal).catch(() => ({
status: 'unreachable' as const,
}));
},
});
const hasNip05 = nip05Status?.status === 'verified';
// Check if user is a platform owner (bypasses all restrictions) // Check if user is a platform owner (bypasses all restrictions)
const isPlatformOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey); const isPlatformOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);

View file

@ -46,6 +46,25 @@ export function useLocalStorage<T>(
const stateRef = useRef(state); const stateRef = useRef(state);
stateRef.current = state; stateRef.current = state;
const defaultValueRef = useRef(defaultValue);
defaultValueRef.current = defaultValue;
// Re-read when the key CHANGES (e.g. a per-account key once the user logs in
// or switches accounts). The lazy initializer only runs on mount.
const prevKeyRef = useRef(key);
useEffect(() => {
if (prevKeyRef.current === key) return;
prevKeyRef.current = key;
try {
const item = localStorage.getItem(key);
const next = item ? deserialize(item) : defaultValueRef.current;
stateRef.current = next;
setState(next);
} catch (error) {
console.warn(`Failed to load ${key} from localStorage:`, error);
}
}, [key, deserialize]);
const setValue = useCallback( const setValue = useCallback(
(value: T | ((prev: T) => T)) => { (value: T | ((prev: T) => T)) => {
try { try {
@ -65,7 +84,9 @@ export function useLocalStorage<T>(
const handleStorageChange = (e: StorageEvent) => { const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) { if (e.key === key && e.newValue !== null) {
try { try {
setState(deserialize(e.newValue)); const next = deserialize(e.newValue);
stateRef.current = next;
setState(next);
} catch (error) { } catch (error) {
console.warn(`Failed to sync ${key} from localStorage:`, error); console.warn(`Failed to sync ${key} from localStorage:`, error);
} }

View file

@ -6,7 +6,7 @@ export function useShops(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({ return useQuery({
queryKey: ['skate-shops', limit, getQueryKeySuffix()], queryKey: ['shop-directory', 'list', limit, getQueryKeySuffix()],
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(10000);
@ -84,7 +84,7 @@ export function useShop(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({ return useQuery({
queryKey: ['skate-shop', identifier, pubkey, getQueryKeySuffix()], queryKey: ['shop-directory', 'item', identifier, pubkey, getQueryKeySuffix()],
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(10000);
@ -167,7 +167,7 @@ export function useUserShops(pubkey: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({ return useQuery({
queryKey: ['user-skate-shops', pubkey, getQueryKeySuffix()], queryKey: ['shop-directory', 'user', pubkey, getQueryKeySuffix()],
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(10000);

View file

@ -153,7 +153,7 @@ export function useSkateRelays() {
*/ */
const queryCanonical = async ( const queryCanonical = async (
filters: NostrFilter[], filters: NostrFilter[],
options: { signal: AbortSignal; requireAny?: boolean } options: { signal: AbortSignal; requireAny?: boolean; onError?: () => void }
): Promise<NostrEvent[]> => { ): Promise<NostrEvent[]> => {
try { try {
const relay = nostr.relay(SKATE_RELAY); const relay = nostr.relay(SKATE_RELAY);
@ -174,6 +174,7 @@ export function useSkateRelays() {
return Array.from(eventMap.values()); return Array.from(eventMap.values());
} catch (err) { } catch (err) {
// See querySkateContent: an outage must not look like "empty". // See querySkateContent: an outage must not look like "empty".
options.onError?.();
if (options.requireAny) throw err; if (options.requireAny) throw err;
return []; return [];
} }

View file

@ -4,6 +4,24 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
/**
* Preserve tags this app doesn't own. Kind 30003 is a NIP-51 bookmark set and
* re-publishing an addressable event replaces the WHOLE tag set, so foreign
* `e`/`r`/`p`/extra `t` (or `a` to a non-spot coordinate) from other clients
* must be carried forward or they are silently erased.
*/
function foreignCollectionTags(collection: SpotCollection): string[][] {
const out: string[][] = [];
for (const tag of collection.event.tags) {
const [n, v] = tag;
if (n === 'd' || n === 'title' || n === 'description' || n === 'image' || n === 'alt') continue;
if (n === 't' && v === 'skatespots') continue;
if (n === 'a' && v?.startsWith(`${SKATE_SPOT_KIND}:`)) continue;
out.push(tag);
}
return out;
}
/** /**
* Fetch all spot collections for a specific user * Fetch all spot collections for a specific user
*/ */
@ -201,6 +219,9 @@ export function useAddSpotToCollection() {
// Add new spot ref // Add new spot ref
tags.push(['a', spotRef]); tags.push(['a', spotRef]);
// Preserve foreign tags from the original event.
tags.push(...foreignCollectionTags(collection));
const event = await publishEvent({ const event = await publishEvent({
kind: SPOT_COLLECTION_KIND, kind: SPOT_COLLECTION_KIND,
content: collection.description || '', content: collection.description || '',
@ -263,6 +284,9 @@ export function useRemoveSpotFromCollection() {
tags.push(['a', ref]); tags.push(['a', ref]);
} }
// Preserve foreign tags from the original event.
tags.push(...foreignCollectionTags(collection));
const event = await publishEvent({ const event = await publishEvent({
kind: SPOT_COLLECTION_KIND, kind: SPOT_COLLECTION_KIND,
content: collection.description || '', content: collection.description || '',
@ -355,6 +379,9 @@ export function useUpdateSpotCollection() {
tags.push(['a', ref]); tags.push(['a', ref]);
} }
// Preserve foreign tags from the original event.
tags.push(...foreignCollectionTags(collection));
const event = await publishEvent({ const event = await publishEvent({
kind: SPOT_COLLECTION_KIND, kind: SPOT_COLLECTION_KIND,
content: newDescription || '', content: newDescription || '',

View file

@ -120,8 +120,16 @@ export function useSpotTagSuggestions(spotPubkey: string, spotIdentifier: string
queryFn: async (c) => { queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
// Query kind 1111 comments that are tag contributions // Query kind 1111 comments that are tag contributions. Read BOTH the
// NIP-22 uppercase root (`#A`, new writes) and the legacy lowercase `#a`
// so tag suggestions published before the fix still show up.
const events = await nostr.query([ const events = await nostr.query([
{
kinds: [1111],
'#A': [spotRef],
'#t': ['skatespot-tags'],
limit: 100,
},
{ {
kinds: [1111], kinds: [1111],
'#a': [spotRef], '#a': [spotRef],
@ -130,7 +138,10 @@ export function useSpotTagSuggestions(spotPubkey: string, spotIdentifier: string
}, },
], { signal }); ], { signal });
// Dedupe (an event could match both filters).
const seen = new Set<string>();
return events return events
.filter((e) => (seen.has(e.id) ? false : (seen.add(e.id), true)))
.map(parseTagContribution) .map(parseTagContribution)
.filter((t): t is TagContribution => t !== null) .filter((t): t is TagContribution => t !== null)
.sort((a, b) => b.createdAt - a.createdAt); .sort((a, b) => b.createdAt - a.createdAt);

View file

@ -1,12 +1,14 @@
import labOptions from './labOptions'; import labOptions from './labOptions';
import sasquatchChat from './sasquatchChat'; import sasquatchChat from './sasquatchChat';
import feesUi from './feesUi'; import feesUi from './feesUi';
import navSheet from './navSheet';
/** Namespaces added by the batch-4 translation pass, spread into `en`. */ /** Namespaces added by the batch-4 translation pass, spread into `en`. */
export const batch4En = { export const batch4En = {
labOptions: labOptions.en, labOptions: labOptions.en,
sasquatchChat: sasquatchChat.en, sasquatchChat: sasquatchChat.en,
feesUi: feesUi.en, feesUi: feesUi.en,
navSheet: navSheet.en,
}; };
/** Namespaces added by the batch-4 translation pass, spread into `es`. */ /** Namespaces added by the batch-4 translation pass, spread into `es`. */
@ -14,4 +16,5 @@ export const batch4Es = {
labOptions: labOptions.es, labOptions: labOptions.es,
sasquatchChat: sasquatchChat.es, sasquatchChat: sasquatchChat.es,
feesUi: feesUi.es, feesUi: feesUi.es,
navSheet: navSheet.es,
}; };

View file

@ -0,0 +1,38 @@
/**
* Batch 4 — mobile nav customizer (`navSheet.*`). Keys must exist in BOTH
* languages.
*/
const navSheet = {
en: {
title: 'Customize your bar',
desc: 'Pick the {{count}} tabs you use most. Everything else stays in the menu.',
preview: 'Preview',
empty: 'Empty',
order: 'Order ({{done}}/{{total}})',
available: 'Available tabs',
full: 'Bar full — remove one to swap',
moveUp: 'Move {{name}} up',
moveDown: 'Move {{name}} down',
login: 'Log in',
profile: 'Profile',
reset: 'Reset',
done: 'Done',
},
es: {
title: 'Personaliza tu barra',
desc: 'Elige las {{count}} pestañas que más uses. El resto se queda en el menú.',
preview: 'Vista previa',
empty: 'Vacío',
order: 'Orden ({{done}}/{{total}})',
available: 'Pestañas disponibles',
full: 'Barra llena: quita una para cambiar',
moveUp: 'Subir {{name}}',
moveDown: 'Bajar {{name}}',
login: 'Iniciar sesión',
profile: 'Perfil',
reset: 'Restablecer',
done: 'Listo',
},
};
export default navSheet;

10
src/lib/backup.ts Normal file
View file

@ -0,0 +1,10 @@
/**
* Per-account key for the "I've saved my recovery code" flag.
*
* It MUST be namespaced by pubkey: a device-global flag let account A's
* confirmation suppress the backup reminder for account B created later on the
* same device — leaving B's only key unrecovered.
*/
export function backupConfirmedKey(pubkey: string | undefined): string {
return `backup-confirmed:${pubkey ?? 'anon'}`;
}

View file

@ -282,8 +282,11 @@ export function parseAdminPubkeys(event: NostrEvent): string[] {
.map(([, value]) => value.toLowerCase()); .map(([, value]) => value.toLowerCase());
} }
/** All Lab provider kinds (The Lab service profiles). */ /** All Lab provider kinds (The Lab service profiles). 35006 is crews. */
export const LAB_KINDS = [35001, 35002, 35003, 35004, 35005] as const; export const LAB_KINDS = [
35001, 35002, 35003, 35004, 35005,
35007, 35008, 35009, 35010, 35011, 35012, 35013, 35014,
] as const;
/** /**
* "Pro" / featured status for Lab profiles and events. * "Pro" / featured status for Lab profiles and events.

View file

@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCashu } from '@/hooks/wallet/useCashu'; import { useCashu } from '@/hooks/wallet/useCashu';
import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useLocalStorage } from '@/hooks/useLocalStorage';
import { backupConfirmedKey } from '@/lib/backup';
import { getCurrentPosition } from '@/lib/geolocation'; import { getCurrentPosition } from '@/lib/geolocation';
import { getLastLocation } from '@/lib/lastLocation'; import { getLastLocation } from '@/lib/lastLocation';
import { LoginArea } from '@/components/auth/LoginArea'; import { LoginArea } from '@/components/auth/LoginArea';
@ -115,7 +116,7 @@ export default function OnboardingPage() {
// Shared with BackupReminder — the single source of truth for "has the user // Shared with BackupReminder — the single source of truth for "has the user
// saved their login/key?". The banner below stays until this is true, so // saved their login/key?". The banner below stays until this is true, so
// "remind me later" can never silently become "never" for this critical step. // "remind me later" can never silently become "never" for this critical step.
const [backupConfirmed] = useLocalStorage('backup-confirmed', false); const [backupConfirmed] = useLocalStorage(backupConfirmedKey(user?.pubkey), false);
// "Where do you skate?" — the single most useful first step. We reuse the // "Where do you skate?" — the single most useful first step. We reuse the
// same last-location cache MapPage reads on mount, so setting it here means // same last-location cache MapPage reads on mount, so setting it here means

View file

@ -175,6 +175,8 @@ export default function ShopDetailPage() {
queryClient.invalidateQueries({ queryKey: ['skate-shops'] }); queryClient.invalidateQueries({ queryKey: ['skate-shops'] });
queryClient.invalidateQueries({ queryKey: ['skate-shop', shop.identifier, shop.pubkey] }); queryClient.invalidateQueries({ queryKey: ['skate-shop', shop.identifier, shop.pubkey] });
queryClient.invalidateQueries({ queryKey: ['user-skate-shops', user.pubkey] }); queryClient.invalidateQueries({ queryKey: ['user-skate-shops', user.pubkey] });
// The legacy directory hook (`useShops`) keeps its own cache namespace.
queryClient.invalidateQueries({ queryKey: ['shop-directory'] });
setTimeout(() => navigate('/map'), 1000); setTimeout(() => navigate('/map'), 1000);
} catch (error) { } catch (error) {