feat: add inbox (#p) chunking to relay filter splitting and clean up logging

Extend relay filter chunking to route #p tags to inbox/read relays,
matching the existing outbox/write routing for authors. Remove debug
console.log statements across the codebase while preserving error-level
logging. Delete unused logger utility. Expand test coverage for all
chunking scenarios.
This commit is contained in:
Alejandro Gómez
2026-03-23 09:58:07 +01:00
parent 9af896127e
commit 62785fa336
23 changed files with 783 additions and 707 deletions

View File

@@ -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:

View File

@@ -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<string, import("nostr-tools").Filter[]>;
relayFilterMap?: Record<string, Filter[]>;
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 (
<div className="border-b border-border px-4 py-3 bg-muted/30 space-y-3">
{/* Summary Header */}
<FilterSummaryBadges filter={filter} />
<div className="border-b border-border px-4 py-2 bg-muted/30 space-y-0.5">
{isComplexQuery ? (
/* Accordion for complex queries */
<Accordion
type="multiple"
defaultValue={[
"kinds",
"ids",
"authors",
"mentions",
"time",
"search",
"tags",
]}
className="space-y-2"
>
<Accordion type="multiple" defaultValue={[]} className="space-y-0.5">
{/* Kinds Section */}
{filter.kinds && filter.kinds.length > 0 && (
<AccordionItem value="kinds" className="border-0">
<AccordionTrigger className="py-2 hover:no-underline">
<AccordionTrigger className="py-1 hover:no-underline">
<div className="flex items-center gap-2 text-xs font-semibold">
<FileText className="size-3.5 text-muted-foreground" />
Kinds ({filter.kinds.length})
@@ -243,7 +234,7 @@ function QueryDropdown({
{/* IDs Section (direct event lookup) */}
{eventIds.length > 0 && (
<AccordionItem value="ids" className="border-0">
<AccordionTrigger className="py-2 hover:no-underline">
<AccordionTrigger className="py-1 hover:no-underline">
<div className="flex items-center gap-2 text-xs font-semibold">
<Target className="size-3.5 text-muted-foreground" />
Event IDs ({eventIds.length})
@@ -272,7 +263,7 @@ function QueryDropdown({
{/* Time Range Section */}
{(filter.since || filter.until) && (
<AccordionItem value="time" className="border-0">
<AccordionTrigger className="py-2 hover:no-underline">
<AccordionTrigger className="py-1 hover:no-underline">
<div className="flex items-center gap-2 text-xs font-semibold">
<Clock className="size-3.5 text-muted-foreground" />
Time Range
@@ -289,7 +280,7 @@ function QueryDropdown({
{/* Search Section */}
{filter.search && (
<AccordionItem value="search" className="border-0">
<AccordionTrigger className="py-2 hover:no-underline">
<AccordionTrigger className="py-1 hover:no-underline">
<div className="flex items-center gap-2 text-xs font-semibold">
<Search className="size-3.5 text-muted-foreground" />
Search
@@ -308,7 +299,7 @@ function QueryDropdown({
{/* Authors Section */}
{authorPubkeys.length > 0 && (
<AccordionItem value="authors" className="border-0">
<AccordionTrigger className="py-2 hover:no-underline">
<AccordionTrigger className="py-1 hover:no-underline">
<div className="flex items-center gap-2 text-xs font-semibold">
<User className="size-3.5 text-muted-foreground" />
Authors ({authorPubkeys.length})
@@ -361,7 +352,7 @@ function QueryDropdown({
{/* Mentions Section */}
{pTagPubkeys.length > 0 && (
<AccordionItem value="mentions" className="border-0">
<AccordionTrigger className="py-2 hover:no-underline">
<AccordionTrigger className="py-1 hover:no-underline">
<div className="flex items-center gap-2 text-xs font-semibold">
<User className="size-3.5 text-muted-foreground" />
Mentions ({pTagPubkeys.length})
@@ -408,7 +399,7 @@ function QueryDropdown({
{/* Tags Section */}
{tagCount > 0 && (
<AccordionItem value="tags" className="border-0">
<AccordionTrigger className="py-2 hover:no-underline">
<AccordionTrigger className="py-1 hover:no-underline">
<div className="flex items-center gap-2 text-xs font-semibold">
<Hash className="size-3.5 text-muted-foreground" />
Tags ({tagCount})
@@ -691,95 +682,86 @@ function QueryDropdown({
</div>
)}
{/* Per-Relay Chunked Filters (NIP-65 Outbox) */}
{/* Per-Relay REQs (NIP-65) */}
{relayFilterMap && Object.keys(relayFilterMap).length > 0 && (
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-full">
<Sparkles className="size-3" />
Chunked REQ (NIP-65 Outbox) {
Object.keys(relayFilterMap).length
}{" "}
relays
<ChevronDown className="size-3 ml-auto" />
<CollapsibleTrigger className="flex flex-1 items-center gap-2 py-1 text-xs font-semibold w-full">
<GitBranch className="size-3.5 text-muted-foreground" />
REQs ({Object.keys(relayFilterMap).length})
<ChevronDown className="size-4 shrink-0 ml-auto text-muted-foreground transition-transform duration-200" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 space-y-2">
{/* Common fields shared across all relays */}
{(() => {
const commonFields: Record<string, unknown> = {};
for (const [key, value] of Object.entries(filter)) {
if (key !== "authors" && key !== "#p") {
commonFields[key] = value;
}
}
return Object.keys(commonFields).length > 0 ? (
<div className="text-xs text-muted-foreground px-2 py-1 bg-muted/30 rounded border border-border/40">
<span className="font-medium">Common:</span>{" "}
{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(", ")}]`}
</div>
) : null;
})()}
<div className="mt-1 pl-1">
{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 (
<Collapsible key={relayUrl}>
<CollapsibleTrigger className="flex items-center gap-2 text-xs w-full px-2 py-1.5 rounded hover:bg-muted/50 transition-colors">
<ChevronRight className="size-3 text-muted-foreground" />
<RelayLink url={relayUrl} />
<span className="text-muted-foreground ml-auto flex items-center gap-2">
{authorCount > 0 && (
<span>
{authorCount} author{authorCount !== 1 && "s"}
</span>
)}
{isFallback && (
<span className="text-[10px] bg-muted px-1 py-0.5 rounded font-medium">
FB
</span>
)}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-5 mt-1 space-y-1">
{filters.map((f, i) => (
<div key={i}>
{f.authors && f.authors.length > 0 && (
<div className="flex flex-wrap gap-1 items-center">
<span className="text-[10px] text-muted-foreground font-medium">
authors:
</span>
{f.authors.map((pubkey) => (
<UserName
key={pubkey}
pubkey={pubkey}
className="text-xs"
/>
))}
</div>
)}
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
return (
<Collapsible key={relayUrl}>
<CollapsibleTrigger className="flex items-center gap-1.5 text-xs w-full py-0.5 px-1 rounded hover:bg-muted/50 transition-colors min-w-0">
<div className="min-w-0 flex-1 overflow-hidden">
<RelayLink url={relayUrl} showInboxOutbox={false} />
</div>
<div className="shrink-0 text-muted-foreground flex items-center gap-1.5 text-[10px]">
{authorCount > 0 && (
<span
className="flex items-center gap-0.5"
title="outbox / write"
>
<Send className="size-2.5" />
{authorCount}
</span>
)}
{pTagCount > 0 && (
<span
className="flex items-center gap-0.5"
title="inbox / read"
>
<Inbox className="size-2.5" />
{pTagCount}
</span>
)}
{isFallback && (
<span className="bg-muted px-1 py-0.5 rounded font-medium">
FB
</span>
)}
<ChevronRight className="size-3 text-muted-foreground" />
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="relative ml-5 mt-1">
<SyntaxHighlight
code={relayJson}
language="json"
className="bg-muted/50 p-2 pr-10 overflow-x-auto border border-border/40 rounded text-[11px]"
/>
<CodeCopyButton
onCopy={() => handleCopy(relayJson)}
copied={copied}
label="Copy relay filter JSON"
/>
</div>
</CollapsibleContent>
</Collapsible>
);
},
)}
</div>
</CollapsibleContent>
</Collapsible>
@@ -787,10 +769,10 @@ function QueryDropdown({
{/* Raw Query - Always at bottom */}
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-full">
<Code className="size-3" />
<CollapsibleTrigger className="flex flex-1 items-center gap-2 py-1 text-xs font-semibold w-full">
<Code className="size-3.5 text-muted-foreground" />
Raw Query JSON
<ChevronDown className="size-3 ml-auto" />
<ChevronDown className="size-4 shrink-0 ml-auto text-muted-foreground transition-transform duration-200" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="relative mt-2">
@@ -1337,7 +1319,7 @@ export default function ReqViewer({
Inbox (Read)
</div>
<div className="font-medium">
{nip65Info.readers.length} author
{nip65Info.readers.length} reader
{nip65Info.readers.length !== 1 ? "s" : ""}
</div>
</div>
@@ -1348,7 +1330,7 @@ export default function ReqViewer({
Outbox (Write)
</div>
<div className="font-medium">
{nip65Info.writers.length} author
{nip65Info.writers.length} writer
{nip65Info.writers.length !== 1 ? "s" : ""}
</div>
</div>
@@ -1372,6 +1354,26 @@ export default function ReqViewer({
{/* Right side: compact status icons */}
<div className="flex items-center gap-1.5 flex-shrink-0">
{/* NIP-65 write/read counts */}
{nip65Info && nip65Info.writers.length > 0 && (
<div
className="flex items-center gap-0.5 text-[10px] text-muted-foreground"
title="outbox / write"
>
<Send className="size-2.5" />
<span>{nip65Info.writers.length}</span>
</div>
)}
{nip65Info && nip65Info.readers.length > 0 && (
<div
className="flex items-center gap-0.5 text-[10px] text-muted-foreground"
title="inbox / read"
>
<Inbox className="size-2.5" />
<span>{nip65Info.readers.length}</span>
</div>
)}
{/* Event count badge */}
{reqState && reqState.eventCount > 0 && (
<div className="flex items-center gap-1 text-[10px] text-muted-foreground font-medium">
@@ -1481,6 +1483,7 @@ export default function ReqViewer({
domainAuthors={domainAuthors}
domainPTags={domainPTags}
relayFilterMap={stableRelayFilterMap}
relayReasoning={reasoning}
/>
)}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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();

View File

@@ -46,9 +46,6 @@ const storage = createJSONStorage<GrimoireState>(() => ({
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", {

View File

@@ -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);

View File

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

View File

@@ -58,8 +58,6 @@ export function useReqTimeline(
return;
}
console.log("REQ: Starting query", { relays, filters, limit, stream });
setLoading(true);
setError(null);
setEoseReceived(false);

View File

@@ -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);
},
);
});

View File

@@ -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<void>((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<void>((resolve) => {
const timeout = setTimeout(() => {
console.log(
`[NIP-29] Reply message fetch timeout for ${eventId.slice(0, 8)}...`,
);
resolve();
}, 3000);

View File

@@ -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<void>((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<void>((resolve) => {
const timeout = setTimeout(() => {
console.log(
`[NIP-53] Reply message fetch timeout for ${eventId.slice(0, 8)}...`,
);
resolve();
}, 3000);

View File

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

View File

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

View File

@@ -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");

View File

@@ -67,9 +67,6 @@ export async function resolveNip05(nip05: string): Promise<string | null> {
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,

View File

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

View File

@@ -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<string>();
const allAssignedReaders = new Set<string>();
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<string, unknown>)[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);

View File

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

View File

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

View File

@@ -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<RelayAuthPreference>("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>("relayInfo").toArray();
const normalizedRelayInfos = new Map<string, RelayInfo>();
@@ -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<any>("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

View File

@@ -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

View File

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