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:
parent
883338f060
commit
1577ddc86f
3 changed files with 126 additions and 5 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useTradeReputation } from '@/hooks/useTradeAttestations';
|
import { useTradeReputation } from '@/hooks/useTradeAttestations';
|
||||||
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Handshake } from 'lucide-react';
|
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 }) {
|
export function TradeReputationCard({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||||
const { data } = useTradeReputation(pubkey);
|
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;
|
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 (
|
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)}>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
|
@ -46,6 +58,13 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
|
||||||
{data.counterparties.length > 0 &&
|
{data.counterparties.length > 0 &&
|
||||||
` · ${data.counterparties.length} counterpart${data.counterparties.length === 1 ? 'y' : 'ies'}`}
|
` · ${data.counterparties.length} counterpart${data.counterparties.length === 1 ? 'y' : 'ies'}`}
|
||||||
</p>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,8 @@ 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;
|
||||||
|
/** Per-counterparty count of mutually-confirmed trades (for WoT weighting). */
|
||||||
|
verifiedWith: { pubkey: string; count: number }[];
|
||||||
/** Distinct counterparties involved. */
|
/** Distinct counterparties involved. */
|
||||||
counterparties: string[];
|
counterparties: string[];
|
||||||
/** Raw trade attestations this pubkey is involved in. */
|
/** Raw trade attestations this pubkey is involved in. */
|
||||||
|
|
@ -143,7 +145,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, counterparties: [], attestations: [] };
|
const empty: TradeReputation = { sales: 0, purchases: 0, verified: 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)]);
|
||||||
|
|
@ -171,6 +173,7 @@ export function useTradeReputation(pubkey: string | undefined) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const counterparties = new Set<string>();
|
const counterparties = new Set<string>();
|
||||||
|
const verifiedWith = new Map<string, number>();
|
||||||
let sales = 0;
|
let sales = 0;
|
||||||
let purchases = 0;
|
let purchases = 0;
|
||||||
let verified = 0;
|
let verified = 0;
|
||||||
|
|
@ -186,7 +189,13 @@ export function useTradeReputation(pubkey: string | undefined) {
|
||||||
if (involved) {
|
if (involved) {
|
||||||
if (pubkey === seller) sales++;
|
if (pubkey === seller) sales++;
|
||||||
else purchases++;
|
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).
|
// Record counterparties (everyone else who attested this listing).
|
||||||
for (const a of attestors) if (a !== pubkey) counterparties.add(a);
|
for (const a of attestors) if (a !== pubkey) counterparties.add(a);
|
||||||
|
|
@ -202,6 +211,7 @@ export function useTradeReputation(pubkey: string | undefined) {
|
||||||
sales,
|
sales,
|
||||||
purchases,
|
purchases,
|
||||||
verified,
|
verified,
|
||||||
|
verifiedWith: [...verifiedWith.entries()].map(([pubkey, count]) => ({ pubkey, count })),
|
||||||
counterparties: [...counterparties],
|
counterparties: [...counterparties],
|
||||||
attestations: unique,
|
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:
|
* Trade attestations for one listing, from the viewer's perspective:
|
||||||
* `iConfirmed` (they attested it) and `counterparty` (who else has).
|
* `iConfirmed` (they attested it) and `counterparty` (who else has).
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { useToast } from '@/hooks/useToast';
|
||||||
import { useNostrLogin } from '@nostrify/react/login';
|
import { useNostrLogin } from '@nostrify/react/login';
|
||||||
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities';
|
||||||
import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages';
|
import { useConversation, useSendDirectMessage, type DirectMessage } from '@/hooks/useDirectMessages';
|
||||||
|
import { usePendingTradeConfirmations, useTradeAttestation } from '@/hooks/useTradeAttestations';
|
||||||
import { useDMRead } from '@/hooks/useDMRead';
|
import { useDMRead } from '@/hooks/useDMRead';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
@ -25,6 +26,7 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
CheckCircle2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
|
import { AmberApprovalHint } from '@/components/AmberApprovalHint';
|
||||||
|
|
@ -102,6 +104,8 @@ export default function ConversationPage() {
|
||||||
|
|
||||||
const { data: messages, isLoading, error, refetch, isRefetching } = useConversation(otherPubkey || '');
|
const { data: messages, isLoading, error, refetch, isRefetching } = useConversation(otherPubkey || '');
|
||||||
const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage();
|
const { mutate: sendMessage, isPending: isSending } = useSendDirectMessage();
|
||||||
|
const { data: pendingTrades } = usePendingTradeConfirmations(user?.pubkey);
|
||||||
|
const { publishTrade, isPending: confirmingTrade } = useTradeAttestation();
|
||||||
const { markRead } = useDMRead();
|
const { markRead } = useDMRead();
|
||||||
const author = useAuthor(otherPubkey || undefined);
|
const author = useAuthor(otherPubkey || undefined);
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
|
|
@ -148,8 +152,26 @@ export default function ConversationPage() {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
// A pending trade the other party recorded but we haven't confirmed yet.
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
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();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
}
|
}
|
||||||
|
|
@ -246,6 +268,30 @@ export default function ConversationPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 */}
|
{/* Messages Area */}
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-2 sm:space-y-3 min-h-0"
|
className="flex-1 overflow-y-auto py-3 sm:py-4 space-y-2 sm:space-y-3 min-h-0"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue