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 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
{
"kind": 1111,
"content": "Suggested tags for Hollywood High: #marble #ledge #10-stair",
"tags": [
["a", "30818:<spot-pubkey>:<spot-d-tag>"],
["A", "30818:<spot-pubkey>:<spot-d-tag>"],
["K", "30818"],
["P", "<spot-pubkey>"],
["t", "skatespot-tags"],
["t", "marble"],
["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.
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

View file

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

View file

@ -4,6 +4,7 @@ import { AlertTriangle, X, Download, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { backupConfirmedKey } from '@/lib/backup';
import { useTranslation } from '@/contexts/language';
import {
Sheet,
@ -13,12 +14,12 @@ import {
SheetTitle,
} from '@/components/ui/sheet';
const BACKUP_CONFIRMED_KEY = 'backup-confirmed';
// Session-scoped snooze key. "Remind me later" used to set a PERMANENT
// localStorage flag, which silently turned into "never remind again" — a real
// 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.
// 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';
/**
@ -30,7 +31,7 @@ const BACKUP_SNOOZE_KEY = 'backup-reminder-snoozed-session';
*/
export function BackupReminder() {
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 { t } = useTranslation();
const navigate = useNavigate();

View file

@ -149,7 +149,9 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
setNoffer(tag('noffer') ?? '');
setEntityType((tag('entity') as EntityType) ?? 'individual');
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> = {};
for (const f of extraFields) {
const v = tag(f.tag);
@ -281,6 +283,12 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
}
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') {
for (const pk of members) tags.push(['p', pk]);
if (crewRef) tags.push(['a', crewRef]);

View file

@ -85,16 +85,16 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
>
<SheetHeader className="text-left">
<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>
<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>
</SheetHeader>
{/* Live preview */}
<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">
{/* Fixed Map */}
<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"
>
<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>
);
}
return <PreviewSlot key={tab.id} icon={tab.icon} label={label(tab)} accent="#ff0040" />;
})}
{/* 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>
@ -123,7 +123,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
{draftTabs.length > 0 && (
<div className="mt-5">
<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>
<div className="space-y-1.5">
{draftTabs.map((tab, i) => (
@ -137,7 +137,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
type="button"
onClick={() => move(tab.id, -1)}
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"
>
<ArrowUp className="h-4 w-4" />
@ -146,7 +146,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
type="button"
onClick={() => move(tab.id, 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"
>
<ArrowDown className="h-4 w-4" />
@ -160,10 +160,10 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
{/* Catalog picker */}
<div className="mt-5">
<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 && (
<span className="text-[10px] font-bold text-amber-400">
Bar full — remove one to swap
{t('navSheet.full')}
</span>
)}
</div>
@ -197,7 +197,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
<span className="text-[11px] font-bold uppercase leading-tight">{label(tab)}</span>
{gatedOut && (
<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>
)}
</button>
@ -213,13 +213,13 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps
onClick={handleReset}
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
onClick={save}
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>
</div>
</SheetContent>

View file

