From 9a668bbdac5e8c3e4cf6123aaeaeb7c69e97c684 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Jan 2026 22:17:39 +0000 Subject: [PATCH 1/9] feat: centralize publish flow with RxJS-based PublishService Create a unified PublishService that: - Provides consistent relay selection (outbox + state + hints + fallbacks) - Emits RxJS observables for per-relay status updates - Handles EventStore integration automatically - Supports both fire-and-forget and observable-based publishing Refactor all publish locations to use the centralized service: - hub.ts: Use PublishService for ActionRunner publish - delete-event.ts: Use PublishService (fixes missing eventStore.add) - publish-spell.ts: Use PublishService with relay hint support - PostViewer.tsx: Use publishWithUpdates() for per-relay UI tracking This lays the groundwork for the event log feature by providing observable hooks into all publish operations. --- src/actions/delete-event.ts | 36 +-- src/actions/publish-spell.ts | 50 ++-- src/components/PostViewer.tsx | 139 +++++----- src/services/hub.ts | 62 +++-- src/services/publish-service.ts | 435 ++++++++++++++++++++++++++++++++ 5 files changed, 552 insertions(+), 170 deletions(-) create mode 100644 src/services/publish-service.ts diff --git a/src/actions/delete-event.ts b/src/actions/delete-event.ts index 6bf127c1..f9539b13 100644 --- a/src/actions/delete-event.ts +++ b/src/actions/delete-event.ts @@ -1,11 +1,6 @@ import accountManager from "@/services/accounts"; -import pool from "@/services/relay-pool"; +import publishService from "@/services/publish-service"; 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 +32,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); + // Publish via centralized PublishService + // Relay selection is handled automatically (outbox + state + aggregators) + const result = await publishService.publish(event); + + 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.ts b/src/actions/publish-spell.ts index be1255ab..8f48123f 100644 --- a/src/actions/publish-spell.ts +++ b/src/actions/publish-spell.ts @@ -1,14 +1,10 @@ import { LocalSpell } from "@/services/db"; import accountManager from "@/services/accounts"; -import pool from "@/services/relay-pool"; +import publishService from "@/services/publish-service"; 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 +21,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 +29,7 @@ export class PublishSpellAction { const encoded = encodeSpell({ command: spell.command, - name: spell.name, - description: spell.description, }); @@ -50,38 +43,35 @@ 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)) || []; + // Get relay hints from event tags + const eventRelayHints = + event.tags.find((t) => t[0] === "relays")?.slice(1) || []; - relays = mergeRelaySets( - event.tags.find((t) => t[0] === "relays")?.slice(1) || [], - - authorWriteRelays, - - AGGREGATOR_RELAYS, - ); + // Publish via centralized PublishService + let result; + if (targetRelays && targetRelays.length > 0) { + // Use explicit target relays + result = await publishService.publishToRelays(event, targetRelays); + } else { + // Use automatic relay selection with event hints + result = await publishService.publish(event, { + relayHints: eventRelayHints, + }); } - // Publish to all target relays - - await pool.publish(relays, event); - - // 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/PostViewer.tsx b/src/components/PostViewer.tsx index e06d2971..bfa4edf5 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,39 @@ 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, { + relays: 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 +475,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 +493,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) { setIsPublishing(false); } }, - [canSign, signer, pubkey, selectedRelays, settings], + [canSign, signer, pubkey, selectedRelays, settings, windowId], ); // Handle file paste @@ -585,7 +560,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/services/hub.ts b/src/services/hub.ts index cb887650..ea9ce4ed 100644 --- a/src/services/hub.ts +++ b/src/services/hub.ts @@ -1,40 +1,30 @@ 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 accountManager from "./accounts"; +import publishService from "./publish-service"; /** - * 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 centralized PublishService + * + * Relay selection strategy (in priority order): + * 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); - - // 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); - // 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 +36,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 centralized PublishService */ export const hub = new ActionRunner(eventStore, factory, publishEvent); @@ -56,20 +46,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.publishToRelays(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..ca7b21bd --- /dev/null +++ b/src/services/publish-service.ts @@ -0,0 +1,435 @@ +/** + * 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 + * + * 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 +// ============================================================================ + +/** 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 { + /** 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 +// ============================================================================ + +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), + ); + } + + /** + * Get status updates for a specific relay + */ + getRelayStatusUpdates(relay: string): Observable { + 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 + // -------------------------------------------------------------------------- + + /** + * Generate a unique publish ID + */ + private generatePublishId(): string { + return `pub_${Date.now()}_${++this.publishCounter}`; + } + + /** + * Publish an event and return a Promise with the result + * + * This is the main publish method - use this for simple fire-and-forget publishing. + */ + async publish( + event: NostrEvent, + 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.", + ); + } + + // 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 { + await pool.publish([relay], event); + publishEvent.results.set(relay, { status: "success" }); + this.emitStatus(publishId, relay, "success"); + return { relay, success: true as const }; + } 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; + } + + /** + * 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 + * + * 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, + 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, { ...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(), + }); + } + + /** + * Get active publish operations + */ + getActivePublishes(): PublishEvent[] { + return Array.from(this.activePublishes.values()); + } + + /** + * Check if a publish operation is active + */ + isPublishing(publishId: string): boolean { + return this.activePublishes.has(publishId); + } +} + +// ============================================================================ +// Singleton Export +// ============================================================================ + +const publishService = new PublishService(); +export default publishService; + +// Also export the class for testing +export { PublishService }; From b89fd2c5ac1e68198d22cdedad8e250481e1567d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Jan 2026 22:25:18 +0000 Subject: [PATCH 2/9] feat: add LOG command for relay event introspection Add an ephemeral event log system that tracks relay operations: - EventLogService (src/services/event-log.ts): - Subscribes to PublishService for PUBLISH events with per-relay status - Monitors relay pool for CONNECT/DISCONNECT events - Tracks AUTH challenges and results - Captures NOTICE messages from relays - Uses RxJS BehaviorSubject for reactive updates - Circular buffer with configurable max entries (default 500) - useEventLog hook (src/hooks/useEventLog.ts): - React hook for filtering and accessing log entries - Filter by type, relay, or limit - Retry failed relays directly from the hook - EventLogViewer component (src/components/EventLogViewer.tsx): - Tab-based filtering (All/Publish/Connect/Auth/Notice) - Expandable PUBLISH entries showing per-relay status - Click to retry failed relays - Auto-scroll to new entries (pause on scroll) - Clear log button - LOG command accessible via Cmd+K palette --- src/components/EventLogViewer.tsx | 424 +++++++++++++++++++++++++ src/components/WindowRenderer.tsx | 6 + src/hooks/useEventLog.ts | 144 +++++++++ src/services/event-log.ts | 498 ++++++++++++++++++++++++++++++ src/types/app.ts | 1 + src/types/man.ts | 12 + 6 files changed, 1085 insertions(+) create mode 100644 src/components/EventLogViewer.tsx create mode 100644 src/hooks/useEventLog.ts create mode 100644 src/services/event-log.ts diff --git a/src/components/EventLogViewer.tsx b/src/components/EventLogViewer.tsx new file mode 100644 index 00000000..e9c23216 --- /dev/null +++ b/src/components/EventLogViewer.tsx @@ -0,0 +1,424 @@ +/** + * 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 + */ + +import { useState, useCallback, useRef, useEffect } from "react"; +import { + Check, + X, + Loader2, + Wifi, + WifiOff, + Shield, + ShieldAlert, + MessageSquare, + Send, + RotateCcw, + Trash2, + ChevronDown, + ChevronRight, +} from "lucide-react"; +import { Button } from "./ui/button"; +import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; +import { RelayLink } from "./nostr/RelayLink"; +import { useEventLog } from "@/hooks/useEventLog"; +import { + type LogEntry, + type EventLogType, + type PublishLogEntry, + type ConnectLogEntry, + type AuthLogEntry, + type NoticeLogEntry, +} from "@/services/event-log"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { cn } from "@/lib/utils"; + +// ============================================================================ +// Tab Filter Types +// ============================================================================ + +type TabFilter = "all" | EventLogType; + +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" }, +]; + +// ============================================================================ +// Entry Renderers +// ============================================================================ + +interface EntryProps { + entry: LogEntry; + onRetry?: (entryId: string) => void; +} + +function PublishEntry({ + entry, + onRetry, +}: EntryProps & { entry: PublishLogEntry }) { + 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( + (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 */} +
+ {Array.from(entry.relayStatus.entries()).map(([relay, status]) => ( +
+ {status.status === "success" && ( + + )} + {status.status === "error" && ( + + )} + {(status.status === "pending" || + status.status === "publishing") && ( + + )} + + {status.error && ( + + {status.error} + + )} +
+ ))} +
+ + {/* Retry button for failed relays */} + {hasFailures && onRetry && ( + + )} + + {/* Event ID */} +
+ {entry.event.id.slice(0, 16)}... +
+
+ )} +
+ ); +} + +function ConnectEntry({ entry }: EntryProps & { entry: ConnectLogEntry }) { + const isConnect = entry.type === "CONNECT"; + + return ( +
+ {isConnect ? ( + + ) : ( + + )} +
+
+ + {formatTimestamp(entry.timestamp / 1000, "time")} + + + {entry.type} + +
+ +
+
+ ); +} + +function AuthEntry({ entry }: EntryProps & { entry: AuthLogEntry }) { + const statusColors = { + challenge: "text-yellow-500", + success: "text-green-500", + failed: "text-red-500", + rejected: "text-muted-foreground", + }; + + return ( +
+ {entry.status === "success" ? ( + + ) : ( + + )} +
+
+ + {formatTimestamp(entry.timestamp / 1000, "time")} + + AUTH + + {entry.status} + +
+ +
+
+ ); +} + +function NoticeEntry({ entry }: EntryProps & { entry: NoticeLogEntry }) { + return ( +
+ +
+
+ + {formatTimestamp(entry.timestamp / 1000, "time")} + + NOTICE +
+ +
+ {entry.message} +
+
+
+ ); +} + +function LogEntryRenderer({ entry, onRetry }: EntryProps) { + switch (entry.type) { + case "PUBLISH": + return ; + case "CONNECT": + case "DISCONNECT": + return ; + case "AUTH": + return ; + case "NOTICE": + return ; + default: + return null; + } +} + +// ============================================================================ +// Main Component +// ============================================================================ + +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({ + 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); + } + }, []); + + const handleRetry = useCallback( + async (entryId: string) => { + await retryFailedRelays(entryId); + }, + [retryFailedRelays], + ); + + return ( +
+ {/* Header */} +
+ setActiveTab(v as TabFilter)} + > + + {TAB_FILTERS.map((tab) => ( + + {tab.label} + + ))} + + + +
+ + {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 +

+
+
+ ) : ( +
+ {entries.map((entry) => ( + + ))} +
+ )} +
+ + {/* Auto-scroll indicator */} + {!autoScroll && entries.length > 0 && ( +
+ +
+ )} +
+ ); +} 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/hooks/useEventLog.ts b/src/hooks/useEventLog.ts new file mode 100644 index 00000000..6d7c3407 --- /dev/null +++ b/src/hooks/useEventLog.ts @@ -0,0 +1,144 @@ +/** + * 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, + type PublishLogEntry, +} 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[]; + /** Publish entries with full status info */ + publishEntries: PublishLogEntry[]; + /** Clear all log entries */ + clear: () => void; + /** Retry failed relays for a publish entry */ + retryFailedRelays: (entryId: string) => Promise; + /** Total count of all entries (before filtering) */ + totalCount: number; +} + +/** + * Hook to access and filter event log entries + * + * @example + * ```tsx + * // Get all entries + * const { entries } = useEventLog(); + * + * // Filter by type + * const { entries } = useEventLog({ types: ["PUBLISH", "CONNECT"] }); + * + * // Filter by relay + * const { entries } = useEventLog({ relay: "wss://relay.example.com/" }); + * + * // Limit results + * const { entries } = useEventLog({ limit: 50 }); + * ``` + */ +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((newEntries) => { + setEntries(newEntries); + }); + + return () => subscription.unsubscribe(); + }, []); + + // Filter entries based on options + const filteredEntries = useMemo(() => { + let result = entries; + + // Filter by types + if (types && types.length > 0) { + result = result.filter((e) => types.includes(e.type)); + } + + // Filter by relay + if (relay) { + result = result.filter((e) => e.relay === relay); + } + + // Apply limit + if (limit && limit > 0) { + result = result.slice(0, limit); + } + + return result; + }, [entries, types, relay, limit]); + + // Get publish entries + const publishEntries = useMemo(() => { + return filteredEntries.filter( + (e): e is PublishLogEntry => e.type === "PUBLISH", + ); + }, [filteredEntries]); + + // Clear all entries + const clear = useCallback(() => { + eventLog.clear(); + }, []); + + // Retry failed relays + const retryFailedRelays = useCallback(async (entryId: string) => { + await eventLog.retryFailedRelays(entryId); + }, []); + + return { + entries: filteredEntries, + publishEntries, + clear, + retryFailedRelays, + totalCount: entries.length, + }; +} + +/** + * Hook to get the latest entry of a specific type + */ +export function useLatestLogEntry(type: EventLogType): LogEntry | undefined { + const { entries } = useEventLog({ types: [type], limit: 1 }); + return entries[0]; +} + +/** + * Hook to subscribe to new log entries as they arrive + */ +export function useNewLogEntry( + callback: (entry: LogEntry) => void, + types?: EventLogType[], +): void { + useEffect(() => { + const subscription = eventLog.newEntry$.subscribe((entry) => { + if (!types || types.length === 0 || types.includes(entry.type)) { + callback(entry); + } + }); + + return () => subscription.unsubscribe(); + }, [callback, types]); +} diff --git a/src/services/event-log.ts b/src/services/event-log.ts new file mode 100644 index 00000000..212c55fd --- /dev/null +++ b/src/services/event-log.ts @@ -0,0 +1,498 @@ +/** + * Event Log Service + * + * Provides an ephemeral log of relay operations for introspection: + * - PUBLISH events with per-relay status + * - CONNECT/DISCONNECT events + * - 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" + | "AUTH" + | "NOTICE"; + +/** 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 */ + 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; +} + +/** 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 + | AuthLogEntry + | NoticeLogEntry; + +// ============================================================================ +// EventLogService Class +// ============================================================================ + +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(); + + /** 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 + this.pollingIntervalId = setInterval(() => { + pool.relays.forEach((relay) => { + if (!this.relaySubscriptions.has(relay.url)) { + this.monitorRelay(relay); + } + }); + }, 1000); + } + + /** + * 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, 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 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 + subscription.add( + relay.notice$.subscribe((notices) => { + // notices can be a single string or array + const noticeArray = Array.isArray(notices) + ? notices + : notices + ? [notices] + : []; + // Only log new notices (last one) + if (noticeArray.length > 0) { + const latestNotice = noticeArray[noticeArray.length - 1]; + this.addEntry({ + type: "NOTICE", + relay: url, + message: latestNotice, + }); + } + }), + ); + + this.relaySubscriptions.set(url, subscription); + } + + // -------------------------------------------------------------------------- + // Publish Event Handling + // -------------------------------------------------------------------------- + + /** + * Handle a publish event from PublishService + */ + private handlePublishEvent(event: PublishEvent): void { + const entryId = this.generateId(); + + // Create initial publish entry + const entry: PublishLogEntry = { + id: entryId, + type: "PUBLISH", + timestamp: event.startedAt, + event: event.event, + relays: event.relays, + relayStatus: new Map(event.results), + status: this.calculatePublishStatus(event.results), + 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 and update 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 relay status + entry.relayStatus.set(update.relay, { + status: update.status, + error: update.error, + }); + + // Recalculate overall status + entry.status = this.calculatePublishStatus(entry.relayStatus); + + // 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: + | (Omit & { + id?: string; + timestamp?: number; + }) + | (Omit & { + id?: string; + timestamp?: number; + }) + | (Omit & { + id?: string; + timestamp?: number; + }) + | (Omit & { + id?: string; + timestamp?: number; + }), + ): 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]; + } + + /** + * Get entries filtered by type + */ + getEntriesByType(type: EventLogType): LogEntry[] { + return this.entries.filter((e) => e.type === type); + } + + /** + * Get entries for a specific relay + */ + getEntriesByRelay(relay: string): LogEntry[] { + return this.entries.filter((e) => e.relay === relay); + } + + /** + * Get publish entries only + */ + getPublishEntries(): PublishLogEntry[] { + return this.entries.filter( + (e): e is PublishLogEntry => e.type === "PUBLISH", + ); + } + + /** + * Clear all entries + */ + clear(): void { + this.entries = []; + this.publishIdToEntryId.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, + ); + } +} + +// ============================================================================ +// Singleton Export +// ============================================================================ + +const eventLog = new EventLogService(); + +// Initialize on module load +eventLog.initialize(); + +export default eventLog; + +// Also export the class for testing +export { EventLogService }; 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: {}, + }, }; From b20eda536fec82297f14cdd14680ca70c157a760 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Jan 2026 22:39:49 +0000 Subject: [PATCH 3/9] fix: prevent duplicate log entries and check relay OK response - EventLogService: Check for existing entry before creating new one when handling publish events (prevents duplicates from start/complete) - PublishService: Check response.ok from pool.publish() to detect relay rejections instead of assuming success on resolve - Update test mock to return proper publish response format --- src/actions/publish-spell.test.ts | 4 +++- src/services/event-log.ts | 16 ++++++++++++++++ src/services/publish-service.ts | 20 ++++++++++++++++---- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/actions/publish-spell.test.ts b/src/actions/publish-spell.test.ts index 078f3ce8..33fa4b3f 100644 --- a/src/actions/publish-spell.test.ts +++ b/src/actions/publish-spell.test.ts @@ -17,7 +17,9 @@ vi.mock("@/services/accounts", () => ({ vi.mock("@/services/relay-pool", () => ({ default: { - publish: vi.fn(), + publish: vi + .fn() + .mockResolvedValue([{ from: "wss://test.relay/", ok: true }]), }, })); diff --git a/src/services/event-log.ts b/src/services/event-log.ts index 212c55fd..360ee2ab 100644 --- a/src/services/event-log.ts +++ b/src/services/event-log.ts @@ -282,6 +282,22 @@ class EventLogService { * 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 instead of creating a new one + const entryIndex = this.entries.findIndex( + (e) => e.id === existingEntryId && e.type === "PUBLISH", + ); + if (entryIndex !== -1) { + const entry = this.entries[entryIndex] as PublishLogEntry; + entry.relayStatus = new Map(event.results); + entry.status = this.calculatePublishStatus(event.results); + this.entriesSubject.next([...this.entries]); + } + return; + } + const entryId = this.generateId(); // Create initial publish entry diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index ca7b21bd..30b73169 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -281,10 +281,22 @@ class PublishService { publishEvent.results.set(relay, { status: "publishing" }); try { - await pool.publish([relay], event); - publishEvent.results.set(relay, { status: "success" }); - this.emitStatus(publishId, relay, "success"); - return { relay, success: true as const }; + // 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 }); 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 4/9] 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 f9539b13..0a1ff074 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 33fa4b3f..8e6b54e0 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 8f48123f..01570846 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 e9c23216..7c45f644 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 bfa4edf5..e8b7043a 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 6d7c3407..8e633563 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 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..2c09c2e3 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 360ee2ab..d6995167 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 ea9ce4ed..3f7a46f6 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 30b73169..064c3e48 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 2cc1c909..54bb1fb2 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; From 498007401dd71b16dec6964d26a027550b4c6f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Wed, 4 Mar 2026 17:12:35 +0100 Subject: [PATCH 5/9] chore: cleanup --- src/actions/publish-spell.test.ts | 24 +++--- src/components/EventLogViewer.tsx | 96 ++++++++++------------- src/components/chat/EmojiPickerDialog.tsx | 11 ++- src/hooks/useEventLog.ts | 89 ++++----------------- src/lib/themes/builtin/dark.ts | 2 +- src/services/event-log.ts | 26 ------ src/services/publish-service.ts | 24 ------ src/services/relay-selection.ts | 15 +++- 8 files changed, 97 insertions(+), 190 deletions(-) diff --git a/src/actions/publish-spell.test.ts b/src/actions/publish-spell.test.ts index 8e6b54e0..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,11 +15,15 @@ vi.mock("@/services/accounts", () => ({ }, })); -vi.mock("@/services/relay-pool", () => ({ +vi.mock("@/services/publish-service", () => ({ default: { - publish: vi - .fn() - .mockResolvedValue([{ from: "wss://test.relay/", ok: true }]), + publish: vi.fn().mockResolvedValue({ + publishId: "pub_1", + event: {}, + successful: ["wss://test.relay/"], + failed: [], + ok: true, + }), }, })); @@ -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/components/EventLogViewer.tsx b/src/components/EventLogViewer.tsx index 7c45f644..2726fd7c 100644 --- a/src/components/EventLogViewer.tsx +++ b/src/components/EventLogViewer.tsx @@ -4,7 +4,7 @@ * Compact log of relay operations for debugging and introspection. */ -import { useState, useCallback, useMemo } from "react"; +import { useState, useMemo } from "react"; import { Check, X, @@ -45,7 +45,6 @@ import { EventErrorBoundary } from "./EventErrorBoundary"; 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"], @@ -62,6 +61,17 @@ const TAB_FILTERS: { value: TabFilter; label: string }[] = [ { value: "notice", label: "Notice" }, ]; +// ============================================================================ +// Constants +// ============================================================================ + +const AUTH_STATUS_TOOLTIP: Record = { + challenge: "Auth challenge", + success: "Auth success", + failed: "Auth failed", + rejected: "Auth rejected", +}; + // ============================================================================ // Shared row layout // ============================================================================ @@ -284,13 +294,6 @@ function ConnectEntry({ entry }: { entry: ConnectLogEntry }) { 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 ( ) } - tooltip={statusTooltip[entry.status] ?? "Auth"} + tooltip={AUTH_STATUS_TOOLTIP[entry.status] ?? "Auth"} timestamp={entry.timestamp} expanded={expanded} onToggle={() => setExpanded(!expanded)} @@ -389,6 +392,16 @@ function LogEntryRenderer({ // 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"); @@ -400,33 +413,7 @@ export function EventLogViewer() { retryRelay, totalCount, typeCounts, - } = useEventLog({ - types: filterTypes, - }); - - /** 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) => { - await retryFailedRelays(entryId); - }, - [retryFailedRelays], - ); - - const handleRetryRelay = useCallback( - async (entryId: string, relay: string) => { - await retryRelay(entryId, relay); - }, - [retryRelay], - ); + } = useEventLog({ types: filterTypes }); return (
@@ -437,20 +424,23 @@ export function EventLogViewer() { onValueChange={(v) => setActiveTab(v as TabFilter)} > - {TAB_FILTERS.map((tab) => ( - - {tab.label} - {getTabCount(tab.value) > 0 && ( - - {getTabCount(tab.value)} - - )} - - ))} + {TAB_FILTERS.map((tab) => { + const count = getTabCount(tab.value, totalCount, typeCounts); + return ( + + {tab.label} + {count > 0 && ( + + {count} + + )} + + ); + })} @@ -476,8 +466,8 @@ export function EventLogViewer() { )) )} 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 index 8e633563..a1f2be7d 100644 --- a/src/hooks/useEventLog.ts +++ b/src/hooks/useEventLog.ts @@ -8,7 +8,6 @@ import { useState, useEffect, useCallback, useMemo } from "react"; import eventLog, { type LogEntry, type EventLogType, - type PublishLogEntry, } from "@/services/event-log"; export interface UseEventLogOptions { @@ -23,8 +22,6 @@ export interface UseEventLogOptions { export interface UseEventLogResult { /** Filtered log entries */ entries: LogEntry[]; - /** Publish entries with full status info */ - publishEntries: PublishLogEntry[]; /** Clear all log entries */ clear: () => void; /** Retry failed relays for a publish entry */ @@ -42,17 +39,9 @@ export interface UseEventLogResult { * * @example * ```tsx - * // Get all entries * const { entries } = useEventLog(); - * - * // Filter by type * const { entries } = useEventLog({ types: ["PUBLISH", "CONNECT"] }); - * - * // Filter by relay * const { entries } = useEventLog({ relay: "wss://relay.example.com/" }); - * - * // Limit results - * const { entries } = useEventLog({ limit: 50 }); * ``` */ export function useEventLog( @@ -66,10 +55,7 @@ export function useEventLog( // Subscribe to log updates useEffect(() => { - const subscription = eventLog.entries$.subscribe((newEntries) => { - setEntries(newEntries); - }); - + const subscription = eventLog.entries$.subscribe(setEntries); return () => subscription.unsubscribe(); }, []); @@ -77,17 +63,14 @@ export function useEventLog( const filteredEntries = useMemo(() => { let result = entries; - // Filter by types if (types && types.length > 0) { result = result.filter((e) => types.includes(e.type)); } - // Filter by relay if (relay) { result = result.filter((e) => e.relay === relay); } - // Apply limit if (limit && limit > 0) { result = result.slice(0, limit); } @@ -95,41 +78,29 @@ export function useEventLog( return result; }, [entries, types, relay, limit]); - // Get publish entries - const publishEntries = useMemo(() => { - return filteredEntries.filter( - (e): e is PublishLogEntry => e.type === "PUBLISH", - ); - }, [filteredEntries]); - - // Clear all entries - const clear = useCallback(() => { - eventLog.clear(); - }, []); + const clear = useCallback(() => eventLog.clear(), []); - // Retry failed relays - const retryFailedRelays = useCallback(async (entryId: string) => { - await eventLog.retryFailedRelays(entryId); - }, []); + const retryFailedRelays = useCallback( + (entryId: string) => eventLog.retryFailedRelays(entryId), + [], + ); - // Retry a single relay - const retryRelay = useCallback(async (entryId: string, relay: string) => { - await eventLog.retryRelay(entryId, relay); - }, []); + const retryRelay = useCallback( + (entryId: string, relay: string) => 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], - ); + 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, - publishEntries, clear, retryFailedRelays, retryRelay, @@ -137,29 +108,3 @@ export function useEventLog( typeCounts, }; } - -/** - * Hook to get the latest entry of a specific type - */ -export function useLatestLogEntry(type: EventLogType): LogEntry | undefined { - const { entries } = useEventLog({ types: [type], limit: 1 }); - return entries[0]; -} - -/** - * Hook to subscribe to new log entries as they arrive - */ -export function useNewLogEntry( - callback: (entry: LogEntry) => void, - types?: EventLogType[], -): void { - useEffect(() => { - const subscription = eventLog.newEntry$.subscribe((entry) => { - if (!types || types.length === 0 || types.includes(entry.type)) { - callback(entry); - } - }); - - return () => subscription.unsubscribe(); - }, [callback, types]); -} diff --git a/src/lib/themes/builtin/dark.ts b/src/lib/themes/builtin/dark.ts index 2c09c2e3..d3ed7283 100644 --- a/src/lib/themes/builtin/dark.ts +++ b/src/lib/themes/builtin/dark.ts @@ -32,7 +32,7 @@ export const darkTheme: Theme = { mutedForeground: "215 20.2% 70%", destructive: "0 72% 63%", - destructiveForeground: "0 0% 100%", + destructiveForeground: "210 40% 98%", border: "217.2 32.6% 17.5%", input: "217.2 32.6% 17.5%", diff --git a/src/services/event-log.ts b/src/services/event-log.ts index d6995167..c88103bf 100644 --- a/src/services/event-log.ts +++ b/src/services/event-log.ts @@ -442,29 +442,6 @@ class EventLogService { return [...this.entries]; } - /** - * Get entries filtered by type - */ - getEntriesByType(type: EventLogType): LogEntry[] { - return this.entries.filter((e) => e.type === type); - } - - /** - * Get entries for a specific relay - */ - getEntriesByRelay(relay: string): LogEntry[] { - return this.entries.filter((e) => e.relay === relay); - } - - /** - * Get publish entries only - */ - getPublishEntries(): PublishLogEntry[] { - return this.entries.filter( - (e): e is PublishLogEntry => e.type === "PUBLISH", - ); - } - /** * Clear all entries */ @@ -525,6 +502,3 @@ const eventLog = new EventLogService(); eventLog.initialize(); export default eventLog; - -// Also export the class for testing -export { EventLogService }; diff --git a/src/services/publish-service.ts b/src/services/publish-service.ts index 064c3e48..364bdff9 100644 --- a/src/services/publish-service.ts +++ b/src/services/publish-service.ts @@ -114,13 +114,6 @@ class PublishService { ); } - /** - * Get status updates for a specific relay - */ - getRelayStatusUpdates(relay: string): Observable { - return this.status$.pipe(filter((update) => update.relay === relay)); - } - // -------------------------------------------------------------------------- // Publish Methods // -------------------------------------------------------------------------- @@ -303,20 +296,6 @@ class PublishService { timestamp: Date.now(), }); } - - /** - * Get active publish operations - */ - getActivePublishes(): PublishEvent[] { - return Array.from(this.activePublishes.values()); - } - - /** - * Check if a publish operation is active - */ - isPublishing(publishId: string): boolean { - return this.activePublishes.has(publishId); - } } // ============================================================================ @@ -325,6 +304,3 @@ class PublishService { const publishService = new PublishService(); export default publishService; - -// Also export the class for testing -export { PublishService }; diff --git a/src/services/relay-selection.ts b/src/services/relay-selection.ts index 54bb1fb2..10fbc0a0 100644 --- a/src/services/relay-selection.ts +++ b/src/services/relay-selection.ts @@ -617,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 || []; From 9a4dc2e71b300afc9089df7e9034032e885ab2cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Wed, 4 Mar 2026 17:29:24 +0100 Subject: [PATCH 6/9] fix: make Timestamp component locale-aware via formatTimestamp Timestamp was hardcoded to "es" locale. Now uses formatTimestamp() from useLocale.ts for consistent locale-aware time formatting. Added Timestamp to CLAUDE.md shared components documentation. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + src/components/Timestamp.tsx | 10 ++-------- 2 files changed, 3 insertions(+), 8 deletions(-) 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/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"); } From 7277a3db0745386867da8fb67b14e3dcb8384259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Wed, 4 Mar 2026 17:29:40 +0100 Subject: [PATCH 7/9] feat: improve event-log reliability, add ERROR type and per-relay timing Service improvements: - Fix notice$ duplicate logging with per-relay dedup tracking - Remove dead Array.isArray code path (notice$ emits strings) - Increase relay poll interval from 1s to 5s - Clean publishIdToEntryId map on terminal state, not just overflow - Immutable entry updates (spread instead of in-place mutation) - Extract NewEntry/AddEntryInput helper types for clean addEntry signature - Clear lastNoticePerRelay on log clear New capabilities: - ERROR log type: subscribes to relay.error$ for connection failure reasons - RelayStatusEntry with updatedAt timestamp for per-relay response timing Co-Authored-By: Claude Opus 4.6 --- src/services/event-log.ts | 164 ++++++++++++++++++++++++++------------ 1 file changed, 115 insertions(+), 49 deletions(-) diff --git a/src/services/event-log.ts b/src/services/event-log.ts index c88103bf..ac280dd5 100644 --- a/src/services/event-log.ts +++ b/src/services/event-log.ts @@ -2,8 +2,9 @@ * Event Log Service * * Provides an ephemeral log of relay operations for introspection: - * - PUBLISH events with per-relay status + * - PUBLISH events with per-relay status and timing * - CONNECT/DISCONNECT events + * - ERROR events for connection failures * - AUTH events * - NOTICE events * @@ -30,9 +31,18 @@ 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 */ @@ -52,8 +62,8 @@ export interface PublishLogEntry extends BaseLogEntry { event: NostrEvent; /** Target relays */ relays: string[]; - /** Per-relay status */ - relayStatus: Map; + /** Per-relay status with timing */ + relayStatus: Map; /** Overall status: pending, partial, success, failed */ status: "pending" | "partial" | "success" | "failed"; /** Publish ID from PublishService */ @@ -66,6 +76,14 @@ export interface ConnectLogEntry extends BaseLogEntry { 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"; @@ -88,13 +106,30 @@ export interface NoticeLogEntry extends BaseLogEntry { 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; @@ -120,6 +155,9 @@ class EventLogService { /** 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; @@ -163,14 +201,14 @@ class EventLogService { // Monitor existing relays pool.relays.forEach((relay) => this.monitorRelay(relay)); - // Poll for new relays + // 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); } }); - }, 1000); + }, RELAY_POLL_INTERVAL); } /** @@ -194,7 +232,7 @@ class EventLogService { // -------------------------------------------------------------------------- /** - * Monitor a relay for connection, auth, and notice events + * Monitor a relay for connection, error, auth, and notice events */ private monitorRelay(relay: IRelay): void { const url = relay.url; @@ -219,6 +257,19 @@ class EventLogService { }), ); + // 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$ @@ -250,22 +301,19 @@ class EventLogService { }), ); - // Track notices + // Track notices — deduplicate per relay subscription.add( - relay.notice$.subscribe((notices) => { - // notices can be a single string or array - const noticeArray = Array.isArray(notices) - ? notices - : notices - ? [notices] - : []; - // Only log new notices (last one) - if (noticeArray.length > 0) { - const latestNotice = noticeArray[noticeArray.length - 1]; + 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: latestNotice, + message: notice, }); } }), @@ -285,30 +333,48 @@ class EventLogService { // Check if we already have an entry for this publish (avoid duplicates) const existingEntryId = this.publishIdToEntryId.get(event.id); if (existingEntryId) { - // Update existing entry instead of creating a new one + // 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; - entry.relayStatus = new Map(event.results); - entry.status = this.calculatePublishStatus(event.results); + 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 }); + } - // Create initial publish entry const entry: PublishLogEntry = { id: entryId, type: "PUBLISH", timestamp: event.startedAt, event: event.event, relays: event.relays, - relayStatus: new Map(event.results), - status: this.calculatePublishStatus(event.results), + relayStatus, + status: this.calculatePublishStatus(relayStatus), publishId: event.id, }; @@ -325,7 +391,7 @@ class EventLogService { const entryId = this.publishIdToEntryId.get(update.publishId); if (!entryId) return; - // Find and update the publish entry + // Find the publish entry const entryIndex = this.entries.findIndex( (e) => e.id === entryId && e.type === "PUBLISH", ); @@ -333,14 +399,31 @@ class EventLogService { const entry = this.entries[entryIndex] as PublishLogEntry; - // Update relay status - entry.relayStatus.set(update.relay, { + // Update immutably with timing + const newRelayStatus = new Map(entry.relayStatus); + newRelayStatus.set(update.relay, { status: update.status, error: update.error, + updatedAt: update.timestamp, }); - // Recalculate overall status - entry.status = this.calculatePublishStatus(entry.relayStatus); + const newStatus = this.calculatePublishStatus(newRelayStatus); + + this.entries[entryIndex] = { + ...entry, + relayStatus: newRelayStatus, + status: newStatus, + }; + + // Clean up publish ID mapping when publish reaches terminal state + if (newStatus !== "pending") { + const allTerminal = Array.from(newRelayStatus.values()).every( + (r) => r.status === "success" || r.status === "error", + ); + if (allTerminal) { + this.publishIdToEntryId.delete(update.publishId); + } + } // Notify subscribers this.entriesSubject.next([...this.entries]); @@ -350,7 +433,7 @@ class EventLogService { * Calculate overall publish status from relay results */ private calculatePublishStatus( - results: Map, + results: Map, ): "pending" | "partial" | "success" | "failed" { const statuses = Array.from(results.values()).map((r) => r.status); @@ -387,25 +470,7 @@ class EventLogService { * Add an entry to the log * Accepts partial entries without id/timestamp (they will be generated) */ - private addEntry( - entry: - | (Omit & { - id?: string; - timestamp?: number; - }) - | (Omit & { - id?: string; - timestamp?: number; - }) - | (Omit & { - id?: string; - timestamp?: number; - }) - | (Omit & { - id?: string; - timestamp?: number; - }), - ): void { + private addEntry(entry: AddEntryInput): void { const fullEntry = { id: entry.id || this.generateId(), timestamp: entry.timestamp || Date.now(), @@ -448,6 +513,7 @@ class EventLogService { clear(): void { this.entries = []; this.publishIdToEntryId.clear(); + this.lastNoticePerRelay.clear(); this.entriesSubject.next([]); } From 9ebf9b54a262f313d15849cfa5e6f5ebca02e64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Wed, 4 Mar 2026 17:29:51 +0100 Subject: [PATCH 8/9] feat: improve EventLogViewer with virtualization, timing, and error display - Virtualize log list with react-virtuoso for 500-entry buffer performance - Add ErrorEntry renderer for new ERROR log type (AlertTriangle icon) - Show per-relay response time (e.g. "142ms", "2.3s") in publish details - Make all entry types expandable (connect/disconnect now have details) - Show absolute timestamp in all expanded detail views - Group ERROR events under Connect tab filter Co-Authored-By: Claude Opus 4.6 --- src/components/EventLogViewer.tsx | 126 +++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 13 deletions(-) diff --git a/src/components/EventLogViewer.tsx b/src/components/EventLogViewer.tsx index 2726fd7c..b81929b1 100644 --- a/src/components/EventLogViewer.tsx +++ b/src/components/EventLogViewer.tsx @@ -4,7 +4,7 @@ * Compact log of relay operations for debugging and introspection. */ -import { useState, useMemo } from "react"; +import { useState, useMemo, useCallback } from "react"; import { Check, X, @@ -19,7 +19,9 @@ import { 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"; @@ -30,8 +32,10 @@ import { 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"; @@ -48,7 +52,7 @@ type TabFilter = "all" | "publish" | "connect" | "auth" | "notice"; const TAB_TYPE_MAP: Record = { all: undefined, publish: ["PUBLISH"], - connect: ["CONNECT", "DISCONNECT"], + connect: ["CONNECT", "DISCONNECT", "ERROR"], auth: ["AUTH"], notice: ["NOTICE"], }; @@ -72,6 +76,20 @@ const AUTH_STATUS_TOOLTIP: Record = { 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 // ============================================================================ @@ -143,12 +161,16 @@ function EntryRow({ function PublishRelayRow({ relay, status, + publishTimestamp, onRetry, }: { relay: string; - status: { status: string; error?: string }; + status: RelayStatusEntry; + publishTimestamp: number; onRetry?: () => void; }) { + const isTerminal = status.status === "success" || status.status === "error"; + return (
@@ -166,6 +188,11 @@ function PublishRelayRow({ showInboxOutbox={false} className="flex-1 min-w-0" /> + {isTerminal && ( + + {formatRelayTime(publishTimestamp, status.updatedAt)} + + )} {status.status === "error" && onRetry && (
)} +
+ Time: + + {formatTimestamp(entry.timestamp / 1000, "absolute")} + +
} > @@ -350,7 +430,17 @@ function NoticeEntry({ entry }: { entry: NoticeLogEntry }) { expanded={expanded} onToggle={() => setExpanded(!expanded)} details={ -
{entry.message}
+
+
+ {entry.message} +
+
+ Time: + + {formatTimestamp(entry.timestamp / 1000, "absolute")} + +
+
} > @@ -379,6 +469,8 @@ function LogEntryRenderer({ case "CONNECT": case "DISCONNECT": return ; + case "ERROR": + return ; case "AUTH": return ; case "NOTICE": @@ -415,6 +507,17 @@ export function EventLogViewer() { typeCounts, } = useEventLog({ types: filterTypes }); + const renderItem = useCallback( + (_index: number, entry: LogEntry) => ( + + ), + [retryFailedRelays, retryRelay], + ); + return (
{/* Header */} @@ -456,20 +559,17 @@ export function EventLogViewer() {
{/* Log entries */} -
+
{entries.length === 0 ? (

No events logged yet

) : ( - entries.map((entry) => ( - - )) + )}
From a297bc45ecd6daf69920752e958fa27f6063c6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Wed, 4 Mar 2026 17:32:30 +0100 Subject: [PATCH 9/9] fix: prevent duplicate PUBLISH log entries from completion event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublishService emits publish$ twice: once at start, once on completion. The eager publishIdToEntryId cleanup in handleStatusUpdate fired before the completion emission, causing handlePublishEvent to create a second entry. Removed eager cleanup — overflow eviction is sufficient. Co-Authored-By: Claude Opus 4.6 --- src/services/event-log.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/services/event-log.ts b/src/services/event-log.ts index ac280dd5..3f980f77 100644 --- a/src/services/event-log.ts +++ b/src/services/event-log.ts @@ -415,16 +415,6 @@ class EventLogService { status: newStatus, }; - // Clean up publish ID mapping when publish reaches terminal state - if (newStatus !== "pending") { - const allTerminal = Array.from(newRelayStatus.values()).every( - (r) => r.status === "success" || r.status === "error", - ); - if (allTerminal) { - this.publishIdToEntryId.delete(update.publishId); - } - } - // Notify subscribers this.entriesSubject.next([...this.entries]); }