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 { 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue