From 26bb0713ac660838eecd94df9d65535f6e062626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Wed, 4 Mar 2026 17:00:57 +0100 Subject: [PATCH] feat: keep relay selection in call site, compact logs --- src/actions/delete-event.ts | 7 +- src/actions/publish-spell.test.ts | 6 +- src/actions/publish-spell.ts | 19 +- src/components/EventLogViewer.tsx | 621 ++++++++++++++++-------------- src/components/PostViewer.tsx | 7 +- src/hooks/useEventLog.ts | 21 + src/index.css | 2 +- src/lib/themes/builtin/dark.ts | 10 +- src/services/event-log.ts | 16 + src/services/hub.ts | 31 +- src/services/publish-service.ts | 141 +------ src/services/relay-selection.ts | 43 +++ 12 files changed, 485 insertions(+), 439 deletions(-) diff --git a/src/actions/delete-event.ts b/src/actions/delete-event.ts index f9539b1..0a1ff07 100644 --- a/src/actions/delete-event.ts +++ b/src/actions/delete-event.ts @@ -1,5 +1,6 @@ import accountManager from "@/services/accounts"; import publishService from "@/services/publish-service"; +import { selectRelaysForPublish } from "@/services/relay-selection"; import { EventFactory } from "applesauce-core/event-factory"; import { NostrEvent } from "@/types/nostr"; import { settingsManager } from "@/services/settings"; @@ -32,9 +33,9 @@ export class DeleteEventAction { const event = await factory.sign(draft); - // Publish via centralized PublishService - // Relay selection is handled automatically (outbox + state + aggregators) - const result = await publishService.publish(event); + // Select relays and publish + const relays = await selectRelaysForPublish(account.pubkey); + const result = await publishService.publish(event, relays); if (!result.ok) { const errors = result.failed diff --git a/src/actions/publish-spell.test.ts b/src/actions/publish-spell.test.ts index 33fa4b3..8e6b54e 100644 --- a/src/actions/publish-spell.test.ts +++ b/src/actions/publish-spell.test.ts @@ -27,10 +27,8 @@ vi.mock("@/services/spell-storage", () => ({ markSpellPublished: vi.fn(), })); -vi.mock("@/services/relay-list-cache", () => ({ - relayListCache: { - getOutboxRelays: vi.fn().mockResolvedValue([]), - }, +vi.mock("@/services/relay-selection", () => ({ + selectRelaysForPublish: vi.fn().mockResolvedValue(["wss://test.relay/"]), })); vi.mock("@/services/event-store", () => ({ diff --git a/src/actions/publish-spell.ts b/src/actions/publish-spell.ts index 8f48123..0157084 100644 --- a/src/actions/publish-spell.ts +++ b/src/actions/publish-spell.ts @@ -1,6 +1,7 @@ import { LocalSpell } from "@/services/db"; import accountManager from "@/services/accounts"; import publishService from "@/services/publish-service"; +import { selectRelaysForPublish } from "@/services/relay-selection"; import { encodeSpell } from "@/lib/spell-conversion"; import { markSpellPublished } from "@/services/spell-storage"; import { EventFactory } from "applesauce-core/event-factory"; @@ -50,22 +51,20 @@ export class PublishSpellAction { event = (await factory.sign(draft)) as SpellEvent; } - // Get relay hints from event tags - const eventRelayHints = - event.tags.find((t) => t[0] === "relays")?.slice(1) || []; - - // Publish via centralized PublishService - let result; + // Determine relays: explicit target relays or outbox selection with hints + let relays: string[]; if (targetRelays && targetRelays.length > 0) { - // Use explicit target relays - result = await publishService.publishToRelays(event, targetRelays); + relays = targetRelays; } else { - // Use automatic relay selection with event hints - result = await publishService.publish(event, { + const eventRelayHints = + event.tags.find((t) => t[0] === "relays")?.slice(1) || []; + relays = await selectRelaysForPublish(account.pubkey, { relayHints: eventRelayHints, }); } + const result = await publishService.publish(event, relays); + if (!result.ok) { const errors = result.failed .map((f) => `${f.relay}: ${f.error}`) diff --git a/src/components/EventLogViewer.tsx b/src/components/EventLogViewer.tsx index e9c2321..7c45f64 100644 --- a/src/components/EventLogViewer.tsx +++ b/src/components/EventLogViewer.tsx @@ -1,14 +1,10 @@ /** * Event Log Viewer * - * Displays a log of relay operations for debugging and introspection: - * - PUBLISH events with per-relay status and retry functionality - * - CONNECT/DISCONNECT events - * - AUTH events - * - NOTICE events + * Compact log of relay operations for debugging and introspection. */ -import { useState, useCallback, useRef, useEffect } from "react"; +import { useState, useCallback, useMemo } from "react"; import { Check, X, @@ -26,6 +22,7 @@ import { } from "lucide-react"; import { Button } from "./ui/button"; import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { RelayLink } from "./nostr/RelayLink"; import { useEventLog } from "@/hooks/useEventLog"; import { @@ -38,253 +35,344 @@ import { } from "@/services/event-log"; import { formatTimestamp } from "@/hooks/useLocale"; import { cn } from "@/lib/utils"; +import { KindBadge } from "./KindBadge"; +import { KindRenderer } from "./nostr/kinds"; +import { EventErrorBoundary } from "./EventErrorBoundary"; // ============================================================================ -// Tab Filter Types +// Tab Filters // ============================================================================ -type TabFilter = "all" | EventLogType; +type TabFilter = "all" | "publish" | "connect" | "auth" | "notice"; + +/** Map tab values to the EventLogType(s) they filter */ +const TAB_TYPE_MAP: Record = { + all: undefined, + publish: ["PUBLISH"], + connect: ["CONNECT", "DISCONNECT"], + auth: ["AUTH"], + notice: ["NOTICE"], +}; const TAB_FILTERS: { value: TabFilter; label: string }[] = [ { value: "all", label: "All" }, - { value: "PUBLISH", label: "Publish" }, - { value: "CONNECT", label: "Connect" }, - { value: "AUTH", label: "Auth" }, - { value: "NOTICE", label: "Notice" }, + { value: "publish", label: "Publish" }, + { value: "connect", label: "Connect" }, + { value: "auth", label: "Auth" }, + { value: "notice", label: "Notice" }, ]; +// ============================================================================ +// Shared row layout +// ============================================================================ + +function EntryRow({ + icon, + tooltip, + children, + timestamp, + className, + expanded, + onToggle, + details, +}: { + icon: React.ReactNode; + tooltip: string; + children: React.ReactNode; + timestamp: number; + className?: string; + expanded?: boolean; + onToggle?: () => void; + details?: React.ReactNode; +}) { + return ( +
+
+ + +
{icon}
+
+ {tooltip} +
+
+ {children} +
+ + {formatTimestamp(timestamp / 1000, "relative")} + + {onToggle && ( +
+ {expanded ? ( + + ) : ( + + )} +
+ )} +
+ {expanded && details && ( +
+ {details} +
+ )} +
+ ); +} + // ============================================================================ // Entry Renderers // ============================================================================ -interface EntryProps { - entry: LogEntry; - onRetry?: (entryId: string) => void; +function PublishRelayRow({ + relay, + status, + onRetry, +}: { + relay: string; + status: { status: string; error?: string }; + onRetry?: () => void; +}) { + return ( +
+
+ {status.status === "success" && ( + + )} + {status.status === "error" && ( + + )} + {(status.status === "pending" || status.status === "publishing") && ( + + )} + + {status.status === "error" && onRetry && ( + + )} +
+ {status.error && ( +
+ {status.error} +
+ )} +
+ ); } function PublishEntry({ entry, onRetry, -}: EntryProps & { entry: PublishLogEntry }) { + onRetryRelay, +}: { + entry: PublishLogEntry; + onRetry?: (entryId: string) => void; + onRetryRelay?: (entryId: string, relay: string) => void; +}) { const [expanded, setExpanded] = useState(false); - const successCount = Array.from(entry.relayStatus.values()).filter( - (s) => s.status === "success", - ).length; - const errorCount = Array.from(entry.relayStatus.values()).filter( - (s) => s.status === "error", - ).length; - const pendingCount = Array.from(entry.relayStatus.values()).filter( + const statuses = Array.from(entry.relayStatus.values()); + const successCount = statuses.filter((s) => s.status === "success").length; + const errorCount = statuses.filter((s) => s.status === "error").length; + const isPending = statuses.some( (s) => s.status === "pending" || s.status === "publishing", - ).length; - - const hasFailures = errorCount > 0; - const isPending = pendingCount > 0; - - // Truncate event content for preview - const contentPreview = - entry.event.content.length > 60 - ? entry.event.content.slice(0, 60) + "..." - : entry.event.content; + ); return ( -
- - - {expanded && ( -
- {/* Relay status list */} + } + tooltip="Publish" + timestamp={entry.timestamp} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + details={ + <>
{Array.from(entry.relayStatus.entries()).map(([relay, status]) => ( -
- {status.status === "success" && ( - - )} - {status.status === "error" && ( - - )} - {(status.status === "pending" || - status.status === "publishing") && ( - - )} - - {status.error && ( - - {status.error} - - )} -
+ onRetryRelay(entry.id, relay) : undefined + } + /> ))}
- - {/* Retry button for failed relays */} - {hasFailures && onRetry && ( - - )} - - {/* Event ID */} -
- {entry.event.id.slice(0, 16)}... +
+ + +
-
+ {errorCount > 0 && onRetry && ( +
+ +
+ )} + + } + > + + {isPending && ( + )} -
+ {!isPending && successCount > 0 && ( + {successCount} ok + )} + {!isPending && errorCount > 0 && ( + {errorCount} fail + )} + ); } -function ConnectEntry({ entry }: EntryProps & { entry: ConnectLogEntry }) { +function ConnectEntry({ entry }: { entry: ConnectLogEntry }) { const isConnect = entry.type === "CONNECT"; return ( -
- {isConnect ? ( - - ) : ( - - )} -
-
- - {formatTimestamp(entry.timestamp / 1000, "time")} - - - {entry.type} - -
- -
-
+ + ) : ( + + ) + } + tooltip={isConnect ? "Connected" : "Disconnected"} + timestamp={entry.timestamp} + > + + ); } -function AuthEntry({ entry }: EntryProps & { entry: AuthLogEntry }) { - const statusColors = { - challenge: "text-yellow-500", - success: "text-green-500", - failed: "text-red-500", - rejected: "text-muted-foreground", +function AuthEntry({ entry }: { entry: AuthLogEntry }) { + const [expanded, setExpanded] = useState(false); + + const statusTooltip: Record = { + challenge: "Auth challenge", + success: "Auth success", + failed: "Auth failed", + rejected: "Auth rejected", }; return ( -
- {entry.status === "success" ? ( - - ) : ( - - )} -
-
- - {formatTimestamp(entry.timestamp / 1000, "time")} - - AUTH - - {entry.status} - + + ) : entry.status === "failed" ? ( + + ) : entry.status === "challenge" ? ( + + ) : ( + + ) + } + tooltip={statusTooltip[entry.status] ?? "Auth"} + timestamp={entry.timestamp} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + details={ +
+
+ Status: + + {entry.status} + +
+ {entry.challenge && ( +
+ challenge: {entry.challenge} +
+ )}
- -
-
+ } + > + + ); } -function NoticeEntry({ entry }: EntryProps & { entry: NoticeLogEntry }) { +function NoticeEntry({ entry }: { entry: NoticeLogEntry }) { + const [expanded, setExpanded] = useState(false); + return ( -
- -
-
- - {formatTimestamp(entry.timestamp / 1000, "time")} - - NOTICE -
- -
- {entry.message} -
-
-
+ } + tooltip="Notice" + timestamp={entry.timestamp} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + details={ +
{entry.message}
+ } + > + +
); } -function LogEntryRenderer({ entry, onRetry }: EntryProps) { +function LogEntryRenderer({ + entry, + onRetry, + onRetryRelay, +}: { + entry: LogEntry; + onRetry?: (entryId: string) => void; + onRetryRelay?: (entryId: string, relay: string) => void; +}) { switch (entry.type) { case "PUBLISH": - return ; + return ( + + ); case "CONNECT": case "DISCONNECT": return ; @@ -303,29 +391,28 @@ function LogEntryRenderer({ entry, onRetry }: EntryProps) { export function EventLogViewer() { const [activeTab, setActiveTab] = useState("all"); - const [autoScroll, setAutoScroll] = useState(true); - const scrollRef = useRef(null); - const filterTypes = activeTab === "all" ? undefined : [activeTab]; - const { entries, clear, retryFailedRelays, totalCount } = useEventLog({ + const filterTypes = useMemo(() => TAB_TYPE_MAP[activeTab], [activeTab]); + const { + entries, + clear, + retryFailedRelays, + retryRelay, + totalCount, + typeCounts, + } = useEventLog({ types: filterTypes, }); - // Auto-scroll to top when new entries arrive - useEffect(() => { - if (autoScroll && scrollRef.current) { - scrollRef.current.scrollTop = 0; - } - }, [entries.length, autoScroll]); - - // Pause auto-scroll when user scrolls down - const handleScroll = useCallback(() => { - if (scrollRef.current) { - const { scrollTop } = scrollRef.current; - // If user scrolls down more than 50px, pause auto-scroll - setAutoScroll(scrollTop < 50); - } - }, []); + /** Get count for a tab filter */ + const getTabCount = useCallback( + (tab: TabFilter): number => { + const types = TAB_TYPE_MAP[tab]; + if (!types) return totalCount; + return types.reduce((sum, t) => sum + (typeCounts[t] || 0), 0); + }, + [totalCount, typeCounts], + ); const handleRetry = useCallback( async (entryId: string) => { @@ -334,91 +421,67 @@ export function EventLogViewer() { [retryFailedRelays], ); + const handleRetryRelay = useCallback( + async (entryId: string, relay: string) => { + await retryRelay(entryId, relay); + }, + [retryRelay], + ); + return (
{/* Header */} -
+
setActiveTab(v as TabFilter)} > - + {TAB_FILTERS.map((tab) => ( {tab.label} + {getTabCount(tab.value) > 0 && ( + + {getTabCount(tab.value)} + + )} ))} -
- - {entries.length} - {totalCount !== entries.length && ` / ${totalCount}`} entries - - -
+
{/* Log entries */} -
+
{entries.length === 0 ? (
-
- -

No events logged yet

-

- Events will appear here as you interact with relays -

-
+

No events logged yet

) : ( -
- {entries.map((entry) => ( - - ))} -
+ entries.map((entry) => ( + + )) )}
- - {/* Auto-scroll indicator */} - {!autoScroll && entries.length > 0 && ( -
- -
- )}
); } diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index bfa4edf..e8b7043 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -415,9 +415,10 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { setLastPublishedEvent(event); // Use PublishService with status updates - const { updates$, result } = publishService.publishWithUpdates(event, { - relays: selected, - }); + const { updates$, result } = publishService.publishWithUpdates( + event, + selected, + ); // Subscribe to per-relay status updates for UI const subscription = updates$.subscribe((update) => { diff --git a/src/hooks/useEventLog.ts b/src/hooks/useEventLog.ts index 6d7c340..8e63356 100644 --- a/src/hooks/useEventLog.ts +++ b/src/hooks/useEventLog.ts @@ -29,8 +29,12 @@ export interface UseEventLogResult { clear: () => void; /** Retry failed relays for a publish entry */ retryFailedRelays: (entryId: string) => Promise; + /** Retry a single relay for a publish entry */ + retryRelay: (entryId: string, relay: string) => Promise; /** Total count of all entries (before filtering) */ totalCount: number; + /** Per-type counts (before filtering) */ + typeCounts: Record; } /** @@ -108,12 +112,29 @@ export function useEventLog( await eventLog.retryFailedRelays(entryId); }, []); + // Retry a single relay + const retryRelay = useCallback(async (entryId: string, relay: string) => { + await eventLog.retryRelay(entryId, relay); + }, []); + + // Per-type counts from unfiltered entries + const typeCounts = useMemo( + () => + entries.reduce>( + (acc, e) => ({ ...acc, [e.type]: (acc[e.type] || 0) + 1 }), + {}, + ), + [entries], + ); + return { entries: filteredEntries, publishEntries, clear, retryFailedRelays, + retryRelay, totalCount: entries.length, + typeCounts, }; } diff --git a/src/index.css b/src/index.css index 62a9fc7..ed0e975 100644 --- a/src/index.css +++ b/src/index.css @@ -203,7 +203,7 @@ --muted-foreground: 215 20.2% 70%; --accent: 270 100% 70%; --accent-foreground: 222.2 84% 4.9%; - --destructive: 0 75% 75%; + --destructive: 0 72% 63%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; diff --git a/src/lib/themes/builtin/dark.ts b/src/lib/themes/builtin/dark.ts index 0950733..2c09c2e 100644 --- a/src/lib/themes/builtin/dark.ts +++ b/src/lib/themes/builtin/dark.ts @@ -31,17 +31,17 @@ export const darkTheme: Theme = { muted: "217.2 32.6% 17.5%", mutedForeground: "215 20.2% 70%", - destructive: "0 62.8% 30.6%", - destructiveForeground: "210 40% 98%", + destructive: "0 72% 63%", + destructiveForeground: "0 0% 100%", border: "217.2 32.6% 17.5%", input: "217.2 32.6% 17.5%", ring: "212.7 26.8% 83.9%", // Status colors - success: "142 76% 36%", - warning: "45 93% 47%", - info: "199 89% 48%", + success: "142 76% 46%", + warning: "38 92% 60%", + info: "199 89% 58%", // Nostr-specific colors zap: "45 93% 58%", // Gold/yellow for zaps diff --git a/src/services/event-log.ts b/src/services/event-log.ts index 360ee2a..d699516 100644 --- a/src/services/event-log.ts +++ b/src/services/event-log.ts @@ -497,6 +497,22 @@ class EventLogService { entry.publishId, ); } + + /** + * Retry a single relay for a publish entry + */ + async retryRelay(entryId: string, relay: string): Promise { + const entry = this.entries.find( + (e) => e.id === entryId && e.type === "PUBLISH", + ) as PublishLogEntry | undefined; + + if (!entry) return; + + const status = entry.relayStatus.get(relay); + if (!status || status.status !== "error") return; + + await publishService.retryRelays(entry.event, [relay], entry.publishId); + } } // ============================================================================ diff --git a/src/services/hub.ts b/src/services/hub.ts index ea9ce4e..3f7a46f 100644 --- a/src/services/hub.ts +++ b/src/services/hub.ts @@ -2,13 +2,28 @@ import { ActionRunner } from "applesauce-actions"; import eventStore from "./event-store"; import { EventFactory } from "applesauce-core/event-factory"; import type { NostrEvent } from "nostr-tools/core"; +import { getSeenRelays } from "applesauce-core/helpers/relays"; +import { getDefaultStore } from "jotai"; import accountManager from "./accounts"; import publishService from "./publish-service"; +import { selectRelaysForPublish } from "./relay-selection"; +import { grimoireStateAtom } from "@/core/state"; /** - * Publishes a Nostr event to relays using the centralized PublishService + * Get the active user's configured write relays from Grimoire state + */ +function getStateWriteRelays(): string[] { + const store = getDefaultStore(); + const state = store.get(grimoireStateAtom); + return ( + state.activeAccount?.relays?.filter((r) => r.write).map((r) => r.url) || [] + ); +} + +/** + * Publishes a Nostr event to relays using the outbox model * - * Relay selection strategy (in priority order): + * Relay selection via selectRelaysForPublish(): * 1. Author's outbox relays (kind 10002) * 2. User's configured write relays (from Grimoire state) * 3. Seen relays from the event @@ -17,7 +32,13 @@ import publishService from "./publish-service"; * @param event - The signed Nostr event to publish */ export async function publishEvent(event: NostrEvent): Promise { - const result = await publishService.publish(event); + const seenRelays = getSeenRelays(event); + const relays = await selectRelaysForPublish(event.pubkey, { + writeRelays: getStateWriteRelays(), + relayHints: seenRelays ? Array.from(seenRelays) : [], + }); + + const result = await publishService.publish(event, relays); if (!result.ok) { const errors = result.failed @@ -36,7 +57,7 @@ const factory = new EventFactory(); * Configured with: * - EventStore: Single source of truth for Nostr events * - EventFactory: Creates and signs events - * - publishEvent: Publishes events via centralized PublishService + * - publishEvent: Publishes events via outbox relay selection + PublishService */ export const hub = new ActionRunner(eventStore, factory, publishEvent); @@ -60,7 +81,7 @@ export async function publishEventToRelays( throw new Error("No relays provided for publishing."); } - const result = await publishService.publishToRelays(event, relays); + const result = await publishService.publish(event, relays); if (!result.ok) { const errors = result.failed diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index 30b7316..064c3e4 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -2,24 +2,22 @@ * Centralized Publish Service * * Provides a unified API for publishing Nostr events with: - * - Smart relay selection (outbox + state write relays + hints + fallbacks) * - Per-relay status tracking via RxJS observables * - EventStore integration * - Logging/observability hooks for EventLogService * + * Relay selection is NOT handled here — callers must provide + * an explicit relay list. Use selectRelaysForPublish() or + * selectRelaysForInteraction() from relay-selection.ts. + * * All publishing in Grimoire should go through this service. */ import { Subject, Observable } from "rxjs"; import { filter } from "rxjs/operators"; import type { NostrEvent } from "nostr-tools"; -import { mergeRelaySets, getSeenRelays } from "applesauce-core/helpers"; import pool from "./relay-pool"; import eventStore from "./event-store"; -import { relayListCache } from "./relay-list-cache"; -import { AGGREGATOR_RELAYS } from "./loaders"; -import { grimoireStateAtom } from "@/core/state"; -import { getDefaultStore } from "jotai"; // ============================================================================ // Types @@ -74,26 +72,12 @@ export interface PublishResult { /** Options for publish operations */ export interface PublishOptions { - /** Explicit relays to publish to (overrides automatic selection) */ - relays?: string[]; - /** Additional relay hints to include */ - relayHints?: string[]; /** Skip adding to EventStore after publish */ skipEventStore?: boolean; /** Custom publish ID (for retry operations) */ publishId?: string; } -/** Options for relay selection */ -export interface RelaySelectionOptions { - /** Author pubkey for outbox relay lookup */ - authorPubkey?: string; - /** Additional relay hints */ - relayHints?: string[]; - /** Include aggregator relays as fallback */ - includeAggregators?: boolean; -} - // ============================================================================ // PublishService Class // ============================================================================ @@ -137,88 +121,6 @@ class PublishService { return this.status$.pipe(filter((update) => update.relay === relay)); } - // -------------------------------------------------------------------------- - // Relay Selection - // -------------------------------------------------------------------------- - - /** - * Select relays for publishing an event - * - * Priority order: - * 1. Author's outbox relays (kind 10002) - * 2. User's configured write relays (from Grimoire state) - * 3. Relay hints (seen relays, explicit hints) - * 4. Aggregator relays (fallback) - */ - async selectRelays(options: RelaySelectionOptions = {}): Promise { - const { - authorPubkey, - relayHints = [], - includeAggregators = true, - } = options; - - const relaySets: string[][] = []; - - // 1. Author's outbox relays from kind 10002 - if (authorPubkey) { - const outboxRelays = await relayListCache.getOutboxRelays(authorPubkey); - if (outboxRelays && outboxRelays.length > 0) { - relaySets.push(outboxRelays); - } - } - - // 2. User's configured write relays from Grimoire state - const store = getDefaultStore(); - const state = store.get(grimoireStateAtom); - const stateWriteRelays = - state.activeAccount?.relays?.filter((r) => r.write).map((r) => r.url) || - []; - if (stateWriteRelays.length > 0) { - relaySets.push(stateWriteRelays); - } - - // 3. Relay hints - if (relayHints.length > 0) { - relaySets.push(relayHints); - } - - // 4. Aggregator relays as fallback - if (includeAggregators) { - relaySets.push(AGGREGATOR_RELAYS); - } - - // Merge and deduplicate - const merged = mergeRelaySets(...relaySets); - - // If still empty, return aggregators as last resort - if (merged.length === 0) { - return AGGREGATOR_RELAYS; - } - - return merged; - } - - /** - * Select relays for an event using its metadata - */ - async selectRelaysForEvent( - event: NostrEvent, - additionalHints: string[] = [], - ): Promise { - // Get seen relays from the event - const seenRelays = getSeenRelays(event); - const hints = [ - ...additionalHints, - ...(seenRelays ? Array.from(seenRelays) : []), - ]; - - return this.selectRelays({ - authorPubkey: event.pubkey, - relayHints: hints, - includeAggregators: true, - }); - } - // -------------------------------------------------------------------------- // Publish Methods // -------------------------------------------------------------------------- @@ -231,28 +133,22 @@ class PublishService { } /** - * Publish an event and return a Promise with the result + * Publish an event to the given relays * - * This is the main publish method - use this for simple fire-and-forget publishing. + * Callers must provide an explicit relay list — use selectRelaysForPublish() + * or selectRelaysForInteraction() from relay-selection.ts to build it. */ async publish( event: NostrEvent, + relays: string[], options: PublishOptions = {}, ): Promise { const publishId = options.publishId || this.generatePublishId(); const startedAt = Date.now(); - // Determine target relays - let relays: string[]; - if (options.relays && options.relays.length > 0) { - relays = options.relays; - } else { - relays = await this.selectRelaysForEvent(event, options.relayHints); - } - if (relays.length === 0) { throw new Error( - "No relays available for publishing. Please configure relay list or provide relay hints.", + "No relays provided for publishing. Use selectRelaysForPublish() to select relays.", ); } @@ -340,19 +236,6 @@ class PublishService { return result; } - /** - * Publish to specific relays (explicit relay list) - * - * Use this when you know exactly which relays to publish to. - */ - async publishToRelays( - event: NostrEvent, - relays: string[], - options: Omit = {}, - ): Promise { - return this.publish(event, { ...options, relays }); - } - /** * Retry publishing to specific relays * @@ -363,8 +246,7 @@ class PublishService { relays: string[], originalPublishId?: string, ): Promise { - return this.publish(event, { - relays, + return this.publish(event, relays, { publishId: originalPublishId ? `${originalPublishId}_retry` : undefined, skipEventStore: true, // Event should already be in store from original publish }); @@ -382,6 +264,7 @@ class PublishService { */ publishWithUpdates( event: NostrEvent, + relays: string[], options: PublishOptions = {}, ): { publishId: string; @@ -394,7 +277,7 @@ class PublishService { const updates$ = this.getStatusUpdates(publishId); // Start the publish (returns promise) - const result = this.publish(event, { ...options, publishId }); + const result = this.publish(event, relays, { ...options, publishId }); return { publishId, updates$, result }; } diff --git a/src/services/relay-selection.ts b/src/services/relay-selection.ts index 2cc1c90..54bb1fb 100644 --- a/src/services/relay-selection.ts +++ b/src/services/relay-selection.ts @@ -552,6 +552,49 @@ export async function selectRelaysForFilter( }; } +/** + * Selects relays for publishing an event using the outbox model + * + * Strategy (in priority order): + * 1. Author's outbox relays (kind 10002) + * 2. Caller-provided write relays (e.g. from Grimoire state) + * 3. Additional relay hints (seen relays, explicit hints) + * 4. Aggregator relays (fallback) + * + * @param authorPubkey - Pubkey of the event author + * @param options - Write relays and hints to merge + * @returns Promise resolving to deduplicated array of relay URLs + */ +export async function selectRelaysForPublish( + authorPubkey: string, + options: { writeRelays?: string[]; relayHints?: string[] } = {}, +): Promise { + const { writeRelays = [], relayHints = [] } = options; + + const relaySets: string[][] = []; + + // 1. Author's outbox relays from kind 10002 + const outboxRelays = await relayListCache.getOutboxRelays(authorPubkey); + if (outboxRelays && outboxRelays.length > 0) { + relaySets.push(outboxRelays); + } + + // 2. Caller-provided write relays + if (writeRelays.length > 0) { + relaySets.push(writeRelays); + } + + // 3. Relay hints + if (relayHints.length > 0) { + relaySets.push(relayHints); + } + + // 4. Aggregator relays as fallback + relaySets.push(AGGREGATOR_RELAYS); + + return mergeRelaySets(...relaySets); +} + /** Maximum number of relays for interactions */ const MAX_INTERACTION_RELAYS = 10;