So Local relays are used and Blocked relays are excluded EVERYWHERE, not just in the NPool routers: - useListingReports, useSellerInfo, useSkateRelays (read+write), useZaps (NIP-57 relays tag), usePurchasePro, CollectionDetailPage, CrewForm, useDirectMessages write fallback - AppProvider's empty-relays defaults check is the only intentional direct reader - AGENTS.md updated Gate: 101 tests, 0 errors/warnings, boot smoke green.
156 lines
4.9 KiB
TypeScript
156 lines
4.9 KiB
TypeScript
import { useNostr } from '@nostrify/react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
|
import { useRelays } from '@/lib/relays';
|
|
|
|
export interface SellerInfo {
|
|
pubkey: string;
|
|
accountAge: number; // Days since first event
|
|
listingCount: number;
|
|
activeListingCount: number;
|
|
hasNip05: boolean;
|
|
nip05?: string;
|
|
firstSeen?: number; // Unix timestamp of first event
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch seller information including account age and listing count.
|
|
*/
|
|
export function useSellerInfo(pubkey: string | undefined) {
|
|
const { nostr } = useNostr();
|
|
const { read } = useRelays();
|
|
|
|
const relayUrls = read.slice().sort().join(',');
|
|
|
|
return useQuery({
|
|
queryKey: ['seller-info', pubkey, relayUrls],
|
|
queryFn: async (c) => {
|
|
if (!pubkey) return null;
|
|
|
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
|
|
|
try {
|
|
const readRelays = read;
|
|
|
|
// Query profile (kind 0) and marketplace listings in parallel
|
|
const profileEventsPromise: Promise<NostrEvent[]>[] = [];
|
|
const listingEventsPromise: Promise<NostrEvent[]>[] = [];
|
|
|
|
for (const relayUrl of readRelays) {
|
|
try {
|
|
const relay = nostr.relay(relayUrl);
|
|
|
|
profileEventsPromise.push(
|
|
relay.query(
|
|
[{ kinds: [0], authors: [pubkey], limit: 1 }],
|
|
{ signal }
|
|
).catch(() => [])
|
|
);
|
|
|
|
listingEventsPromise.push(
|
|
relay.query(
|
|
[{
|
|
kinds: [MARKETPLACE_ITEM_KIND],
|
|
authors: [pubkey],
|
|
'#t': ['skateboarding'],
|
|
limit: 100
|
|
}],
|
|
{ signal }
|
|
).catch(() => [])
|
|
);
|
|
} catch {
|
|
// Ignore individual relay errors
|
|
}
|
|
}
|
|
|
|
const [profileResults, listingResults] = await Promise.all([
|
|
Promise.all(profileEventsPromise),
|
|
Promise.all(listingEventsPromise),
|
|
]);
|
|
|
|
// Flatten results
|
|
const profileEvents = profileResults.flat();
|
|
const listingEvents = listingResults.flat();
|
|
|
|
// Get profile metadata
|
|
let hasNip05 = false;
|
|
let nip05: string | undefined;
|
|
let profileCreatedAt: number | undefined;
|
|
|
|
if (profileEvents.length > 0) {
|
|
const profile = profileEvents.reduce((latest, current) =>
|
|
current.created_at > latest.created_at ? current : latest
|
|
);
|
|
|
|
try {
|
|
const metadata = JSON.parse(profile.content);
|
|
hasNip05 = !!metadata.nip05;
|
|
nip05 = metadata.nip05;
|
|
} catch {
|
|
// Ignore JSON parse errors
|
|
}
|
|
|
|
profileCreatedAt = profile.created_at;
|
|
}
|
|
|
|
// Deduplicate listings by d-tag
|
|
const listingMap = new Map<string, NostrEvent>();
|
|
for (const event of listingEvents) {
|
|
const d = event.tags.find(([name]: [string]) => name === 'd')?.[1];
|
|
if (d) {
|
|
const key = `${event.pubkey}:${event.kind}:${d}`;
|
|
const existing = listingMap.get(key);
|
|
if (!existing || event.created_at > existing.created_at) {
|
|
listingMap.set(key, event);
|
|
}
|
|
}
|
|
}
|
|
|
|
const uniqueListings = Array.from(listingMap.values());
|
|
|
|
// Count active listings (not expired, status = active)
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const activeListings = uniqueListings.filter(event => {
|
|
const status = event.tags.find(([name]: [string]) => name === 'status')?.[1];
|
|
const expiration = event.tags.find(([name]: [string]) => name === 'expiration')?.[1];
|
|
|
|
if (status && status !== 'active') return false;
|
|
if (expiration && parseInt(expiration) < now) return false;
|
|
|
|
return true;
|
|
});
|
|
|
|
// Find oldest event timestamp (approximation of account age)
|
|
const allTimestamps = [
|
|
...uniqueListings.map(e => e.created_at),
|
|
...(profileCreatedAt ? [profileCreatedAt] : []),
|
|
];
|
|
|
|
const firstSeen = allTimestamps.length > 0
|
|
? Math.min(...allTimestamps)
|
|
: undefined;
|
|
|
|
const accountAge = firstSeen
|
|
? Math.floor((now - firstSeen) / (24 * 60 * 60)) // Days
|
|
: 0;
|
|
|
|
return {
|
|
pubkey,
|
|
accountAge,
|
|
listingCount: uniqueListings.length,
|
|
activeListingCount: activeListings.length,
|
|
hasNip05,
|
|
nip05,
|
|
firstSeen,
|
|
} as SellerInfo;
|
|
} catch (error) {
|
|
console.warn('[useSellerInfo] Error fetching seller info:', error);
|
|
return null;
|
|
}
|
|
},
|
|
enabled: !!pubkey,
|
|
staleTime: 300000, // Cache for 5 minutes
|
|
gcTime: 600000, // Keep in cache for 10 minutes
|
|
});
|
|
}
|