@ -535,16 +535,20 @@ export function Layout({ children, noPadding }: LayoutProps) {
return location.pathname.startsWith(path);
};
const mainRef = useRef<HTMLElement>(null);
return (
<div className="min-h-screen bg-black text-white">
{/* Skip-to-content — visually hidden until focused, so keyboard and
screen-reader users can jump past the nav on every page. */}
<a
href="#main"
{/* Skip-to-content — visually hidden until focused. A real <button> (not
href="#main") because HashRouter treats the hash AS the route, so an
anchor would navigate away instead of focusing the content. */}
<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"
>
{t('chromeUi.skipToContent')}
</a>
</button>
{/* Desktop Header */}
<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">
@ -1047,7 +1051,12 @@ export function Layout({ children, noPadding }: LayoutProps) {
{/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */}
{/* 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 />}
</main>

View file

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

View file

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

View file

@ -15,6 +15,8 @@ import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { backupConfirmedKey } from '@/lib/backup';
import { generateSecretKey, nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
import { SkateNip05Input } from '@/components/SkateNip05Input';
@ -80,7 +82,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
const avatarFileInputRef = useRef<HTMLInputElement>(null);
// 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.
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.
// No forced download, no crypto vocabulary — the account "just works".

View file

@ -99,8 +99,10 @@ export function useAdminModeration() {
{
kinds: [1985],
authors: ADMIN_PUBKEYS,
// One request for BOTH admin verdicts: hidden and verified.
'#l': [MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED],
// One request for ALL admin verdicts: hidden, verified and cleared.
// `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,
},
],

View file

@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdminPubkey } from '@/lib/admins';
import { addLocalHidden } from '@/lib/localModeration';
import { useAdminHide } from '@/hooks/useAdminHide';
import { toast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
@ -46,6 +46,7 @@ export function useDeleteEntity() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { hideContent } = useAdminHide();
return useMutation({
mutationFn: ({
@ -72,6 +73,20 @@ export function useDeleteEntity() {
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
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[][] = [
['a', coord],
['e', event.id],
@ -134,9 +149,7 @@ export function useDeleteEntity() {
);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// The event was SIGNED fine — the relay is the slow/failing part. Hide it
// locally so the user's action still takes visible effect on this device.
if (isAdmin && !isOwner) addLocalHidden({ coord, id: event.id });
// The event was SIGNED fine — the relay is the slow/failing part.
toast({
title: 'Stuck at PUBLISHING',
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}`);
}
// 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.' });
}
}

View file

@ -189,7 +189,7 @@ export function useGrowthStats() {
const querySkateContent = (
filters: Parameters<typeof queryCanonical>[0],
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.
// 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 { useLocalStorage } from '@/hooks/useLocalStorage';
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)
@ -68,8 +69,22 @@ export function useListingRestrictions(linkedSpotPubkey?: string): ListingRestri
null
);
// Check if user has NIP-05 verification
const hasNip05 = !!metadata?.nip05;
// SECURITY: NIP-05 must be VERIFIED against the user's own key, not merely
// 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)
const isPlatformOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);

View file

@ -46,6 +46,25 @@ export function useLocalStorage<T>(
const stateRef = useRef(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(
(value: T | ((prev: T) => T)) => {
try {
@ -65,7 +84,9 @@ export function useLocalStorage<T>(
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
try {
setState(deserialize(e.newValue));
const next = deserialize(e.newValue);
stateRef.current = next;
setState(next);
} catch (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();
return useQuery({
queryKey: ['skate-shops', limit, getQueryKeySuffix()],
queryKey: ['shop-directory', 'list', limit, getQueryKeySuffix()],
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
@ -84,7 +84,7 @@ export function useShop(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['skate-shop', identifier, pubkey, getQueryKeySuffix()],
queryKey: ['shop-directory', 'item', identifier, pubkey, getQueryKeySuffix()],
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
@ -167,7 +167,7 @@ export function useUserShops(pubkey: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['user-skate-shops', pubkey, getQueryKeySuffix()],
queryKey: ['shop-directory', 'user', pubkey, getQueryKeySuffix()],
queryFn: async () => {
const signal = AbortSignal.timeout(10000);

View file

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

View file

@ -4,6 +4,24 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
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
*/
@ -201,6 +219,9 @@ export function useAddSpotToCollection() {
// Add new spot ref
tags.push(['a', spotRef]);
// Preserve foreign tags from the original event.
tags.push(...foreignCollectionTags(collection));
const event = await publishEvent({
kind: SPOT_COLLECTION_KIND,
content: collection.description || '',
@ -263,6 +284,9 @@ export function useRemoveSpotFromCollection() {
tags.push(['a', ref]);
}
// Preserve foreign tags from the original event.
tags.push(...foreignCollectionTags(collection));
const event = await publishEvent({
kind: SPOT_COLLECTION_KIND,
content: collection.description || '',
@ -355,6 +379,9 @@ export function useUpdateSpotCollection() {
tags.push(['a', ref]);
}
// Preserve foreign tags from the original event.
tags.push(...foreignCollectionTags(collection));
const event = await publishEvent({
kind: SPOT_COLLECTION_KIND,
content: newDescription || '',

View file

@ -120,8 +120,16 @@ export function useSpotTagSuggestions(spotPubkey: string, spotIdentifier: string
queryFn: async (c) => {
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([
{
kinds: [1111],
'#A': [spotRef],
'#t': ['skatespot-tags'],
limit: 100,
},
{
kinds: [1111],
'#a': [spotRef],
@ -129,8 +137,11 @@ export function useSpotTagSuggestions(spotPubkey: string, spotIdentifier: string
limit: 100,
},
], { signal });
// Dedupe (an event could match both filters).
const seen = new Set<string>();
return events
.filter((e) => (seen.has(e.id) ? false : (seen.add(e.id), true)))
.map(parseTagContribution)
.filter((t): t is TagContribution => t !== null)
.sort((a, b) => b.createdAt - a.createdAt);

View file

@ -1,12 +1,14 @@
import labOptions from './labOptions';
import sasquatchChat from './sasquatchChat';
import feesUi from './feesUi';
import navSheet from './navSheet';
/** Namespaces added by the batch-4 translation pass, spread into `en`. */
export const batch4En = {
labOptions: labOptions.en,
sasquatchChat: sasquatchChat.en,
feesUi: feesUi.en,
navSheet: navSheet.en,
};
/** Namespaces added by the batch-4 translation pass, spread into `es`. */
@ -14,4 +16,5 @@ export const batch4Es = {
labOptions: labOptions.es,
sasquatchChat: sasquatchChat.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());
}
/** All Lab provider kinds (The Lab service profiles). */
export const LAB_KINDS = [35001, 35002, 35003, 35004, 35005] as const;
/** All Lab provider kinds (The Lab service profiles). 35006 is crews. */
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.

View file

@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCashu } from '@/hooks/wallet/useCashu';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { backupConfirmedKey } from '@/lib/backup';
import { getCurrentPosition } from '@/lib/geolocation';
import { getLastLocation } from '@/lib/lastLocation';
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
// 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.
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
// 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-shop', shop.identifier, shop.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);
} catch (error) {