feat(marketplace): trust-weight trade reputation + in-thread trade confirmation

- Reputation now exposes per-counterparty verified counts; the profile card
  trust-weights each confirmed trade by the counterparty's web-of-trust score
  (fresh-account rings can't inflate it) and labels Highly trusted / Building /
  New trader
- usePendingTradeConfirmations finds sales a seller recorded that name you but
  you haven't mirrored; the 1:1 DM thread shows a 'Did you complete this trade?'
  banner with one-tap Confirm
This commit is contained in:
SkateSpots 2026-09-26 20:22:37 +07:00
parent 883338f060
commit 1577ddc86f
3 changed files with 126 additions and 5 deletions

View file

@ -1,5 +1,6 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useTradeReputation } from '@/hooks/useTradeAttestations';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { cn } from '@/lib/utils';
import { Handshake } from 'lucide-react';
@ -21,10 +22,21 @@ function Stat({ label, value, accent }: { label: string; value: number; accent?:
*/
export function TradeReputationCard({ pubkey, className }: { pubkey: string; className?: string }) {
const { data } = useTradeReputation(pubkey);
const total = (data?.sales ?? 0) + (data?.purchases ?? 0);
const verifiedWith = data?.verifiedWith ?? [];
// Trust-weight each mutually-confirmed trade by the counterparty's web-of-trust
// score, so a ring of fresh accounts can't inflate reputation.
const { data: scores } = useBatchTrustScores(verifiedWith.map((v) => v.pubkey));
const total = (data?.sales ?? 0) + (data?.purchases ?? 0);
if (!data || total === 0) return null;
const weighted = verifiedWith.reduce(
(sum, v) => sum + v.count * ((scores?.get(v.pubkey) ?? 0) / 100),
0,
);
const trustLabel =
weighted >= 5 ? 'Highly trusted trader' : weighted >= 1 ? 'Building reputation' : 'New trader';
return (
<Card className={cn('bg-black/90 border-2 border-[#ff6600]/25', className)}>
<CardHeader className="pb-3">
@ -46,6 +58,13 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
{data.counterparties.length > 0 &&
` · ${data.counterparties.length} counterpart${data.counterparties.length === 1 ? 'y' : 'ies'}`}
</p>
{data.verified > 0 && (
<p className="text-xs text-gray-500">
Trust-weighted <span className="font-bold text-[#ccff00]">{weighted.toFixed(1)}</span>
{' · '}
{trustLabel}
</p>
)}
</CardContent>
</Card>
);

View file

@ -121,6 +121,8 @@ export interface TradeReputation {
purchases: number;
/** Deals where both sides attested the same listing. */
verified: number;
/** Per-counterparty count of mutually-confirmed trades (for WoT weighting). */
verifiedWith: { pubkey: string; count: number }[];
/** Distinct counterparties involved. */
counterparties: string[];
/** Raw trade attestations this pubkey is involved in. */
@ -143,7 +145,7 @@ export function useTradeReputation(pubkey: string | undefined) {
enabled: !!pubkey,
staleTime: 5 * 60 * 1000,
queryFn: async (c): Promise<TradeReputation> => {
const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, counterparties: [], attestations: [] };
const empty: TradeReputation = { sales: 0, purchases: 0, verified: 0, verifiedWith: [], counterparties: [], attestations: [] };
if (!pubkey) return empty;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
@ -171,6 +173,7 @@ export function useTradeReputation(pubkey: string | undefined) {
}
const counterparties = new Set<string>();
const verifiedWith = new Map<string, number>();
let sales = 0;
let purchases = 0;
let verified = 0;
@ -186,7 +189,13 @@ export function useTradeReputation(pubkey: string | undefined) {
if (involved) {
if (pubkey === seller) sales++;
else purchases++;
if (isVerified) verified++;
if (isVerified) {
verified++;
// Attribute the verified trade to the other attestor(s).
for (const a of attestors) {
if (a !== pubkey) verifiedWith.set(a, (verifiedWith.get(a) ?? 0) + 1);
}
}
// Record counterparties (everyone else who attested this listing).
for (const a of attestors) if (a !== pubkey) counterparties.add(a);
@ -202,6 +211,7 @@ export function useTradeReputation(pubkey: string | undefined) {
sales,
purchases,
verified,
verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count })),
counterparties: [...counterparties],
attestations: unique,
};
@ -209,6 +219,52 @@ export function useTradeReputation(pubkey: string | undefined) {
});
}
/**
* Trades where someone named the viewer as counterparty and the viewer has NOT
* yet published their own attestation — used to prompt "confirm this trade".
*/
export function usePendingTradeConfirmations(viewerPubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['pending-trade-confirmations', viewerPubkey],
enabled: !!viewerPubkey,
staleTime: 60 * 1000,
queryFn: async (c) => {
type Pending = { coord: string; listing: { pubkey: string; identifier: string }; seller: string };
if (!viewerPubkey) return [] as Pending[];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
const [naming, mine] = await Promise.all([
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }),
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }),
]);
const myCoords = new Set(
mine.filter(isTradeAttestation).map((e) => e.tags.find(([t]) => t === 'a')?.[1]).filter(Boolean),
);
const pending: Pending[] = [];
const seen = new Set<string>();
for (const ev of naming.filter(isTradeAttestation)) {
if (ev.pubkey === viewerPubkey) continue;
const coord = ev.tags.find(([t]) => t === 'a')?.[1];
const parsed = parseListingCoord(coord);
// The attestor must be the listing author (i.e. the seller's record).
if (!coord || !parsed || parsed.pubkey !== ev.pubkey) continue;
if (myCoords.has(coord) || seen.has(coord)) continue;
seen.add(coord);
pending.push({
coord,
listing: { pubkey: parsed.pubkey, identifier: parsed.identifier },
seller: ev.pubkey,
});
}
return pending;
},
});
}
/**
* Trade attestations for one listing, from the viewer's perspective:
* `iConfirmed` (they attested it) and `counterparty` (who else has).

View file

@ -14,6 +14,7 @@ import { useToast } from '@/hooks/useToast';
import { useNostrLogin } from '@nostrify/react/login';
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages';
import { usePendingTradeConfirmations, useTradeAttestation } from '@/hooks/useTradeAttestations';
import { useDMRead } from '@/hooks/useDMRead';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
@ -25,6 +26,7 @@ import {
Loader2,
AlertCircle,
RefreshCw,
CheckCircle2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
@ -102,6 +104,8 @@ export default function ConversationPage() {
const { data: messages, isLoading, error, refetch, isRefetching } = useConversation(otherPubkey || '');
const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage();
const { data: pendingTrades } = usePendingTradeConfirmations(user?.pubkey);
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
const { markRead } = useDMRead();
const author = useAuthor(otherPubkey || undefined);
const metadata = author.data?.metadata;
@ -148,8 +152,26 @@ export default function ConversationPage() {
);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
// A pending trade the other party recorded but we haven't confirmed yet.
const pendingTrade = otherPubkey
? pendingTrades?.find((t) => t.seller === otherPubkey)
: undefined;
const handleConfirmTrade = async () => {
if (!pendingTrade || !otherPubkey) return;
try {
await publishTrade({ listing: pendingTrade.listing, role: 'bought', counterparty: otherPubkey });
toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' });
} catch (e) {
toast({
title: 'Could not confirm the trade',
description: e instanceof Error ? e.message : 'Something went wrong.',
variant: 'destructive',
});
}
};
const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
@ -246,6 +268,30 @@ export default function ConversationPage() {
</Button>
</div>
{/* Pending trade confirmation (the seller recorded the sale) */}
{pendingTrade && (
<Card className="mb-3 flex-shrink-0 border-2 border-[#ccff00]/40 bg-[#ccff00]/5">
<CardContent className="flex items-center gap-3 p-3">
<CheckCircle2 className="h-5 w-5 shrink-0 text-[#ccff00]" />
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white">Did you complete this trade?</p>
<p className="text-xs text-gray-400">
{displayName} recorded the sale. Confirm to update reputation for both of you.
</p>
</div>
<Button
size="sm"
onClick={handleConfirmTrade}
disabled={confirmingTrade}
className="shrink-0 gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
{confirmingTrade ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
Confirm
</Button>
</CardContent>
</Card>
)}
{/* Messages Area */}
<div
className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-2 sm:space-y-3 min-h-0"