- TradeRecommendationsCard on profiles: shows who recommends this trader (kind 31873 'Attestor Recommendation') and lets a logged-in viewer recommend them. New useTraderRecommendations hook. - OfferDialog sends a best-effort NIP-17 DM to the seller when an offer is made; ListingOffersPanel DMs the buyer when the seller accepts/declines. - AGENTS.md notes 31873. Gate: 101 tests, 0 errors/warnings, boot smoke green.
119 lines
3.9 KiB
TypeScript
119 lines
3.9 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { useNostr } from '@nostrify/react';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useRelays } from '@/lib/relays';
|
|
|
|
/**
|
|
* Trusted-trader recommendations — built on the Attestations NIP's
|
|
* **Attestor Recommendation** (kind `31873`). A skater recommends another as a
|
|
* trusted marketplace trader, for discovery: profiles show who vouches for them.
|
|
*
|
|
* d = `<recommender>:reco:<target>` · p = target · k = `30402` (listings)
|
|
*/
|
|
export const RECOMMENDATION_KIND = 31873;
|
|
|
|
export interface TraderRecommendation {
|
|
id: string;
|
|
recommender: string;
|
|
target: string;
|
|
kinds: string[];
|
|
note: string;
|
|
createdAt: number;
|
|
event: NostrEvent;
|
|
}
|
|
|
|
function parse(event: NostrEvent): TraderRecommendation | null {
|
|
if (event.kind !== RECOMMENDATION_KIND) return null;
|
|
const d = event.tags.find(([t]) => t === 'd')?.[1];
|
|
const target = event.tags.find(([t]) => t === 'p')?.[1];
|
|
if (!d || !target) return null;
|
|
return {
|
|
id: event.id,
|
|
recommender: event.pubkey,
|
|
target,
|
|
kinds: event.tags.filter(([t]) => t === 'k').map(([, v]) => v),
|
|
note: event.content,
|
|
createdAt: event.created_at,
|
|
event,
|
|
};
|
|
}
|
|
|
|
/** Recommendations received by `pubkey` (newest per recommender). */
|
|
export function useTraderRecommendations(pubkey?: string) {
|
|
const { nostr } = useNostr();
|
|
const { read } = useRelays();
|
|
const relayKey = read.slice().sort().join(',');
|
|
|
|
return useQuery<TraderRecommendation[]>({
|
|
queryKey: ['trader-recommendations', pubkey, relayKey],
|
|
enabled: !!pubkey,
|
|
staleTime: 5 * 60 * 1000,
|
|
queryFn: async (c) => {
|
|
if (!pubkey) return [];
|
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
|
const events = await nostr.query(
|
|
[{ kinds: [RECOMMENDATION_KIND], '#p': [pubkey], limit: 200 }],
|
|
{ signal },
|
|
);
|
|
const byRecommender = new Map<string, TraderRecommendation>();
|
|
for (const rec of events
|
|
.map(parse)
|
|
.filter((r): r is TraderRecommendation => !!r)
|
|
.sort((a, b) => b.createdAt - a.createdAt)) {
|
|
if (!byRecommender.has(rec.recommender)) byRecommender.set(rec.recommender, rec);
|
|
}
|
|
return [...byRecommender.values()];
|
|
},
|
|
});
|
|
}
|
|
|
|
/** The current user's own recommendation for `target`, if any. */
|
|
export function useMyRecommendation(target?: string) {
|
|
const { nostr } = useNostr();
|
|
const { user } = useCurrentUser();
|
|
|
|
return useQuery<TraderRecommendation | null>({
|
|
queryKey: ['my-recommendation', user?.pubkey, target],
|
|
enabled: !!user?.pubkey && !!target,
|
|
staleTime: 60 * 1000,
|
|
queryFn: async (c) => {
|
|
if (!user?.pubkey || !target) return null;
|
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
|
const events = await nostr.query(
|
|
[{ kinds: [RECOMMENDATION_KIND], authors: [user.pubkey], '#p': [target], limit: 10 }],
|
|
{ signal },
|
|
);
|
|
return (
|
|
events
|
|
.map(parse)
|
|
.filter((r): r is TraderRecommendation => !!r)
|
|
.sort((a, b) => b.createdAt - a.createdAt)[0] ?? null
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useRecommendationActions() {
|
|
const { user } = useCurrentUser();
|
|
const { mutateAsync: publish } = useNostrPublish();
|
|
|
|
/** Recommend `target` as a trusted trader (re-publishing replaces yours). */
|
|
const recommend = async (target: string, note?: string) => {
|
|
if (!user) throw new Error('Log in to recommend a trader.');
|
|
if (target === user.pubkey) throw new Error('You cannot recommend yourself.');
|
|
await publish({
|
|
kind: RECOMMENDATION_KIND,
|
|
content: note?.trim() || '',
|
|
tags: [
|
|
['d', `${user.pubkey}:reco:${target}`],
|
|
['p', target],
|
|
['k', '30402'],
|
|
['alt', 'SkateSpots: trusted-trader recommendation'],
|
|
],
|
|
});
|
|
};
|
|
|
|
return { recommend };
|
|
}
|