diff --git a/CLAUDE.md b/CLAUDE.md index bffbed0c..0713d376 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -346,6 +346,7 @@ This allows `applyTheme()` to switch themes at runtime. - **`Label`** (`src/components/ui/label.tsx`): Dotted-border tag/badge for metadata labels (language, kind, status, metric type). Two sizes: `sm` (default) and `md`. Use instead of ad-hoc `` tags for tag-like indicators. - **`RichText`** (`src/components/nostr/RichText.tsx`): Universal Nostr content renderer. Parses mentions, hashtags, custom emoji, media embeds, and nostr: references. Use for any event body text — never render `event.content` as a raw string. - **`CustomEmoji`** (`src/components/nostr/CustomEmoji.tsx`): Renders NIP-30 custom emoji images inline. Shows shortcode tooltip, handles load errors gracefully. + - **`Timestamp`** (`src/components/Timestamp.tsx`): Locale-aware short time display (e.g., "2:30 PM" or "14:30"). Takes a Unix timestamp in seconds. Use for inline time rendering in chat messages, lists, and log entries. For other formats (relative, date, datetime), use `formatTimestamp()` from `src/hooks/useLocale.ts`. ## Important Patterns diff --git a/src/actions/delete-event.ts b/src/actions/delete-event.ts index 6bf127c1..0a1ff074 100644 --- a/src/actions/delete-event.ts +++ b/src/actions/delete-event.ts @@ -1,11 +1,7 @@ import accountManager from "@/services/accounts"; -import pool from "@/services/relay-pool"; +import publishService from "@/services/publish-service"; +import { selectRelaysForPublish } from "@/services/relay-selection"; import { EventFactory } from "applesauce-core/event-factory"; -import { relayListCache } from "@/services/relay-list-cache"; -import { AGGREGATOR_RELAYS } from "@/services/loaders"; -import { mergeRelaySets } from "applesauce-core/helpers"; -import { grimoireStateAtom } from "@/core/state"; -import { getDefaultStore } from "jotai"; import { NostrEvent } from "@/types/nostr"; import { settingsManager } from "@/services/settings"; import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; @@ -37,24 +33,15 @@ export class DeleteEventAction { const event = await factory.sign(draft); - // Get write relays from cache and state - const authorWriteRelays = - (await relayListCache.getOutboxRelays(account.pubkey)) || []; - - const store = getDefaultStore(); - const state = store.get(grimoireStateAtom); - const stateWriteRelays = - state.activeAccount?.relays?.filter((r) => r.write).map((r) => r.url) || - []; - - // Combine all relay sources - const writeRelays = mergeRelaySets( - authorWriteRelays, - stateWriteRelays, - AGGREGATOR_RELAYS, - ); - - // Publish to all target relays - await pool.publish(writeRelays, 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 + .map((f) => `${f.relay}: ${f.error}`) + .join(", "); + throw new Error(`Failed to publish deletion event. Errors: ${errors}`); + } } } diff --git a/src/actions/publish-spell.test.ts b/src/actions/publish-spell.test.ts index 078f3ce8..6bc5892a 100644 --- a/src/actions/publish-spell.test.ts +++ b/src/actions/publish-spell.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { PublishSpellAction } from "./publish-spell"; import accountManager from "@/services/accounts"; -import pool from "@/services/relay-pool"; +import publishService from "@/services/publish-service"; import * as spellStorage from "@/services/spell-storage"; import { LocalSpell } from "@/services/db"; @@ -15,9 +15,15 @@ vi.mock("@/services/accounts", () => ({ }, })); -vi.mock("@/services/relay-pool", () => ({ +vi.mock("@/services/publish-service", () => ({ default: { - publish: vi.fn(), + publish: vi.fn().mockResolvedValue({ + publishId: "pub_1", + event: {}, + successful: ["wss://test.relay/"], + failed: [], + ok: true, + }), }, })); @@ -25,10 +31,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", () => ({ @@ -89,18 +93,18 @@ describe("PublishSpellAction", () => { await action.execute(spell); - // Check if signer was called expect(mockSigner.signEvent).toHaveBeenCalled(); - // Check if published to pool - expect(pool.publish).toHaveBeenCalled(); + // Verify publishService was called (not pool.publish) + expect(publishService.publish).toHaveBeenCalledWith( + expect.objectContaining({ kind: 777 }), + ["wss://test.relay/"], + ); - // Check if storage updated expect(spellStorage.markSpellPublished).toHaveBeenCalledWith( "local-id", expect.objectContaining({ kind: 777, - // We expect tags to contain name and alt (description) tags: expect.arrayContaining([ ["name", "My Spell"], ["alt", expect.stringContaining("Description")], diff --git a/src/actions/publish-spell.ts b/src/actions/publish-spell.ts index be1255ab..01570846 100644 --- a/src/actions/publish-spell.ts +++ b/src/actions/publish-spell.ts @@ -1,14 +1,11 @@ import { LocalSpell } from "@/services/db"; import accountManager from "@/services/accounts"; -import pool from "@/services/relay-pool"; +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"; import { SpellEvent } from "@/types/spell"; -import { relayListCache } from "@/services/relay-list-cache"; -import { AGGREGATOR_RELAYS } from "@/services/loaders"; -import { mergeRelaySets } from "applesauce-core/helpers"; -import eventStore from "@/services/event-store"; import { settingsManager } from "@/services/settings"; import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; @@ -25,7 +22,6 @@ export class PublishSpellAction { if (spell.isPublished && spell.event) { // Use existing signed event for rebroadcasting - event = spell.event; } else { const signer = account.signer; @@ -34,9 +30,7 @@ export class PublishSpellAction { const encoded = encodeSpell({ command: spell.command, - name: spell.name, - description: spell.description, }); @@ -50,38 +44,33 @@ export class PublishSpellAction { const draft = await factory.build({ kind: 777, - content: encoded.content, - tags, }); event = (await factory.sign(draft)) as SpellEvent; } - // Use provided relays or fallback to author's write relays + aggregators - - let relays = targetRelays; - - if (!relays || relays.length === 0) { - const authorWriteRelays = - (await relayListCache.getOutboxRelays(account.pubkey)) || []; - - relays = mergeRelaySets( - event.tags.find((t) => t[0] === "relays")?.slice(1) || [], - - authorWriteRelays, - - AGGREGATOR_RELAYS, - ); + // Determine relays: explicit target relays or outbox selection with hints + let relays: string[]; + if (targetRelays && targetRelays.length > 0) { + relays = targetRelays; + } else { + const eventRelayHints = + event.tags.find((t) => t[0] === "relays")?.slice(1) || []; + relays = await selectRelaysForPublish(account.pubkey, { + relayHints: eventRelayHints, + }); } - // Publish to all target relays - - await pool.publish(relays, event); + const result = await publishService.publish(event, relays); - // Add to event store for immediate availability - eventStore.add(event); + if (!result.ok) { + const errors = result.failed + .map((f) => `${f.relay}: ${f.error}`) + .join(", "); + throw new Error(`Failed to publish spell. Errors: ${errors}`); + } await markSpellPublished(spell.id, event); } diff --git a/src/components/EventLogViewer.tsx b/src/components/EventLogViewer.tsx new file mode 100644 index 00000000..b81929b1 --- /dev/null +++ b/src/components/EventLogViewer.tsx @@ -0,0 +1,577 @@ +/** + * Event Log Viewer + * + * Compact log of relay operations for debugging and introspection. + */ + +import { useState, useMemo, useCallback } from "react"; +import { + Check, + X, + Loader2, + Wifi, + WifiOff, + Shield, + ShieldAlert, + MessageSquare, + Send, + RotateCcw, + Trash2, + ChevronDown, + ChevronRight, + AlertTriangle, +} from "lucide-react"; +import { Virtuoso } from "react-virtuoso"; +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 { + type LogEntry, + type EventLogType, + type PublishLogEntry, + type ConnectLogEntry, + type ErrorLogEntry, + type AuthLogEntry, + type NoticeLogEntry, + type RelayStatusEntry, +} 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 Filters +// ============================================================================ + +type TabFilter = "all" | "publish" | "connect" | "auth" | "notice"; + +const TAB_TYPE_MAP: Record = { + all: undefined, + publish: ["PUBLISH"], + connect: ["CONNECT", "DISCONNECT", "ERROR"], + 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" }, +]; + +// ============================================================================ +// Constants +// ============================================================================ + +const AUTH_STATUS_TOOLTIP: Record = { + challenge: "Auth challenge", + success: "Auth success", + failed: "Auth failed", + rejected: "Auth rejected", +}; + +// ============================================================================ +// Helpers +// ============================================================================ + +/** Format relay response time relative to publish start */ +function formatRelayTime( + publishTimestamp: number, + relayUpdatedAt: number, +): string { + const ms = relayUpdatedAt - publishTimestamp; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +// ============================================================================ +// 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 +// ============================================================================ + +function PublishRelayRow({ + relay, + status, + publishTimestamp, + onRetry, +}: { + relay: string; + status: RelayStatusEntry; + publishTimestamp: number; + onRetry?: () => void; +}) { + const isTerminal = status.status === "success" || status.status === "error"; + + return ( +
+
+ {status.status === "success" && ( + + )} + {status.status === "error" && ( + + )} + {(status.status === "pending" || status.status === "publishing") && ( + + )} + + {isTerminal && ( + + {formatRelayTime(publishTimestamp, status.updatedAt)} + + )} + {status.status === "error" && onRetry && ( + + )} +
+ {status.error && ( +
+ {status.error} +
+ )} +
+ ); +} + +function PublishEntry({ + entry, + onRetry, + onRetryRelay, +}: { + entry: PublishLogEntry; + onRetry?: (entryId: string) => void; + onRetryRelay?: (entryId: string, relay: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + + 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", + ); + + return ( + } + tooltip="Publish" + timestamp={entry.timestamp} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + details={ + <> +
+ {Array.from(entry.relayStatus.entries()).map(([relay, status]) => ( + onRetryRelay(entry.id, relay) : undefined + } + /> + ))} +
+
+ + + +
+ {errorCount > 0 && onRetry && ( +
+ +
+ )} + + } + > + + {isPending && ( + + )} + {!isPending && successCount > 0 && ( + {successCount} ok + )} + {!isPending && errorCount > 0 && ( + {errorCount} fail + )} +
+ ); +} + +function ConnectEntry({ entry }: { entry: ConnectLogEntry }) { + const [expanded, setExpanded] = useState(false); + const isConnect = entry.type === "CONNECT"; + + return ( + + ) : ( + + ) + } + tooltip={isConnect ? "Connected" : "Disconnected"} + timestamp={entry.timestamp} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + details={ +
+
+ Event: + {isConnect ? "Connected" : "Disconnected"} +
+
+ Time: + + {formatTimestamp(entry.timestamp / 1000, "absolute")} + +
+
+ } + > + +
+ ); +} + +function ErrorEntry({ entry }: { entry: ErrorLogEntry }) { + const [expanded, setExpanded] = useState(false); + + return ( + } + tooltip="Connection error" + timestamp={entry.timestamp} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + details={ +
+
+ {entry.message} +
+
+ Time: + + {formatTimestamp(entry.timestamp / 1000, "absolute")} + +
+
+ } + > + +
+ ); +} + +function AuthEntry({ entry }: { entry: AuthLogEntry }) { + const [expanded, setExpanded] = useState(false); + + return ( + + ) : entry.status === "failed" ? ( + + ) : entry.status === "challenge" ? ( + + ) : ( + + ) + } + tooltip={AUTH_STATUS_TOOLTIP[entry.status] ?? "Auth"} + timestamp={entry.timestamp} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + details={ +
+
+ Status: + + {entry.status} + +
+ {entry.challenge && ( +
+ challenge: {entry.challenge} +
+ )} +
+ Time: + + {formatTimestamp(entry.timestamp / 1000, "absolute")} + +
+
+ } + > + +
+ ); +} + +function NoticeEntry({ entry }: { entry: NoticeLogEntry }) { + const [expanded, setExpanded] = useState(false); + + return ( + } + tooltip="Notice" + timestamp={entry.timestamp} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} + details={ +
+
+ {entry.message} +
+
+ Time: + + {formatTimestamp(entry.timestamp / 1000, "absolute")} + +
+
+ } + > + +
+ ); +} + +function LogEntryRenderer({ + entry, + onRetry, + onRetryRelay, +}: { + entry: LogEntry; + onRetry?: (entryId: string) => void; + onRetryRelay?: (entryId: string, relay: string) => void; +}) { + switch (entry.type) { + case "PUBLISH": + return ( + + ); + case "CONNECT": + case "DISCONNECT": + return ; + case "ERROR": + return ; + case "AUTH": + return ; + case "NOTICE": + return ; + default: + return null; + } +} + +// ============================================================================ +// Main Component +// ============================================================================ + +function getTabCount( + tab: TabFilter, + totalCount: number, + typeCounts: Record, +): number { + const types = TAB_TYPE_MAP[tab]; + if (!types) return totalCount; + return types.reduce((sum, t) => sum + (typeCounts[t] || 0), 0); +} + +export function EventLogViewer() { + const [activeTab, setActiveTab] = useState("all"); + + const filterTypes = useMemo(() => TAB_TYPE_MAP[activeTab], [activeTab]); + const { + entries, + clear, + retryFailedRelays, + retryRelay, + totalCount, + typeCounts, + } = useEventLog({ types: filterTypes }); + + const renderItem = useCallback( + (_index: number, entry: LogEntry) => ( + + ), + [retryFailedRelays, retryRelay], + ); + + return ( +
+ {/* Header */} +
+ setActiveTab(v as TabFilter)} + > + + {TAB_FILTERS.map((tab) => { + const count = getTabCount(tab.value, totalCount, typeCounts); + return ( + + {tab.label} + {count > 0 && ( + + {count} + + )} + + ); + })} + + + + +
+ + {/* Log entries */} +
+ {entries.length === 0 ? ( +
+

No events logged yet

+
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index e06d2971..e8b7043a 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -37,7 +37,9 @@ import { import { RelayLink } from "./nostr/RelayLink"; import { Kind1Renderer } from "./nostr/kinds"; import pool from "@/services/relay-pool"; -import eventStore from "@/services/event-store"; +import publishService, { + type RelayPublishStatus, +} from "@/services/publish-service"; import { EventFactory } from "applesauce-core/event-factory"; import { NoteBlueprint } from "@/lib/blueprints"; import { useGrimoire } from "@/core/state"; @@ -47,12 +49,9 @@ import { use$ } from "applesauce-react/hooks"; import { getAuthIcon } from "@/lib/relay-status-utils"; import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; -// Per-relay publish status -type RelayStatus = "pending" | "publishing" | "success" | "error"; - interface RelayPublishState { url: string; - status: RelayStatus; + status: RelayPublishStatus; error?: string; } @@ -100,7 +99,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { setRelayStates( writeRelays.map((url) => ({ url, - status: "pending" as RelayStatus, + status: "pending" as RelayPublishStatus, })), ); setSelectedRelays(new Set(writeRelays)); @@ -157,7 +156,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { .filter((url: string) => !currentRelayUrls.has(url)) .map((url: string) => ({ url, - status: "pending" as RelayStatus, + status: "pending" as RelayPublishStatus, })); return newRelays.length > 0 ? [...prev, ...newRelays] : prev; }); @@ -275,39 +274,42 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { return; } - try { - // Update status to publishing - setRelayStates((prev) => - prev.map((r) => - r.url === relayUrl - ? { ...r, status: "publishing" as RelayStatus } - : r, - ), - ); - - // Republish the same signed event - await pool.publish([relayUrl], lastPublishedEvent); + // Update status to publishing + setRelayStates((prev) => + prev.map((r) => + r.url === relayUrl + ? { ...r, status: "publishing" as RelayPublishStatus } + : r, + ), + ); + + // Retry via PublishService (skipEventStore since it's already in store) + const result = await publishService.retryRelays(lastPublishedEvent, [ + relayUrl, + ]); - // Update status to success + if (result.ok) { setRelayStates((prev) => prev.map((r) => r.url === relayUrl - ? { ...r, status: "success" as RelayStatus, error: undefined } + ? { + ...r, + status: "success" as RelayPublishStatus, + error: undefined, + } : r, ), ); - toast.success(`Published to ${relayUrl.replace(/^wss?:\/\//, "")}`); - } catch (error) { - console.error(`Failed to retry publish to ${relayUrl}:`, error); + } else { + const error = result.failed[0]?.error || "Unknown error"; setRelayStates((prev) => prev.map((r) => r.url === relayUrl ? { ...r, - status: "error" as RelayStatus, - error: - error instanceof Error ? error.message : "Unknown error", + status: "error" as RelayPublishStatus, + error, } : r, ), @@ -409,67 +411,40 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { } // Signing succeeded, now publish to relays - try { - // Store the signed event for potential retries - setLastPublishedEvent(event); + // Store the signed event for potential retries + setLastPublishedEvent(event); + + // Use PublishService with status updates + const { updates$, result } = publishService.publishWithUpdates( + event, + selected, + ); - // Update relay states - set selected to publishing, keep others as pending + // Subscribe to per-relay status updates for UI + const subscription = updates$.subscribe((update) => { setRelayStates((prev) => prev.map((r) => - selected.includes(r.url) - ? { ...r, status: "publishing" as RelayStatus } + r.url === update.relay + ? { + ...r, + status: update.status, + error: update.error, + } : r, ), ); + }); - // Publish to each relay individually to track status - const publishPromises = selected.map(async (relayUrl) => { - try { - await pool.publish([relayUrl], event); - - // Update status to success - setRelayStates((prev) => - prev.map((r) => - r.url === relayUrl - ? { ...r, status: "success" as RelayStatus } - : r, - ), - ); - return { success: true, relayUrl }; - } catch (error) { - console.error(`Failed to publish to ${relayUrl}:`, error); - - // Update status to error - setRelayStates((prev) => - prev.map((r) => - r.url === relayUrl - ? { - ...r, - status: "error" as RelayStatus, - error: - error instanceof Error - ? error.message - : "Unknown error", - } - : r, - ), - ); - return { success: false, relayUrl }; - } - }); - - // Wait for all publishes to complete (settled = all finished, regardless of success/failure) - const results = await Promise.allSettled(publishPromises); + try { + // Wait for publish to complete + const publishResult = await result; - // Check how many relays succeeded - const successCount = results.filter( - (r) => r.status === "fulfilled" && r.value.success, - ).length; + // Unsubscribe from updates + subscription.unsubscribe(); - if (successCount > 0) { - // At least one relay succeeded - add to event store - eventStore.add(event); + const successCount = publishResult.successful.length; + if (publishResult.ok) { // Clear draft from localStorage if (pubkey) { const draftKey = windowId @@ -501,16 +476,17 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { ); } } catch (error) { + subscription.unsubscribe(); console.error("Failed to publish:", error); toast.error( error instanceof Error ? error.message : "Failed to publish note", ); - // Reset relay states to pending on publishing error + // Reset relay states to error on publishing error setRelayStates((prev) => prev.map((r) => ({ ...r, - status: "error" as RelayStatus, + status: "error" as RelayPublishStatus, error: error instanceof Error ? error.message : "Unknown error", })), ); @@ -518,7 +494,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { setIsPublishing(false); } }, - [canSign, signer, pubkey, selectedRelays, settings], + [canSign, signer, pubkey, selectedRelays, settings, windowId], ); // Handle file paste @@ -585,7 +561,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { // Add to relay states setRelayStates((prev) => [ ...prev, - { url: normalizedUrl, status: "pending" as RelayStatus }, + { url: normalizedUrl, status: "pending" as RelayPublishStatus }, ]); // Select the new relay diff --git a/src/components/Timestamp.tsx b/src/components/Timestamp.tsx index f2461bdc..0c99206c 100644 --- a/src/components/Timestamp.tsx +++ b/src/components/Timestamp.tsx @@ -1,11 +1,5 @@ -import { useMemo } from "react"; +import { formatTimestamp } from "@/hooks/useLocale"; export default function Timestamp({ timestamp }: { timestamp: number }) { - const formatted = useMemo(() => { - const intl = new Intl.DateTimeFormat("es", { - timeStyle: "short", - }); - return intl.format(timestamp * 1000); - }, [timestamp]); - return formatted; + return formatTimestamp(timestamp, "time"); } diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 496e5718..c1a1a6eb 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -53,6 +53,9 @@ const PostViewer = lazy(() => const SettingsViewer = lazy(() => import("./SettingsViewer").then((m) => ({ default: m.SettingsViewer })), ); +const EventLogViewer = lazy(() => + import("./EventLogViewer").then((m) => ({ default: m.EventLogViewer })), +); // Loading fallback component function ViewerLoading() { @@ -257,6 +260,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { case "settings": content = ; break; + case "log": + content = ; + break; default: content = (
diff --git a/src/components/chat/EmojiPickerDialog.tsx b/src/components/chat/EmojiPickerDialog.tsx index 830bcf70..4784b99d 100644 --- a/src/components/chat/EmojiPickerDialog.tsx +++ b/src/components/chat/EmojiPickerDialog.tsx @@ -66,6 +66,7 @@ export function EmojiPickerDialog({ const [searchResults, setSearchResults] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const virtuosoRef = useRef(null); + const searchInputRef = useRef(null); // Use the same emoji search hook as chat autocomplete const { service } = useEmojiSearch(); @@ -247,7 +248,13 @@ export function EmojiPickerDialog({ return ( - + { + e.preventDefault(); + searchInputRef.current?.focus(); + }} + > {/* Top emojis — recently used quick-picks. This section also provides natural spacing for the dialog close (X) button, which is absolutely positioned at top-right of the dialog. */} @@ -289,13 +296,13 @@ export function EmojiPickerDialog({
setSearchQuery(e.target.value)} onKeyDown={handleKeyDown} className="pl-9" - autoFocus />
diff --git a/src/hooks/useEventLog.ts b/src/hooks/useEventLog.ts new file mode 100644 index 00000000..a1f2be7d --- /dev/null +++ b/src/hooks/useEventLog.ts @@ -0,0 +1,110 @@ +/** + * React hook for accessing the Event Log + * + * Provides reactive access to relay operation logs with filtering capabilities. + */ + +import { useState, useEffect, useCallback, useMemo } from "react"; +import eventLog, { + type LogEntry, + type EventLogType, +} from "@/services/event-log"; + +export interface UseEventLogOptions { + /** Filter by event type(s) */ + types?: EventLogType[]; + /** Filter by relay URL */ + relay?: string; + /** Maximum entries to return */ + limit?: number; +} + +export interface UseEventLogResult { + /** Filtered log entries */ + entries: LogEntry[]; + /** Clear all log entries */ + 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; +} + +/** + * Hook to access and filter event log entries + * + * @example + * ```tsx + * const { entries } = useEventLog(); + * const { entries } = useEventLog({ types: ["PUBLISH", "CONNECT"] }); + * const { entries } = useEventLog({ relay: "wss://relay.example.com/" }); + * ``` + */ +export function useEventLog( + options: UseEventLogOptions = {}, +): UseEventLogResult { + const { types, relay, limit } = options; + + const [entries, setEntries] = useState(() => + eventLog.getEntries(), + ); + + // Subscribe to log updates + useEffect(() => { + const subscription = eventLog.entries$.subscribe(setEntries); + return () => subscription.unsubscribe(); + }, []); + + // Filter entries based on options + const filteredEntries = useMemo(() => { + let result = entries; + + if (types && types.length > 0) { + result = result.filter((e) => types.includes(e.type)); + } + + if (relay) { + result = result.filter((e) => e.relay === relay); + } + + if (limit && limit > 0) { + result = result.slice(0, limit); + } + + return result; + }, [entries, types, relay, limit]); + + const clear = useCallback(() => eventLog.clear(), []); + + const retryFailedRelays = useCallback( + (entryId: string) => eventLog.retryFailedRelays(entryId), + [], + ); + + const retryRelay = useCallback( + (entryId: string, relay: string) => eventLog.retryRelay(entryId, relay), + [], + ); + + // Per-type counts from unfiltered entries + const typeCounts = useMemo(() => { + const counts: Record = {}; + for (const e of entries) { + counts[e.type] = (counts[e.type] || 0) + 1; + } + return counts; + }, [entries]); + + return { + entries: filteredEntries, + clear, + retryFailedRelays, + retryRelay, + totalCount: entries.length, + typeCounts, + }; +} diff --git a/src/index.css b/src/index.css index 62a9fc7b..ed0e9757 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 09507335..d3ed7283 100644 --- a/src/lib/themes/builtin/dark.ts +++ b/src/lib/themes/builtin/dark.ts @@ -31,7 +31,7 @@ export const darkTheme: Theme = { muted: "217.2 32.6% 17.5%", mutedForeground: "215 20.2% 70%", - destructive: "0 62.8% 30.6%", + destructive: "0 72% 63%", destructiveForeground: "210 40% 98%", border: "217.2 32.6% 17.5%", @@ -39,9 +39,9 @@ export const darkTheme: Theme = { 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 new file mode 100644 index 00000000..3f980f77 --- /dev/null +++ b/src/services/event-log.ts @@ -0,0 +1,560 @@ +/** + * Event Log Service + * + * Provides an ephemeral log of relay operations for introspection: + * - PUBLISH events with per-relay status and timing + * - CONNECT/DISCONNECT events + * - ERROR events for connection failures + * - AUTH events + * - NOTICE events + * + * Uses RxJS for reactive updates and maintains a circular buffer + * of recent events (configurable max size). + */ + +import { BehaviorSubject, Subject, Subscription } from "rxjs"; +import { startWith, pairwise, filter } from "rxjs/operators"; +import type { NostrEvent } from "nostr-tools"; +import publishService, { + type PublishEvent, + type RelayStatusUpdate, +} from "./publish-service"; +import pool from "./relay-pool"; +import type { IRelay } from "applesauce-relay"; + +// ============================================================================ +// Types +// ============================================================================ + +/** Types of events tracked in the log */ +export type EventLogType = + | "PUBLISH" + | "CONNECT" + | "DISCONNECT" + | "ERROR" + | "AUTH" + | "NOTICE"; + +/** Per-relay status with timing */ +export interface RelayStatusEntry { + status: string; + error?: string; + /** Timestamp of the last status transition */ + updatedAt: number; +} + +/** Base interface for all log entries */ +interface BaseLogEntry { + /** Unique ID for this log entry */ + id: string; + /** Type of event */ + type: EventLogType; + /** Timestamp when event occurred */ + timestamp: number; + /** Relay URL (if applicable) */ + relay?: string; +} + +/** Publish event log entry */ +export interface PublishLogEntry extends BaseLogEntry { + type: "PUBLISH"; + /** The Nostr event being published */ + event: NostrEvent; + /** Target relays */ + relays: string[]; + /** Per-relay status with timing */ + relayStatus: Map; + /** Overall status: pending, partial, success, failed */ + status: "pending" | "partial" | "success" | "failed"; + /** Publish ID from PublishService */ + publishId: string; +} + +/** Connection event log entry */ +export interface ConnectLogEntry extends BaseLogEntry { + type: "CONNECT" | "DISCONNECT"; + relay: string; +} + +/** Connection error log entry */ +export interface ErrorLogEntry extends BaseLogEntry { + type: "ERROR"; + relay: string; + /** Error message */ + message: string; +} + +/** Auth event log entry */ +export interface AuthLogEntry extends BaseLogEntry { + type: "AUTH"; + relay: string; + /** Auth status: challenge, success, failed, rejected */ + status: "challenge" | "success" | "failed" | "rejected"; + /** Challenge string (for challenge events) */ + challenge?: string; +} + +/** Notice event log entry */ +export interface NoticeLogEntry extends BaseLogEntry { + type: "NOTICE"; + relay: string; + /** Notice message from relay */ + message: string; +} + +/** Union type for all log entries */ +export type LogEntry = + | PublishLogEntry + | ConnectLogEntry + | ErrorLogEntry + | AuthLogEntry + | NoticeLogEntry; + +/** Helper type for creating new entries (id/timestamp auto-generated) */ +type NewEntry = Omit & { + id?: string; + timestamp?: number; +}; + +type AddEntryInput = + | NewEntry + | NewEntry + | NewEntry + | NewEntry + | NewEntry; + +// ============================================================================ +// EventLogService Class +// ============================================================================ + +/** Interval for polling new relays (ms) */ +const RELAY_POLL_INTERVAL = 5000; + +class EventLogService { + /** Maximum number of entries to keep in the log */ + private maxEntries: number; + + /** Circular buffer of log entries */ + private entries: LogEntry[] = []; + + /** BehaviorSubject for reactive updates */ + private entriesSubject = new BehaviorSubject([]); + + /** Subject for new entry notifications */ + private newEntrySubject = new Subject(); + + /** Active subscriptions */ + private subscriptions: Subscription[] = []; + + /** Relay subscriptions for connection/auth/notice tracking */ + private relaySubscriptions = new Map(); + + /** Counter for generating unique IDs */ + private idCounter = 0; + + /** Map of publish IDs to log entry IDs */ + private publishIdToEntryId = new Map(); + + /** Track last seen notice per relay to prevent duplicates */ + private lastNoticePerRelay = new Map(); + + /** Polling interval for new relays */ + private pollingIntervalId?: NodeJS.Timeout; + + constructor(maxEntries = 500) { + this.maxEntries = maxEntries; + } + + // -------------------------------------------------------------------------- + // Public Observables + // -------------------------------------------------------------------------- + + /** Observable of all log entries (emits current state on subscribe) */ + readonly entries$ = this.entriesSubject.asObservable(); + + /** Observable of new entries as they arrive */ + readonly newEntry$ = this.newEntrySubject.asObservable(); + + // -------------------------------------------------------------------------- + // Initialization + // -------------------------------------------------------------------------- + + /** + * Initialize the event log service + * Subscribes to PublishService and relay pool events + */ + initialize(): void { + // Subscribe to publish events + this.subscriptions.push( + publishService.publish$.subscribe((event) => + this.handlePublishEvent(event), + ), + ); + + // Subscribe to per-relay status updates + this.subscriptions.push( + publishService.status$.subscribe((update) => + this.handleStatusUpdate(update), + ), + ); + + // Monitor existing relays + pool.relays.forEach((relay) => this.monitorRelay(relay)); + + // Poll for new relays (infrequent — new relays don't appear often) + this.pollingIntervalId = setInterval(() => { + pool.relays.forEach((relay) => { + if (!this.relaySubscriptions.has(relay.url)) { + this.monitorRelay(relay); + } + }); + }, RELAY_POLL_INTERVAL); + } + + /** + * Clean up subscriptions + */ + destroy(): void { + this.subscriptions.forEach((sub) => sub.unsubscribe()); + this.subscriptions = []; + + this.relaySubscriptions.forEach((sub) => sub.unsubscribe()); + this.relaySubscriptions.clear(); + + if (this.pollingIntervalId) { + clearInterval(this.pollingIntervalId); + this.pollingIntervalId = undefined; + } + } + + // -------------------------------------------------------------------------- + // Relay Monitoring + // -------------------------------------------------------------------------- + + /** + * Monitor a relay for connection, error, auth, and notice events + */ + private monitorRelay(relay: IRelay): void { + const url = relay.url; + + if (this.relaySubscriptions.has(url)) return; + + const subscription = new Subscription(); + + // Track connection state changes + subscription.add( + relay.connected$ + .pipe( + startWith(relay.connected), + pairwise(), + filter(([prev, curr]) => prev !== curr), + ) + .subscribe(([, connected]) => { + this.addEntry({ + type: connected ? "CONNECT" : "DISCONNECT", + relay: url, + }); + }), + ); + + // Track connection errors + subscription.add( + relay.error$ + .pipe(filter((error): error is Error => error !== null)) + .subscribe((error) => { + this.addEntry({ + type: "ERROR", + relay: url, + message: error.message || "Unknown connection error", + }); + }), + ); + + // Track authentication events + subscription.add( + relay.authenticated$ + .pipe( + startWith(relay.authenticated), + pairwise(), + filter(([prev, curr]) => prev !== curr && curr === true), + ) + .subscribe(() => { + this.addEntry({ + type: "AUTH", + relay: url, + status: "success", + }); + }), + ); + + // Track challenges + subscription.add( + relay.challenge$ + .pipe(filter((challenge): challenge is string => !!challenge)) + .subscribe((challenge) => { + this.addEntry({ + type: "AUTH", + relay: url, + status: "challenge", + challenge, + }); + }), + ); + + // Track notices — deduplicate per relay + subscription.add( + relay.notice$.subscribe((notice) => { + if ( + typeof notice === "string" && + notice && + notice !== this.lastNoticePerRelay.get(url) + ) { + this.lastNoticePerRelay.set(url, notice); + this.addEntry({ + type: "NOTICE", + relay: url, + message: notice, + }); + } + }), + ); + + this.relaySubscriptions.set(url, subscription); + } + + // -------------------------------------------------------------------------- + // Publish Event Handling + // -------------------------------------------------------------------------- + + /** + * Handle a publish event from PublishService + */ + private handlePublishEvent(event: PublishEvent): void { + // Check if we already have an entry for this publish (avoid duplicates) + const existingEntryId = this.publishIdToEntryId.get(event.id); + if (existingEntryId) { + // Update existing entry immutably + const entryIndex = this.entries.findIndex( + (e) => e.id === existingEntryId && e.type === "PUBLISH", + ); + if (entryIndex !== -1) { + const entry = this.entries[entryIndex] as PublishLogEntry; + const newRelayStatus = new Map(); + // Preserve timing from existing entries, add timing for new ones + for (const [relay, status] of event.results) { + const existing = entry.relayStatus.get(relay); + newRelayStatus.set(relay, { + ...status, + updatedAt: existing?.updatedAt ?? Date.now(), + }); + } + this.entries[entryIndex] = { + ...entry, + relayStatus: newRelayStatus, + status: this.calculatePublishStatus(newRelayStatus), + }; + this.entriesSubject.next([...this.entries]); + } + return; + } + + const entryId = this.generateId(); + const now = Date.now(); + + // Create initial publish entry with timing + const relayStatus = new Map(); + for (const [relay, status] of event.results) { + relayStatus.set(relay, { ...status, updatedAt: now }); + } + + const entry: PublishLogEntry = { + id: entryId, + type: "PUBLISH", + timestamp: event.startedAt, + event: event.event, + relays: event.relays, + relayStatus, + status: this.calculatePublishStatus(relayStatus), + publishId: event.id, + }; + + // Map publish ID to entry ID for status updates + this.publishIdToEntryId.set(event.id, entryId); + + this.addEntry(entry); + } + + /** + * Handle a per-relay status update from PublishService + */ + private handleStatusUpdate(update: RelayStatusUpdate): void { + const entryId = this.publishIdToEntryId.get(update.publishId); + if (!entryId) return; + + // Find the publish entry + const entryIndex = this.entries.findIndex( + (e) => e.id === entryId && e.type === "PUBLISH", + ); + if (entryIndex === -1) return; + + const entry = this.entries[entryIndex] as PublishLogEntry; + + // Update immutably with timing + const newRelayStatus = new Map(entry.relayStatus); + newRelayStatus.set(update.relay, { + status: update.status, + error: update.error, + updatedAt: update.timestamp, + }); + + const newStatus = this.calculatePublishStatus(newRelayStatus); + + this.entries[entryIndex] = { + ...entry, + relayStatus: newRelayStatus, + status: newStatus, + }; + + // Notify subscribers + this.entriesSubject.next([...this.entries]); + } + + /** + * Calculate overall publish status from relay results + */ + private calculatePublishStatus( + results: Map, + ): "pending" | "partial" | "success" | "failed" { + const statuses = Array.from(results.values()).map((r) => r.status); + + if (statuses.every((s) => s === "pending" || s === "publishing")) { + return "pending"; + } + + const successCount = statuses.filter((s) => s === "success").length; + const errorCount = statuses.filter((s) => s === "error").length; + + if (successCount === statuses.length) { + return "success"; + } else if (errorCount === statuses.length) { + return "failed"; + } else if (successCount > 0) { + return "partial"; + } + + return "pending"; + } + + // -------------------------------------------------------------------------- + // Entry Management + // -------------------------------------------------------------------------- + + /** + * Generate a unique ID for a log entry + */ + private generateId(): string { + return `log_${Date.now()}_${++this.idCounter}`; + } + + /** + * Add an entry to the log + * Accepts partial entries without id/timestamp (they will be generated) + */ + private addEntry(entry: AddEntryInput): void { + const fullEntry = { + id: entry.id || this.generateId(), + timestamp: entry.timestamp || Date.now(), + ...entry, + } as LogEntry; + + // Add to front (most recent first) + this.entries.unshift(fullEntry); + + // Trim to max size + if (this.entries.length > this.maxEntries) { + const removed = this.entries.splice(this.maxEntries); + // Clean up publish ID mappings for removed entries + removed.forEach((e) => { + if (e.type === "PUBLISH") { + this.publishIdToEntryId.delete((e as PublishLogEntry).publishId); + } + }); + } + + // Notify subscribers + this.entriesSubject.next([...this.entries]); + this.newEntrySubject.next(fullEntry); + } + + // -------------------------------------------------------------------------- + // Public Methods + // -------------------------------------------------------------------------- + + /** + * Get all log entries + */ + getEntries(): LogEntry[] { + return [...this.entries]; + } + + /** + * Clear all entries + */ + clear(): void { + this.entries = []; + this.publishIdToEntryId.clear(); + this.lastNoticePerRelay.clear(); + this.entriesSubject.next([]); + } + + /** + * Retry failed relays for a publish entry + */ + async retryFailedRelays(entryId: string): Promise { + const entry = this.entries.find( + (e) => e.id === entryId && e.type === "PUBLISH", + ) as PublishLogEntry | undefined; + + if (!entry) return; + + const failedRelays = Array.from(entry.relayStatus.entries()) + .filter(([, status]) => status.status === "error") + .map(([relay]) => relay); + + if (failedRelays.length === 0) return; + + // Retry via PublishService + await publishService.retryRelays( + entry.event, + failedRelays, + 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); + } +} + +// ============================================================================ +// Singleton Export +// ============================================================================ + +const eventLog = new EventLogService(); + +// Initialize on module load +eventLog.initialize(); + +export default eventLog; diff --git a/src/services/hub.ts b/src/services/hub.ts index cb887650..3f7a46f6 100644 --- a/src/services/hub.ts +++ b/src/services/hub.ts @@ -1,40 +1,51 @@ import { ActionRunner } from "applesauce-actions"; import eventStore from "./event-store"; import { EventFactory } from "applesauce-core/event-factory"; -import pool from "./relay-pool"; -import { relayListCache } from "./relay-list-cache"; -import { getSeenRelays } from "applesauce-core/helpers/relays"; 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"; + +/** + * 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 author's outbox relays - * Falls back to seen relays from the event if no relay list found + * Publishes a Nostr event to relays using the outbox model + * + * 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 + * 4. Aggregator relays (fallback) * * @param event - The signed Nostr event to publish */ export async function publishEvent(event: NostrEvent): Promise { - // Try to get author's outbox relays from EventStore (kind 10002) - let relays = await relayListCache.getOutboxRelays(event.pubkey); + const seenRelays = getSeenRelays(event); + const relays = await selectRelaysForPublish(event.pubkey, { + writeRelays: getStateWriteRelays(), + relayHints: seenRelays ? Array.from(seenRelays) : [], + }); - // Fallback to relays from the event itself (where it was seen) - if (!relays || relays.length === 0) { - const seenRelays = getSeenRelays(event); - relays = seenRelays ? Array.from(seenRelays) : []; - } + const result = await publishService.publish(event, relays); - // If still no relays, throw error - if (relays.length === 0) { - throw new Error( - "No relays found for publishing. Please configure relay list (kind 10002) or ensure event has relay hints.", - ); + if (!result.ok) { + const errors = result.failed + .map((f) => `${f.relay}: ${f.error}`) + .join(", "); + throw new Error(`Failed to publish to any relay. Errors: ${errors}`); } - - // Publish to relay pool - await pool.publish(relays, event); - - // Add to EventStore for immediate local availability - eventStore.add(event); } const factory = new EventFactory(); @@ -46,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 to author's outbox relays (with fallback to seen relays) + * - publishEvent: Publishes events via outbox relay selection + PublishService */ export const hub = new ActionRunner(eventStore, factory, publishEvent); @@ -56,20 +67,26 @@ accountManager.active$.subscribe((account) => { factory.setSigner(account?.signer || undefined); }); +/** + * Publishes a Nostr event to specific relays + * + * @param event - The signed Nostr event to publish + * @param relays - Explicit list of relay URLs to publish to + */ export async function publishEventToRelays( event: NostrEvent, relays: string[], ): Promise { - // If no relays, throw error if (relays.length === 0) { - throw new Error( - "No relays found for publishing. Please configure relay list (kind 10002) or ensure event has relay hints.", - ); + throw new Error("No relays provided for publishing."); } - // Publish to relay pool - await pool.publish(relays, event); + const result = await publishService.publish(event, relays); - // Add to EventStore for immediate local availability - eventStore.add(event); + if (!result.ok) { + const errors = result.failed + .map((f) => `${f.relay}: ${f.error}`) + .join(", "); + throw new Error(`Failed to publish to any relay. Errors: ${errors}`); + } } diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts new file mode 100644 index 00000000..364bdff9 --- /dev/null +++ b/src/services/publish-service.ts @@ -0,0 +1,306 @@ +/** + * Centralized Publish Service + * + * Provides a unified API for publishing Nostr events with: + * - 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 pool from "./relay-pool"; +import eventStore from "./event-store"; + +// ============================================================================ +// Types +// ============================================================================ + +/** Status of a publish attempt to a single relay */ +export type RelayPublishStatus = "pending" | "publishing" | "success" | "error"; + +/** Per-relay status update */ +export interface RelayStatusUpdate { + /** Unique ID for this publish operation */ + publishId: string; + /** Relay URL */ + relay: string; + /** Current status */ + status: RelayPublishStatus; + /** Error message if status is 'error' */ + error?: string; + /** Timestamp of this status update */ + timestamp: number; +} + +/** Overall publish operation event */ +export interface PublishEvent { + /** Unique ID for this publish operation */ + id: string; + /** The event being published */ + event: NostrEvent; + /** Target relays */ + relays: string[]; + /** Timestamp when publish started */ + startedAt: number; + /** Timestamp when publish completed (all relays resolved) */ + completedAt?: number; + /** Per-relay results */ + results: Map; +} + +/** Result returned from publish operations */ +export interface PublishResult { + /** Unique ID for this publish operation */ + publishId: string; + /** The published event */ + event: NostrEvent; + /** Relays that succeeded */ + successful: string[]; + /** Relays that failed with their errors */ + failed: Array<{ relay: string; error: string }>; + /** Whether at least one relay succeeded */ + ok: boolean; +} + +/** Options for publish operations */ +export interface PublishOptions { + /** Skip adding to EventStore after publish */ + skipEventStore?: boolean; + /** Custom publish ID (for retry operations) */ + publishId?: string; +} + +// ============================================================================ +// PublishService Class +// ============================================================================ + +class PublishService { + /** Subject for all publish events (start, complete) */ + private publishSubject = new Subject(); + + /** Subject for per-relay status updates */ + private statusSubject = new Subject(); + + /** Active publish operations */ + private activePublishes = new Map(); + + /** Counter for generating unique publish IDs */ + private publishCounter = 0; + + // -------------------------------------------------------------------------- + // Public Observables + // -------------------------------------------------------------------------- + + /** Observable of all publish events */ + readonly publish$ = this.publishSubject.asObservable(); + + /** Observable of all relay status updates */ + readonly status$ = this.statusSubject.asObservable(); + + /** + * Get status updates for a specific publish operation + */ + getStatusUpdates(publishId: string): Observable { + return this.status$.pipe( + filter((update) => update.publishId === publishId), + ); + } + + // -------------------------------------------------------------------------- + // Publish Methods + // -------------------------------------------------------------------------- + + /** + * Generate a unique publish ID + */ + private generatePublishId(): string { + return `pub_${Date.now()}_${++this.publishCounter}`; + } + + /** + * Publish an event to the given relays + * + * 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(); + + if (relays.length === 0) { + throw new Error( + "No relays provided for publishing. Use selectRelaysForPublish() to select relays.", + ); + } + + // Initialize publish event + const publishEvent: PublishEvent = { + id: publishId, + event, + relays, + startedAt, + results: new Map(), + }; + this.activePublishes.set(publishId, publishEvent); + + // Emit initial publish event + this.publishSubject.next(publishEvent); + + // Emit initial pending status for all relays + for (const relay of relays) { + publishEvent.results.set(relay, { status: "pending" }); + this.emitStatus(publishId, relay, "pending"); + } + + // Publish to each relay individually for status tracking + const publishPromises = relays.map(async (relay) => { + this.emitStatus(publishId, relay, "publishing"); + publishEvent.results.set(relay, { status: "publishing" }); + + try { + // pool.publish returns array of { from: string, ok: boolean, message?: string } + const responses = await pool.publish([relay], event); + const response = responses[0]; + + // Check if relay accepted the event + if (response && response.ok) { + publishEvent.results.set(relay, { status: "success" }); + this.emitStatus(publishId, relay, "success"); + return { relay, success: true as const }; + } else { + // Relay rejected the event + const error = response?.message || "Relay rejected event"; + publishEvent.results.set(relay, { status: "error", error }); + this.emitStatus(publishId, relay, "error", error); + return { relay, success: false as const, error }; + } + } catch (err) { + const error = err instanceof Error ? err.message : "Unknown error"; + publishEvent.results.set(relay, { status: "error", error }); + this.emitStatus(publishId, relay, "error", error); + return { relay, success: false as const, error }; + } + }); + + // Wait for all to complete + const results = await Promise.all(publishPromises); + + // Update publish event + publishEvent.completedAt = Date.now(); + this.publishSubject.next(publishEvent); + + // Build result + const successful = results.filter((r) => r.success).map((r) => r.relay); + const failed = results + .filter( + (r): r is { relay: string; success: false; error: string } => + !r.success, + ) + .map((r) => ({ relay: r.relay, error: r.error })); + + const result: PublishResult = { + publishId, + event, + successful, + failed, + ok: successful.length > 0, + }; + + // Add to EventStore if at least one relay succeeded + if (result.ok && !options.skipEventStore) { + eventStore.add(event); + } + + // Cleanup + this.activePublishes.delete(publishId); + + return result; + } + + /** + * Retry publishing to specific relays + * + * Use this to retry failed relays from a previous publish. + */ + async retryRelays( + event: NostrEvent, + relays: string[], + originalPublishId?: string, + ): Promise { + return this.publish(event, relays, { + publishId: originalPublishId ? `${originalPublishId}_retry` : undefined, + skipEventStore: true, // Event should already be in store from original publish + }); + } + + // -------------------------------------------------------------------------- + // Observable-based Publishing (for UI with live updates) + // -------------------------------------------------------------------------- + + /** + * Start a publish operation and return an Observable of status updates + * + * Use this when you need to show per-relay status in the UI. + * The Observable completes when all relays have resolved. + */ + publishWithUpdates( + event: NostrEvent, + relays: string[], + options: PublishOptions = {}, + ): { + publishId: string; + updates$: Observable; + result: Promise; + } { + const publishId = options.publishId || this.generatePublishId(); + + // Create filtered observable for this publish + const updates$ = this.getStatusUpdates(publishId); + + // Start the publish (returns promise) + const result = this.publish(event, relays, { ...options, publishId }); + + return { publishId, updates$, result }; + } + + // -------------------------------------------------------------------------- + // Helpers + // -------------------------------------------------------------------------- + + /** + * Emit a status update + */ + private emitStatus( + publishId: string, + relay: string, + status: RelayPublishStatus, + error?: string, + ): void { + this.statusSubject.next({ + publishId, + relay, + status, + error, + timestamp: Date.now(), + }); + } +} + +// ============================================================================ +// Singleton Export +// ============================================================================ + +const publishService = new PublishService(); +export default publishService; diff --git a/src/services/relay-selection.ts b/src/services/relay-selection.ts index 2cc1c909..10fbc0a0 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; @@ -574,12 +617,23 @@ export async function selectRelaysForInteraction( authorPubkey: string, targetPubkey: string, ): Promise { - // Fetch relays in parallel - const [authorOutbox, targetInbox] = await Promise.all([ + // Check cache first, only fetch from network if missing + const [cachedOutbox, cachedInbox] = await Promise.all([ relayListCache.getOutboxRelays(authorPubkey), relayListCache.getInboxRelays(targetPubkey), ]); + const needsFetch: Promise[] = []; + if (!cachedOutbox) needsFetch.push(fetchRelayList(authorPubkey, 1000)); + if (!cachedInbox) needsFetch.push(fetchRelayList(targetPubkey, 1000)); + if (needsFetch.length > 0) await Promise.all(needsFetch); + + // Re-read after fetch (use cached values if no fetch was needed) + const authorOutbox = + cachedOutbox ?? (await relayListCache.getOutboxRelays(authorPubkey)); + const targetInbox = + cachedInbox ?? (await relayListCache.getInboxRelays(targetPubkey)); + const outboxRelays = authorOutbox || []; const inboxRelays = targetInbox || []; diff --git a/src/types/app.ts b/src/types/app.ts index 97ee2354..45e02909 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -25,6 +25,7 @@ export type AppId = | "zap" | "post" | "settings" + | "log" | "win"; export interface WindowInstance { diff --git a/src/types/man.ts b/src/types/man.ts index 6904df79..97968430 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -883,4 +883,16 @@ export const manPages: Record = { category: "System", defaultProps: {}, }, + log: { + name: "log", + section: "1", + synopsis: "log", + description: + "View ephemeral log of relay operations for debugging and introspection. Shows PUBLISH events with per-relay status (success/error/pending), CONNECT/DISCONNECT events, AUTH challenges and results, and relay NOTICE messages. Click on failed relays to retry publishing. Filter by event type using tabs. Log is ephemeral and stored in memory only.", + examples: ["log Open event log viewer"], + seeAlso: ["conn", "relay", "post"], + appId: "log", + category: "System", + defaultProps: {}, + }, };