mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-04 17:51:12 +02:00
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:
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -58,8 +58,6 @@ export function useReqTimeline(
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("REQ: Starting query", { relays, filters, limit, stream });
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setEoseReceived(false);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user