diff --git a/CLAUDE.md b/CLAUDE.md index 0713d37..30fab07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -326,6 +326,7 @@ This allows `applyTheme()` to switch themes at runtime. - **Path Alias**: `@/` = `./src/` - **Styling**: Tailwind v4 + HSL CSS variables (theme tokens defined in `index.css`) - **Types**: Prefer types from `applesauce-core`, extend in `src/types/` when needed +- **No Inline Imports**: Never use `import("module").Type` in type annotations. Always use top-level `import type` statements. - **Locale-Aware Formatting** (`src/hooks/useLocale.ts`): All date, time, number, and currency formatting MUST use the user's locale: - **`useLocale()` hook**: Returns `{ locale, language, region, timezone, timeFormat }` - use in components that need locale config - **`formatTimestamp(timestamp, style)`**: Preferred utility for all timestamp formatting: diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 4f1f06e..d683d72 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -16,6 +16,8 @@ import { Loader2, Inbox, Sparkles, + Send, + GitBranch, Link as LinkIcon, Check, Target, @@ -62,7 +64,9 @@ import { AccordionTrigger, } from "./ui/accordion"; import { RelayLink } from "./nostr/RelayLink"; +import type { Filter } from "nostr-tools"; import type { NostrFilter } from "@/types/nostr"; +import type { RelaySelectionReasoning } from "@/types/relay-selection"; import { formatEventIds, formatDTags, @@ -85,7 +89,7 @@ import { import { resolveFilterAliases, getTagValues } from "@/lib/nostr-utils"; import { chunkFiltersByRelay } from "@/lib/relay-filter-chunking"; import { useStableValue } from "@/hooks/useStable"; -import { FilterSummaryBadges } from "./nostr/FilterSummaryBadges"; + import { useNostrEvent } from "@/hooks/useNostrEvent"; import { MemoizedCompactEventRow } from "./nostr/CompactEventRow"; import type { ViewMode } from "@/lib/req-parser"; @@ -138,7 +142,8 @@ interface QueryDropdownProps { nip05PTags?: string[]; domainAuthors?: string[]; domainPTags?: string[]; - relayFilterMap?: Record; + relayFilterMap?: Record; + relayReasoning?: RelaySelectionReasoning[]; } function QueryDropdown({ @@ -147,6 +152,7 @@ function QueryDropdown({ domainAuthors, domainPTags, relayFilterMap, + relayReasoning, }: QueryDropdownProps) { const { copy: handleCopy, copied } = useCopy(); @@ -196,29 +202,14 @@ function QueryDropdown({ 5; return ( -
- {/* Summary Header */} - - +
{isComplexQuery ? ( /* Accordion for complex queries */ - + {/* Kinds Section */} {filter.kinds && filter.kinds.length > 0 && ( - +
Kinds ({filter.kinds.length}) @@ -243,7 +234,7 @@ function QueryDropdown({ {/* IDs Section (direct event lookup) */} {eventIds.length > 0 && ( - +
Event IDs ({eventIds.length}) @@ -272,7 +263,7 @@ function QueryDropdown({ {/* Time Range Section */} {(filter.since || filter.until) && ( - +
Time Range @@ -289,7 +280,7 @@ function QueryDropdown({ {/* Search Section */} {filter.search && ( - +
Search @@ -308,7 +299,7 @@ function QueryDropdown({ {/* Authors Section */} {authorPubkeys.length > 0 && ( - +
Authors ({authorPubkeys.length}) @@ -361,7 +352,7 @@ function QueryDropdown({ {/* Mentions Section */} {pTagPubkeys.length > 0 && ( - +
Mentions ({pTagPubkeys.length}) @@ -408,7 +399,7 @@ function QueryDropdown({ {/* Tags Section */} {tagCount > 0 && ( - +
Tags ({tagCount}) @@ -691,95 +682,86 @@ function QueryDropdown({
)} - {/* Per-Relay Chunked Filters (NIP-65 Outbox) */} + {/* Per-Relay REQs (NIP-65) */} {relayFilterMap && Object.keys(relayFilterMap).length > 0 && ( - - - Chunked REQ (NIP-65 Outbox) — { - Object.keys(relayFilterMap).length - }{" "} - relays - + + + REQs ({Object.keys(relayFilterMap).length}) + -
- {/* Common fields shared across all relays */} - {(() => { - const commonFields: Record = {}; - for (const [key, value] of Object.entries(filter)) { - if (key !== "authors" && key !== "#p") { - commonFields[key] = value; - } - } - return Object.keys(commonFields).length > 0 ? ( -
- Common:{" "} - {filter.kinds && `kinds [${filter.kinds.join(", ")}]`} - {filter.since && - `, since ${new Date(filter.since * 1000).toLocaleDateString()}`} - {filter.until && - `, until ${new Date(filter.until * 1000).toLocaleDateString()}`} - {filter.limit && `, limit ${filter.limit}`} - {filter.search && `, search "${filter.search}"`} - {filter["#t"] && `, #t [${filter["#t"].join(", ")}]`} -
- ) : null; - })()} +
+ {Object.entries(relayFilterMap).map( + ([relayUrl, relayFilters]) => { + const authorCount = relayFilters.reduce( + (sum, f) => sum + (f.authors?.length || 0), + 0, + ); + const pTagCount = relayFilters.reduce( + (sum, f) => sum + (f["#p"]?.length || 0), + 0, + ); + const isFallback = !!relayReasoning?.find( + (r) => r.relay === relayUrl, + )?.isFallback; + const relayJson = JSON.stringify( + relayFilters.length === 1 ? relayFilters[0] : relayFilters, + null, + 2, + ); - {/* Per-relay filters */} - {Object.entries(relayFilterMap).map(([relayUrl, filters]) => { - const authorCount = filters.reduce( - (sum, f) => sum + (f.authors?.length || 0), - 0, - ); - const isFallback = - filters.length === 1 && - JSON.stringify(filters[0]) === JSON.stringify(filter); - - return ( - - - - - - {authorCount > 0 && ( - - {authorCount} author{authorCount !== 1 && "s"} - - )} - {isFallback && ( - - FB - - )} - - - -
- {filters.map((f, i) => ( -
- {f.authors && f.authors.length > 0 && ( -
- - authors: - - {f.authors.map((pubkey) => ( - - ))} -
- )} -
- ))} -
-
-
- ); - })} + return ( + + +
+ +
+
+ {authorCount > 0 && ( + + + {authorCount} + + )} + {pTagCount > 0 && ( + + + {pTagCount} + + )} + {isFallback && ( + + FB + + )} + +
+
+ +
+ + handleCopy(relayJson)} + copied={copied} + label="Copy relay filter JSON" + /> +
+
+
+ ); + }, + )}
@@ -787,10 +769,10 @@ function QueryDropdown({ {/* Raw Query - Always at bottom */} - - + + Raw Query JSON - +
@@ -1337,7 +1319,7 @@ export default function ReqViewer({ Inbox (Read)
- {nip65Info.readers.length} author + {nip65Info.readers.length} reader {nip65Info.readers.length !== 1 ? "s" : ""}
@@ -1348,7 +1330,7 @@ export default function ReqViewer({ Outbox (Write)
- {nip65Info.writers.length} author + {nip65Info.writers.length} writer {nip65Info.writers.length !== 1 ? "s" : ""}
@@ -1372,6 +1354,26 @@ export default function ReqViewer({ {/* Right side: compact status icons */}
+ {/* NIP-65 write/read counts */} + {nip65Info && nip65Info.writers.length > 0 && ( +
+ + {nip65Info.writers.length} +
+ )} + {nip65Info && nip65Info.readers.length > 0 && ( +
+ + {nip65Info.readers.length} +
+ )} + {/* Event count badge */} {reqState && reqState.eventCount > 0 && (
@@ -1481,6 +1483,7 @@ export default function ReqViewer({ domainAuthors={domainAuthors} domainPTags={domainPTags} relayFilterMap={stableRelayFilterMap} + relayReasoning={reasoning} /> )} diff --git a/src/components/chat/MessageReactions.tsx b/src/components/chat/MessageReactions.tsx index 1cb30f2..484b05f 100644 --- a/src/components/chat/MessageReactions.tsx +++ b/src/components/chat/MessageReactions.tsx @@ -48,13 +48,8 @@ export function MessageReactions({ messageId, relays }: MessageReactionsProps) { eventStore, // Automatically add reactions to EventStore }) .subscribe({ - next: (response) => { - if (typeof response !== "string") { - // Event received - it's automatically added to EventStore - console.log( - `[MessageReactions] Reaction received for ${messageId.slice(0, 8)}...`, - ); - } + next: () => { + // Events are automatically added to EventStore }, error: (err) => { console.error( diff --git a/src/components/nostr/LoginDialog.tsx b/src/components/nostr/LoginDialog.tsx index 50c4536..657d33f 100644 --- a/src/components/nostr/LoginDialog.tsx +++ b/src/components/nostr/LoginDialog.tsx @@ -328,8 +328,6 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { url: window.location.origin, }); - // Log for debugging - console.log("[NIP-46] Generated nostrconnect URI:", uri); setConnectUri(uri); // Generate QR code with extra margin for better scanning @@ -349,13 +347,10 @@ export default function LoginDialog({ open, onOpenChange }: LoginDialogProps) { setLoading(false); // Wait for the remote signer to connect - console.log("[NIP-46] Waiting for remote signer..."); await signer.waitForSigner(abortControllerRef.current.signal); - console.log("[NIP-46] Remote signer connected!"); // Get the user's pubkey const pubkey = await signer.getPublicKey(); - console.log("[NIP-46] Got pubkey:", pubkey); const account = new NostrConnectAccount(pubkey, signer); handleSuccess(account); diff --git a/src/components/nostr/kinds/PublicChatsRenderer.tsx b/src/components/nostr/kinds/PublicChatsRenderer.tsx index 6f036fa..1de34a1 100644 --- a/src/components/nostr/kinds/PublicChatsRenderer.tsx +++ b/src/components/nostr/kinds/PublicChatsRenderer.tsx @@ -58,10 +58,6 @@ export function PublicChatsRenderer({ event }: BaseEventProps) { useEffect(() => { if (groupIds.length === 0) return; - console.log( - `[PublicChatsRenderer] Fetching metadata for ${groupIds.length} groups from ${relayUrls.length} relays`, - ); - // Subscribe to fetch metadata events (kind 39000) from the group relays const subscription = pool .subscription( @@ -69,17 +65,7 @@ export function PublicChatsRenderer({ event }: BaseEventProps) { [{ kinds: [39000], "#d": groupIds }], { eventStore }, // Automatically add to store ) - .subscribe({ - next: (response) => { - if (typeof response === "string") { - console.log("[PublicChatsRenderer] EOSE received for metadata"); - } else { - console.log( - `[PublicChatsRenderer] Received metadata: ${response.id.slice(0, 8)}...`, - ); - } - }, - }); + .subscribe(); return () => { subscription.unsubscribe(); diff --git a/src/core/state.ts b/src/core/state.ts index 4729f2c..cfbb604 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -46,9 +46,6 @@ const storage = createJSONStorage(() => ({ const storedVersion = parsed.__version || 5; if (storedVersion < CURRENT_VERSION) { - console.log( - `[Storage] State version outdated (v${storedVersion}), migrating...`, - ); const migrated = migrateState(parsed); localStorage.setItem(key, JSON.stringify(migrated)); toast.success("State Updated", { diff --git a/src/hooks/useLiveTimeline.ts b/src/hooks/useLiveTimeline.ts index 21d0b21..9b1f523 100644 --- a/src/hooks/useLiveTimeline.ts +++ b/src/hooks/useLiveTimeline.ts @@ -50,14 +50,6 @@ export function useLiveTimeline( return; } - console.log("LiveTimeline: Starting query", { - id, - relays, - filters, - limit, - stream, - }); - setLoading(true); setError(null); setEoseReceived(false); @@ -81,7 +73,6 @@ export function useLiveTimeline( (response) => { // Response can be an event or 'EOSE' string if (typeof response === "string") { - console.log("LiveTimeline: EOSE received"); setEoseReceived(true); if (!stream) { setLoading(false); diff --git a/src/hooks/useNostrEvent.ts b/src/hooks/useNostrEvent.ts index b81bd8e..f90b79e 100644 --- a/src/hooks/useNostrEvent.ts +++ b/src/hooks/useNostrEvent.ts @@ -78,27 +78,16 @@ export function useNostrEvent( // Handle string ID if (typeof pointer === "string") { - console.log("[useNostrEvent] Loading event by ID:", pointer); const subscription = eventLoader({ id: pointer }, context).subscribe(); return () => subscription.unsubscribe(); } if (isEventPointer(pointer)) { - console.log("[useNostrEvent] Loading event by EventPointer:", pointer); const subscription = eventLoader(pointer, context).subscribe(); return () => subscription.unsubscribe(); } else if (isAddressPointer(pointer)) { - console.log("[useNostrEvent] Loading event by AddressPointer:", pointer); - const subscription = addressLoader(pointer).subscribe({ - next: (event) => - console.log("[useNostrEvent] Received event:", event.id), - error: (err) => console.error("[useNostrEvent] Error loading:", err), - complete: () => console.log("[useNostrEvent] Loading complete"), - }); - return () => { - console.log("[useNostrEvent] Unsubscribing from addressLoader"); - subscription.unsubscribe(); - }; + const subscription = addressLoader(pointer).subscribe(); + return () => subscription.unsubscribe(); } else { console.warn("[useNostrEvent] Unknown pointer type:", pointer); } diff --git a/src/hooks/useReqTimeline.ts b/src/hooks/useReqTimeline.ts index b8d15c7..52a698f 100644 --- a/src/hooks/useReqTimeline.ts +++ b/src/hooks/useReqTimeline.ts @@ -58,8 +58,6 @@ export function useReqTimeline( return; } - console.log("REQ: Starting query", { relays, filters, limit, stream }); - setLoading(true); setError(null); setEoseReceived(false); diff --git a/src/hooks/useReqTimelineEnhanced.ts b/src/hooks/useReqTimelineEnhanced.ts index 4a2316a..f97516e 100644 --- a/src/hooks/useReqTimelineEnhanced.ts +++ b/src/hooks/useReqTimelineEnhanced.ts @@ -129,11 +129,6 @@ export function useReqTimelineEnhanced( disconnectedAt: globalState?.lastDisconnected, }); changed = true; - console.log( - "REQ Enhanced: Initialized missing relay state", - url, - globalState?.connectionState, - ); } else if ( globalState && globalState.connectionState !== currentState.connectionState @@ -146,13 +141,6 @@ export function useReqTimelineEnhanced( disconnectedAt: globalState.lastDisconnected, }); changed = true; - console.log( - "REQ Enhanced: Connection state changed", - url, - currentState.connectionState, - "→", - globalState.connectionState, - ); } } @@ -167,13 +155,6 @@ export function useReqTimelineEnhanced( return; } - console.log("REQ Enhanced: Starting query", { - relays, - filters, - limit, - stream, - }); - setLoading(true); setError(null); setEoseReceived(false); @@ -209,8 +190,6 @@ export function useReqTimelineEnhanced( (response) => { // Response can be an event or 'EOSE' string if (typeof response === "string" && response === "EOSE") { - console.log("REQ Enhanced: EOSE received from", url); - // Mark THIS specific relay as having received EOSE setRelayStates((prev) => { const state = prev.get(url); @@ -234,7 +213,6 @@ export function useReqTimelineEnhanced( ); if (allEose && !eoseReceivedRef.current) { - console.log("REQ Enhanced: All relays finished"); setEoseReceived(true); if (!stream) { setLoading(false); @@ -317,7 +295,6 @@ export function useReqTimelineEnhanced( }, () => { // This relay's observable completed - console.log("REQ Enhanced: Relay completed", url); }, ); }); diff --git a/src/lib/chat/adapters/nip-29-adapter.ts b/src/lib/chat/adapters/nip-29-adapter.ts index 30dad44..97ed943 100644 --- a/src/lib/chat/adapters/nip-29-adapter.ts +++ b/src/lib/chat/adapters/nip-29-adapter.ts @@ -132,10 +132,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("No active account"); } - console.log( - `[NIP-29] Fetching group metadata for ${groupId} from ${relayUrl}`, - ); - // Fetch group metadata from the specific relay (kind 39000) const metadataFilter: Filter = { kinds: [39000], @@ -152,7 +148,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { // Subscribe and wait for EOSE await new Promise((resolve, reject) => { const timeout = setTimeout(() => { - console.log("[NIP-29] Metadata fetch timeout"); resolve(); }, 5000); @@ -161,9 +156,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { if (typeof response === "string") { // EOSE received clearTimeout(timeout); - console.log( - `[NIP-29] Got ${metadataEvents.length} metadata events`, - ); sub.unsubscribe(); resolve(); } else { @@ -182,11 +174,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { const metadataEvent = metadataEvents[0]; - // Debug: Log metadata event tags - if (metadataEvent) { - console.log(`[NIP-29] Metadata event tags:`, metadataEvent.tags); - } - // Resolve group metadata with profile fallback const resolved = await resolveGroupMetadata( groupId, @@ -198,8 +185,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { const description = resolved.description; const icon = resolved.icon; - console.log(`[NIP-29] Group title: ${title} (source: ${resolved.source})`); - // Fetch admins (kind 39001) and members (kind 39002) in parallel // Both use d tag (addressable events signed by relay) const adminsFilter: Filter = { @@ -221,8 +206,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { .pipe(toArray()), ); - console.log(`[NIP-29] Got ${participantEvents.length} participant events`); - const adminEvents = participantEvents.filter((e) => e.kind === 39001); const memberEvents = participantEvents.filter((e) => e.kind === 39002); @@ -274,13 +257,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { const participants = Array.from(participantsMap.values()); - console.log( - `[NIP-29] Found ${participants.length} participants (${adminEvents.length} admin events, ${memberEvents.length} member events)`, - ); - console.log( - `[NIP-29] Metadata - title: ${title}, icon: ${icon}, description: ${description}`, - ); - return { id: `nip-29:${relayUrl}'${groupId}`, type: "group", @@ -311,8 +287,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("Group ID and relay URL required"); } - console.log(`[NIP-29] Loading messages for ${groupId} from ${relayUrl}`); - // Single filter for all group events: // kind 9: chat messages // kind 9000: put-user (admin adds user) @@ -346,12 +320,7 @@ export class Nip29Adapter extends ChatProtocolAdapter { .subscribe({ next: (response) => { if (typeof response === "string") { - console.log("[NIP-29] EOSE received"); eoseReceived$.next(true); - } else { - console.log( - `[NIP-29] Received event k${response.kind}: ${response.id.slice(0, 8)}...`, - ); } }, }); @@ -372,7 +341,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { return this.eventToMessage(event, conversation.id); }); - console.log(`[NIP-29] Timeline has ${messages.length} events`); // EventStore timeline returns events sorted by created_at desc, // we need ascending order for chat. Since it's already sorted, // just reverse instead of full sort (O(n) vs O(n log n)) @@ -395,10 +363,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { throw new Error("Group ID and relay URL required"); } - console.log( - `[NIP-29] Loading older messages for ${groupId} before ${before}`, - ); - // Same filter as loadMessages but with until for pagination const filter: Filter = { kinds: [9, 9000, 9001, 9321], @@ -412,8 +376,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { pool.request([relayUrl], [filter], { eventStore }).pipe(toArray()), ); - console.log(`[NIP-29] Loaded ${events.length} older events`); - // Convert events to messages const messages = events.map((event) => { if (event.kind === 9321) { @@ -833,10 +795,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { return null; } - console.log( - `[NIP-29] Fetching reply message ${eventId.slice(0, 8)}... from ${relays.join(", ")}`, - ); - const filter: Filter = { ids: [eventId], limit: 1, @@ -847,9 +805,6 @@ export class Nip29Adapter extends ChatProtocolAdapter { await new Promise((resolve) => { const timeout = setTimeout(() => { - console.log( - `[NIP-29] Reply message fetch timeout for ${eventId.slice(0, 8)}...`, - ); resolve(); }, 3000); diff --git a/src/lib/chat/adapters/nip-53-adapter.ts b/src/lib/chat/adapters/nip-53-adapter.ts index 431c15d..6a34496 100644 --- a/src/lib/chat/adapters/nip-53-adapter.ts +++ b/src/lib/chat/adapters/nip-53-adapter.ts @@ -113,10 +113,6 @@ export class Nip53Adapter extends ChatProtocolAdapter { throw new Error("No active account"); } - console.log( - `[NIP-53] Fetching live activity ${dTag} by ${pubkey.slice(0, 8)}...`, - ); - // Use author's outbox relays plus any relay hints const authorOutboxes = await this.getAuthorOutboxes(pubkey); const relays = [...new Set([...relayHints, ...authorOutboxes])]; @@ -125,8 +121,6 @@ export class Nip53Adapter extends ChatProtocolAdapter { throw new Error("No relays available to fetch live activity"); } - console.log(`[NIP-53] Using relays: ${relays.join(", ")}`); - // Fetch the kind 30311 live activity event const activityFilter: Filter = { kinds: [30311], @@ -142,7 +136,6 @@ export class Nip53Adapter extends ChatProtocolAdapter { await new Promise((resolve, reject) => { const timeout = setTimeout(() => { - console.log("[NIP-53] Activity fetch timeout"); resolve(); }, 5000); @@ -151,9 +144,6 @@ export class Nip53Adapter extends ChatProtocolAdapter { if (typeof response === "string") { // EOSE received clearTimeout(timeout); - console.log( - `[NIP-53] Got ${activityEvents.length} activity events`, - ); sub.unsubscribe(); resolve(); } else { @@ -197,10 +187,6 @@ export class Nip53Adapter extends ChatProtocolAdapter { ...new Set([...activity.relays, ...relayHints, ...authorOutboxes]), ]; - console.log( - `[NIP-53] Resolved: "${activity.title}" (${status}), ${participants.length} participants, ${chatRelays.length} relays`, - ); - return { id: `nip-53:${pubkey}:${dTag}`, type: "live-chat", @@ -271,10 +257,6 @@ export class Nip53Adapter extends ChatProtocolAdapter { throw new Error("No relays available for live chat"); } - console.log( - `[NIP-53] Loading messages for ${aTagValue} from ${relays.length} relays`, - ); - // Single filter for live chat messages (kind 1311) and zaps (kind 9735) const filter: Filter = { kinds: [1311, 9735], @@ -303,12 +285,7 @@ export class Nip53Adapter extends ChatProtocolAdapter { .subscribe({ next: (response) => { if (typeof response === "string") { - console.log("[NIP-53] EOSE received"); eoseReceived$.next(true); - } else { - console.log( - `[NIP-53] Received event k${response.kind}: ${response.id.slice(0, 8)}...`, - ); } }, }); @@ -333,7 +310,6 @@ export class Nip53Adapter extends ChatProtocolAdapter { }) .filter((msg): msg is Message => msg !== null); - console.log(`[NIP-53] Timeline has ${messages.length} events`); // EventStore timeline returns events sorted by created_at desc, // we need ascending order for chat. Since it's already sorted, // just reverse instead of full sort (O(n) vs O(n log n)) @@ -376,10 +352,6 @@ export class Nip53Adapter extends ChatProtocolAdapter { throw new Error("No relays available for live chat"); } - console.log( - `[NIP-53] Loading older messages for ${aTagValue} before ${before}`, - ); - // Same filter as loadMessages but with until for pagination const filter: Filter = { kinds: [1311, 9735], @@ -393,8 +365,6 @@ export class Nip53Adapter extends ChatProtocolAdapter { pool.request(relays, [filter], { eventStore }).pipe(toArray()), ); - console.log(`[NIP-53] Loaded ${events.length} older events`); - // Convert events to messages const messages = events .map((event) => { @@ -693,10 +663,6 @@ export class Nip53Adapter extends ChatProtocolAdapter { return null; } - console.log( - `[NIP-53] Fetching reply message ${eventId.slice(0, 8)}... from ${relays.length} relays`, - ); - const filter: Filter = { ids: [eventId], limit: 1, @@ -707,9 +673,6 @@ export class Nip53Adapter extends ChatProtocolAdapter { await new Promise((resolve) => { const timeout = setTimeout(() => { - console.log( - `[NIP-53] Reply message fetch timeout for ${eventId.slice(0, 8)}...`, - ); resolve(); }, 3000); diff --git a/src/lib/error-handler.ts b/src/lib/error-handler.ts index c7b168f..10e5536 100644 --- a/src/lib/error-handler.ts +++ b/src/lib/error-handler.ts @@ -61,7 +61,6 @@ class GlobalErrorHandler { this.wrapLocalStorage(); this.initialized = true; - console.log("[ErrorHandler] Global error handler initialized"); } /** @@ -186,15 +185,10 @@ class GlobalErrorHandler { keysToRemove.forEach((key) => { try { localStorage.removeItem(key); - console.log(`[ErrorHandler] Removed localStorage key: ${key}`); } catch { // Ignore removal errors } }); - - console.log( - `[ErrorHandler] Removed ${keysToRemove.length} localStorage items`, - ); } catch (error) { console.error("[ErrorHandler] Failed to clean up localStorage:", error); } diff --git a/src/lib/logger.ts b/src/lib/logger.ts deleted file mode 100644 index c60b2a3..0000000 --- a/src/lib/logger.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Structured logging utility with log levels - * Provides context-aware logging with timestamps and user context - */ - -type LogLevel = "debug" | "info" | "warn" | "error"; - -interface LogContext { - timestamp: string; - context: string; - user?: string; - action?: string; -} - -interface LogEntry { - level: LogLevel; - message: string; - context: LogContext; - data?: unknown; -} - -class Logger { - private context: string; - private static logs: LogEntry[] = []; - private static maxLogs = 100; // Keep last 100 logs in memory - - constructor(context: string) { - this.context = context; - } - - private getLogContext(action?: string): LogContext { - const ctx: LogContext = { - timestamp: new Date().toISOString(), - context: this.context, - }; - - // Try to get active user from state - try { - const state = localStorage.getItem("grimoire_v6"); - if (state) { - const parsed = JSON.parse(state); - if (parsed.activeAccount?.pubkey) { - ctx.user = parsed.activeAccount.pubkey.slice(0, 8); // First 8 chars for privacy - } - } - } catch { - // Ignore localStorage errors - } - - if (action) { - ctx.action = action; - } - - return ctx; - } - - private log( - level: LogLevel, - message: string, - data?: unknown, - action?: string, - ) { - const logContext = this.getLogContext(action); - const entry: LogEntry = { - level, - message, - context: logContext, - data, - }; - - // Store in memory for debugging - Logger.logs.push(entry); - if (Logger.logs.length > Logger.maxLogs) { - Logger.logs.shift(); - } - - // Format for console - const prefix = `[${logContext.timestamp}] [${logContext.context}]`; - const userInfo = logContext.user ? ` [user:${logContext.user}]` : ""; - const actionInfo = logContext.action - ? ` [action:${logContext.action}]` - : ""; - const fullPrefix = `${prefix}${userInfo}${actionInfo}`; - - const logMessage = - data !== undefined ? [fullPrefix, message, data] : [fullPrefix, message]; - - switch (level) { - case "debug": - // Only log debug in development - if (import.meta.env.DEV) { - console.log(...logMessage); - } - break; - case "info": - console.log(...logMessage); - break; - case "warn": - console.warn(...logMessage); - break; - case "error": - console.error(...logMessage); - break; - } - } - - debug(message: string, data?: unknown, action?: string) { - this.log("debug", message, data, action); - } - - info(message: string, data?: unknown, action?: string) { - this.log("info", message, data, action); - } - - warn(message: string, data?: unknown, action?: string) { - this.log("warn", message, data, action); - } - - error(message: string, error?: unknown, action?: string) { - this.log("error", message, error, action); - } - - /** - * Get recent logs for debugging - */ - static getRecentLogs(level?: LogLevel): LogEntry[] { - if (level) { - return Logger.logs.filter((log) => log.level === level); - } - return [...Logger.logs]; - } - - /** - * Clear logs from memory - */ - static clearLogs() { - Logger.logs = []; - } - - /** - * Export logs as JSON for debugging - */ - static exportLogs(): string { - return JSON.stringify(Logger.logs, null, 2); - } -} - -/** - * Create a logger for a specific context - */ -export function createLogger(context: string): Logger { - return new Logger(context); -} diff --git a/src/lib/migrations.ts b/src/lib/migrations.ts index 283f917..0d6e354 100644 --- a/src/lib/migrations.ts +++ b/src/lib/migrations.ts @@ -186,17 +186,10 @@ export function migrateState(state: any): GrimoireState { let currentState = state; const startVersion = state.__version || 5; // Default to 5 if no version - console.log( - `[Migrations] Migrating from v${startVersion} to v${CURRENT_VERSION}`, - ); - // Apply migrations sequentially for (let version = startVersion; version < CURRENT_VERSION; version++) { const migration = migrations[version]; if (migration) { - console.log( - `[Migrations] Applying migration v${version} -> v${version + 1}`, - ); try { currentState = migration(currentState); } catch (error) { @@ -234,9 +227,6 @@ export function loadStateWithMigration( // Check if migration is needed const storedVersion = parsed.__version || 5; if (storedVersion < CURRENT_VERSION) { - console.log( - `[Migrations] State version outdated (v${storedVersion}), migrating...`, - ); const migrated = migrateState(parsed); // Save migrated state @@ -314,9 +304,6 @@ export function importState( let finalState: GrimoireState; if (storedVersion < CURRENT_VERSION) { - console.log( - `[Migrations] Imported state is v${storedVersion}, migrating...`, - ); finalState = migrateState(parsed); } else if (!validateState(parsed)) { throw new Error("Imported state failed validation"); diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts index 5b3848b..54c7a2d 100644 --- a/src/lib/nip05.ts +++ b/src/lib/nip05.ts @@ -67,9 +67,6 @@ export async function resolveNip05(nip05: string): Promise { return null; } - console.log( - `NIP-05: Resolved ${nip05} → ${normalized} → ${profile.pubkey}`, - ); return profile.pubkey.toLowerCase(); } catch (error) { console.warn(`NIP-05: Resolution failed for ${normalized}:`, error); @@ -133,7 +130,6 @@ export async function resolveDomainDirectory( // Check cache first const cached = domainDirectoryCache.get(normalizedDomain); if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { - console.log(`Domain directory cache hit for @${normalizedDomain}`); return cached.pubkeys; } @@ -163,11 +159,6 @@ export async function resolveDomainDirectory( .filter((pk): pk is string => typeof pk === "string") .map((pk) => pk.toLowerCase()); - console.log( - `Resolved @${normalizedDomain} → ${pubkeys.length} pubkeys`, - pubkeys.slice(0, 5), - ); - // Cache the result domainDirectoryCache.set(normalizedDomain, { pubkeys, diff --git a/src/lib/relay-filter-chunking.test.ts b/src/lib/relay-filter-chunking.test.ts index 6d4733f..11b13be 100644 --- a/src/lib/relay-filter-chunking.test.ts +++ b/src/lib/relay-filter-chunking.test.ts @@ -12,203 +12,601 @@ describe("chunkFiltersByRelay", () => { const carol = "cccc".repeat(16); const dave = "dddd".repeat(16); - it("splits 2 authors on different relays so each only gets its author", () => { - const reasoning: RelaySelectionReasoning[] = [ - { relay: relay1, writers: [alice], readers: [], isFallback: false }, - { relay: relay2, writers: [bob], readers: [], isFallback: false }, - ]; + describe("authors only (outbox chunking)", () => { + it("splits 2 authors on different relays", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [bob], readers: [], isFallback: false }, + ]; - const result = chunkFiltersByRelay( - { kinds: [1], authors: [alice, bob] }, - reasoning, - ); - - expect(result[relay1]).toEqual([{ kinds: [1], authors: [alice] }]); - expect(result[relay2]).toEqual([{ kinds: [1], authors: [bob] }]); - }); - - it("gives both authors to a relay when they share it", () => { - const reasoning: RelaySelectionReasoning[] = [ - { - relay: relay1, - writers: [alice, bob], - readers: [], - isFallback: false, - }, - ]; - - const result = chunkFiltersByRelay( - { kinds: [1], authors: [alice, bob] }, - reasoning, - ); - - expect(result[relay1]).toEqual([{ kinds: [1], authors: [alice, bob] }]); - }); - - it("gives fallback relays the full unmodified filter", () => { - const filter = { kinds: [1], authors: [alice, bob], "#p": [carol] }; - const reasoning: RelaySelectionReasoning[] = [ - { relay: relay1, writers: [alice], readers: [], isFallback: false }, - { relay: relay3, writers: [], readers: [], isFallback: true }, - ]; - - const result = chunkFiltersByRelay(filter, reasoning); - - // Fallback gets exact original filter - expect(result[relay3]).toEqual([filter]); - // Non-fallback gets chunked authors, but bob is unassigned so goes to all - expect(result[relay1]![0].authors).toContain(alice); - expect(result[relay1]![0].authors).toContain(bob); - }); - - it("includes unassigned authors (no kind:10002) in ALL relay filters", () => { - const reasoning: RelaySelectionReasoning[] = [ - { relay: relay1, writers: [alice], readers: [], isFallback: false }, - { relay: relay2, writers: [bob], readers: [], isFallback: false }, - ]; - - const result = chunkFiltersByRelay( - { kinds: [1], authors: [alice, bob, dave] }, - reasoning, - ); - - // dave is unassigned, should appear in both relays - expect(result[relay1]![0].authors).toContain(alice); - expect(result[relay1]![0].authors).toContain(dave); - expect(result[relay1]![0].authors).not.toContain(bob); - - expect(result[relay2]![0].authors).toContain(bob); - expect(result[relay2]![0].authors).toContain(dave); - expect(result[relay2]![0].authors).not.toContain(alice); - }); - - it("passes #p through unchanged to all relays", () => { - const reasoning: RelaySelectionReasoning[] = [ - { relay: relay1, writers: [alice], readers: [carol], isFallback: false }, - { relay: relay2, writers: [bob], readers: [], isFallback: false }, - ]; - - const result = chunkFiltersByRelay( - { kinds: [1], authors: [alice, bob], "#p": [carol, dave] }, - reasoning, - ); - - // Both relays should get the full #p array unchanged - expect(result[relay1]![0]["#p"]).toEqual([carol, dave]); - expect(result[relay2]![0]["#p"]).toEqual([carol, dave]); - }); - - it("returns empty object for empty reasoning", () => { - const result = chunkFiltersByRelay({ kinds: [1], authors: [alice] }, []); - expect(result).toEqual({}); - }); - - it("preserves non-pubkey filter fields", () => { - const reasoning: RelaySelectionReasoning[] = [ - { relay: relay1, writers: [alice], readers: [], isFallback: false }, - ]; - - const result = chunkFiltersByRelay( - { - kinds: [1, 30023], - authors: [alice], - since: 1000, - until: 2000, - limit: 50, - "#t": ["nostr"], - "#p": [carol], - search: "hello", - }, - reasoning, - ); - - expect(result[relay1]).toEqual([ - { - kinds: [1, 30023], - authors: [alice], - since: 1000, - until: 2000, - limit: 50, - "#t": ["nostr"], - "#p": [carol], - search: "hello", - }, - ]); - }); - - it("returns empty object for filter with no authors", () => { - const reasoning: RelaySelectionReasoning[] = [ - { relay: relay1, writers: [alice], readers: [carol], isFallback: false }, - ]; - - // Filter only has #p, no authors — nothing to chunk - const result = chunkFiltersByRelay( - { kinds: [1], "#p": [carol] }, - reasoning, - ); - expect(result).toEqual({}); - }); - - it("returns empty object for filter with no authors and no #p", () => { - const reasoning: RelaySelectionReasoning[] = [ - { relay: relay1, writers: [alice], readers: [], isFallback: false }, - ]; - - const result = chunkFiltersByRelay({ kinds: [1] }, reasoning); - expect(result).toEqual({}); - }); - - it("handles filter array input — each filter chunked independently and merged per relay", () => { - const reasoning: RelaySelectionReasoning[] = [ - { relay: relay1, writers: [alice], readers: [carol], isFallback: false }, - { relay: relay2, writers: [bob], readers: [], isFallback: false }, - ]; - - const result = chunkFiltersByRelay( - [ + const result = chunkFiltersByRelay( { kinds: [1], authors: [alice, bob] }, - { kinds: [7], authors: [alice] }, - ], - reasoning, - ); + reasoning, + ); - // relay1 gets alice from both filters - expect(result[relay1]).toHaveLength(2); - expect(result[relay1]![0]).toEqual({ kinds: [1], authors: [alice] }); - expect(result[relay1]![1]).toEqual({ kinds: [7], authors: [alice] }); + expect(result[relay1]).toEqual([{ kinds: [1], authors: [alice] }]); + expect(result[relay2]).toEqual([{ kinds: [1], authors: [bob] }]); + }); - // relay2 gets bob from first filter only - expect(result[relay2]).toHaveLength(1); - expect(result[relay2]![0]).toEqual({ kinds: [1], authors: [bob] }); + it("gives both authors to a shared relay", () => { + const reasoning: RelaySelectionReasoning[] = [ + { + relay: relay1, + writers: [alice, bob], + readers: [], + isFallback: false, + }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob] }, + reasoning, + ); + + expect(result[relay1]).toEqual([{ kinds: [1], authors: [alice, bob] }]); + }); + + it("includes unassigned authors in ALL relay filters", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [bob], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob, dave] }, + reasoning, + ); + + expect(result[relay1]![0].authors).toContain(alice); + expect(result[relay1]![0].authors).toContain(dave); + expect(result[relay1]![0].authors).not.toContain(bob); + + expect(result[relay2]![0].authors).toContain(bob); + expect(result[relay2]![0].authors).toContain(dave); + expect(result[relay2]![0].authors).not.toContain(alice); + }); }); - it("skips a relay when it has no relevant authors", () => { - const reasoning: RelaySelectionReasoning[] = [ - { relay: relay1, writers: [alice], readers: [], isFallback: false }, - { relay: relay2, writers: [dave], readers: [], isFallback: false }, - ]; + describe("#p only (inbox chunking)", () => { + it("splits 2 p-tags on different relays", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [], readers: [carol], isFallback: false }, + { relay: relay2, writers: [], readers: [dave], isFallback: false }, + ]; - // dave is assigned to relay2 but not in the filter — relay2 gets skipped - const result = chunkFiltersByRelay( - { kinds: [1], authors: [alice] }, - reasoning, - ); + const result = chunkFiltersByRelay( + { kinds: [1], "#p": [carol, dave] }, + reasoning, + ); - expect(result[relay1]).toBeDefined(); - expect(result[relay2]).toBeUndefined(); + expect(result[relay1]).toEqual([{ kinds: [1], "#p": [carol] }]); + expect(result[relay2]).toEqual([{ kinds: [1], "#p": [dave] }]); + }); + + it("includes unassigned p-tags in ALL relay filters", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [], readers: [carol], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], "#p": [carol, dave] }, + reasoning, + ); + + // dave is unassigned, goes to all relays + expect(result[relay1]![0]["#p"]).toContain(carol); + expect(result[relay1]![0]["#p"]).toContain(dave); + }); }); - it("deduplicates authors that appear in both reasoning and unassigned", () => { - const reasoning: RelaySelectionReasoning[] = [ - { relay: relay1, writers: [alice], readers: [], isFallback: false }, - ]; + describe("both authors and #p (combined)", () => { + it("outbox relay gets chunked authors + full #p", () => { + // relay1 is alice's outbox, relay2 is carol's inbox + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [], readers: [carol], isFallback: false }, + ]; - const result = chunkFiltersByRelay( - { kinds: [1], authors: [alice] }, - reasoning, - ); + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob], "#p": [carol] }, + reasoning, + ); - // alice should appear exactly once - expect(result[relay1]![0].authors).toEqual([alice]); + // relay1: selected for alice (outbox) → chunked authors, full #p + expect(result[relay1]![0].authors).toContain(alice); + expect(result[relay1]![0].authors).toContain(bob); // unassigned + expect(result[relay1]![0]["#p"]).toEqual([carol]); + + // relay2: selected for carol (inbox) → full authors, chunked #p + expect(result[relay2]![0].authors).toEqual([alice, bob]); + expect(result[relay2]![0]["#p"]).toEqual([carol]); + }); + + it("relay selected for both gets chunked authors + chunked #p", () => { + const reasoning: RelaySelectionReasoning[] = [ + { + relay: relay1, + writers: [alice], + readers: [carol], + isFallback: false, + }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob], "#p": [carol, dave] }, + reasoning, + ); + + // relay1 is selected for both alice (writer) and carol (reader) + // Gets chunked authors (alice + unassigned bob) and chunked #p (carol + unassigned dave) + expect(result[relay1]![0].authors).toContain(alice); + expect(result[relay1]![0].authors).toContain(bob); // unassigned + expect(result[relay1]![0]["#p"]).toContain(carol); + expect(result[relay1]![0]["#p"]).toContain(dave); // unassigned + }); + + it("inbox-only relay gets full authors when not selected for any writer", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [], readers: [carol], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice], "#p": [carol] }, + reasoning, + ); + + // relay2 selected for carol's inbox, not for any writer + // Gets full authors + chunked #p + expect(result[relay2]![0].authors).toEqual([alice]); + expect(result[relay2]![0]["#p"]).toEqual([carol]); + }); + }); + + describe("fallback relays", () => { + it("gives fallback relays the full unmodified filter", () => { + const filter = { kinds: [1], authors: [alice], "#p": [carol] }; + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay3, writers: [], readers: [], isFallback: true }, + ]; + + const result = chunkFiltersByRelay(filter, reasoning); + + expect(result[relay3]).toEqual([filter]); + }); + }); + + describe("edge cases", () => { + it("returns empty object for empty reasoning", () => { + const result = chunkFiltersByRelay({ kinds: [1], authors: [alice] }, []); + expect(result).toEqual({}); + }); + + it("returns empty object for filter with no authors and no #p", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay({ kinds: [1] }, reasoning); + expect(result).toEqual({}); + }); + + it("preserves non-pubkey filter fields", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { + kinds: [1, 30023], + authors: [alice], + since: 1000, + until: 2000, + limit: 50, + "#t": ["nostr"], + search: "hello", + }, + reasoning, + ); + + expect(result[relay1]).toEqual([ + { + kinds: [1, 30023], + authors: [alice], + since: 1000, + until: 2000, + limit: 50, + "#t": ["nostr"], + search: "hello", + }, + ]); + }); + + it("skips relay with no relevant pubkeys", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [dave], readers: [dave], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice], "#p": [carol] }, + reasoning, + ); + + expect(result[relay1]).toBeDefined(); + expect(result[relay2]).toBeUndefined(); + }); + + it("handles filter array input — each chunked independently per relay", () => { + const reasoning: RelaySelectionReasoning[] = [ + { + relay: relay1, + writers: [alice], + readers: [carol], + isFallback: false, + }, + { relay: relay2, writers: [bob], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + [ + { kinds: [1], authors: [alice, bob] }, + { kinds: [7], "#p": [carol] }, + ], + reasoning, + ); + + // relay1: alice from first filter + carol from second filter + expect(result[relay1]).toHaveLength(2); + expect(result[relay1]![0]).toEqual({ kinds: [1], authors: [alice] }); + expect(result[relay1]![1]).toEqual({ kinds: [7], "#p": [carol] }); + + // relay2: bob from first filter, no readers for carol + expect(result[relay2]).toHaveLength(1); + expect(result[relay2]![0]).toEqual({ kinds: [1], authors: [bob] }); + }); + + it("deduplicates pubkeys", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice] }, + reasoning, + ); + + expect(result[relay1]![0].authors).toEqual([alice]); + }); + + it("treats empty authors array same as absent", () => { + const reasoning: RelaySelectionReasoning[] = [ + { + relay: relay1, + writers: [alice], + readers: [carol], + isFallback: false, + }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [], "#p": [carol] }, + reasoning, + ); + + // authors: [] is falsy-length, so only #p is chunked + expect(result[relay1]).toHaveLength(1); + expect(result[relay1]![0].authors).toBeUndefined(); + expect(result[relay1]![0]["#p"]).toEqual([carol]); + }); + }); + + describe("real-world scenarios", () => { + it("disjoint relays: authors outbox and #p inbox share no relays", () => { + // Very common: Alice writes to relay1, Carol reads on relay2, zero overlap + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [], readers: [carol], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice], "#p": [carol] }, + reasoning, + ); + + // relay1 (alice's outbox): chunked authors [alice], full #p [carol] + expect(result[relay1]![0].authors).toEqual([alice]); + expect(result[relay1]![0]["#p"]).toEqual([carol]); + + // relay2 (carol's inbox): full authors [alice], chunked #p [carol] + expect(result[relay2]![0].authors).toEqual([alice]); + expect(result[relay2]![0]["#p"]).toEqual([carol]); + + // Both relays get the complete filter — but for different routing reasons + expect(result[relay1]![0].kinds).toEqual([1]); + expect(result[relay2]![0].kinds).toEqual([1]); + }); + + it("all authors unassigned — everyone uses fallback relays", () => { + // Nobody has kind:10002, relay selection returned only fallbacks + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [], readers: [], isFallback: true }, + { relay: relay2, writers: [], readers: [], isFallback: true }, + ]; + + const filter = { kinds: [1], authors: [alice, bob] }; + const result = chunkFiltersByRelay(filter, reasoning); + + // Both fallbacks get the full unmodified filter + expect(result[relay1]).toEqual([filter]); + expect(result[relay2]).toEqual([filter]); + }); + + it("only fallback reasoning — no NIP-65 data at all", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [], readers: [], isFallback: true }, + { relay: relay2, writers: [], readers: [], isFallback: true }, + { relay: relay3, writers: [], readers: [], isFallback: true }, + ]; + + const filter = { kinds: [1], authors: [alice], "#p": [carol] }; + const result = chunkFiltersByRelay(filter, reasoning); + + // Every fallback gets the full filter + expect(Object.keys(result)).toHaveLength(3); + for (const relayFilters of Object.values(result)) { + expect(relayFilters).toEqual([filter]); + } + }); + + it("same pubkey in both authors and #p — self-mentions", () => { + // "Show me my posts that mention me" — alice is both author and p-tag + const reasoning: RelaySelectionReasoning[] = [ + { + relay: relay1, + writers: [alice], + readers: [alice], + isFallback: false, + }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice], "#p": [alice] }, + reasoning, + ); + + // relay1 selected for both writer and reader + expect(result[relay1]![0].authors).toEqual([alice]); + expect(result[relay1]![0]["#p"]).toEqual([alice]); + }); + + it("author publishes to multiple relays", () => { + // Alice writes to both relay1 and relay2 + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [alice], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice] }, + reasoning, + ); + + // Both relays get alice + expect(result[relay1]![0].authors).toEqual([alice]); + expect(result[relay2]![0].authors).toEqual([alice]); + }); + + it("all #p unassigned, relays selected only via authors", () => { + // Carol and dave have no relay lists, but alice and bob do + // #p pubkeys should appear in full on all author-selected relays + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [bob], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob], "#p": [carol, dave] }, + reasoning, + ); + + // Both relays selected for writers, not for readers + // → chunked authors, full #p on each + expect(result[relay1]![0].authors).toEqual([alice]); + expect(result[relay1]![0]["#p"]).toEqual([carol, dave]); + + expect(result[relay2]![0].authors).toEqual([bob]); + expect(result[relay2]![0]["#p"]).toEqual([carol, dave]); + }); + + it("single author + many p-tags — common notes-from-X query", () => { + // "notes from alice mentioning bob, carol, or dave" + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [bob], isFallback: false }, + { relay: relay2, writers: [], readers: [carol], isFallback: false }, + { relay: relay3, writers: [], readers: [dave], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice], "#p": [bob, carol, dave] }, + reasoning, + ); + + // relay1: selected for alice (writer) + bob (reader) + // → chunked authors [alice], chunked #p [bob] + expect(result[relay1]![0].authors).toEqual([alice]); + expect(result[relay1]![0]["#p"]).toEqual([bob]); + + // relay2: selected for carol (reader only) + // → full authors [alice], chunked #p [carol] + expect(result[relay2]![0].authors).toEqual([alice]); + expect(result[relay2]![0]["#p"]).toEqual([carol]); + + // relay3: selected for dave (reader only) + // → full authors [alice], chunked #p [dave] + expect(result[relay3]![0].authors).toEqual([alice]); + expect(result[relay3]![0]["#p"]).toEqual([dave]); + }); + + it("many authors + single #p — common $me mentions query", () => { + // "events from anyone in my contacts that mention me" + const reasoning: RelaySelectionReasoning[] = [ + { + relay: relay1, + writers: [alice, bob], + readers: [carol], + isFallback: false, + }, + { relay: relay2, writers: [dave], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob, dave], "#p": [carol] }, + reasoning, + ); + + // relay1: selected for writers [alice, bob] AND reader [carol] + // → chunked authors [alice, bob], chunked #p [carol] + expect(result[relay1]![0].authors).toEqual([alice, bob]); + expect(result[relay1]![0]["#p"]).toEqual([carol]); + + // relay2: selected for writer [dave] only + // → chunked authors [dave], full #p [carol] + expect(result[relay2]![0].authors).toEqual([dave]); + expect(result[relay2]![0]["#p"]).toEqual([carol]); + }); + + it("large realistic scenario: 5 authors across 3 relays with overlap", () => { + const eve = "eeee".repeat(16); + const reasoning: RelaySelectionReasoning[] = [ + { + relay: relay1, + writers: [alice, bob, carol], + readers: [], + isFallback: false, + }, + { + relay: relay2, + writers: [bob, dave], + readers: [], + isFallback: false, + }, + { + relay: relay3, + writers: [eve], + readers: [], + isFallback: false, + }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob, carol, dave, eve] }, + reasoning, + ); + + // relay1 gets its 3 writers + expect(result[relay1]![0].authors).toEqual( + expect.arrayContaining([alice, bob, carol]), + ); + expect(result[relay1]![0].authors).toHaveLength(3); + + // relay2 gets its 2 writers + expect(result[relay2]![0].authors).toEqual( + expect.arrayContaining([bob, dave]), + ); + expect(result[relay2]![0].authors).toHaveLength(2); + + // relay3 gets its 1 writer + expect(result[relay3]![0].authors).toEqual([eve]); + + // bob appears on both relay1 and relay2 (writes to both) + expect(result[relay1]![0].authors).toContain(bob); + expect(result[relay2]![0].authors).toContain(bob); + }); + + it("mix of fallback and NIP-65 relays", () => { + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [], readers: [], isFallback: true }, + ]; + + const filter = { kinds: [1], authors: [alice, bob] }; + const result = chunkFiltersByRelay(filter, reasoning); + + // relay1: NIP-65 selected, gets chunked (alice + unassigned bob) + expect(result[relay1]![0].authors).toContain(alice); + expect(result[relay1]![0].authors).toContain(bob); + + // relay2: fallback, gets full original filter + expect(result[relay2]).toEqual([filter]); + }); + + it("reasoning has extra pubkeys not in filter — ignored", () => { + // relay1's writers include eve who isn't in the filter at all + const eve = "eeee".repeat(16); + const reasoning: RelaySelectionReasoning[] = [ + { + relay: relay1, + writers: [alice, eve], + readers: [], + isFallback: false, + }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice] }, + reasoning, + ); + + // Only alice (from the filter) appears, eve is ignored + expect(result[relay1]![0].authors).toEqual([alice]); + }); + + it("multiple unassigned authors spread across few relays", () => { + // Only alice has a relay list; bob, carol, dave don't + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob, carol, dave] }, + reasoning, + ); + + // relay1 gets alice (assigned) + all unassigned + const authors = result[relay1]![0].authors!; + expect(authors).toContain(alice); + expect(authors).toContain(bob); + expect(authors).toContain(carol); + expect(authors).toContain(dave); + expect(authors).toHaveLength(4); + }); + + it("complex: disjoint authors/p-tags with some unassigned on both sides", () => { + // alice→relay1 (outbox), carol→relay2 (inbox) + // bob (author) and dave (#p) have no relay lists + const reasoning: RelaySelectionReasoning[] = [ + { relay: relay1, writers: [alice], readers: [], isFallback: false }, + { relay: relay2, writers: [], readers: [carol], isFallback: false }, + ]; + + const result = chunkFiltersByRelay( + { kinds: [1], authors: [alice, bob], "#p": [carol, dave] }, + reasoning, + ); + + // relay1: selected for alice (writer) + // → chunked authors [alice, bob(unassigned)], full #p [carol, dave] + expect(result[relay1]![0].authors).toContain(alice); + expect(result[relay1]![0].authors).toContain(bob); + expect(result[relay1]![0]["#p"]).toEqual([carol, dave]); + + // relay2: selected for carol (reader) + // → full authors [alice, bob], chunked #p [carol, dave(unassigned)] + expect(result[relay2]![0].authors).toEqual([alice, bob]); + expect(result[relay2]![0]["#p"]).toContain(carol); + expect(result[relay2]![0]["#p"]).toContain(dave); + }); }); }); diff --git a/src/lib/relay-filter-chunking.ts b/src/lib/relay-filter-chunking.ts index a197778..194d42f 100644 --- a/src/lib/relay-filter-chunking.ts +++ b/src/lib/relay-filter-chunking.ts @@ -1,13 +1,19 @@ /** * Per-Relay Filter Chunking (Outbox-Aware REQ Splitting) * - * Splits filters so each relay only receives the authors relevant to it, + * Splits filters so each relay only receives the pubkeys relevant to it, * based on NIP-65 relay selection reasoning. * - * Only `authors` are chunked (by outbox/write relays). All other filter - * fields — including `#p` — are passed through unchanged to every relay. - * `#p` is a content filter ("find events tagging these pubkeys"), not a - * routing signal, so it belongs on all relays. + * - `authors` → outbox relays (writers): only send author pubkeys to relays where they write + * - `#p` → inbox relays (readers): only send tagged pubkeys to relays where they read + * + * When both `authors` and `#p` are present: + * - Outbox relays (selected for writers) get their chunked authors + full #p + * - Inbox relays (selected for readers) get full authors + their chunked #p + * - A relay selected for both gets chunked authors + chunked #p + * + * Unassigned pubkeys (no kind:10002 relay list) go to ALL relays. + * Fallback relays always get the full unmodified filter. */ import type { Filter } from "nostr-tools"; @@ -26,11 +32,13 @@ export function chunkFiltersByRelay( const filterArray = Array.isArray(filters) ? filters : [filters]; - // Collect all assigned writers across non-fallback reasoning entries + // Collect all assigned writers and readers across non-fallback reasoning const allAssignedWriters = new Set(); + const allAssignedReaders = new Set(); for (const r of reasoning) { if (!r.isFallback) { for (const w of r.writers) allAssignedWriters.add(w); + for (const rd of r.readers) allAssignedReaders.add(rd); } } @@ -38,23 +46,33 @@ export function chunkFiltersByRelay( for (const filter of filterArray) { const originalAuthors = filter.authors; + const originalPTags = filter["#p"]; + const hasAuthors = !!originalAuthors?.length; + const hasPTags = !!originalPTags?.length; - // If filter has no authors, nothing to chunk - if (!originalAuthors?.length) continue; + // Nothing to chunk if no pubkey-based fields + if (!hasAuthors && !hasPTags) continue; - // Find unassigned authors (no kind:10002) — these go to ALL relays - const unassignedAuthors = originalAuthors.filter( - (a) => !allAssignedWriters.has(a), - ); + // Unassigned pubkeys go to ALL relays + const unassignedAuthors = hasAuthors + ? originalAuthors.filter((a) => !allAssignedWriters.has(a)) + : []; + const unassignedPTags = hasPTags + ? originalPTags.filter((p) => !allAssignedReaders.has(p)) + : []; - // Build base filter (everything except authors) + // Build base filter (everything except authors and #p) const base: Filter = {}; for (const [key, value] of Object.entries(filter)) { - if (key !== "authors") { + if (key !== "authors" && key !== "#p") { (base as Record)[key] = value; } } + // Pre-compute sets for intersection checks (constant across relays) + const authorSet = hasAuthors ? new Set(originalAuthors) : undefined; + const pTagSet = hasPTags ? new Set(originalPTags) : undefined; + for (const r of reasoning) { // Fallback relays get the full original filter if (r.isFallback) { @@ -63,17 +81,48 @@ export function chunkFiltersByRelay( continue; } - // Build chunked authors: reasoning writers that overlap with filter authors + unassigned - const authorSet = new Set(originalAuthors); - const relayAuthors = r.writers.filter((w) => authorSet.has(w)); - const chunkedAuthors = [ - ...new Set([...relayAuthors, ...unassignedAuthors]), - ]; + // Find assigned writers/readers for this relay that overlap with the filter + const relayWriters = authorSet + ? r.writers.filter((w) => authorSet.has(w)) + : []; + const relayReaders = pTagSet + ? r.readers.filter((rd) => pTagSet.has(rd)) + : []; - // If no authors for this relay, skip it - if (chunkedAuthors.length === 0) continue; + // "Selected for" means the relay has assigned (non-fallback) writers/readers + // Unassigned pubkeys piggyback but don't make a relay count as selected + const selectedForWriters = relayWriters.length > 0; + const selectedForReaders = relayReaders.length > 0; - const chunkedFilter: Filter = { ...base, authors: chunkedAuthors }; + // Skip relay if it has no assigned pubkeys for this filter + if (!selectedForWriters && !selectedForReaders) continue; + + // Build chunked lists: assigned + unassigned + const chunkedAuthors = hasAuthors + ? [...new Set([...relayWriters, ...unassignedAuthors])] + : undefined; + const chunkedPTags = hasPTags + ? [...new Set([...relayReaders, ...unassignedPTags])] + : undefined; + + const chunkedFilter: Filter = { ...base }; + + if (hasAuthors && hasPTags) { + // Both present: + // - Outbox relay (writers) → chunked authors + full #p + // - Inbox relay (readers) → full authors + chunked #p + // - Both → chunked authors + chunked #p + chunkedFilter.authors = selectedForWriters + ? chunkedAuthors! + : originalAuthors; + chunkedFilter["#p"] = selectedForReaders + ? chunkedPTags! + : originalPTags; + } else if (hasAuthors) { + chunkedFilter.authors = chunkedAuthors!; + } else { + chunkedFilter["#p"] = chunkedPTags!; + } if (!result[r.relay]) result[r.relay] = []; result[r.relay].push(chunkedFilter); diff --git a/src/main.tsx b/src/main.tsx index 59c887b..9d65dd3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -45,11 +45,9 @@ if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker .register("/sw.js") - .then((registration) => { - console.log("SW registered:", registration); - }) + .then(() => {}) .catch((error) => { - console.log("SW registration failed:", error); + console.error("SW registration failed:", error); }); }); } diff --git a/src/services/blossom-server-cache.ts b/src/services/blossom-server-cache.ts index 23ddc09..8b1bccc 100644 --- a/src/services/blossom-server-cache.ts +++ b/src/services/blossom-server-cache.ts @@ -37,10 +37,6 @@ class BlossomServerCache { // Cache each kind:10063 event as it arrives this.set(event); }); - - console.log( - "[BlossomServerCache] Subscribed to EventStore for kind:10063 events", - ); } /** @@ -50,7 +46,6 @@ class BlossomServerCache { if (this.eventStoreSubscription) { this.eventStoreSubscription.unsubscribe(); this.eventStoreSubscription = null; - console.log("[BlossomServerCache] Unsubscribed from EventStore"); } } @@ -226,7 +221,6 @@ class BlossomServerCache { // Also clear memory cache this.memoryCache.clear(); this.cacheOrder = []; - console.log("[BlossomServerCache] Cleared all cached server lists"); } catch (error) { console.error("[BlossomServerCache] Error clearing cache:", error); } diff --git a/src/services/db.ts b/src/services/db.ts index 4dd8206..b84eeec 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -144,8 +144,6 @@ class GrimoireDb extends Dexie { relayAuthPreferences: "&url", }) .upgrade(async (tx) => { - console.log("[DB Migration v6] Normalizing relay URLs..."); - // Migrate relayAuthPreferences const authPrefs = await tx .table("relayAuthPreferences") @@ -178,13 +176,6 @@ class GrimoireDb extends Dexie { await tx .table("relayAuthPreferences") .bulkAdd(Array.from(normalizedAuthPrefs.values())); - console.log( - `[DB Migration v6] Normalized ${normalizedAuthPrefs.size} auth preferences` + - (skippedAuthPrefs > 0 - ? ` (skipped ${skippedAuthPrefs} invalid)` - : ""), - ); - // Migrate relayInfo const relayInfos = await tx.table("relayInfo").toArray(); const normalizedRelayInfos = new Map(); @@ -215,13 +206,6 @@ class GrimoireDb extends Dexie { await tx .table("relayInfo") .bulkAdd(Array.from(normalizedRelayInfos.values())); - console.log( - `[DB Migration v6] Normalized ${normalizedRelayInfos.size} relay infos` + - (skippedRelayInfos > 0 - ? ` (skipped ${skippedRelayInfos} invalid)` - : ""), - ); - console.log("[DB Migration v6] Complete!"); }); // Version 7: Add relay lists caching @@ -270,10 +254,6 @@ class GrimoireDb extends Dexie { spells: "&id, createdAt, isPublished", }) .upgrade(async (tx) => { - console.log( - "[DB Migration v10] Migrating spell schema (localName → alias)...", - ); - const spells = await tx.table("spells").toArray(); for (const spell of spells) { @@ -290,8 +270,6 @@ class GrimoireDb extends Dexie { await tx.table("spells").put(spell); } - - console.log(`[DB Migration v10] Migrated ${spells.length} spells`); }); // Version 11: Add index for spell alias diff --git a/src/services/nwc.ts b/src/services/nwc.ts index 8de88e6..594aff4 100644 --- a/src/services/nwc.ts +++ b/src/services/nwc.ts @@ -94,11 +94,7 @@ function subscribeToNotifications(wallet: WalletConnect) { function subscribe() { notificationSubscription = wallet.notifications$.subscribe({ - next: (notification) => { - console.log( - "[NWC] Notification received:", - notification.notification_type, - ); + next: () => { retryCount = 0; // Recover from error state on successful notification diff --git a/src/services/relay-list-cache.ts b/src/services/relay-list-cache.ts index 13a91aa..9885d47 100644 --- a/src/services/relay-list-cache.ts +++ b/src/services/relay-list-cache.ts @@ -38,10 +38,6 @@ class RelayListCache { // Cache each kind:10002 event as it arrives this.set(event); }); - - console.log( - "[RelayListCache] Subscribed to EventStore for kind:10002 events", - ); } /** @@ -51,7 +47,6 @@ class RelayListCache { if (this.eventStoreSubscription) { this.eventStoreSubscription.unsubscribe(); this.eventStoreSubscription = null; - console.log("[RelayListCache] Unsubscribed from EventStore"); } } @@ -276,7 +271,6 @@ class RelayListCache { // Also clear memory cache this.memoryCache.clear(); this.cacheOrder = []; - console.log("[RelayListCache] Cleared all cached relay lists"); } catch (error) { console.error("[RelayListCache] Error clearing cache:", error); }