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:
parent
a8b4b9fdd0
commit
9189fc6584
24 changed files with 231 additions and 55 deletions
12
NIP.md
12
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:<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.' });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
@ -130,7 +138,10 @@ export function useSpotTagSuggestions(spotPubkey: string, spotIdentifier: string
|
|||
},
|
||||
], { 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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
38
src/i18n/batch4/navSheet.ts
Normal file
38
src/i18n/batch4/navSheet.ts
Normal 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
10
src/lib/backup.ts
Normal 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'}`;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue