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 */}