fix: tier 4 — offer role trust, proof replay, crew alerts, error/nav i18n

- H13 offer threads: the seller is ALWAYS the listing author (derived from the
  event pubkey), never a self-declared role tag — a non-owner can no longer
  appear as the seller with a payment pointer.
- H12 trade reputation: a preimage/deposit proof counts at most once, so a real
  payment can't be reused across attestations to inflate payment-verified.
- Crew alerts: listed crews are filtered against the user's own membership
  confirmation labels (one batched query), so a confirmed member no longer gets
  a phantom 'confirm to join' badge/notification when the crew is edited.
- Error boundaries (app + route) and DesktopNavManager are translated; the
  class boundary renders a translated functional ErrorFallback. New batch4
  namespaces errorsUi.* / desktopNav.*.
This commit is contained in:
SkateSpots 2026-10-02 23:22:13 +07:00
parent 5596c519c6
commit caa5f8c56e
10 changed files with 229 additions and 93 deletions

View file

@ -39,10 +39,9 @@ export function DesktopNavManager() {
<Monitor className="h-5 w-5" />
</div>
<div className="min-w-0">
<h3 className="text-base font-bold text-white">Desktop navigation</h3>
<h3 className="text-base font-bold text-white">{t('desktopNav.title')}</h3>
<p className="text-sm text-muted-foreground">
Choose which tabs appear in the top bar and their order. Hidden tabs
still live in the “More” menu.
{t('desktopNav.desc')}
</p>
</div>
</div>
@ -52,18 +51,18 @@ export function DesktopNavManager() {
<fixedMapTab.icon className="h-4 w-4 text-red-400" />
<span className="flex-1 text-sm font-bold text-white">{label(fixedMapTab)}</span>
<span className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-gray-500">
<Lock className="h-3 w-3" /> Fixed
<Lock className="h-3 w-3" /> {t('desktopNav.fixed')}
</span>
</div>
{/* Shown (primary) tabs — reorderable */}
<div className="space-y-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
Shown in top bar ({primaryIds.length})
{t('desktopNav.shownInTopBar', { count: primaryIds.length })}
</p>
{primaryIds.length === 0 && (
<p className="rounded-lg border border-dashed border-white/10 p-3 text-xs text-gray-500">
No extra tabs shown — everything is in the “More” menu. Add some below.
{t('desktopNav.noExtraTabs')}
</p>
)}
{primaryIds.map((id, i) => {
@ -80,7 +79,7 @@ export function DesktopNavManager() {
type="button"
onClick={() => move(id, -1)}
disabled={i === 0}
aria-label={`Move ${label(tab)} up`}
aria-label={t('desktopNav.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" />
@ -89,7 +88,7 @@ export function DesktopNavManager() {
type="button"
onClick={() => move(id, 1)}
disabled={i === primaryIds.length - 1}
aria-label={`Move ${label(tab)} down`}
aria-label={t('desktopNav.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" />
@ -97,8 +96,8 @@ export function DesktopNavManager() {
<button
type="button"
onClick={() => hide(id)}
aria-label={`Hide ${label(tab)}`}
title="Move to More menu"
aria-label={t('desktopNav.hide', { name: label(tab) })}
title={t('desktopNav.moveToMore')}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/5 hover:text-amber-400"
>
<EyeOff className="h-4 w-4" />
@ -112,7 +111,7 @@ export function DesktopNavManager() {
{hiddenIds.length > 0 && (
<div className="space-y-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
In “More” menu ({hiddenIds.length})
{t('desktopNav.inMoreMenu', { count: hiddenIds.length })}
</p>
{hiddenIds.map((id) => {
const tab = byId(id);
@ -131,11 +130,11 @@ export function DesktopNavManager() {
<button
type="button"
onClick={() => show(id)}
aria-label={`Show ${label(tab)} in top bar`}
title="Show in top bar"
aria-label={t('desktopNav.showInTopBar', { name: label(tab) })}
title={t('desktopNav.showInTopBarTitle')}
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-2.5 py-1 text-xs font-bold text-gray-300 transition-colors hover:border-red-500/40 hover:text-red-400"
>
<Eye className="h-3.5 w-3.5" /> Show
<Eye className="h-3.5 w-3.5" /> {t('desktopNav.show')}
</button>
</div>
);
@ -149,7 +148,7 @@ export function DesktopNavManager() {
onClick={reset}
className="gap-2 border-white/15 text-gray-300 hover:text-white"
>
<RotateCcw className="h-4 w-4" /> Reset to default
<RotateCcw className="h-4 w-4" /> {t('desktopNav.reset')}
</Button>
</div>
</div>

View file

@ -1,4 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { ErrorFallback } from '@/components/ErrorFallback';
interface ErrorBoundaryState {
hasError: boolean;
@ -57,37 +58,11 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
}
return (
<div className="min-h-screen bg-black text-white flex items-center justify-center p-4">
<div className="max-w-md w-full space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-[#ff0040]/15 border border-[#ff0040]/30 text-4xl">
🛹
</div>
<div className="space-y-2">
<h2 className="text-2xl font-black uppercase text-[#ff0040]">
Slammed
</h2>
<p className="text-gray-400">
Something tripped up this screen. Your account and data are safe —
let's get you rolling again.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<button
onClick={this.handleReset}
className="flex-1 px-4 py-3 rounded-full font-black uppercase bg-[#ff0040] text-white hover:bg-[#ff0040]/90 transition-colors"
>
Try again
</button>
<button
onClick={() => window.location.reload()}
className="flex-1 px-4 py-3 rounded-full font-bold border border-white/20 text-white hover:bg-white/5 transition-colors"
>
Reload app
</button>
</div>
</div>
</div>
<ErrorFallback
variant="app"
onReset={this.handleReset}
onReload={() => window.location.reload()}
/>
);
}

View file

@ -0,0 +1,79 @@
import { useTranslation } from '@/contexts/language';
/**
* Translated error-boundary UI. A FUNCTION component so it can use `t()` — the
* class ErrorBoundary renders it, which keeps the class (React error boundaries
* must be classes) while making the copy language-aware.
*/
export function ErrorFallback({
variant,
onReset,
onReload,
onBack,
}: {
variant: 'app' | 'route';
onReset?: () => void;
onReload: () => void;
onBack?: () => void;
}) {
const { t } = useTranslation();
const isApp = variant === 'app';
return (
<div
className={
isApp
? 'min-h-screen bg-black text-white flex items-center justify-center p-4'
: 'min-h-[60vh] flex items-center justify-center p-6'
}
>
<div className={isApp ? 'max-w-md w-full space-y-6 text-center' : 'max-w-sm w-full text-center space-y-5'}>
<div
className={
isApp
? 'mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-[#ff0040]/15 border border-[#ff0040]/30 text-4xl'
: 'mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-[#ff0040]/15 border border-[#ff0040]/30 text-3xl'
}
>
🛹
</div>
<div className={isApp ? 'space-y-2' : 'space-y-1.5'}>
<h2 className={isApp ? 'text-2xl font-black uppercase text-[#ff0040]' : 'text-xl font-black uppercase text-[#ff0040]'}>
{isApp ? t('errorsUi.slammedTitle') : t('errorsUi.pageSlammedTitle')}
</h2>
<p className={isApp ? 'text-gray-400' : 'text-sm text-gray-400'}>
{isApp ? t('errorsUi.slammedBody') : t('errorsUi.pageSlammedBody')}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
{onReset && (
<button
onClick={onReset}
className="flex-1 px-4 py-3 rounded-full font-black uppercase bg-[#ff0040] text-white hover:bg-[#ff0040]/90 transition-colors"
>
{t('errorsUi.tryAgain')}
</button>
)}
<button
onClick={onReload}
className={
isApp
? 'flex-1 px-4 py-3 rounded-full font-bold border border-white/20 text-white hover:bg-white/5 transition-colors'
: 'flex-1 px-4 py-3 rounded-full font-black uppercase bg-[#ff0040] text-white hover:bg-[#ff0040]/90 transition-colors'
}
>
{isApp ? t('errorsUi.reloadApp') : t('errorsUi.reload')}
</button>
{onBack && (
<button
onClick={onBack}
className="flex-1 px-4 py-3 rounded-full font-black uppercase bg-white/5 text-gray-300 border border-white/10 hover:bg-white/10 transition-colors"
>
{t('errorsUi.goBack')}
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -1,5 +1,6 @@
import { useLocation } from 'react-router-dom';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ErrorFallback } from '@/components/ErrorFallback';
/**
* Per-route error boundary.
@ -29,36 +30,11 @@ export function RouteErrorBoundary({ children }: { children: React.ReactNode })
<ErrorBoundary
key={location.pathname}
fallback={
<div className="min-h-[60vh] flex items-center justify-center p-6">
<div className="max-w-sm w-full text-center space-y-5">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-[#ff0040]/15 border border-[#ff0040]/30 text-3xl">
🛹
</div>
<div className="space-y-1.5">
<h2 className="text-xl font-black uppercase text-[#ff0040]">
This page slammed
</h2>
<p className="text-sm text-gray-400">
Something on this screen tripped up. Your account and data are safe.
Usually a quick reload sorts it out.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<button
onClick={hardReload}
className="flex-1 px-4 py-3 rounded-full font-black uppercase bg-[#ff0040] text-white hover:bg-[#ff0040]/90 transition-colors"
>
Reload
</button>
<button
onClick={() => window.history.back()}
className="flex-1 px-4 py-3 rounded-full font-black uppercase bg-white/5 text-gray-300 border border-white/10 hover:bg-white/10 transition-colors"
>
Go back
</button>
</div>
</div>
</div>
<ErrorFallback
variant="route"
onReload={hardReload}
onBack={() => window.history.back()}
/>
}
>
{children}

View file

@ -1,11 +1,13 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useMyCrewInvites, useIncomingJoinRequests, useJoinRequestEndorsements } from '@/hooks/useCrewInvites';
import { useIncomingCollectiveJoinRequests } from '@/hooks/useCollectiveJoinRequests';
import { useCrewDismissals } from '@/hooks/useCrewDismissals';
import { useCrews } from '@/hooks/useCrews';
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { SKATE_CREW_KIND, type CollectiveJoinRequest, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { SKATE_CREW_KIND, MEMBERSHIP_LABEL_NAMESPACE, MEMBERSHIP_LABEL_VALUE, type CollectiveJoinRequest, type CrewEndorsement, type CrewInvite, type CrewJoinRequest, type SkateCrew } from '@/lib/types';
export interface CrewNotification {
/** Stable id for the notification (invite id or crew coord). */
@ -29,6 +31,7 @@ export interface CrewNotification {
*/
export function useCrewNotifications() {
const { user } = useCurrentUser();
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const { data: invites, isLoading: invitesLoading } = useMyCrewInvites(user?.pubkey);
const { data: joinRequests, isLoading: joinLoading } = useIncomingJoinRequests(user?.pubkey);
const { data: collectiveRequestsRaw, isLoading: collectiveLoading } =
@ -46,14 +49,40 @@ export function useCrewNotifications() {
}, [invites, dismissed]);
// Crews that list me as a member (candidates for "pending confirmation").
// One query resolves which crews I've ALREADY confirmed, so a confirmed member
// never gets a phantom "confirm to join" alert when the crew is edited. While
// it loads we return none (better a beat's delay than a false notification).
const { data: confirmedCoords, isLoading: membershipLoading } = useQuery<Set<string>>({
queryKey: ['my-membership-labels', user?.pubkey, getQueryKeySuffix()],
enabled: !!user?.pubkey,
staleTime: 60_000,
queryFn: async () => {
if (!user?.pubkey) return new Set<string>();
const signal = AbortSignal.timeout(8000);
const events = await querySkateContent(
[{ kinds: [1985], authors: [user.pubkey], '#l': [MEMBERSHIP_LABEL_VALUE], limit: 500 }],
{ signal },
);
const coords = new Set<string>();
for (const ev of events) {
const hasNamespace = ev.tags.some(([n, v]) => n === 'L' && v === MEMBERSHIP_LABEL_NAMESPACE);
const hasLabel = ev.tags.some(([n, v]) => n === 'l' && v === MEMBERSHIP_LABEL_VALUE);
if (!hasNamespace || !hasLabel) continue;
for (const [n, v] of ev.tags) if (n === 'a' && v) coords.add(v);
}
return coords;
},
});
const listedCrews = useMemo(() => {
if (!user || !crews) return [];
if (!user || !crews || !confirmedCoords) return [];
return crews.filter(
(c) =>
c.pubkey.toLowerCase() !== user.pubkey.toLowerCase() &&
c.members.some((m) => m.toLowerCase() === user.pubkey.toLowerCase()),
c.members.some((m) => m.toLowerCase() === user.pubkey.toLowerCase()) &&
!confirmedCoords.has(`${SKATE_CREW_KIND}:${c.pubkey}:${c.identifier}`),
);
}, [user, crews]);
}, [user, crews, confirmedCoords]);
// Resolve each incoming join-request to its crew (for approval + display).
const requestsWithCrew = useMemo(() => {
@ -115,7 +144,7 @@ export function useCrewNotifications() {
listedCrews,
joinRequests: requestsWithEndorsements,
collectiveRequests,
isLoading: invitesLoading || joinLoading || collectiveLoading || crewsLoading,
isLoading: invitesLoading || joinLoading || collectiveLoading || crewsLoading || membershipLoading,
};
}

View file

@ -85,23 +85,22 @@ export function parseOffer(event: NostrEvent): MarketplaceOffer | null {
const coord = parseListingCoord(a);
if (!d || !a || !coord) return null;
// Explicit `role` tag wins; fall back to the legacy `d` marker.
const roleTag = tag(event, 'role');
const role: OfferRole =
roleTag === 'response' || roleTag === 'offer'
? roleTag
: d.includes(':offer-response:')
? 'response'
: 'offer';
const counterparty = tag(event, 'p');
// SECURITY: the seller is ALWAYS the listing author. Never trust a
// self-declared `role` tag (or a `p` tag) to decide who the seller is — a
// non-owner could otherwise post `role=response` with a `payment` pointer and
// appear as the seller, tricking the buyer into paying them.
const seller = coord.pubkey;
const isSeller = event.pubkey.toLowerCase() === coord.pubkey.toLowerCase();
const role: OfferRole = isSeller ? 'response' : 'offer';
const amountTag = event.tags.find(([n]) => n === 'amount');
const status = (tag(event, 'status') as OfferStatus) ?? 'open';
// `["payment", "<value>", "<kind>"]` — the seller's pointer, attached on accept.
const paymentTag = event.tags.find(([n]) => n === 'payment');
const buyer = role === 'offer' ? event.pubkey : (counterparty ?? '');
const seller = role === 'offer' ? (counterparty ?? coord.pubkey) : event.pubkey;
const buyer = isSeller ? (counterparty ?? '') : event.pubkey;
return {
id: event.id,

View file

@ -281,12 +281,19 @@ export function useTradeReputation(pubkey: string | undefined) {
// • a Lightning preimage (`sha256(preimage) === payment_hash`), and
// • a NIP-57 zap receipt (signature + recipient + amount, fetched below).
const zapProofs = new Map<string, string>(); // receiptId → expected seller
const usedPreimages = new Set<string>(); // one payment counts once
let paymentVerified = 0;
for (const ev of unique) {
const proof = ev.tags.find(([t]) => t === 'proof');
if (!proof) continue;
if ((proof[1] === 'lnpreimage' || proof[1] === 'deposit') && proof[2] && proof[3]) {
if (verifyReceiptProof(proof[2], proof[3])) paymentVerified += 1;
// SECURITY: a real preimage reused across many attestations must not
// inflate the "payment-verified" tier — count each preimage once.
const preimage = proof[2];
if (!usedPreimages.has(preimage) && verifyReceiptProof(preimage, proof[3])) {
usedPreimages.add(preimage);
paymentVerified += 1;
}
} else if (proof[1] === 'zap' && proof[2]) {
const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]);
if (coord) zapProofs.set(proof[2], coord.pubkey);

View file

@ -0,0 +1,39 @@
/**
* Batch 4 — desktop nav manager (`desktopNav.*`). Keys must exist in BOTH languages.
*/
const desktopNav = {
en: {
title: 'Desktop navigation',
desc: 'Choose which tabs appear in the top bar and their order. Hidden tabs still live in the “More” menu.',
fixed: 'Fixed',
shownInTopBar: 'Shown in top bar ({{count}})',
noExtraTabs: 'No extra tabs shown — everything is in the “More” menu. Add some below.',
inMoreMenu: 'In “More” menu ({{count}})',
moveUp: 'Move {{name}} up',
moveDown: 'Move {{name}} down',
hide: 'Hide {{name}}',
moveToMore: 'Move to More menu',
show: 'Show',
showInTopBar: 'Show {{name}} in top bar',
showInTopBarTitle: 'Show in top bar',
reset: 'Reset to default',
},
es: {
title: 'Navegación de escritorio',
desc: 'Elige qué pestañas aparecen en la barra superior y su orden. Las ocultas siguen en el menú «Más».',
fixed: 'Fija',
shownInTopBar: 'Mostradas en la barra ({{count}})',
noExtraTabs: 'No hay pestañas extra; todo está en el menú «Más». Añade alguna abajo.',
inMoreMenu: 'En el menú «Más» ({{count}})',
moveUp: 'Subir {{name}}',
moveDown: 'Bajar {{name}}',
hide: 'Ocultar {{name}}',
moveToMore: 'Mover al menú «Más»',
show: 'Mostrar',
showInTopBar: 'Mostrar {{name}} en la barra',
showInTopBarTitle: 'Mostrar en la barra',
reset: 'Restablecer',
},
};
export default desktopNav;

View file

@ -0,0 +1,27 @@
/**
* Batch 4 — error-boundary copy (`errorsUi.*`). Keys must exist in BOTH languages.
*/
const errorsUi = {
en: {
slammedTitle: 'Slammed',
slammedBody: "Something tripped up this screen. Your account and data are safe — let's get you rolling again.",
tryAgain: 'Try again',
reloadApp: 'Reload app',
pageSlammedTitle: 'This page slammed',
pageSlammedBody: 'Something on this screen tripped up. Your account and data are safe. Usually a quick reload sorts it out.',
reload: 'Reload',
goBack: 'Go back',
},
es: {
slammedTitle: '¡Cata!',
slammedBody: 'Algo ha fallado en esta pantalla. Tu cuenta y tus datos están a salvo; volvamos a rodar.',
tryAgain: 'Reintentar',
reloadApp: 'Recargar app',
pageSlammedTitle: 'Esta página se ha caído',
pageSlammedBody: 'Algo ha fallado en esta pantalla. Tu cuenta y tus datos están a salvo. Normalmente, recargar lo soluciona.',
reload: 'Recargar',
goBack: 'Volver',
},
};
export default errorsUi;

View file

@ -2,6 +2,8 @@ import labOptions from './labOptions';
import sasquatchChat from './sasquatchChat';
import feesUi from './feesUi';
import navSheet from './navSheet';
import errorsUi from './errorsUi';
import desktopNav from './desktopNav';
/** Namespaces added by the batch-4 translation pass, spread into `en`. */
export const batch4En = {
@ -9,6 +11,8 @@ export const batch4En = {
sasquatchChat: sasquatchChat.en,
feesUi: feesUi.en,
navSheet: navSheet.en,
errorsUi: errorsUi.en,
desktopNav: desktopNav.en,
};
/** Namespaces added by the batch-4 translation pass, spread into `es`. */
@ -17,4 +21,6 @@ export const batch4Es = {
sasquatchChat: sasquatchChat.es,
feesUi: feesUi.es,
navSheet: navSheet.es,
errorsUi: errorsUi.es,
desktopNav: desktopNav.es,
};