diff --git a/src/components/TradeReputationCard.tsx b/src/components/TradeReputationCard.tsx index ecb666a..16a3aa0 100644 --- a/src/components/TradeReputationCard.tsx +++ b/src/components/TradeReputationCard.tsx @@ -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 ( @@ -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'}`}

+ {data.verified > 0 && ( +

+ Trust-weighted {weighted.toFixed(1)} + {' · '} + {trustLabel} +

+ )}
); diff --git a/src/hooks/useTradeAttestations.ts b/src/hooks/useTradeAttestations.ts index 3da89b3..9cd687c 100644 --- a/src/hooks/useTradeAttestations.ts +++ b/src/hooks/useTradeAttestations.ts @@ -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 => { - 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(); + const verifiedWith = new Map(); 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(); + 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). diff --git a/src/pages/ConversationPage.tsx b/src/pages/ConversationPage.tsx index ccb6dd8..7b2730d 100644 --- a/src/pages/ConversationPage.tsx +++ b/src/pages/ConversationPage.tsx @@ -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() { + {/* Pending trade confirmation (the seller recorded the sale) */} + {pendingTrade && ( + + + +
+

Did you complete this trade?

+

+ {displayName} recorded the sale. Confirm to update reputation for both of you. +

+
+ +
+
+ )} + {/* Messages Area */}