Centralize relay publishing via PublishService (#211)

* 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.

* 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

* 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

* feat: keep relay selection in call site, compact logs

* chore: cleanup

* 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 <noreply@anthropic.com>

* 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<T>/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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: prevent duplicate PUBLISH log entries from completion event

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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-03-04 17:35:44 +01:00
committed by GitHub
parent ee09cac2e0
commit 80a421b9fe
18 changed files with 1805 additions and 204 deletions

View File

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

View File

@@ -1,11 +1,7 @@
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import publishService from "@/services/publish-service";
import { selectRelaysForPublish } from "@/services/relay-selection";
import { EventFactory } from "applesauce-core/event-factory";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";
import { grimoireStateAtom } from "@/core/state";
import { getDefaultStore } from "jotai";
import { NostrEvent } from "@/types/nostr";
import { settingsManager } from "@/services/settings";
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
@@ -37,24 +33,15 @@ export class DeleteEventAction {
const event = await factory.sign(draft);
// Get write relays from cache and state
const authorWriteRelays =
(await relayListCache.getOutboxRelays(account.pubkey)) || [];
// Select relays and publish
const relays = await selectRelaysForPublish(account.pubkey);
const result = await publishService.publish(event, relays);
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);
if (!result.ok) {
const errors = result.failed
.map((f) => `${f.relay}: ${f.error}`)
.join(", ");
throw new Error(`Failed to publish deletion event. Errors: ${errors}`);
}
}
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PublishSpellAction } from "./publish-spell";
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import publishService from "@/services/publish-service";
import * as spellStorage from "@/services/spell-storage";
import { LocalSpell } from "@/services/db";
@@ -15,9 +15,15 @@ vi.mock("@/services/accounts", () => ({
},
}));
vi.mock("@/services/relay-pool", () => ({
vi.mock("@/services/publish-service", () => ({
default: {
publish: vi.fn(),
publish: vi.fn().mockResolvedValue({
publishId: "pub_1",
event: {},
successful: ["wss://test.relay/"],
failed: [],
ok: true,
}),
},
}));
@@ -25,10 +31,8 @@ vi.mock("@/services/spell-storage", () => ({
markSpellPublished: vi.fn(),
}));
vi.mock("@/services/relay-list-cache", () => ({
relayListCache: {
getOutboxRelays: vi.fn().mockResolvedValue([]),
},
vi.mock("@/services/relay-selection", () => ({
selectRelaysForPublish: vi.fn().mockResolvedValue(["wss://test.relay/"]),
}));
vi.mock("@/services/event-store", () => ({
@@ -89,18 +93,18 @@ describe("PublishSpellAction", () => {
await action.execute(spell);
// Check if signer was called
expect(mockSigner.signEvent).toHaveBeenCalled();
// Check if published to pool
expect(pool.publish).toHaveBeenCalled();
// Verify publishService was called (not pool.publish)
expect(publishService.publish).toHaveBeenCalledWith(
expect.objectContaining({ kind: 777 }),
["wss://test.relay/"],
);
// Check if storage updated
expect(spellStorage.markSpellPublished).toHaveBeenCalledWith(
"local-id",
expect.objectContaining({
kind: 777,
// We expect tags to contain name and alt (description)
tags: expect.arrayContaining([
["name", "My Spell"],
["alt", expect.stringContaining("Description")],

View File

@@ -1,14 +1,11 @@
import { LocalSpell } from "@/services/db";
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import publishService from "@/services/publish-service";
import { selectRelaysForPublish } from "@/services/relay-selection";
import { encodeSpell } from "@/lib/spell-conversion";
import { markSpellPublished } from "@/services/spell-storage";
import { EventFactory } from "applesauce-core/event-factory";
import { SpellEvent } from "@/types/spell";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";
import eventStore from "@/services/event-store";
import { settingsManager } from "@/services/settings";
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
@@ -25,7 +22,6 @@ export class PublishSpellAction {
if (spell.isPublished && spell.event) {
// Use existing signed event for rebroadcasting
event = spell.event;
} else {
const signer = account.signer;
@@ -34,9 +30,7 @@ export class PublishSpellAction {
const encoded = encodeSpell({
command: spell.command,
name: spell.name,
description: spell.description,
});
@@ -50,38 +44,33 @@ export class PublishSpellAction {
const draft = await factory.build({
kind: 777,
content: encoded.content,
tags,
});
event = (await factory.sign(draft)) as SpellEvent;
}
// Use provided relays or fallback to author's write relays + aggregators
let relays = targetRelays;
if (!relays || relays.length === 0) {
const authorWriteRelays =
(await relayListCache.getOutboxRelays(account.pubkey)) || [];
relays = mergeRelaySets(
event.tags.find((t) => t[0] === "relays")?.slice(1) || [],
authorWriteRelays,
AGGREGATOR_RELAYS,
);
// Determine relays: explicit target relays or outbox selection with hints
let relays: string[];
if (targetRelays && targetRelays.length > 0) {
relays = targetRelays;
} else {
const eventRelayHints =
event.tags.find((t) => t[0] === "relays")?.slice(1) || [];
relays = await selectRelaysForPublish(account.pubkey, {
relayHints: eventRelayHints,
});
}
// Publish to all target relays
const result = await publishService.publish(event, 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);
}

View File

@@ -0,0 +1,577 @@
/**
* Event Log Viewer
*
* Compact log of relay operations for debugging and introspection.
*/
import { useState, useMemo, useCallback } from "react";
import {
Check,
X,
Loader2,
Wifi,
WifiOff,
Shield,
ShieldAlert,
MessageSquare,
Send,
RotateCcw,
Trash2,
ChevronDown,
ChevronRight,
AlertTriangle,
} from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import { Button } from "./ui/button";
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { RelayLink } from "./nostr/RelayLink";
import { useEventLog } from "@/hooks/useEventLog";
import {
type LogEntry,
type EventLogType,
type PublishLogEntry,
type ConnectLogEntry,
type ErrorLogEntry,
type AuthLogEntry,
type NoticeLogEntry,
type RelayStatusEntry,
} from "@/services/event-log";
import { formatTimestamp } from "@/hooks/useLocale";
import { cn } from "@/lib/utils";
import { KindBadge } from "./KindBadge";
import { KindRenderer } from "./nostr/kinds";
import { EventErrorBoundary } from "./EventErrorBoundary";
// ============================================================================
// Tab Filters
// ============================================================================
type TabFilter = "all" | "publish" | "connect" | "auth" | "notice";
const TAB_TYPE_MAP: Record<TabFilter, EventLogType[] | undefined> = {
all: undefined,
publish: ["PUBLISH"],
connect: ["CONNECT", "DISCONNECT", "ERROR"],
auth: ["AUTH"],
notice: ["NOTICE"],
};
const TAB_FILTERS: { value: TabFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "publish", label: "Publish" },
{ value: "connect", label: "Connect" },
{ value: "auth", label: "Auth" },
{ value: "notice", label: "Notice" },
];
// ============================================================================
// Constants
// ============================================================================
const AUTH_STATUS_TOOLTIP: Record<string, string> = {
challenge: "Auth challenge",
success: "Auth success",
failed: "Auth failed",
rejected: "Auth rejected",
};
// ============================================================================
// Helpers
// ============================================================================
/** Format relay response time relative to publish start */
function formatRelayTime(
publishTimestamp: number,
relayUpdatedAt: number,
): string {
const ms = relayUpdatedAt - publishTimestamp;
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
// ============================================================================
// Shared row layout
// ============================================================================
function EntryRow({
icon,
tooltip,
children,
timestamp,
className,
expanded,
onToggle,
details,
}: {
icon: React.ReactNode;
tooltip: string;
children: React.ReactNode;
timestamp: number;
className?: string;
expanded?: boolean;
onToggle?: () => void;
details?: React.ReactNode;
}) {
return (
<div>
<div
className={cn(
"flex items-center gap-2 px-2 py-1 border-b border-border min-w-0",
onToggle && "cursor-pointer hover:bg-muted/50",
className,
)}
onClick={onToggle}
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-shrink-0">{icon}</div>
</TooltipTrigger>
<TooltipContent side="right">{tooltip}</TooltipContent>
</Tooltip>
<div className="flex-1 min-w-0 flex items-center gap-1.5 text-xs">
{children}
</div>
<span className="flex-shrink-0 text-[11px] text-muted-foreground tabular-nums">
{formatTimestamp(timestamp / 1000, "relative")}
</span>
{onToggle && (
<div className="flex-shrink-0 text-muted-foreground">
{expanded ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</div>
)}
</div>
{expanded && details && (
<div className="pl-7 pr-2 py-2 space-y-2 bg-muted/30 border-b border-border text-xs">
{details}
</div>
)}
</div>
);
}
// ============================================================================
// Entry Renderers
// ============================================================================
function PublishRelayRow({
relay,
status,
publishTimestamp,
onRetry,
}: {
relay: string;
status: RelayStatusEntry;
publishTimestamp: number;
onRetry?: () => void;
}) {
const isTerminal = status.status === "success" || status.status === "error";
return (
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
{status.status === "success" && (
<Check className="size-3 text-success flex-shrink-0" />
)}
{status.status === "error" && (
<X className="size-3 text-destructive flex-shrink-0" />
)}
{(status.status === "pending" || status.status === "publishing") && (
<Loader2 className="size-3 animate-spin text-muted-foreground flex-shrink-0" />
)}
<RelayLink
url={relay}
showInboxOutbox={false}
className="flex-1 min-w-0"
/>
{isTerminal && (
<span className="text-[10px] text-muted-foreground tabular-nums">
{formatRelayTime(publishTimestamp, status.updatedAt)}
</span>
)}
{status.status === "error" && onRetry && (
<Button
size="sm"
variant="ghost"
className="h-5 text-[11px] px-1.5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onRetry();
}}
>
<RotateCcw className="size-2.5 mr-0.5" />
Retry
</Button>
)}
</div>
{status.error && (
<div className="pl-[18px] text-[10px] text-destructive/80 break-words">
{status.error}
</div>
)}
</div>
);
}
function PublishEntry({
entry,
onRetry,
onRetryRelay,
}: {
entry: PublishLogEntry;
onRetry?: (entryId: string) => void;
onRetryRelay?: (entryId: string, relay: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const statuses = Array.from(entry.relayStatus.values());
const successCount = statuses.filter((s) => s.status === "success").length;
const errorCount = statuses.filter((s) => s.status === "error").length;
const isPending = statuses.some(
(s) => s.status === "pending" || s.status === "publishing",
);
return (
<EntryRow
icon={<Send className="size-3.5 text-info" />}
tooltip="Publish"
timestamp={entry.timestamp}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
details={
<>
<div className="space-y-1">
{Array.from(entry.relayStatus.entries()).map(([relay, status]) => (
<PublishRelayRow
key={relay}
relay={relay}
status={status}
publishTimestamp={entry.timestamp}
onRetry={
onRetryRelay ? () => onRetryRelay(entry.id, relay) : undefined
}
/>
))}
</div>
<div className="rounded border border-border overflow-hidden">
<EventErrorBoundary event={entry.event}>
<KindRenderer event={entry.event} />
</EventErrorBoundary>
</div>
{errorCount > 0 && onRetry && (
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
className="h-6 text-xs px-2"
onClick={(e) => {
e.stopPropagation();
onRetry(entry.id);
}}
>
<RotateCcw className="size-3 mr-1" />
Retry all ({errorCount})
</Button>
</div>
)}
</>
}
>
<KindBadge
kind={entry.event.kind}
className="text-xs gap-1"
iconClassname="size-3 text-muted-foreground"
/>
{isPending && (
<Loader2 className="size-3 animate-spin text-muted-foreground" />
)}
{!isPending && successCount > 0 && (
<span className="text-success tabular-nums">{successCount} ok</span>
)}
{!isPending && errorCount > 0 && (
<span className="text-destructive tabular-nums">{errorCount} fail</span>
)}
</EntryRow>
);
}
function ConnectEntry({ entry }: { entry: ConnectLogEntry }) {
const [expanded, setExpanded] = useState(false);
const isConnect = entry.type === "CONNECT";
return (
<EntryRow
icon={
isConnect ? (
<Wifi className="size-3.5 text-success" />
) : (
<WifiOff className="size-3.5 text-destructive/70" />
)
}
tooltip={isConnect ? "Connected" : "Disconnected"}
timestamp={entry.timestamp}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
details={
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Event:</span>
<span>{isConnect ? "Connected" : "Disconnected"}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Time:</span>
<span className="tabular-nums">
{formatTimestamp(entry.timestamp / 1000, "absolute")}
</span>
</div>
</div>
}
>
<RelayLink url={entry.relay} showInboxOutbox={false} />
</EntryRow>
);
}
function ErrorEntry({ entry }: { entry: ErrorLogEntry }) {
const [expanded, setExpanded] = useState(false);
return (
<EntryRow
icon={<AlertTriangle className="size-3.5 text-destructive" />}
tooltip="Connection error"
timestamp={entry.timestamp}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
details={
<div className="space-y-1">
<div className="text-destructive/80 break-words font-mono">
{entry.message}
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Time:</span>
<span className="tabular-nums">
{formatTimestamp(entry.timestamp / 1000, "absolute")}
</span>
</div>
</div>
}
>
<RelayLink url={entry.relay} showInboxOutbox={false} />
</EntryRow>
);
}
function AuthEntry({ entry }: { entry: AuthLogEntry }) {
const [expanded, setExpanded] = useState(false);
return (
<EntryRow
icon={
entry.status === "success" ? (
<Shield className="size-3.5 text-success" />
) : entry.status === "failed" ? (
<ShieldAlert className="size-3.5 text-destructive" />
) : entry.status === "challenge" ? (
<ShieldAlert className="size-3.5 text-warning" />
) : (
<ShieldAlert className="size-3.5 text-muted-foreground" />
)
}
tooltip={AUTH_STATUS_TOOLTIP[entry.status] ?? "Auth"}
timestamp={entry.timestamp}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
details={
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Status:</span>
<span
className={cn(
entry.status === "success" && "text-success",
entry.status === "failed" && "text-destructive",
entry.status === "challenge" && "text-warning",
entry.status === "rejected" && "text-muted-foreground",
)}
>
{entry.status}
</span>
</div>
{entry.challenge && (
<div className="text-[10px] text-muted-foreground font-mono truncate">
challenge: {entry.challenge}
</div>
)}
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Time:</span>
<span className="tabular-nums">
{formatTimestamp(entry.timestamp / 1000, "absolute")}
</span>
</div>
</div>
}
>
<RelayLink url={entry.relay} showInboxOutbox={false} />
</EntryRow>
);
}
function NoticeEntry({ entry }: { entry: NoticeLogEntry }) {
const [expanded, setExpanded] = useState(false);
return (
<EntryRow
icon={<MessageSquare className="size-3.5 text-warning" />}
tooltip="Notice"
timestamp={entry.timestamp}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
details={
<div className="space-y-1">
<div className="text-muted-foreground break-words">
{entry.message}
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<span>Time:</span>
<span className="tabular-nums">
{formatTimestamp(entry.timestamp / 1000, "absolute")}
</span>
</div>
</div>
}
>
<RelayLink url={entry.relay} showInboxOutbox={false} />
</EntryRow>
);
}
function LogEntryRenderer({
entry,
onRetry,
onRetryRelay,
}: {
entry: LogEntry;
onRetry?: (entryId: string) => void;
onRetryRelay?: (entryId: string, relay: string) => void;
}) {
switch (entry.type) {
case "PUBLISH":
return (
<PublishEntry
entry={entry}
onRetry={onRetry}
onRetryRelay={onRetryRelay}
/>
);
case "CONNECT":
case "DISCONNECT":
return <ConnectEntry entry={entry as ConnectLogEntry} />;
case "ERROR":
return <ErrorEntry entry={entry as ErrorLogEntry} />;
case "AUTH":
return <AuthEntry entry={entry as AuthLogEntry} />;
case "NOTICE":
return <NoticeEntry entry={entry as NoticeLogEntry} />;
default:
return null;
}
}
// ============================================================================
// Main Component
// ============================================================================
function getTabCount(
tab: TabFilter,
totalCount: number,
typeCounts: Record<string, number>,
): 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<TabFilter>("all");
const filterTypes = useMemo(() => TAB_TYPE_MAP[activeTab], [activeTab]);
const {
entries,
clear,
retryFailedRelays,
retryRelay,
totalCount,
typeCounts,
} = useEventLog({ types: filterTypes });
const renderItem = useCallback(
(_index: number, entry: LogEntry) => (
<LogEntryRenderer
entry={entry}
onRetry={retryFailedRelays}
onRetryRelay={retryRelay}
/>
),
[retryFailedRelays, retryRelay],
);
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-2 py-1 border-b border-border gap-2">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabFilter)}
>
<TabsList className="h-7">
{TAB_FILTERS.map((tab) => {
const count = getTabCount(tab.value, totalCount, typeCounts);
return (
<TabsTrigger
key={tab.value}
value={tab.value}
className="text-xs px-1.5 h-5 gap-1"
>
{tab.label}
{count > 0 && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{count}
</span>
)}
</TabsTrigger>
);
})}
</TabsList>
</Tabs>
<Button
size="sm"
variant="ghost"
className="size-6 p-0 text-destructive/70 hover:text-destructive hover:bg-destructive/10"
onClick={clear}
title="Clear log"
>
<Trash2 className="size-3" />
</Button>
</div>
{/* Log entries */}
<div className="flex-1">
{entries.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p className="text-xs">No events logged yet</p>
</div>
) : (
<Virtuoso
data={entries}
itemContent={renderItem}
style={{ height: "100%" }}
/>
)}
</div>
</div>
);
}

View File

@@ -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,
),
);
// Update status to publishing
setRelayStates((prev) =>
prev.map((r) =>
r.url === relayUrl
? { ...r, status: "publishing" as RelayPublishStatus }
: r,
),
);
// Republish the same signed event
await pool.publish([relayUrl], lastPublishedEvent);
// Retry via PublishService (skipEventStore since it's already in store)
const result = await publishService.retryRelays(lastPublishedEvent, [
relayUrl,
]);
// Update status to success
setRelayStates((prev) =>
prev.map((r) =>
r.url === relayUrl
? { ...r, status: "success" as RelayStatus, error: undefined }
: r,
),
);
toast.success(`Published to ${relayUrl.replace(/^wss?:\/\//, "")}`);
} catch (error) {
console.error(`Failed to retry publish to ${relayUrl}:`, error);
if (result.ok) {
setRelayStates((prev) =>
prev.map((r) =>
r.url === relayUrl
? {
...r,
status: "error" as RelayStatus,
error:
error instanceof Error ? error.message : "Unknown error",
status: "success" as RelayPublishStatus,
error: undefined,
}
: r,
),
);
toast.success(`Published to ${relayUrl.replace(/^wss?:\/\//, "")}`);
} else {
const error = result.failed[0]?.error || "Unknown error";
setRelayStates((prev) =>
prev.map((r) =>
r.url === relayUrl
? {
...r,
status: "error" as RelayPublishStatus,
error,
}
: r,
),
@@ -409,67 +411,40 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
}
// Signing succeeded, now publish to relays
try {
// Store the signed event for potential retries
setLastPublishedEvent(event);
// Store the signed event for potential retries
setLastPublishedEvent(event);
// Update relay states - set selected to publishing, keep others as pending
// Use PublishService with status updates
const { updates$, result } = publishService.publishWithUpdates(
event,
selected,
);
// 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);
try {
// Wait for publish to complete
const publishResult = await result;
// 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);
// Unsubscribe from updates
subscription.unsubscribe();
// 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);
// Check how many relays succeeded
const successCount = results.filter(
(r) => r.status === "fulfilled" && r.value.success,
).length;
if (successCount > 0) {
// At least one relay succeeded - add to event store
eventStore.add(event);
const successCount = publishResult.successful.length;
if (publishResult.ok) {
// Clear draft from localStorage
if (pubkey) {
const draftKey = windowId
@@ -501,16 +476,17 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
);
}
} catch (error) {
subscription.unsubscribe();
console.error("Failed to publish:", error);
toast.error(
error instanceof Error ? error.message : "Failed to publish note",
);
// Reset relay states to pending on publishing error
// Reset relay states to error on publishing error
setRelayStates((prev) =>
prev.map((r) => ({
...r,
status: "error" as RelayStatus,
status: "error" as RelayPublishStatus,
error: error instanceof Error ? error.message : "Unknown error",
})),
);
@@ -518,7 +494,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
setIsPublishing(false);
}
},
[canSign, signer, pubkey, selectedRelays, settings],
[canSign, signer, pubkey, selectedRelays, settings, windowId],
);
// Handle file paste
@@ -585,7 +561,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
// Add to relay states
setRelayStates((prev) => [
...prev,
{ url: normalizedUrl, status: "pending" as RelayStatus },
{ url: normalizedUrl, status: "pending" as RelayPublishStatus },
]);
// Select the new relay

View File

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

View File

@@ -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 = <SettingsViewer />;
break;
case "log":
content = <EventLogViewer />;
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -66,6 +66,7 @@ export function EmojiPickerDialog({
const [searchResults, setSearchResults] = useState<EmojiSearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Use the same emoji search hook as chat autocomplete
const { service } = useEmojiSearch();
@@ -247,7 +248,13 @@ export function EmojiPickerDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xs p-0 gap-0 overflow-hidden">
<DialogContent
className="max-w-xs p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => {
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({
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search emojis..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="pl-9"
autoFocus
/>
</div>
</div>

110
src/hooks/useEventLog.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* React hook for accessing the Event Log
*
* Provides reactive access to relay operation logs with filtering capabilities.
*/
import { useState, useEffect, useCallback, useMemo } from "react";
import eventLog, {
type LogEntry,
type EventLogType,
} from "@/services/event-log";
export interface UseEventLogOptions {
/** Filter by event type(s) */
types?: EventLogType[];
/** Filter by relay URL */
relay?: string;
/** Maximum entries to return */
limit?: number;
}
export interface UseEventLogResult {
/** Filtered log entries */
entries: LogEntry[];
/** Clear all log entries */
clear: () => void;
/** Retry failed relays for a publish entry */
retryFailedRelays: (entryId: string) => Promise<void>;
/** Retry a single relay for a publish entry */
retryRelay: (entryId: string, relay: string) => Promise<void>;
/** Total count of all entries (before filtering) */
totalCount: number;
/** Per-type counts (before filtering) */
typeCounts: Record<string, number>;
}
/**
* Hook to access and filter event log entries
*
* @example
* ```tsx
* const { entries } = useEventLog();
* const { entries } = useEventLog({ types: ["PUBLISH", "CONNECT"] });
* const { entries } = useEventLog({ relay: "wss://relay.example.com/" });
* ```
*/
export function useEventLog(
options: UseEventLogOptions = {},
): UseEventLogResult {
const { types, relay, limit } = options;
const [entries, setEntries] = useState<LogEntry[]>(() =>
eventLog.getEntries(),
);
// Subscribe to log updates
useEffect(() => {
const subscription = eventLog.entries$.subscribe(setEntries);
return () => subscription.unsubscribe();
}, []);
// Filter entries based on options
const filteredEntries = useMemo(() => {
let result = entries;
if (types && types.length > 0) {
result = result.filter((e) => types.includes(e.type));
}
if (relay) {
result = result.filter((e) => e.relay === relay);
}
if (limit && limit > 0) {
result = result.slice(0, limit);
}
return result;
}, [entries, types, relay, limit]);
const clear = useCallback(() => eventLog.clear(), []);
const retryFailedRelays = useCallback(
(entryId: string) => eventLog.retryFailedRelays(entryId),
[],
);
const retryRelay = useCallback(
(entryId: string, relay: string) => eventLog.retryRelay(entryId, relay),
[],
);
// Per-type counts from unfiltered entries
const typeCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const e of entries) {
counts[e.type] = (counts[e.type] || 0) + 1;
}
return counts;
}, [entries]);
return {
entries: filteredEntries,
clear,
retryFailedRelays,
retryRelay,
totalCount: entries.length,
typeCounts,
};
}

View File

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

View File

@@ -31,7 +31,7 @@ export const darkTheme: Theme = {
muted: "217.2 32.6% 17.5%",
mutedForeground: "215 20.2% 70%",
destructive: "0 62.8% 30.6%",
destructive: "0 72% 63%",
destructiveForeground: "210 40% 98%",
border: "217.2 32.6% 17.5%",
@@ -39,9 +39,9 @@ export const darkTheme: Theme = {
ring: "212.7 26.8% 83.9%",
// Status colors
success: "142 76% 36%",
warning: "45 93% 47%",
info: "199 89% 48%",
success: "142 76% 46%",
warning: "38 92% 60%",
info: "199 89% 58%",
// Nostr-specific colors
zap: "45 93% 58%", // Gold/yellow for zaps

560
src/services/event-log.ts Normal file
View File

@@ -0,0 +1,560 @@
/**
* Event Log Service
*
* Provides an ephemeral log of relay operations for introspection:
* - PUBLISH events with per-relay status and timing
* - CONNECT/DISCONNECT events
* - ERROR events for connection failures
* - AUTH events
* - NOTICE events
*
* Uses RxJS for reactive updates and maintains a circular buffer
* of recent events (configurable max size).
*/
import { BehaviorSubject, Subject, Subscription } from "rxjs";
import { startWith, pairwise, filter } from "rxjs/operators";
import type { NostrEvent } from "nostr-tools";
import publishService, {
type PublishEvent,
type RelayStatusUpdate,
} from "./publish-service";
import pool from "./relay-pool";
import type { IRelay } from "applesauce-relay";
// ============================================================================
// Types
// ============================================================================
/** Types of events tracked in the log */
export type EventLogType =
| "PUBLISH"
| "CONNECT"
| "DISCONNECT"
| "ERROR"
| "AUTH"
| "NOTICE";
/** Per-relay status with timing */
export interface RelayStatusEntry {
status: string;
error?: string;
/** Timestamp of the last status transition */
updatedAt: number;
}
/** Base interface for all log entries */
interface BaseLogEntry {
/** Unique ID for this log entry */
id: string;
/** Type of event */
type: EventLogType;
/** Timestamp when event occurred */
timestamp: number;
/** Relay URL (if applicable) */
relay?: string;
}
/** Publish event log entry */
export interface PublishLogEntry extends BaseLogEntry {
type: "PUBLISH";
/** The Nostr event being published */
event: NostrEvent;
/** Target relays */
relays: string[];
/** Per-relay status with timing */
relayStatus: Map<string, RelayStatusEntry>;
/** Overall status: pending, partial, success, failed */
status: "pending" | "partial" | "success" | "failed";
/** Publish ID from PublishService */
publishId: string;
}
/** Connection event log entry */
export interface ConnectLogEntry extends BaseLogEntry {
type: "CONNECT" | "DISCONNECT";
relay: string;
}
/** Connection error log entry */
export interface ErrorLogEntry extends BaseLogEntry {
type: "ERROR";
relay: string;
/** Error message */
message: string;
}
/** Auth event log entry */
export interface AuthLogEntry extends BaseLogEntry {
type: "AUTH";
relay: string;
/** Auth status: challenge, success, failed, rejected */
status: "challenge" | "success" | "failed" | "rejected";
/** Challenge string (for challenge events) */
challenge?: string;
}
/** Notice event log entry */
export interface NoticeLogEntry extends BaseLogEntry {
type: "NOTICE";
relay: string;
/** Notice message from relay */
message: string;
}
/** Union type for all log entries */
export type LogEntry =
| PublishLogEntry
| ConnectLogEntry
| ErrorLogEntry
| AuthLogEntry
| NoticeLogEntry;
/** Helper type for creating new entries (id/timestamp auto-generated) */
type NewEntry<T extends LogEntry> = Omit<T, "id" | "timestamp"> & {
id?: string;
timestamp?: number;
};
type AddEntryInput =
| NewEntry<PublishLogEntry>
| NewEntry<ConnectLogEntry>
| NewEntry<ErrorLogEntry>
| NewEntry<AuthLogEntry>
| NewEntry<NoticeLogEntry>;
// ============================================================================
// EventLogService Class
// ============================================================================
/** Interval for polling new relays (ms) */
const RELAY_POLL_INTERVAL = 5000;
class EventLogService {
/** Maximum number of entries to keep in the log */
private maxEntries: number;
/** Circular buffer of log entries */
private entries: LogEntry[] = [];
/** BehaviorSubject for reactive updates */
private entriesSubject = new BehaviorSubject<LogEntry[]>([]);
/** Subject for new entry notifications */
private newEntrySubject = new Subject<LogEntry>();
/** Active subscriptions */
private subscriptions: Subscription[] = [];
/** Relay subscriptions for connection/auth/notice tracking */
private relaySubscriptions = new Map<string, Subscription>();
/** Counter for generating unique IDs */
private idCounter = 0;
/** Map of publish IDs to log entry IDs */
private publishIdToEntryId = new Map<string, string>();
/** Track last seen notice per relay to prevent duplicates */
private lastNoticePerRelay = new Map<string, string>();
/** Polling interval for new relays */
private pollingIntervalId?: NodeJS.Timeout;
constructor(maxEntries = 500) {
this.maxEntries = maxEntries;
}
// --------------------------------------------------------------------------
// Public Observables
// --------------------------------------------------------------------------
/** Observable of all log entries (emits current state on subscribe) */
readonly entries$ = this.entriesSubject.asObservable();
/** Observable of new entries as they arrive */
readonly newEntry$ = this.newEntrySubject.asObservable();
// --------------------------------------------------------------------------
// Initialization
// --------------------------------------------------------------------------
/**
* Initialize the event log service
* Subscribes to PublishService and relay pool events
*/
initialize(): void {
// Subscribe to publish events
this.subscriptions.push(
publishService.publish$.subscribe((event) =>
this.handlePublishEvent(event),
),
);
// Subscribe to per-relay status updates
this.subscriptions.push(
publishService.status$.subscribe((update) =>
this.handleStatusUpdate(update),
),
);
// Monitor existing relays
pool.relays.forEach((relay) => this.monitorRelay(relay));
// Poll for new relays (infrequent — new relays don't appear often)
this.pollingIntervalId = setInterval(() => {
pool.relays.forEach((relay) => {
if (!this.relaySubscriptions.has(relay.url)) {
this.monitorRelay(relay);
}
});
}, RELAY_POLL_INTERVAL);
}
/**
* Clean up subscriptions
*/
destroy(): void {
this.subscriptions.forEach((sub) => sub.unsubscribe());
this.subscriptions = [];
this.relaySubscriptions.forEach((sub) => sub.unsubscribe());
this.relaySubscriptions.clear();
if (this.pollingIntervalId) {
clearInterval(this.pollingIntervalId);
this.pollingIntervalId = undefined;
}
}
// --------------------------------------------------------------------------
// Relay Monitoring
// --------------------------------------------------------------------------
/**
* Monitor a relay for connection, error, auth, and notice events
*/
private monitorRelay(relay: IRelay): void {
const url = relay.url;
if (this.relaySubscriptions.has(url)) return;
const subscription = new Subscription();
// Track connection state changes
subscription.add(
relay.connected$
.pipe(
startWith(relay.connected),
pairwise(),
filter(([prev, curr]) => prev !== curr),
)
.subscribe(([, connected]) => {
this.addEntry({
type: connected ? "CONNECT" : "DISCONNECT",
relay: url,
});
}),
);
// Track connection errors
subscription.add(
relay.error$
.pipe(filter((error): error is Error => error !== null))
.subscribe((error) => {
this.addEntry({
type: "ERROR",
relay: url,
message: error.message || "Unknown connection error",
});
}),
);
// Track authentication events
subscription.add(
relay.authenticated$
.pipe(
startWith(relay.authenticated),
pairwise(),
filter(([prev, curr]) => prev !== curr && curr === true),
)
.subscribe(() => {
this.addEntry({
type: "AUTH",
relay: url,
status: "success",
});
}),
);
// Track challenges
subscription.add(
relay.challenge$
.pipe(filter((challenge): challenge is string => !!challenge))
.subscribe((challenge) => {
this.addEntry({
type: "AUTH",
relay: url,
status: "challenge",
challenge,
});
}),
);
// Track notices — deduplicate per relay
subscription.add(
relay.notice$.subscribe((notice) => {
if (
typeof notice === "string" &&
notice &&
notice !== this.lastNoticePerRelay.get(url)
) {
this.lastNoticePerRelay.set(url, notice);
this.addEntry({
type: "NOTICE",
relay: url,
message: notice,
});
}
}),
);
this.relaySubscriptions.set(url, subscription);
}
// --------------------------------------------------------------------------
// Publish Event Handling
// --------------------------------------------------------------------------
/**
* Handle a publish event from PublishService
*/
private handlePublishEvent(event: PublishEvent): void {
// Check if we already have an entry for this publish (avoid duplicates)
const existingEntryId = this.publishIdToEntryId.get(event.id);
if (existingEntryId) {
// Update existing entry immutably
const entryIndex = this.entries.findIndex(
(e) => e.id === existingEntryId && e.type === "PUBLISH",
);
if (entryIndex !== -1) {
const entry = this.entries[entryIndex] as PublishLogEntry;
const newRelayStatus = new Map<string, RelayStatusEntry>();
// 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<string, RelayStatusEntry>();
for (const [relay, status] of event.results) {
relayStatus.set(relay, { ...status, updatedAt: now });
}
const entry: PublishLogEntry = {
id: entryId,
type: "PUBLISH",
timestamp: event.startedAt,
event: event.event,
relays: event.relays,
relayStatus,
status: this.calculatePublishStatus(relayStatus),
publishId: event.id,
};
// Map publish ID to entry ID for status updates
this.publishIdToEntryId.set(event.id, entryId);
this.addEntry(entry);
}
/**
* Handle a per-relay status update from PublishService
*/
private handleStatusUpdate(update: RelayStatusUpdate): void {
const entryId = this.publishIdToEntryId.get(update.publishId);
if (!entryId) return;
// Find the publish entry
const entryIndex = this.entries.findIndex(
(e) => e.id === entryId && e.type === "PUBLISH",
);
if (entryIndex === -1) return;
const entry = this.entries[entryIndex] as PublishLogEntry;
// Update immutably with timing
const newRelayStatus = new Map(entry.relayStatus);
newRelayStatus.set(update.relay, {
status: update.status,
error: update.error,
updatedAt: update.timestamp,
});
const newStatus = this.calculatePublishStatus(newRelayStatus);
this.entries[entryIndex] = {
...entry,
relayStatus: newRelayStatus,
status: newStatus,
};
// Notify subscribers
this.entriesSubject.next([...this.entries]);
}
/**
* Calculate overall publish status from relay results
*/
private calculatePublishStatus(
results: Map<string, RelayStatusEntry>,
): "pending" | "partial" | "success" | "failed" {
const statuses = Array.from(results.values()).map((r) => r.status);
if (statuses.every((s) => s === "pending" || s === "publishing")) {
return "pending";
}
const successCount = statuses.filter((s) => s === "success").length;
const errorCount = statuses.filter((s) => s === "error").length;
if (successCount === statuses.length) {
return "success";
} else if (errorCount === statuses.length) {
return "failed";
} else if (successCount > 0) {
return "partial";
}
return "pending";
}
// --------------------------------------------------------------------------
// Entry Management
// --------------------------------------------------------------------------
/**
* Generate a unique ID for a log entry
*/
private generateId(): string {
return `log_${Date.now()}_${++this.idCounter}`;
}
/**
* Add an entry to the log
* Accepts partial entries without id/timestamp (they will be generated)
*/
private addEntry(entry: AddEntryInput): void {
const fullEntry = {
id: entry.id || this.generateId(),
timestamp: entry.timestamp || Date.now(),
...entry,
} as LogEntry;
// Add to front (most recent first)
this.entries.unshift(fullEntry);
// Trim to max size
if (this.entries.length > this.maxEntries) {
const removed = this.entries.splice(this.maxEntries);
// Clean up publish ID mappings for removed entries
removed.forEach((e) => {
if (e.type === "PUBLISH") {
this.publishIdToEntryId.delete((e as PublishLogEntry).publishId);
}
});
}
// Notify subscribers
this.entriesSubject.next([...this.entries]);
this.newEntrySubject.next(fullEntry);
}
// --------------------------------------------------------------------------
// Public Methods
// --------------------------------------------------------------------------
/**
* Get all log entries
*/
getEntries(): LogEntry[] {
return [...this.entries];
}
/**
* Clear all entries
*/
clear(): void {
this.entries = [];
this.publishIdToEntryId.clear();
this.lastNoticePerRelay.clear();
this.entriesSubject.next([]);
}
/**
* Retry failed relays for a publish entry
*/
async retryFailedRelays(entryId: string): Promise<void> {
const entry = this.entries.find(
(e) => e.id === entryId && e.type === "PUBLISH",
) as PublishLogEntry | undefined;
if (!entry) return;
const failedRelays = Array.from(entry.relayStatus.entries())
.filter(([, status]) => status.status === "error")
.map(([relay]) => relay);
if (failedRelays.length === 0) return;
// Retry via PublishService
await publishService.retryRelays(
entry.event,
failedRelays,
entry.publishId,
);
}
/**
* Retry a single relay for a publish entry
*/
async retryRelay(entryId: string, relay: string): Promise<void> {
const entry = this.entries.find(
(e) => e.id === entryId && e.type === "PUBLISH",
) as PublishLogEntry | undefined;
if (!entry) return;
const status = entry.relayStatus.get(relay);
if (!status || status.status !== "error") return;
await publishService.retryRelays(entry.event, [relay], entry.publishId);
}
}
// ============================================================================
// Singleton Export
// ============================================================================
const eventLog = new EventLogService();
// Initialize on module load
eventLog.initialize();
export default eventLog;

View File

@@ -1,40 +1,51 @@
import { ActionRunner } from "applesauce-actions";
import eventStore from "./event-store";
import { EventFactory } from "applesauce-core/event-factory";
import pool from "./relay-pool";
import { relayListCache } from "./relay-list-cache";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import type { NostrEvent } from "nostr-tools/core";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import { getDefaultStore } from "jotai";
import accountManager from "./accounts";
import publishService from "./publish-service";
import { selectRelaysForPublish } from "./relay-selection";
import { grimoireStateAtom } from "@/core/state";
/**
* 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
* 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 via selectRelaysForPublish():
* 1. Author's outbox relays (kind 10002)
* 2. User's configured write relays (from Grimoire state)
* 3. Seen relays from the event
* 4. Aggregator relays (fallback)
*
* @param event - The signed Nostr event to publish
*/
export async function publishEvent(event: NostrEvent): Promise<void> {
// Try to get author's outbox relays from EventStore (kind 10002)
let relays = await relayListCache.getOutboxRelays(event.pubkey);
const seenRelays = getSeenRelays(event);
const relays = await selectRelaysForPublish(event.pubkey, {
writeRelays: getStateWriteRelays(),
relayHints: seenRelays ? Array.from(seenRelays) : [],
});
// Fallback to relays from the event itself (where it was seen)
if (!relays || relays.length === 0) {
const seenRelays = getSeenRelays(event);
relays = seenRelays ? Array.from(seenRelays) : [];
const result = await publishService.publish(event, relays);
if (!result.ok) {
const errors = result.failed
.map((f) => `${f.relay}: ${f.error}`)
.join(", ");
throw new Error(`Failed to publish to any relay. Errors: ${errors}`);
}
// 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.",
);
}
// Publish to relay pool
await pool.publish(relays, event);
// Add to EventStore for immediate local availability
eventStore.add(event);
}
const factory = new EventFactory();
@@ -46,7 +57,7 @@ const factory = new EventFactory();
* Configured with:
* - EventStore: Single source of truth for Nostr events
* - EventFactory: Creates and signs events
* - publishEvent: Publishes events to author's outbox relays (with fallback to seen relays)
* - publishEvent: Publishes events via outbox relay selection + PublishService
*/
export const hub = new ActionRunner(eventStore, factory, publishEvent);
@@ -56,20 +67,26 @@ accountManager.active$.subscribe((account) => {
factory.setSigner(account?.signer || undefined);
});
/**
* Publishes a Nostr event to specific relays
*
* @param event - The signed Nostr event to publish
* @param relays - Explicit list of relay URLs to publish to
*/
export async function publishEventToRelays(
event: NostrEvent,
relays: string[],
): Promise<void> {
// If no relays, throw error
if (relays.length === 0) {
throw new Error(
"No relays found for publishing. Please configure relay list (kind 10002) or ensure event has relay hints.",
);
throw new Error("No relays provided for publishing.");
}
// Publish to relay pool
await pool.publish(relays, event);
const result = await publishService.publish(event, relays);
// Add to EventStore for immediate local availability
eventStore.add(event);
if (!result.ok) {
const errors = result.failed
.map((f) => `${f.relay}: ${f.error}`)
.join(", ");
throw new Error(`Failed to publish to any relay. Errors: ${errors}`);
}
}

View File

@@ -0,0 +1,306 @@
/**
* Centralized Publish Service
*
* Provides a unified API for publishing Nostr events with:
* - Per-relay status tracking via RxJS observables
* - EventStore integration
* - Logging/observability hooks for EventLogService
*
* Relay selection is NOT handled here — callers must provide
* an explicit relay list. Use selectRelaysForPublish() or
* selectRelaysForInteraction() from relay-selection.ts.
*
* All publishing in Grimoire should go through this service.
*/
import { Subject, Observable } from "rxjs";
import { filter } from "rxjs/operators";
import type { NostrEvent } from "nostr-tools";
import pool from "./relay-pool";
import eventStore from "./event-store";
// ============================================================================
// Types
// ============================================================================
/** Status of a publish attempt to a single relay */
export type RelayPublishStatus = "pending" | "publishing" | "success" | "error";
/** Per-relay status update */
export interface RelayStatusUpdate {
/** Unique ID for this publish operation */
publishId: string;
/** Relay URL */
relay: string;
/** Current status */
status: RelayPublishStatus;
/** Error message if status is 'error' */
error?: string;
/** Timestamp of this status update */
timestamp: number;
}
/** Overall publish operation event */
export interface PublishEvent {
/** Unique ID for this publish operation */
id: string;
/** The event being published */
event: NostrEvent;
/** Target relays */
relays: string[];
/** Timestamp when publish started */
startedAt: number;
/** Timestamp when publish completed (all relays resolved) */
completedAt?: number;
/** Per-relay results */
results: Map<string, { status: RelayPublishStatus; error?: string }>;
}
/** Result returned from publish operations */
export interface PublishResult {
/** Unique ID for this publish operation */
publishId: string;
/** The published event */
event: NostrEvent;
/** Relays that succeeded */
successful: string[];
/** Relays that failed with their errors */
failed: Array<{ relay: string; error: string }>;
/** Whether at least one relay succeeded */
ok: boolean;
}
/** Options for publish operations */
export interface PublishOptions {
/** Skip adding to EventStore after publish */
skipEventStore?: boolean;
/** Custom publish ID (for retry operations) */
publishId?: string;
}
// ============================================================================
// PublishService Class
// ============================================================================
class PublishService {
/** Subject for all publish events (start, complete) */
private publishSubject = new Subject<PublishEvent>();
/** Subject for per-relay status updates */
private statusSubject = new Subject<RelayStatusUpdate>();
/** Active publish operations */
private activePublishes = new Map<string, PublishEvent>();
/** 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<RelayStatusUpdate> {
return this.status$.pipe(
filter((update) => update.publishId === publishId),
);
}
// --------------------------------------------------------------------------
// Publish Methods
// --------------------------------------------------------------------------
/**
* Generate a unique publish ID
*/
private generatePublishId(): string {
return `pub_${Date.now()}_${++this.publishCounter}`;
}
/**
* Publish an event to the given relays
*
* Callers must provide an explicit relay list — use selectRelaysForPublish()
* or selectRelaysForInteraction() from relay-selection.ts to build it.
*/
async publish(
event: NostrEvent,
relays: string[],
options: PublishOptions = {},
): Promise<PublishResult> {
const publishId = options.publishId || this.generatePublishId();
const startedAt = Date.now();
if (relays.length === 0) {
throw new Error(
"No relays provided for publishing. Use selectRelaysForPublish() to select relays.",
);
}
// Initialize publish event
const publishEvent: PublishEvent = {
id: publishId,
event,
relays,
startedAt,
results: new Map(),
};
this.activePublishes.set(publishId, publishEvent);
// Emit initial publish event
this.publishSubject.next(publishEvent);
// Emit initial pending status for all relays
for (const relay of relays) {
publishEvent.results.set(relay, { status: "pending" });
this.emitStatus(publishId, relay, "pending");
}
// Publish to each relay individually for status tracking
const publishPromises = relays.map(async (relay) => {
this.emitStatus(publishId, relay, "publishing");
publishEvent.results.set(relay, { status: "publishing" });
try {
// pool.publish returns array of { from: string, ok: boolean, message?: string }
const responses = await pool.publish([relay], event);
const response = responses[0];
// Check if relay accepted the event
if (response && response.ok) {
publishEvent.results.set(relay, { status: "success" });
this.emitStatus(publishId, relay, "success");
return { relay, success: true as const };
} else {
// Relay rejected the event
const error = response?.message || "Relay rejected event";
publishEvent.results.set(relay, { status: "error", error });
this.emitStatus(publishId, relay, "error", error);
return { relay, success: false as const, error };
}
} catch (err) {
const error = err instanceof Error ? err.message : "Unknown error";
publishEvent.results.set(relay, { status: "error", error });
this.emitStatus(publishId, relay, "error", error);
return { relay, success: false as const, error };
}
});
// Wait for all to complete
const results = await Promise.all(publishPromises);
// Update publish event
publishEvent.completedAt = Date.now();
this.publishSubject.next(publishEvent);
// Build result
const successful = results.filter((r) => r.success).map((r) => r.relay);
const failed = results
.filter(
(r): r is { relay: string; success: false; error: string } =>
!r.success,
)
.map((r) => ({ relay: r.relay, error: r.error }));
const result: PublishResult = {
publishId,
event,
successful,
failed,
ok: successful.length > 0,
};
// Add to EventStore if at least one relay succeeded
if (result.ok && !options.skipEventStore) {
eventStore.add(event);
}
// Cleanup
this.activePublishes.delete(publishId);
return result;
}
/**
* Retry publishing to specific relays
*
* Use this to retry failed relays from a previous publish.
*/
async retryRelays(
event: NostrEvent,
relays: string[],
originalPublishId?: string,
): Promise<PublishResult> {
return this.publish(event, relays, {
publishId: originalPublishId ? `${originalPublishId}_retry` : undefined,
skipEventStore: true, // Event should already be in store from original publish
});
}
// --------------------------------------------------------------------------
// Observable-based Publishing (for UI with live updates)
// --------------------------------------------------------------------------
/**
* Start a publish operation and return an Observable of status updates
*
* Use this when you need to show per-relay status in the UI.
* The Observable completes when all relays have resolved.
*/
publishWithUpdates(
event: NostrEvent,
relays: string[],
options: PublishOptions = {},
): {
publishId: string;
updates$: Observable<RelayStatusUpdate>;
result: Promise<PublishResult>;
} {
const publishId = options.publishId || this.generatePublishId();
// Create filtered observable for this publish
const updates$ = this.getStatusUpdates(publishId);
// Start the publish (returns promise)
const result = this.publish(event, relays, { ...options, publishId });
return { publishId, updates$, result };
}
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
/**
* Emit a status update
*/
private emitStatus(
publishId: string,
relay: string,
status: RelayPublishStatus,
error?: string,
): void {
this.statusSubject.next({
publishId,
relay,
status,
error,
timestamp: Date.now(),
});
}
}
// ============================================================================
// Singleton Export
// ============================================================================
const publishService = new PublishService();
export default publishService;

View File

@@ -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<string[]> {
const { writeRelays = [], relayHints = [] } = options;
const relaySets: string[][] = [];
// 1. Author's outbox relays from kind 10002
const outboxRelays = await relayListCache.getOutboxRelays(authorPubkey);
if (outboxRelays && outboxRelays.length > 0) {
relaySets.push(outboxRelays);
}
// 2. Caller-provided write relays
if (writeRelays.length > 0) {
relaySets.push(writeRelays);
}
// 3. Relay hints
if (relayHints.length > 0) {
relaySets.push(relayHints);
}
// 4. Aggregator relays as fallback
relaySets.push(AGGREGATOR_RELAYS);
return mergeRelaySets(...relaySets);
}
/** Maximum number of relays for interactions */
const MAX_INTERACTION_RELAYS = 10;
@@ -574,12 +617,23 @@ export async function selectRelaysForInteraction(
authorPubkey: string,
targetPubkey: string,
): Promise<string[]> {
// 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<void>[] = [];
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 || [];

View File

@@ -25,6 +25,7 @@ export type AppId =
| "zap"
| "post"
| "settings"
| "log"
| "win";
export interface WindowInstance {

View File

@@ -883,4 +883,16 @@ export const manPages: Record<string, ManPageEntry> = {
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: {},
},
};