SkateSpots/src/hooks/useTraderRecommendations.ts
SkateSpots 584561e390 feat(trades): trusted-trader recommendations (31873) + offer DM nudges
- 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.
2026-09-26 23:24:23 +07:00

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 };
}