diff --git a/src/components/DesktopNavManager.tsx b/src/components/DesktopNavManager.tsx index 42581f5..bf88611 100644 --- a/src/components/DesktopNavManager.tsx +++ b/src/components/DesktopNavManager.tsx @@ -39,10 +39,9 @@ export function DesktopNavManager() {
-

Desktop navigation

+

{t('desktopNav.title')}

- Choose which tabs appear in the top bar and their order. Hidden tabs - still live in the “More” menu. + {t('desktopNav.desc')}

@@ -52,18 +51,18 @@ export function DesktopNavManager() { {label(fixedMapTab)} - Fixed + {t('desktopNav.fixed')} {/* Shown (primary) tabs — reorderable */}

- Shown in top bar ({primaryIds.length}) + {t('desktopNav.shownInTopBar', { count: primaryIds.length })}

{primaryIds.length === 0 && (

- No extra tabs shown — everything is in the “More” menu. Add some below. + {t('desktopNav.noExtraTabs')}

)} {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" > @@ -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" > @@ -97,8 +96,8 @@ export function DesktopNavManager() {
); @@ -149,7 +148,7 @@ export function DesktopNavManager() { onClick={reset} className="gap-2 border-white/15 text-gray-300 hover:text-white" > - Reset to default + {t('desktopNav.reset')} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index f8a3778..2401b98 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -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 -
-
- 🛹 -
-
-

- Slammed -

-

- Something tripped up this screen. Your account and data are safe — - let's get you rolling again. -

-
- -
- - -
-
- + window.location.reload()} + /> ); } diff --git a/src/components/ErrorFallback.tsx b/src/components/ErrorFallback.tsx new file mode 100644 index 0000000..d0aad94 --- /dev/null +++ b/src/components/ErrorFallback.tsx @@ -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 ( +
+
+
+ 🛹 +
+
+

+ {isApp ? t('errorsUi.slammedTitle') : t('errorsUi.pageSlammedTitle')} +

+

+ {isApp ? t('errorsUi.slammedBody') : t('errorsUi.pageSlammedBody')} +

+
+
+ {onReset && ( + + )} + + {onBack && ( + + )} +
+
+
+ ); +} diff --git a/src/components/RouteErrorBoundary.tsx b/src/components/RouteErrorBoundary.tsx index 322ad0e..41cb59a 100644 --- a/src/components/RouteErrorBoundary.tsx +++ b/src/components/RouteErrorBoundary.tsx @@ -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 }) -
-
- 🛹 -
-
-

- This page slammed -

-

- Something on this screen tripped up. Your account and data are safe. - Usually a quick reload sorts it out. -

-
-
- - -
-
- + window.history.back()} + /> } > {children} diff --git a/src/hooks/useCrewNotifications.ts b/src/hooks/useCrewNotifications.ts index 035fc46..66322fe 100644 --- a/src/hooks/useCrewNotifications.ts +++ b/src/hooks/useCrewNotifications.ts @@ -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>({ + queryKey: ['my-membership-labels', user?.pubkey, getQueryKeySuffix()], + enabled: !!user?.pubkey, + staleTime: 60_000, + queryFn: async () => { + if (!user?.pubkey) return new Set(); + 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(); + 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, }; } diff --git a/src/hooks/useMarketplaceOffers.ts b/src/hooks/useMarketplaceOffers.ts index c5cd1df..d041188 100644 --- a/src/hooks/useMarketplaceOffers.ts +++ b/src/hooks/useMarketplaceOffers.ts @@ -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", "", ""]` — 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, diff --git a/src/hooks/useTradeAttestations.ts b/src/hooks/useTradeAttestations.ts index f36c294..0cbcf6d 100644 --- a/src/hooks/useTradeAttestations.ts +++ b/src/hooks/useTradeAttestations.ts @@ -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(); // receiptId → expected seller + const usedPreimages = new Set(); // 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); diff --git a/src/i18n/batch4/desktopNav.ts b/src/i18n/batch4/desktopNav.ts new file mode 100644 index 0000000..d3b5d16 --- /dev/null +++ b/src/i18n/batch4/desktopNav.ts @@ -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; diff --git a/src/i18n/batch4/errorsUi.ts b/src/i18n/batch4/errorsUi.ts new file mode 100644 index 0000000..e08afee --- /dev/null +++ b/src/i18n/batch4/errorsUi.ts @@ -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; diff --git a/src/i18n/batch4/index.ts b/src/i18n/batch4/index.ts index c1bd95b..a3732b7 100644 --- a/src/i18n/batch4/index.ts +++ b/src/i18n/batch4/index.ts @@ -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, };