refactor(relays): route every relay consumer through useRelays()

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.
This commit is contained in:
SkateSpots 2026-09-26 22:36:14 +07:00
parent 5d1a0e0fb0
commit 21a0390ed5
9 changed files with 39 additions and 59 deletions

View file

@ -66,10 +66,12 @@ session with **no other memory of this project**. So:
"Trusted" (no distinct behaviour — would be empty UI), and Amethyst's proxy/
broadcast/private-home (we don't have that relay-discovery model) — don't add
UI for features we don't implement.
- ⚠️ A few hooks build relay lists directly from `config.relayMetadata.relays`
(listing reports, seller info, skate relays, zaps) and therefore do NOT yet
honour Local/Blocked — migrate them to `useRelays()` when touched. DMs are
migrated, but their DM relays aren't block-filtered either.
- All relay consumers now go through **`useRelays()`** (read/write — blocklist
applied, local included): listing reports, seller info, skate relays, zaps,
Sasquatch purchase, collections, crew form, and DMs. AppProvider's
empty-relays defaults check is the only intentional direct reader.
- ⚠️ DM **inbox** relays (kind 10050) are delivered-to/read-from but are not
yet block-filtered (they're a separate list from the NIP-65 `relays`).
- **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`).
- **Mints directory**: `/mints` page lists Cashu mints from the community
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin

View file

@ -22,7 +22,7 @@ import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
import { NofferField } from '@/components/NofferField';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types';
import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useAppContext } from '@/hooks/useAppContext';
import { useRelays } from '@/lib/relays';
import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server } from 'lucide-react';
@ -286,7 +286,7 @@ export function CrewForm({ existing }: CrewFormProps) {
// Crew treasury (NIP-75 zap goal). Load existing goal to prefill when editing.
const { data: existingGoal } = useCrewGoal(existing?.goalRef);
const { config } = useAppContext();
const { write } = useRelays();
const [goalEnabled, setGoalEnabled] = useState(!!existing?.goalRef);
const [goalTitle, setGoalTitle] = useState('');
const [goalTarget, setGoalTarget] = useState('');
@ -420,7 +420,7 @@ export function CrewForm({ existing }: CrewFormProps) {
let goalRef: string | undefined = existing?.goalRef;
const targetSats = parseInt(goalTarget, 10);
if (goalEnabled && goalTitle.trim() && targetSats > 0) {
const relayUrls = config.relayMetadata.relays.filter((r) => r.write).map((r) => r.url);
const relayUrls = write;
const needsNewGoal =
!existingGoal ||
existingGoal.description !== goalTitle.trim() ||

View file

@ -8,6 +8,7 @@ import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from './useCurrentUser';
import { useAppContext } from './useAppContext';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
import { useRelays } from '@/lib/relays';
import type { RelayMetadata } from '@/contexts/AppContext';
/**
@ -124,6 +125,7 @@ export function useSendDirectMessage() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { write } = useRelays();
const queryClient = useQueryClient();
const sendMessage = useCallback(async (
@ -189,9 +191,7 @@ export function useSendDirectMessage() {
// 4. Deliver to NIP-17 DM relays (kind 10050): the recipients' inboxes plus
// our own, so both sides can read from their DM relays. Fall back to the
// NIP-65 write relays when nobody has published a DM relay list.
const writeRelays = config.relayMetadata.relays
.filter(r => r.write)
.map(r => r.url);
const writeRelays = write;
const dmTargets = [...new Set([...recipients, user.pubkey])];
const deliverRelays = new Set<string>(config.relayMetadata.dmRelays ?? []);
@ -230,7 +230,7 @@ export function useSendDirectMessage() {
queryClient.invalidateQueries({ queryKey: ['group-conversations'] });
}
}, [user, nostr, config, queryClient]);
}, [user, nostr, config, write, queryClient]);
return useMutation({
mutationFn: async ({ recipients, content, replyTo, subject }: {

View file

@ -1,7 +1,7 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAppContext } from '@/hooks/useAppContext';
import { useRelays } from '@/lib/relays';
const REPORT_KIND = 1984; // NIP-56 Report kind
@ -21,13 +21,9 @@ export interface ListingReport {
*/
export function useListingReports(eventId: string, pubkey: string) {
const { nostr } = useNostr();
const { config } = useAppContext();
const { read } = useRelays();
const relayUrls = config.relayMetadata.relays
.filter(r => r.read)
.map(r => r.url)
.sort()
.join(',');
const relayUrls = read.slice().sort().join(',');
return useQuery({
queryKey: ['listing-reports', eventId, relayUrls],
@ -35,9 +31,7 @@ export function useListingReports(eventId: string, pubkey: string) {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
try {
const readRelays = config.relayMetadata.relays
.filter(r => r.read)
.map(r => r.url);
const readRelays = read;
const allEvents: NostrEvent[] = [];
@ -115,13 +109,9 @@ export function useListingReports(eventId: string, pubkey: string) {
*/
export function useBatchListingReports(eventIds: string[]) {
const { nostr } = useNostr();
const { config } = useAppContext();
const { read } = useRelays();
const relayUrls = config.relayMetadata.relays
.filter(r => r.read)
.map(r => r.url)
.sort()
.join(',');
const relayUrls = read.slice().sort().join(',');
return useQuery({
queryKey: ['batch-listing-reports', eventIds.sort().join(','), relayUrls],
@ -137,9 +127,7 @@ export function useBatchListingReports(eventIds: string[]) {
}
try {
const readRelays = config.relayMetadata.relays
.filter(r => r.read)
.map(r => r.url);
const readRelays = read;
const allEvents: NostrEvent[] = [];

View file

@ -19,7 +19,7 @@ import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet';
import { useAppContext } from '@/hooks/useAppContext';
import { useRelays } from '@/lib/relays';
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
import {
@ -46,7 +46,7 @@ export function usePurchasePro() {
const { user } = useCurrentUser();
const { getActiveConnection, sendPayment } = useNWC();
const { webln } = useWallet();
const { config } = useAppContext();
const { read, write } = useRelays();
const { mutateAsync: sendDM } = useSendDirectMessage();
const [result, setResult] = useState<PurchaseResult>({ invoice: null, state: 'idle', error: null });
@ -121,7 +121,7 @@ export function usePurchasePro() {
const now = Math.floor(Date.now() / 1000);
const expiry = now + tier.days * DAY_SECONDS;
const amountMsats = tier.sats * 1000;
const relays = config.relayMetadata.relays.map((r) => r.url);
const relays = [...new Set([...read, ...write])];
const comment = `Sasquatch Pro — ${tier.label} 🦧`;
// Try a NIP-57 zap first (unforgeable, npub-bound). Fall back to a plain
@ -198,7 +198,7 @@ export function usePurchasePro() {
});
}
},
[user, config, getActiveConnection, sendPayment, webln, publishReceipt, sendConfirmationDM],
[user, read, write, getActiveConnection, sendPayment, webln, publishReceipt, sendConfirmationDM],
);
/** Called after a manual (QR) payment to record the pass locally. */

View file

@ -2,7 +2,7 @@ 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 { useAppContext } from '@/hooks/useAppContext';
import { useRelays } from '@/lib/relays';
export interface SellerInfo {
pubkey: string;
@ -19,13 +19,9 @@ export interface SellerInfo {
*/
export function useSellerInfo(pubkey: string | undefined) {
const { nostr } = useNostr();
const { config } = useAppContext();
const { read } = useRelays();
const relayUrls = config.relayMetadata.relays
.filter(r => r.read)
.map(r => r.url)
.sort()
.join(',');
const relayUrls = read.slice().sort().join(',');
return useQuery({
queryKey: ['seller-info', pubkey, relayUrls],
@ -35,9 +31,7 @@ export function useSellerInfo(pubkey: string | undefined) {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
try {
const readRelays = config.relayMetadata.relays
.filter(r => r.read)
.map(r => r.url);
const readRelays = read;
// Query profile (kind 0) and marketplace listings in parallel
const profileEventsPromise: Promise<NostrEvent[]>[] = [];

View file

@ -1,6 +1,7 @@
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useRelays } from '@/lib/relays';
import { useCitrine } from '@/hooks/useCitrine';
import { SKATE_RELAY } from '@/lib/types';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
@ -17,6 +18,7 @@ export function useSkateRelays() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { read, write } = useRelays();
const citrine = useCitrine();
const isLoggedIn = !!user;
@ -43,9 +45,7 @@ export function useSkateRelays() {
}
// Logged in: check setting
const userRelays = config.relayMetadata.relays
.filter(r => r.read)
.map(r => r.url);
const userRelays = read;
if (useSkateRelay) {
// Include community relay + user relays (deduplicated)
@ -67,9 +67,7 @@ export function useSkateRelays() {
return [SKATE_RELAY];
}
const userRelays = config.relayMetadata.relays
.filter(r => r.write)
.map(r => r.url);
const userRelays = write;
if (useSkateRelay) {
// Include community relay + user relays (deduplicated)

View file

@ -1,7 +1,7 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext';
import { useRelays } from '@/lib/relays';
import { useToast } from '@/hooks/useToast';
import { useNWC } from '@/hooks/useNWCContext';
import type { NWCConnection } from '@/hooks/useNWC';
@ -23,7 +23,7 @@ export function useZaps(
const { nostr } = useNostr();
const { toast } = useToast();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { read, write } = useRelays();
const queryClient = useQueryClient();
// Handle the case where an empty array is passed (from ZapButton when external data is provided)
@ -162,7 +162,7 @@ export function useZaps(
const isAddressable = actualTarget.kind >= 30000 && actualTarget.kind < 40000;
const zapTags: string[][] = [
['relays', ...config.relayMetadata.relays.map((r) => r.url)],
['relays', ...read, ...write],
['amount', String(zapAmount)],
['p', actualTarget.pubkey],
];
@ -202,7 +202,7 @@ export function useZaps(
assertInvoiceAmount(newInvoice, amount);
return newInvoice;
}, [user, actualTarget, author.data, config.relayMetadata.relays]);
}, [user, actualTarget, author.data, read, write]);
const zap = async (amount: number, comment: string) => {
if (amount <= 0) {

View file

@ -14,7 +14,7 @@ import { Badge } from '@/components/ui/badge';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { useToast } from '@/hooks/useToast';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAppContext } from '@/hooks/useAppContext';
import { useRelays } from '@/lib/relays';
import { useSpotCollection, useRemoveSpotFromCollection, useDeleteSpotCollection } from '@/hooks/useSpotCollections';
import type { NostrEvent } from '@nostrify/nostrify';
import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types';
@ -46,7 +46,7 @@ export default function CollectionDetailPage() {
const { data: collection, isLoading, error, refetch } = useSpotCollection(identifier || '', pubkey);
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { config } = useAppContext();
const { read } = useRelays();
const { removeSpotFromCollection, isPending: isRemoving } = useRemoveSpotFromCollection();
const { deleteCollection, isPending: isDeleting } = useDeleteSpotCollection();
const { toast } = useToast();
@ -69,9 +69,7 @@ export default function CollectionDetailPage() {
if (!collection || collection.spotRefs.length === 0) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
const readRelays = config.relayMetadata.relays
.filter(r => r.read)
.map(r => r.url);
const readRelays = read;
// Parse spot references to build filters
const spotFilters = collection.spotRefs.map((ref) => {