From 9189fc6584cacc5998331300d88f7a9843fc7655 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 21:30:01 +0700 Subject: [PATCH] =?UTF-8?q?fix(trust):=20tier=202=20=E2=80=94=20moderation?= =?UTF-8?q?,=20labels=20&=20data=20integrity=20(H2=E2=80=93H17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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
(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:). --- NIP.md | 12 +++++++-- src/components/AddTagsToSpot.tsx | 8 +++--- src/components/BackupReminder.tsx | 7 ++--- src/components/CraftProfileForm.tsx | 10 +++++++- src/components/CustomizeNavSheet.tsx | 26 +++++++++---------- src/components/Layout.tsx | 21 ++++++++++----- src/components/SecuritySettings.tsx | 3 ++- src/components/SpotOwnership.tsx | 4 ++- src/components/auth/SignupDialog.tsx | 5 +++- src/hooks/useAdminModeration.ts | 6 +++-- src/hooks/useDeleteEntity.ts | 26 ++++++++++++------- src/hooks/useGrowthStats.ts | 2 +- src/hooks/useListingRestrictions.ts | 19 ++++++++++++-- src/hooks/useLocalStorage.ts | 23 ++++++++++++++++- src/hooks/useShops.ts | 6 ++--- src/hooks/useSkateRelays.ts | 3 ++- src/hooks/useSpotCollections.ts | 27 ++++++++++++++++++++ src/hooks/useSpotContributions.ts | 15 +++++++++-- src/i18n/batch4/index.ts | 3 +++ src/i18n/batch4/navSheet.ts | 38 ++++++++++++++++++++++++++++ src/lib/backup.ts | 10 ++++++++ src/lib/types.ts | 7 +++-- src/pages/OnboardingPage.tsx | 3 ++- src/pages/ShopDetailPage.tsx | 2 ++ 24 files changed, 231 insertions(+), 55 deletions(-) create mode 100644 src/i18n/batch4/navSheet.ts create mode 100644 src/lib/backup.ts diff --git a/NIP.md b/NIP.md index 6c97620..18ca1b5 100644 --- a/NIP.md +++ b/NIP.md @@ -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::"], + ["A", "30818::"], ["K", "30818"], + ["P", ""], ["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", "", "", "new-owner"]`. ### Querying Contributions diff --git a/src/components/AddTagsToSpot.tsx b/src/components/AddTagsToSpot.tsx index b91bb13..e9cfbe9 100644 --- a/src/components/AddTagsToSpot.tsx +++ b/src/components/AddTagsToSpot.tsx @@ -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'], ]; diff --git a/src/components/BackupReminder.tsx b/src/components/BackupReminder.tsx index f77d9f4..62c112f 100644 --- a/src/components/BackupReminder.tsx +++ b/src/components/BackupReminder.tsx @@ -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:` 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(); diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx index dd2dd98..ca1298b 100644 --- a/src/components/CraftProfileForm.tsx +++ b/src/components/CraftProfileForm.tsx @@ -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 = {}; 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]); diff --git a/src/components/CustomizeNavSheet.tsx b/src/components/CustomizeNavSheet.tsx index 4545732..6003499 100644 --- a/src/components/CustomizeNavSheet.tsx +++ b/src/components/CustomizeNavSheet.tsx @@ -85,16 +85,16 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps > - Customize your bar + {t('navSheet.title')} - Pick the {MIDDLE_SLOTS} tabs you use most. Everything else stays in the menu. + {t('navSheet.desc', { count: MIDDLE_SLOTS })} {/* Live preview */}
-

Preview

+

{t('navSheet.preview')}

{/* Fixed Map */} @@ -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" > + - Empty + {t('navSheet.empty')}
); } return ; })} {/* Fixed Profile */} - +
@@ -123,7 +123,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps {draftTabs.length > 0 && (

- Order ({draft.length}/{MIDDLE_SLOTS}) + {t('navSheet.order', { done: draft.length, total: MIDDLE_SLOTS })}

{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" > @@ -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" > @@ -160,10 +160,10 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps {/* Catalog picker */}
-

Available tabs

+

{t('navSheet.available')}

{full && ( - Bar full — remove one to swap + {t('navSheet.full')} )}
@@ -197,7 +197,7 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps {label(tab)} {gatedOut && ( - Log in + {t('navSheet.login')} )} @@ -213,13 +213,13 @@ export function CustomizeNavSheet({ open, onOpenChange }: CustomizeNavSheetProps onClick={handleReset} className="gap-2 border-white/15 text-gray-300 hover:text-white" > - Reset + {t('navSheet.reset')}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 0382e6b..938f0b7 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -535,16 +535,20 @@ export function Layout({ children, noPadding }: LayoutProps) { return location.pathname.startsWith(path); }; + const mainRef = useRef(null); + return (
- {/* Skip-to-content — visually hidden until focused, so keyboard and - screen-reader users can jump past the nav on every page. */} - {/* Desktop Header */}
@@ -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 */} -
+
{children ?? }
diff --git a/src/components/SecuritySettings.tsx b/src/components/SecuritySettings.tsx index 8ee88c9..fee6ded 100644 --- a/src/components/SecuritySettings.tsx +++ b/src/components/SecuritySettings.tsx @@ -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 diff --git a/src/components/SpotOwnership.tsx b/src/components/SpotOwnership.tsx index c3907c3..178612d 100644 --- a/src/components/SpotOwnership.tsx +++ b/src/components/SpotOwnership.tsx @@ -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'], ], diff --git a/src/components/auth/SignupDialog.tsx b/src/components/auth/SignupDialog.tsx index a514eee..861c89c 100644 --- a/src/components/auth/SignupDialog.tsx +++ b/src/components/auth/SignupDialog.tsx @@ -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 = ({ isOpen, onClose, onComplete const avatarFileInputRef = useRef(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". diff --git a/src/hooks/useAdminModeration.ts b/src/hooks/useAdminModeration.ts index d8baa14..78c4224 100644 --- a/src/hooks/useAdminModeration.ts +++ b/src/hooks/useAdminModeration.ts @@ -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, }, ], diff --git a/src/hooks/useDeleteEntity.ts b/src/hooks/useDeleteEntity.ts index 53851fc..4e049cb 100644 --- a/src/hooks/useDeleteEntity.ts +++ b/src/hooks/useDeleteEntity.ts @@ -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.' }); } } diff --git a/src/hooks/useGrowthStats.ts b/src/hooks/useGrowthStats.ts index aad1697..de3b43d 100644 --- a/src/hooks/useGrowthStats.ts +++ b/src/hooks/useGrowthStats.ts @@ -189,7 +189,7 @@ export function useGrowthStats() { const querySkateContent = ( filters: Parameters[0], opts: Parameters[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. diff --git a/src/hooks/useListingRestrictions.ts b/src/hooks/useListingRestrictions.ts index eae15b9..f16c34d 100644 --- a/src/hooks/useListingRestrictions.ts +++ b/src/hooks/useListingRestrictions.ts @@ -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); diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index 15b18fc..74a1f92 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -46,6 +46,25 @@ export function useLocalStorage( 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( 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); } diff --git a/src/hooks/useShops.ts b/src/hooks/useShops.ts index de7bff1..2a986b2 100644 --- a/src/hooks/useShops.ts +++ b/src/hooks/useShops.ts @@ -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); diff --git a/src/hooks/useSkateRelays.ts b/src/hooks/useSkateRelays.ts index 61320ae..62de3bc 100644 --- a/src/hooks/useSkateRelays.ts +++ b/src/hooks/useSkateRelays.ts @@ -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 => { 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 []; } diff --git a/src/hooks/useSpotCollections.ts b/src/hooks/useSpotCollections.ts index cbb1438..c3abe77 100644 --- a/src/hooks/useSpotCollections.ts +++ b/src/hooks/useSpotCollections.ts @@ -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 || '', diff --git a/src/hooks/useSpotContributions.ts b/src/hooks/useSpotContributions.ts index 3cfa05b..6328a12 100644 --- a/src/hooks/useSpotContributions.ts +++ b/src/hooks/useSpotContributions.ts @@ -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(); 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); diff --git a/src/i18n/batch4/index.ts b/src/i18n/batch4/index.ts index 4085e86..c1bd95b 100644 --- a/src/i18n/batch4/index.ts +++ b/src/i18n/batch4/index.ts @@ -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, }; diff --git a/src/i18n/batch4/navSheet.ts b/src/i18n/batch4/navSheet.ts new file mode 100644 index 0000000..0293e80 --- /dev/null +++ b/src/i18n/batch4/navSheet.ts @@ -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; diff --git a/src/lib/backup.ts b/src/lib/backup.ts new file mode 100644 index 0000000..0b7ea04 --- /dev/null +++ b/src/lib/backup.ts @@ -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'}`; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index cae10ee..78d6e08 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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. diff --git a/src/pages/OnboardingPage.tsx b/src/pages/OnboardingPage.tsx index 30e4a39..faee008 100644 --- a/src/pages/OnboardingPage.tsx +++ b/src/pages/OnboardingPage.tsx @@ -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 diff --git a/src/pages/ShopDetailPage.tsx b/src/pages/ShopDetailPage.tsx index f993b16..c12119a 100644 --- a/src/pages/ShopDetailPage.tsx +++ b/src/pages/ShopDetailPage.tsx @@ -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) {