fix(nav,amber,reputation,routing): header crowding, no read-state popups, anti-collusion rep, instant directions
- Header: shrink-0 logo + whitespace-nowrap + gap, nav shrinks/scrolls so it can never collide with (or glow over) the SkateSpots wordmark; tighter item padding. Fixes the top-left logo/nav overlap. - Amber: useReadState keeps read markers LOCAL-ONLY for interactive signers, so merely opening the bell/DM no longer publishes (encrypt + sign = 2 popups). - Reputation: lead with verifiedCounterparties (distinct people), cap each partner at 3 in the WoT-weighted score, warn when every confirmation is with one account. NIP.md documents the collusion limit honestly. - Directions: skip the live GPS call when the cached fix is <2 min old; the blocking 'Finding your location' overlay only shows with no seed (silent refine chip otherwise). Near Me also flips the sort to proximity. - .gitignore: cvm-server/package.json (local dev copy of package.cvm.json).
This commit is contained in:
parent
efa8b2e521
commit
e5e2f7d7a1
9 changed files with 121 additions and 24 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -55,4 +55,7 @@ stats.json
|
||||||
/resources/
|
/resources/
|
||||||
/bun.lock
|
/bun.lock
|
||||||
cvm-server/bun.lock
|
cvm-server/bun.lock
|
||||||
|
# CVM server local dev manifest — the tracked manifest is package.cvm.json
|
||||||
|
# (DEPLOY.md does `cp package.cvm.json package.json` on the server).
|
||||||
|
cvm-server/package.json
|
||||||
*.bak
|
*.bak
|
||||||
|
|
|
||||||
26
AGENTS.md
26
AGENTS.md
|
|
@ -67,7 +67,15 @@ session with **no other memory of this project**. So:
|
||||||
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in
|
||||||
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page
|
the shared **`ListingTradeActions`** (used by BOTH the card and the detail page
|
||||||
— one implementation). Buyer/seller attestations build mutual, WoT-weighted
|
— one implementation). Buyer/seller attestations build mutual, WoT-weighted
|
||||||
reputation. **No payments are implemented** — "Buy" is intentionally absent;
|
reputation. **Anti-collusion (2026-09-27):** two accounts can fake mutual
|
||||||
|
confirmations, so reputation leads with **`verifiedCounterparties`** (distinct
|
||||||
|
people mutually confirmed with) instead of the raw `verified` count, weights
|
||||||
|
by WoT with a **per-partner cap of 3**, and shows a warning when several
|
||||||
|
confirmations all come from one account (`TradeReputationCard`). `NIP.md` →
|
||||||
|
"Trust model, sybil/collusion limits" documents that attestations are a
|
||||||
|
signal, **not proof of payment** — never present them as trustless. Confirming
|
||||||
|
a trade also records an optional **1–10 deck `rating`** (see `rating` tag).
|
||||||
|
**No payments are implemented** — "Buy" is intentionally absent;
|
||||||
buyers contact the seller and settle off-app. A **`/trades`** page ("My Trades")
|
buyers contact the seller and settle off-app. A **`/trades`** page ("My Trades")
|
||||||
manages incoming requests, pending confirmations, and the user's trade records,
|
manages incoming requests, pending confirmations, and the user's trade records,
|
||||||
and can **revoke** a record (`s=revoked`, excluded from reputation) for a dispute
|
and can **revoke** a record (`s=revoked`, excluded from reputation) for a dispute
|
||||||
|
|
@ -186,7 +194,11 @@ session with **no other memory of this project**. So:
|
||||||
labelled honestly. **Transit** mode uses **Transitous** (`api.transitous.org`,
|
labelled honestly. **Transit** mode uses **Transitous** (`api.transitous.org`,
|
||||||
MOTIS 2, open CORS) — note origin/destination go to that service, unlike road
|
MOTIS 2, open CORS) — note origin/destination go to that service, unlike road
|
||||||
modes. **No external map-app handoff anywhere** (product decision) and no
|
modes. **No external map-app handoff anywhere** (product decision) and no
|
||||||
scooter/moped profile.
|
scooter/moped profile. **Speed (2026-09-27):** `RouteDialog` seeds origin from
|
||||||
|
`getLastLocation()` and skips the live GPS call entirely when that fix is
|
||||||
|
**< 2 min old**; the blocking "Finding your location…" overlay shows only when
|
||||||
|
there is no seed (with a seed it refines silently via a corner chip). The map
|
||||||
|
"Near Me" button also flips the sort to `proximity`.
|
||||||
- **`Layout` is a router layout** — `AppRouter` wraps every route (except
|
- **`Layout` is a router layout** — `AppRouter` wraps every route (except
|
||||||
`/amber-callback` and the `*` 404) in **one** `<Route element={<Layout/>}>`
|
`/amber-callback` and the `*` 404) in **one** `<Route element={<Layout/>}>`
|
||||||
with an `<Outlet/>`, so the chrome (header, nav, notifications, Sasquatch)
|
with an `<Outlet/>`, so the chrome (header, nav, notifications, Sasquatch)
|
||||||
|
|
@ -196,7 +208,11 @@ session with **no other memory of this project**. So:
|
||||||
`<Layout>` wrappers** — remounting the chrome re-runs its hooks (and with Amber
|
`<Layout>` wrappers** — remounting the chrome re-runs its hooks (and with Amber
|
||||||
that means signer popups). Encrypted-on-mount hooks are also gated for
|
that means signer popups). Encrypted-on-mount hooks are also gated for
|
||||||
interactive signers (`staleTime/gcTime: Infinity`, `refetchOnMount: false`) —
|
interactive signers (`staleTime/gcTime: Infinity`, `refetchOnMount: false`) —
|
||||||
see `useSyncedAppData` / `useStash`.
|
see `useSyncedAppData` / `useStash`. **Read-state is local-only for interactive
|
||||||
|
signers**: `useReadState` skips the encrypted publish when
|
||||||
|
`isInteractiveSigner` is true, so merely opening the notification bell (which
|
||||||
|
marks read) no longer pops Amber twice (encrypt + sign). Interactive signers
|
||||||
|
only prompt for real signed actions.
|
||||||
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||||||
|
|
@ -218,7 +234,9 @@ session with **no other memory of this project**. So:
|
||||||
Renaming a component to `_Name` also trips `rules-of-hooks` — delete it or
|
Renaming a component to `_Name` also trips `rules-of-hooks` — delete it or
|
||||||
suppress instead.
|
suppress instead.
|
||||||
- **Untracked local junk — do NOT commit** (add to `.gitignore`, not the repo):
|
- **Untracked local junk — do NOT commit** (add to `.gitignore`, not the repo):
|
||||||
`assets/`, `resources/`, `/bun.lock`, `cvm-server/bun.lock`, `*.bak`.
|
`assets/`, `resources/`, `/bun.lock`, `cvm-server/bun.lock`,
|
||||||
|
`cvm-server/package.json` (local dev copy of the tracked `package.cvm.json`),
|
||||||
|
`*.bak`.
|
||||||
(`cvm-server/` *source* IS tracked — only its locks/backups are junk.) Watch
|
(`cvm-server/` *source* IS tracked — only its locks/backups are junk.) Watch
|
||||||
out: `git add -A` in the repo root sweeps these in.
|
out: `git add -A` in the repo root sweeps these in.
|
||||||
- **Never `git add -A` from the repo root** without checking `git status` first.
|
- **Never `git add -A` from the repo root** without checking `git status` first.
|
||||||
|
|
|
||||||
24
NIP.md
24
NIP.md
|
|
@ -3191,6 +3191,30 @@ The base NIP has no counterparty field for this use case, so we add:
|
||||||
`method`/`amount`/`rating` are **self-reported**, never proof of payment or a
|
`method`/`amount`/`rating` are **self-reported**, never proof of payment or a
|
||||||
verified score — the app cannot observe off-app (cash/trade) settlement.
|
verified score — the app cannot observe off-app (cash/trade) settlement.
|
||||||
|
|
||||||
|
## Trust model, sybil/collusion limits (honest)
|
||||||
|
|
||||||
|
Trades here are **attestations, not escrow receipts**. Anyone can mint a listing
|
||||||
|
and two accounts can confirm each other with no goods exchanged. So the app
|
||||||
|
**never** presents mutual confirmation as proof — it is one signal among others.
|
||||||
|
What we do to blunt fake reputation:
|
||||||
|
|
||||||
|
- **Lead with distinct counterparties, not raw counts.** The meaningful headline
|
||||||
|
is `verifiedCounterparties` — the number of *different* people a trade was
|
||||||
|
mutually confirmed with. Two accounts trading only with each other can grow the
|
||||||
|
raw count without bound but can never grow this number past `1`.
|
||||||
|
- **Weight by web-of-trust, and cap per partner.** Each counterparty's confirmed
|
||||||
|
trades are weighted by their trust score and capped (max 3) so a single partner
|
||||||
|
can't dominate.
|
||||||
|
- **Flag the pattern.** When a profile has several confirmations but only one
|
||||||
|
distinct counterparty, the UI shows an explicit warning.
|
||||||
|
- **Revocation is honest.** An attestation can be retracted (`s=revoked`) by its
|
||||||
|
author; revoked records are excluded from reputation (they remain on relays,
|
||||||
|
which we do not hide).
|
||||||
|
|
||||||
|
There is deliberately **no** fake "trustless trade proof" — off-app cash/trade
|
||||||
|
settlement cannot be verified by the app. A payment-proof-based reputation would
|
||||||
|
require assuming Lightning/zaps, which the marketplace intentionally does not.
|
||||||
|
|
||||||
## States & revocation
|
## States & revocation
|
||||||
|
|
||||||
`["s", "verifying" | "valid" | "revoked"]` follows the NIP's lifecycle. Because
|
`["s", "verifying" | "valid" | "revoked"]` follows the NIP's lifecycle. Because
|
||||||
|
|
|
||||||
|
|
@ -534,26 +534,30 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
</a>
|
</a>
|
||||||
{/* Desktop Header */}
|
{/* Desktop Header */}
|
||||||
<header className="hidden md:flex sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur">
|
<header className="hidden md:flex sticky top-0 z-50 border-b border-red-500/30 bg-black/95 backdrop-blur">
|
||||||
<div className="container flex items-center justify-between h-16 px-4">
|
<div className="container flex items-center justify-between h-16 px-4 gap-4 xl:gap-8">
|
||||||
<Link to="/" className="flex items-center gap-3 group">
|
{/* Brand lockup — `shrink-0` + `whitespace-nowrap` guarantee the
|
||||||
|
logo never gets squeezed into (or glowed over by) the nav. */}
|
||||||
|
<Link to="/" className="flex items-center gap-3 group shrink-0">
|
||||||
<img
|
<img
|
||||||
src="/images/icon-1024.png"
|
src="/images/icon-1024.png"
|
||||||
alt="SkateSpots"
|
alt="SkateSpots"
|
||||||
className="w-10 h-10 rounded-xl shadow-lg shadow-red-500/30 group-hover:shadow-red-500/50 transition-shadow"
|
className="w-10 h-10 shrink-0 rounded-xl shadow-lg shadow-red-500/30 group-hover:shadow-red-500/50 transition-shadow"
|
||||||
/>
|
/>
|
||||||
<span className="font-display text-2xl neon-red tracking-wide" style={{
|
<span className="font-display text-2xl neon-red tracking-wide whitespace-nowrap" style={{
|
||||||
textShadow: '0 0 10px #ff0040, 0 0 20px #ff0040'
|
textShadow: '0 0 10px #ff0040, 0 0 20px #ff0040'
|
||||||
}}>SKATESPOTS</span>
|
}}>SKATESPOTS</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-1">
|
{/* `min-w-0` + overflow lets the nav shrink and scroll internally
|
||||||
|
instead of colliding with the logo when a user has several tabs. */}
|
||||||
|
<nav className="flex items-center gap-0.5 min-w-0 overflow-x-auto scrollbar-hide">
|
||||||
{/* Fixed Map tab first, then the user's chosen primary tabs */}
|
{/* Fixed Map tab first, then the user's chosen primary tabs */}
|
||||||
{[desktopMapTab, ...desktopPrimaryTabs].map((item) => (
|
{[desktopMapTab, ...desktopPrimaryTabs].map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide',
|
'flex items-center gap-2 px-3 xl:px-4 py-2 rounded-lg transition-all duration-200 text-sm font-bold uppercase tracking-wide',
|
||||||
isActive(item.href)
|
isActive(item.href)
|
||||||
? 'text-red-500'
|
? 'text-red-500'
|
||||||
: 'text-gray-400 hover:text-red-400'
|
: 'text-gray-400 hover:text-red-400'
|
||||||
|
|
|
||||||
|
|
@ -128,11 +128,13 @@ export function RouteDialog({
|
||||||
// Seed from the last known fix so a route can be drawn immediately, then
|
// Seed from the last known fix so a route can be drawn immediately, then
|
||||||
// refine with a live reading. This is what makes opening Directions feel
|
// refine with a live reading. This is what makes opening Directions feel
|
||||||
// instant instead of hanging on "Finding your location…".
|
// instant instead of hanging on "Finding your location…".
|
||||||
if (!origin) {
|
const cached = getLastLocation();
|
||||||
const cached = getLastLocation();
|
const fresh = !!cached && Date.now() - cached.at < 2 * 60 * 1000;
|
||||||
if (cached) setOrigin({ lat: cached.lat, lng: cached.lng });
|
if (!origin && cached) setOrigin({ lat: cached.lat, lng: cached.lng });
|
||||||
}
|
// Only ask for a live GPS fix when the cached one is missing or stale. A
|
||||||
locate();
|
// recent fix means the route can draw instantly with no "Finding your
|
||||||
|
// location…" wait (and no needless location request on every open).
|
||||||
|
if (!fresh) locate();
|
||||||
// Re-run only when the dialog opens (locate is stable).
|
// Re-run only when the dialog opens (locate is stable).
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
@ -331,15 +333,24 @@ export function RouteDialog({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Overlays */}
|
{/* Overlays */}
|
||||||
{(locating || (routing && !route)) && (
|
{/* Blocking overlay only when we have nothing to show yet. With a
|
||||||
|
cached origin we keep the map visible and refine quietly. */}
|
||||||
|
{((locating && !origin) || (routing && !route)) && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm">
|
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-[#ccff00]" />
|
<Loader2 className="h-4 w-4 animate-spin text-[#ccff00]" />
|
||||||
{locating ? 'Finding your location…' : 'Plotting your route…'}
|
{locating && !origin ? 'Finding your location…' : 'Plotting your route…'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{locating && origin && (
|
||||||
|
<div className="pointer-events-none absolute bottom-2 left-2 flex items-center gap-1.5 rounded-full bg-background/80 px-2 py-1 text-[10px] font-medium text-muted-foreground backdrop-blur-sm">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Refining location…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{locError && !origin && (
|
{locError && !origin && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 p-6">
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80 p-6">
|
||||||
<div className="text-center space-y-3 max-w-xs">
|
<div className="text-center space-y-3 max-w-xs">
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,17 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
|
||||||
const total = (data?.sales ?? 0) + (data?.purchases ?? 0);
|
const total = (data?.sales ?? 0) + (data?.purchases ?? 0);
|
||||||
if (!data || total === 0) return null;
|
if (!data || total === 0) return null;
|
||||||
|
|
||||||
|
const people = data.verifiedCounterparties;
|
||||||
|
// Cap each counterparty's contribution at 3 so one partner — or a colluding
|
||||||
|
// pair confirming each other over and over — can't inflate the score.
|
||||||
const weighted = verifiedWith.reduce(
|
const weighted = verifiedWith.reduce(
|
||||||
(sum, v) => sum + v.count * ((scores?.get(v.pubkey) ?? 0) / 100),
|
(sum, v) => sum + Math.min(v.count, 3) * ((scores?.get(v.pubkey) ?? 0) / 100),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const trustLabel =
|
const trustLabel =
|
||||||
weighted >= 5 ? 'Highly trusted trader' : weighted >= 1 ? 'Building reputation' : 'New trader';
|
weighted >= 5 ? 'Highly trusted trader' : weighted >= 1 ? 'Building reputation' : 'New trader';
|
||||||
|
// The classic fake-reputation pattern: several "trades", all with one account.
|
||||||
|
const singlePartnerWarning = data.verified >= 3 && people <= 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn('bg-black/90 border-2 border-[#ff6600]/25', className)}>
|
<Card className={cn('bg-black/90 border-2 border-[#ff6600]/25', className)}>
|
||||||
|
|
@ -53,11 +58,15 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{data.verified > 0
|
{data.verified > 0
|
||||||
? `${data.verified} mutually-confirmed ${data.verified === 1 ? 'trade' : 'trades'}`
|
? `${data.verified} mutually-confirmed ${data.verified === 1 ? 'trade' : 'trades'} with ${people} ${people === 1 ? 'person' : 'people'}`
|
||||||
: 'Awaiting mutual confirmation'}
|
: 'Awaiting mutual confirmation'}
|
||||||
{data.counterparties.length > 0 &&
|
|
||||||
` · ${data.counterparties.length} counterpart${data.counterparties.length === 1 ? 'y' : 'ies'}`}
|
|
||||||
</p>
|
</p>
|
||||||
|
{singlePartnerWarning && (
|
||||||
|
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
|
||||||
|
⚠ Every confirmation is with the same account. Mutual confirmation is
|
||||||
|
self-reported, so treat this record with care.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{data.verified > 0 && (
|
{data.verified > 0 && (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Trust-weighted <span className="font-bold text-[#ccff00]">{weighted.toFixed(1)}</span>
|
Trust-weighted <span className="font-bold text-[#ccff00]">{weighted.toFixed(1)}</span>
|
||||||
|
|
@ -65,6 +74,9 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
|
||||||
{trustLabel}
|
{trustLabel}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
<p className="text-[10px] leading-tight text-gray-600">
|
||||||
|
Self-reported and mutually signed — a signal, not proof of payment.
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useSyncedAppData, type MergeStrategy } from '@/hooks/useSyncedAppData';
|
import { useSyncedAppData, type MergeStrategy } from '@/hooks/useSyncedAppData';
|
||||||
|
import { isInteractiveSigner } from '@/lib/signerCapabilities';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useReadState — cross-device "what have I already seen" markers.
|
* useReadState — cross-device "what have I already seen" markers.
|
||||||
|
|
@ -84,9 +86,17 @@ function mergeIntoLocal(local: ReadState, incoming: ReadState): ReadState {
|
||||||
*/
|
*/
|
||||||
export function useReadState() {
|
export function useReadState() {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
|
const { logins } = useNostrLogin();
|
||||||
const lsKey = user ? `skatespots:read-state:${user.pubkey}` : 'skatespots:read-state:anon';
|
const lsKey = user ? `skatespots:read-state:${user.pubkey}` : 'skatespots:read-state:anon';
|
||||||
const [local, setLocal] = useLocalStorage<ReadState>(lsKey, EMPTY);
|
const [local, setLocal] = useLocalStorage<ReadState>(lsKey, EMPTY);
|
||||||
|
|
||||||
|
// Amber (NIP-55) prompts on every nip44 encrypt AND every sign. Marking the
|
||||||
|
// bell (or a DM) read is not a signing action, so for an interactive signer we
|
||||||
|
// keep the marker LOCAL-ONLY and never publish — no surprise popups just for
|
||||||
|
// glancing at notifications. Non-interactive signers (nsec, extension) still
|
||||||
|
// mirror the markers to the encrypted kind-30078 event for cross-device sync.
|
||||||
|
const interactive = useMemo(() => isInteractiveSigner(user, logins), [user, logins]);
|
||||||
|
|
||||||
const synced = useSyncedAppData<ReadState>({
|
const synced = useSyncedAppData<ReadState>({
|
||||||
dTag: D_TAG,
|
dTag: D_TAG,
|
||||||
alt: 'SkateSpots read-state (encrypted)',
|
alt: 'SkateSpots read-state (encrypted)',
|
||||||
|
|
@ -111,10 +121,13 @@ export function useReadState() {
|
||||||
const apply = useCallback(
|
const apply = useCallback(
|
||||||
(producePatch: (current: ReadState) => ReadState) => {
|
(producePatch: (current: ReadState) => ReadState) => {
|
||||||
setLocal((prev) => producePatch(prev));
|
setLocal((prev) => producePatch(prev));
|
||||||
// Mirror to the synced event (no-op for anon; debounced + merged inside).
|
// Interactive signer: local-only (see `interactive` above) — publishing
|
||||||
|
// here would pop Amber just for reading. Otherwise mirror to the synced
|
||||||
|
// event (no-op for anon; debounced + merged inside).
|
||||||
|
if (interactive) return;
|
||||||
void synced.patch((current) => producePatch(current));
|
void synced.patch((current) => producePatch(current));
|
||||||
},
|
},
|
||||||
[setLocal, synced],
|
[setLocal, synced, interactive],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { state: local, apply };
|
return { state: local, apply };
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,14 @@ export interface TradeReputation {
|
||||||
purchases: number;
|
purchases: number;
|
||||||
/** Deals where both sides attested the same listing. */
|
/** Deals where both sides attested the same listing. */
|
||||||
verified: number;
|
verified: number;
|
||||||
|
/**
|
||||||
|
* Distinct counterparties with at least one mutually-confirmed trade.
|
||||||
|
*
|
||||||
|
* This is the anti-collusion headline: two accounts trading only with each
|
||||||
|
* other can inflate `verified` without bound, but never `verifiedCounterparties`
|
||||||
|
* beyond 1. UI should lead with this instead of the raw count.
|
||||||
|
*/
|
||||||
|
verifiedCounterparties: number;
|
||||||
/** Per-counterparty count of mutually-confirmed trades (for WoT weighting). */
|
/** Per-counterparty count of mutually-confirmed trades (for WoT weighting). */
|
||||||
verifiedWith: { pubkey: string; count: number }[];
|
verifiedWith: { pubkey: string; count: number }[];
|
||||||
/** Distinct counterparties involved. */
|
/** Distinct counterparties involved. */
|
||||||
|
|
@ -174,7 +182,7 @@ export function useTradeReputation(pubkey: string | undefined) {
|
||||||
enabled: !!pubkey,
|
enabled: !!pubkey,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
queryFn: async (c): Promise<TradeReputation> => {
|
queryFn: async (c): Promise<TradeReputation> => {
|
||||||
const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, verifiedWith: [], counterparties: [], attestations: [] };
|
const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, verifiedCounterparties: 0, verifiedWith: [], counterparties: [], attestations: [] };
|
||||||
if (!pubkey) return empty;
|
if (!pubkey) return empty;
|
||||||
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
|
||||||
|
|
@ -240,6 +248,7 @@ export function useTradeReputation(pubkey: string | undefined) {
|
||||||
sales,
|
sales,
|
||||||
purchases,
|
purchases,
|
||||||
verified,
|
verified,
|
||||||
|
verifiedCounterparties: verifiedWith.size,
|
||||||
verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count })),
|
verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count })),
|
||||||
counterparties: [...counterparties],
|
counterparties: [...counterparties],
|
||||||
attestations: unique,
|
attestations: unique,
|
||||||
|
|
|
||||||
|
|
@ -621,6 +621,9 @@ export default function MapPage() {
|
||||||
// Clear previous error
|
// Clear previous error
|
||||||
setNearMeError(null);
|
setNearMeError(null);
|
||||||
setNearMeLoading(true);
|
setNearMeLoading(true);
|
||||||
|
// "Near Me" should also MEAN "show me what's near" — surface the closest
|
||||||
|
// spots first instead of leaving the list on Most Recent.
|
||||||
|
setSortBy('proximity');
|
||||||
|
|
||||||
// OPTIMISTIC: if we already know roughly where they are, fly there IMMEDIATELY
|
// OPTIMISTIC: if we already know roughly where they are, fly there IMMEDIATELY
|
||||||
// so the map reacts on tap. The fresh fix below refines the exact position.
|
// so the map reacts on tap. The fresh fix below refines the exact position.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue