feat: keep relay selection in call site, compact logs

This commit is contained in:
Alejandro Gómez
2026-03-04 17:00:57 +01:00
parent b20eda536f
commit 26bb0713ac
12 changed files with 485 additions and 439 deletions

View File

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

View File

@@ -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", () => ({

View File

@@ -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}`)

View File

@@ -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<TabFilter, EventLogType[] | undefined> = {
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 (
<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
// ============================================================================
interface EntryProps {
entry: LogEntry;
onRetry?: (entryId: string) => void;
function PublishRelayRow({
relay,
status,
onRetry,
}: {
relay: string;
status: { status: string; error?: string };
onRetry?: () => void;
}) {
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"
/>
{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,
}: 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 (
<div className="border-b border-border last:border-b-0">
<button
className="w-full flex items-start gap-2 p-2 hover:bg-muted/50 text-left"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-4 w-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 mt-0.5 flex-shrink-0 text-muted-foreground" />
)}
<Send className="h-4 w-4 mt-0.5 flex-shrink-0 text-blue-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatTimestamp(entry.timestamp / 1000, "time")}
</span>
<span className="font-medium">PUBLISH</span>
<span className="text-xs text-muted-foreground">
kind:{entry.event.kind}
</span>
{isPending && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
{!isPending && successCount > 0 && (
<span className="text-xs text-green-500">
{successCount}/{entry.relays.length}
</span>
)}
{!isPending && errorCount > 0 && (
<span className="text-xs text-red-500">{errorCount} failed</span>
)}
</div>
<div className="text-sm text-muted-foreground truncate">
{contentPreview || "(empty content)"}
</div>
</div>
</button>
{expanded && (
<div className="pl-10 pr-2 pb-2 space-y-2">
{/* Relay status list */}
<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]) => (
<div key={relay} className="flex items-center gap-2 text-sm">
{status.status === "success" && (
<Check className="h-3 w-3 text-green-500" />
)}
{status.status === "error" && (
<X className="h-3 w-3 text-red-500" />
)}
{(status.status === "pending" ||
status.status === "publishing") && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
<RelayLink
url={relay}
write={true}
showInboxOutbox={false}
className="text-xs"
/>
{status.error && (
<span className="text-xs text-red-500 truncate">
{status.error}
</span>
)}
</div>
<PublishRelayRow
key={relay}
relay={relay}
status={status}
onRetry={
onRetryRelay ? () => onRetryRelay(entry.id, relay) : undefined
}
/>
))}
</div>
{/* Retry button for failed relays */}
{hasFailures && onRetry && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={(e) => {
e.stopPropagation();
onRetry(entry.id);
}}
>
<RotateCcw className="h-3 w-3 mr-1" />
Retry failed ({errorCount})
</Button>
)}
{/* Event ID */}
<div className="text-xs text-muted-foreground font-mono">
{entry.event.id.slice(0, 16)}...
<div className="rounded border border-border overflow-hidden">
<EventErrorBoundary event={entry.event}>
<KindRenderer event={entry.event} />
</EventErrorBoundary>
</div>
</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" />
)}
</div>
{!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 }: EntryProps & { entry: ConnectLogEntry }) {
function ConnectEntry({ entry }: { entry: ConnectLogEntry }) {
const isConnect = entry.type === "CONNECT";
return (
<div className="flex items-center gap-2 p-2 border-b border-border last:border-b-0">
{isConnect ? (
<Wifi className="h-4 w-4 text-green-500" />
) : (
<WifiOff className="h-4 w-4 text-muted-foreground" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatTimestamp(entry.timestamp / 1000, "time")}
</span>
<span
className={cn(
"font-medium",
isConnect ? "text-green-500" : "text-muted-foreground",
)}
>
{entry.type}
</span>
</div>
<RelayLink
url={entry.relay}
write={true}
showInboxOutbox={false}
className="text-sm"
/>
</div>
</div>
<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}
>
<RelayLink url={entry.relay} showInboxOutbox={false} />
</EntryRow>
);
}
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<string, string> = {
challenge: "Auth challenge",
success: "Auth success",
failed: "Auth failed",
rejected: "Auth rejected",
};
return (
<div className="flex items-center gap-2 p-2 border-b border-border last:border-b-0">
{entry.status === "success" ? (
<Shield className="h-4 w-4 text-green-500" />
) : (
<ShieldAlert className={cn("h-4 w-4", statusColors[entry.status])} />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatTimestamp(entry.timestamp / 1000, "time")}
</span>
<span className="font-medium">AUTH</span>
<span className={cn("text-xs", statusColors[entry.status])}>
{entry.status}
</span>
<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={statusTooltip[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>
<RelayLink
url={entry.relay}
write={true}
showInboxOutbox={false}
className="text-sm"
/>
</div>
</div>
}
>
<RelayLink url={entry.relay} showInboxOutbox={false} />
</EntryRow>
);
}
function NoticeEntry({ entry }: EntryProps & { entry: NoticeLogEntry }) {
function NoticeEntry({ entry }: { entry: NoticeLogEntry }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="flex items-start gap-2 p-2 border-b border-border last:border-b-0">
<MessageSquare className="h-4 w-4 mt-0.5 text-amber-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatTimestamp(entry.timestamp / 1000, "time")}
</span>
<span className="font-medium text-amber-500">NOTICE</span>
</div>
<RelayLink
url={entry.relay}
write={true}
showInboxOutbox={false}
className="text-sm"
/>
<div className="text-sm text-muted-foreground mt-1 break-words">
{entry.message}
</div>
</div>
</div>
<EntryRow
icon={<MessageSquare className="size-3.5 text-warning" />}
tooltip="Notice"
timestamp={entry.timestamp}
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
details={
<div className="text-muted-foreground break-words">{entry.message}</div>
}
>
<RelayLink url={entry.relay} showInboxOutbox={false} />
</EntryRow>
);
}
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 <PublishEntry entry={entry} onRetry={onRetry} />;
return (
<PublishEntry
entry={entry}
onRetry={onRetry}
onRetryRelay={onRetryRelay}
/>
);
case "CONNECT":
case "DISCONNECT":
return <ConnectEntry entry={entry as ConnectLogEntry} />;
@@ -303,29 +391,28 @@ function LogEntryRenderer({ entry, onRetry }: EntryProps) {
export function EventLogViewer() {
const [activeTab, setActiveTab] = useState<TabFilter>("all");
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(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 (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-2 border-b border-border">
<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-8">
<TabsList className="h-7">
{TAB_FILTERS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="text-xs px-2"
className="text-xs px-1.5 h-5 gap-1"
>
{tab.label}
{getTabCount(tab.value) > 0 && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{getTabCount(tab.value)}
</span>
)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{entries.length}
{totalCount !== entries.length && ` / ${totalCount}`} entries
</span>
<Button
size="sm"
variant="ghost"
className="h-7"
onClick={clear}
title="Clear log"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<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
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
<div className="flex-1 overflow-y-auto">
{entries.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="text-center">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No events logged yet</p>
<p className="text-xs mt-1">
Events will appear here as you interact with relays
</p>
</div>
<p className="text-xs">No events logged yet</p>
</div>
) : (
<div>
{entries.map((entry) => (
<LogEntryRenderer
key={entry.id}
entry={entry}
onRetry={handleRetry}
/>
))}
</div>
entries.map((entry) => (
<LogEntryRenderer
key={entry.id}
entry={entry}
onRetry={handleRetry}
onRetryRelay={handleRetryRelay}
/>
))
)}
</div>
{/* Auto-scroll indicator */}
{!autoScroll && entries.length > 0 && (
<div className="absolute bottom-4 right-4">
<Button
size="sm"
variant="secondary"
onClick={() => {
setAutoScroll(true);
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
}}
>
<ChevronDown className="h-3 w-3 mr-1" />
New events
</Button>
</div>
)}
</div>
);
}

View File

@@ -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) => {

View File

@@ -29,8 +29,12 @@ export interface UseEventLogResult {
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>;
}
/**
@@ -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<Record<string, number>>(
(acc, e) => ({ ...acc, [e.type]: (acc[e.type] || 0) + 1 }),
{},
),
[entries],
);
return {
entries: filteredEntries,
publishEntries,
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,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

View File

@@ -497,6 +497,22 @@ class EventLogService {
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);
}
}
// ============================================================================

View File

@@ -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<void> {
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

View File

@@ -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<string[]> {
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<string[]> {
// 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<PublishResult> {
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<PublishOptions, "relays"> = {},
): Promise<PublishResult> {
return this.publish(event, { ...options, relays });
}
/**
* Retry publishing to specific relays
*
@@ -363,8 +246,7 @@ class PublishService {
relays: string[],
originalPublishId?: string,
): Promise<PublishResult> {
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 };
}

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;