refactor: migrate publishing to unified PublishingService

- Migrate hub.ts to use PublishingService for event publishing
- Migrate PostViewer to use PublishingService.signAndPublish()
- Migrate delete-event action to use PublishingService
- Migrate publish-spell action to use PublishingService
- Add LOG command to view publishing activity and history
- Create PublishHistoryViewer component with sign/publish tabs
- Update tests for new publishing patterns

The unified PublishingService now tracks all sign and publish
operations with per-relay status, enabling transparency into
event delivery and the ability to replay/republish events.
This commit is contained in:
Claude
2026-01-21 17:02:05 +00:00
parent b4f0b35200
commit a30cc76d03
10 changed files with 540 additions and 237 deletions

View File

@@ -1,5 +1,4 @@
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import { EventFactory } from "applesauce-core/event-factory";
import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
@@ -7,6 +6,7 @@ import { mergeRelaySets } from "applesauce-core/helpers";
import { grimoireStateAtom } from "@/core/state";
import { getDefaultStore } from "jotai";
import { NostrEvent } from "@/types/nostr";
import { publishingService } from "@/services/publishing";
export class DeleteEventAction {
type = "delete-event";
@@ -46,7 +46,14 @@ export class DeleteEventAction {
AGGREGATOR_RELAYS,
);
// Publish to all target relays
await pool.publish(writeRelays, event);
// Publish to all target relays using PublishingService
const result = await publishingService.publish(event, {
mode: "explicit",
relays: writeRelays,
});
if (result.status === "failed") {
throw new Error("Failed to publish deletion event to any relay");
}
}
}

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 { publishingService } from "@/services/publishing";
import * as spellStorage from "@/services/spell-storage";
import { LocalSpell } from "@/services/db";
@@ -15,9 +15,12 @@ vi.mock("@/services/accounts", () => ({
},
}));
vi.mock("@/services/relay-pool", () => ({
default: {
publish: vi.fn(),
vi.mock("@/services/publishing", () => ({
publishingService: {
publish: vi.fn().mockResolvedValue({
status: "success",
relayResults: {},
}),
},
}));
@@ -31,12 +34,6 @@ vi.mock("@/services/relay-list-cache", () => ({
},
}));
vi.mock("@/services/event-store", () => ({
default: {
add: vi.fn(),
},
}));
describe("PublishSpellAction", () => {
let action: PublishSpellAction;
@@ -92,8 +89,8 @@ describe("PublishSpellAction", () => {
// Check if signer was called
expect(mockSigner.signEvent).toHaveBeenCalled();
// Check if published to pool
expect(pool.publish).toHaveBeenCalled();
// Check if published via PublishingService
expect(publishingService.publish).toHaveBeenCalled();
// Check if storage updated
expect(spellStorage.markSpellPublished).toHaveBeenCalledWith(

View File

@@ -1,6 +1,5 @@
import { LocalSpell } from "@/services/db";
import accountManager from "@/services/accounts";
import pool from "@/services/relay-pool";
import { encodeSpell } from "@/lib/spell-conversion";
import { markSpellPublished } from "@/services/spell-storage";
import { EventFactory } from "applesauce-core/event-factory";
@@ -8,7 +7,7 @@ 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 { publishingService } from "@/services/publishing";
export class PublishSpellAction {
type = "publish-spell";
@@ -68,13 +67,17 @@ export class PublishSpellAction {
);
}
// Publish to all target relays
// Publish to all target relays using PublishingService
const result = await publishingService.publish(event, {
mode: "explicit",
relays,
});
await pool.publish(relays, event);
// Add to event store for immediate availability
eventStore.add(event);
await markSpellPublished(spell.id, event);
// Only mark as published if at least one relay succeeded
if (result.status !== "failed") {
await markSpellPublished(spell.id, event);
} else {
throw new Error("Failed to publish spell to any relay");
}
}
}

View File

@@ -31,8 +31,7 @@ import type { BlobAttachment, EmojiTag } from "./editor/MentionEditor";
import { RelayLink } from "./nostr/RelayLink";
import { Kind1Renderer } from "./nostr/kinds";
import pool from "@/services/relay-pool";
import eventStore from "@/services/event-store";
import { EventFactory } from "applesauce-core/event-factory";
import { publishingService } from "@/services/publishing";
import { useGrimoire } from "@/core/state";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { normalizeRelayURL } from "@/lib/relay-url";
@@ -66,7 +65,7 @@ interface PostViewerProps {
}
export function PostViewer({ windowId }: PostViewerProps = {}) {
const { pubkey, canSign, signer } = useAccount();
const { pubkey, canSign } = useAccount();
const { searchProfiles } = useProfileSearch();
const { searchEmojis } = useEmojiSearch();
const { state } = useGrimoire();
@@ -293,39 +292,65 @@ 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 RelayStatus }
: r,
),
);
// Republish the same signed event
await pool.publish([relayUrl], lastPublishedEvent);
// Republish using PublishingService
const result = await publishingService.publish(
lastPublishedEvent,
{ mode: "explicit", relays: [relayUrl] },
{
onRelayStatus: (relay, relayResult) => {
if (relayResult.status === "success") {
setRelayStates((prev) =>
prev.map((r) =>
r.url === relay
? {
...r,
status: "success" as RelayStatus,
error: undefined,
}
: r,
),
);
toast.success(
`Published to ${relayUrl.replace(/^wss?:\/\//, "")}`,
);
} else if (relayResult.status === "failed") {
setRelayStates((prev) =>
prev.map((r) =>
r.url === relay
? {
...r,
status: "error" as RelayStatus,
error: relayResult.error || "Unknown error",
}
: r,
),
);
toast.error(
`Failed to publish to ${relayUrl.replace(/^wss?:\/\//, "")}`,
);
}
},
},
);
// 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 relay resolver returned no relays (shouldn't happen with explicit mode)
if (result.status === "failed" && result.resolvedRelays.length === 0) {
setRelayStates((prev) =>
prev.map((r) =>
r.url === relayUrl
? {
...r,
status: "error" as RelayStatus,
error:
error instanceof Error ? error.message : "Unknown error",
error: "Failed to resolve relay",
}
: r,
),
@@ -348,7 +373,7 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
eventRefs: string[],
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>,
) => {
if (!canSign || !signer || !pubkey) {
if (!canSign || !pubkey) {
toast.error("Please log in to publish");
return;
}
@@ -366,184 +391,160 @@ export function PostViewer({ windowId }: PostViewerProps = {}) {
setIsPublishing(true);
// Create and sign event first
let event;
try {
// Create event factory with signer
const factory = new EventFactory();
factory.setSigner(signer);
// Build tags array
const tags: string[][] = [];
// Build tags array
const tags: string[][] = [];
// Add p tags for mentions
for (const mentionPubkey of mentions) {
tags.push(["p", mentionPubkey]);
}
// Add p tags for mentions
for (const pubkey of mentions) {
tags.push(["p", pubkey]);
// Add e tags for event references
for (const eventId of eventRefs) {
tags.push(["e", eventId]);
}
// Add a tags for address references
for (const addr of addressRefs) {
tags.push(["a", `${addr.kind}:${addr.pubkey}:${addr.identifier}`]);
}
// Add client tag (if enabled)
if (settings.includeClientTag) {
tags.push(GRIMOIRE_CLIENT_TAG);
}
// Add emoji tags
for (const emoji of emojiTags) {
tags.push(["emoji", emoji.shortcode, emoji.url]);
}
// Add blob attachment tags (imeta)
for (const blob of blobAttachments) {
const imetaTag = [
"imeta",
`url ${blob.url}`,
`m ${blob.mimeType}`,
`x ${blob.sha256}`,
`size ${blob.size}`,
];
if (blob.server) {
imetaTag.push(`server ${blob.server}`);
}
tags.push(imetaTag);
}
// Add e tags for event references
for (const eventId of eventRefs) {
tags.push(["e", eventId]);
}
// Create unsigned event (kind 1 note)
const unsignedEvent = {
kind: 1,
content: content.trim(),
tags,
pubkey,
created_at: Math.floor(Date.now() / 1000),
};
// Add a tags for address references
for (const addr of addressRefs) {
tags.push(["a", `${addr.kind}:${addr.pubkey}:${addr.identifier}`]);
}
// Update relay states - set selected to publishing, keep others as pending
setRelayStates((prev) =>
prev.map((r) =>
selected.includes(r.url)
? { ...r, status: "publishing" as RelayStatus }
: r,
),
);
// Add client tag (if enabled)
if (settings.includeClientTag) {
tags.push(GRIMOIRE_CLIENT_TAG);
}
// Use PublishingService for sign and publish with per-relay tracking
const result = await publishingService.signAndPublish(
unsignedEvent,
{ mode: "explicit", relays: selected },
{
onRelayStatus: (relay, relayResult) => {
if (relayResult.status === "success") {
setRelayStates((prev) =>
prev.map((r) =>
r.url === relay
? {
...r,
status: "success" as RelayStatus,
error: undefined,
}
: r,
),
);
} else if (relayResult.status === "failed") {
setRelayStates((prev) =>
prev.map((r) =>
r.url === relay
? {
...r,
status: "error" as RelayStatus,
error: relayResult.error || "Unknown error",
}
: r,
),
);
}
},
},
);
// Add emoji tags
for (const emoji of emojiTags) {
tags.push(["emoji", emoji.shortcode, emoji.url]);
}
// Add blob attachment tags (imeta)
for (const blob of blobAttachments) {
const imetaTag = [
"imeta",
`url ${blob.url}`,
`m ${blob.mimeType}`,
`x ${blob.sha256}`,
`size ${blob.size}`,
];
if (blob.server) {
imetaTag.push(`server ${blob.server}`);
}
tags.push(imetaTag);
}
// Create and sign event (kind 1 note)
const draft = await factory.build({
kind: 1,
content: content.trim(),
tags,
});
event = await factory.sign(draft);
} catch (error) {
// Signing failed - user might have rejected it
console.error("Failed to sign event:", error);
toast.error(
error instanceof Error ? error.message : "Failed to sign note",
);
// Check signing result
const { signRequest } = result;
if (!signRequest || signRequest.status === "failed") {
console.error("Failed to sign event:", signRequest?.error);
toast.error(signRequest?.error || "Failed to sign note");
setIsPublishing(false);
// Reset relay states to pending
setRelayStates((prev) =>
prev.map((r) => ({ ...r, status: "pending" as RelayStatus })),
);
return; // Don't destroy the post, let user try again
}
// Signing succeeded, now publish to relays
try {
// Store the signed event for potential retries
setLastPublishedEvent(event);
// Store the signed event for potential retries
const signedEvent = signRequest.signedEvent!;
setLastPublishedEvent(signedEvent);
// Update relay states - set selected to publishing, keep others as pending
setRelayStates((prev) =>
prev.map((r) =>
selected.includes(r.url)
? { ...r, status: "publishing" as RelayStatus }
: r,
),
);
// Check publish results
const publishRequest = result.publishRequest;
const successCount = Object.values(publishRequest.relayResults).filter(
(r) => r.status === "success",
).length;
// Publish to each relay individually to track status
const publishPromises = selected.map(async (relayUrl) => {
try {
await pool.publish([relayUrl], event);
if (successCount > 0) {
// Clear draft from localStorage
if (pubkey) {
const draftKey = windowId
? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}`
: `${DRAFT_STORAGE_KEY}-${pubkey}`;
localStorage.removeItem(draftKey);
}
// 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);
// Clear editor content
editorRef.current?.clear();
// 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 };
}
});
// Show published preview
setShowPublishedPreview(true);
// 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);
// Clear draft from localStorage
if (pubkey) {
const draftKey = windowId
? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}`
: `${DRAFT_STORAGE_KEY}-${pubkey}`;
localStorage.removeItem(draftKey);
}
// Clear editor content
editorRef.current?.clear();
// Show published preview
setShowPublishedPreview(true);
// Show success toast
if (successCount === selected.length) {
toast.success(
`Published to all ${selected.length} relay${selected.length > 1 ? "s" : ""}`,
);
} else {
toast.warning(
`Published to ${successCount} of ${selected.length} relays`,
);
}
// Show success toast
if (successCount === selected.length) {
toast.success(
`Published to all ${selected.length} relay${selected.length > 1 ? "s" : ""}`,
);
} else {
// All relays failed - keep the editor visible with content
toast.error(
"Failed to publish to any relay. Please check your relay connections and try again.",
toast.warning(
`Published to ${successCount} of ${selected.length} relays`,
);
}
} catch (error) {
console.error("Failed to publish:", error);
} else {
// All relays failed - keep the editor visible with content
toast.error(
error instanceof Error ? error.message : "Failed to publish note",
"Failed to publish to any relay. Please check your relay connections and try again.",
);
// Reset relay states to pending on publishing error
setRelayStates((prev) =>
prev.map((r) => ({
...r,
status: "error" as RelayStatus,
error: error instanceof Error ? error.message : "Unknown error",
})),
);
} finally {
setIsPublishing(false);
}
setIsPublishing(false);
},
[canSign, signer, pubkey, selectedRelays, settings],
[canSign, pubkey, selectedRelays, settings, windowId],
);
// Handle file paste

View File

@@ -0,0 +1,276 @@
import { useMemo, useState } from "react";
import {
Check,
X,
Clock,
RefreshCw,
AlertTriangle,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { usePublishing } from "@/hooks/usePublishing";
import { Button } from "./ui/button";
import { RelayLink } from "./nostr/RelayLink";
import { formatDistanceToNow } from "date-fns";
import type { SignRequest, PublishRequest } from "@/types/publishing";
function StatusIcon({ status }: { status: string }) {
switch (status) {
case "success":
return <Check className="h-4 w-4 text-green-500" />;
case "failed":
return <X className="h-4 w-4 text-red-500" />;
case "partial":
return <AlertTriangle className="h-4 w-4 text-yellow-500" />;
case "pending":
return <Clock className="h-4 w-4 text-muted-foreground animate-pulse" />;
default:
return null;
}
}
function SignRequestRow({ request }: { request: SignRequest }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border-b border-border last:border-0">
<div
className="flex items-center gap-3 py-2 px-3 hover:bg-muted/50 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
<StatusIcon status={request.status} />
<span className="font-mono text-sm">
Kind {request.unsignedEvent.kind}
</span>
<span className="text-muted-foreground text-xs">
{formatDistanceToNow(request.timestamp, { addSuffix: true })}
</span>
{request.duration && (
<span className="text-muted-foreground text-xs ml-auto">
{request.duration}ms
</span>
)}
</div>
{expanded && (
<div className="bg-muted/30 px-10 py-2 text-xs">
<div className="space-y-1">
<div className="flex gap-2">
<span className="text-muted-foreground">ID:</span>
<span className="font-mono truncate">{request.id}</span>
</div>
{request.signedEvent && (
<div className="flex gap-2">
<span className="text-muted-foreground">Event ID:</span>
<span className="font-mono truncate">
{request.signedEvent.id}
</span>
</div>
)}
{request.error && (
<div className="flex gap-2 text-red-500">
<span>Error:</span>
<span>{request.error}</span>
</div>
)}
<div className="flex gap-2">
<span className="text-muted-foreground">Content:</span>
<span className="truncate max-w-md">
{request.unsignedEvent.content.slice(0, 100)}
{request.unsignedEvent.content.length > 100 && "..."}
</span>
</div>
</div>
</div>
)}
</div>
);
}
function PublishRequestRow({ request }: { request: PublishRequest }) {
const [expanded, setExpanded] = useState(false);
const relayStats = useMemo(() => {
const results = Object.values(request.relayResults);
return {
total: results.length,
success: results.filter((r) => r.status === "success").length,
failed: results.filter((r) => r.status === "failed").length,
pending: results.filter((r) => r.status === "pending").length,
};
}, [request.relayResults]);
return (
<div className="border-b border-border last:border-0">
<div
className="flex items-center gap-3 py-2 px-3 hover:bg-muted/50 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
<StatusIcon status={request.status} />
<span className="font-mono text-sm">Kind {request.event.kind}</span>
<span className="text-muted-foreground text-xs">
{relayStats.success}/{relayStats.total} relays
</span>
<span className="text-muted-foreground text-xs">
{formatDistanceToNow(request.timestamp, { addSuffix: true })}
</span>
{request.duration && (
<span className="text-muted-foreground text-xs ml-auto">
{request.duration}ms
</span>
)}
</div>
{expanded && (
<div className="bg-muted/30 px-10 py-2 text-xs">
<div className="space-y-2">
<div className="flex gap-2">
<span className="text-muted-foreground">Event ID:</span>
<span className="font-mono truncate">{request.eventId}</span>
</div>
<div className="flex gap-2">
<span className="text-muted-foreground">Mode:</span>
<span>{request.relayMode.mode}</span>
</div>
<div>
<span className="text-muted-foreground mb-1 block">
Relay Results:
</span>
<div className="space-y-1 pl-2">
{Object.entries(request.relayResults).map(([relay, result]) => (
<div key={relay} className="flex items-center gap-2">
<StatusIcon status={result.status} />
<RelayLink
url={relay}
showInboxOutbox={false}
className="text-xs"
/>
{result.error && (
<span className="text-red-500 text-xs truncate">
{result.error}
</span>
)}
{result.completedAt && result.startedAt && (
<span className="text-muted-foreground ml-auto">
{result.completedAt - result.startedAt}ms
</span>
)}
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
);
}
export function PublishHistoryViewer() {
const { signHistory, publishHistory, stats, clearAllHistory } =
usePublishing();
const [tab, setTab] = useState<"publish" | "sign">("publish");
return (
<div className="h-full w-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-lg font-semibold">Publishing Activity</h2>
<Button
variant="outline"
size="sm"
onClick={() => clearAllHistory()}
className="gap-2"
>
<RefreshCw className="h-3 w-3" />
Clear History
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4 p-4 border-b bg-muted/30">
<div className="text-center">
<div className="text-2xl font-bold">{stats.totalPublishRequests}</div>
<div className="text-xs text-muted-foreground">Total Publishes</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-500">
{stats.successfulPublishes}
</div>
<div className="text-xs text-muted-foreground">Successful</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-500">
{stats.partialPublishes}
</div>
<div className="text-xs text-muted-foreground">Partial</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-500">
{stats.failedPublishes}
</div>
<div className="text-xs text-muted-foreground">Failed</div>
</div>
</div>
{/* Tabs */}
<div className="flex border-b">
<button
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === "publish"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setTab("publish")}
>
Publish History ({publishHistory.length})
</button>
<button
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === "sign"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={() => setTab("sign")}
>
Sign History ({signHistory.length})
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
{tab === "publish" ? (
publishHistory.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
No publish history yet. Events you publish will appear here.
</div>
) : (
<div>
{publishHistory.map((request) => (
<PublishRequestRow key={request.id} request={request} />
))}
</div>
)
) : signHistory.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
No sign history yet. Events you sign will appear here.
</div>
) : (
<div>
{signHistory.map((request) => (
<SignRequestRow key={request.id} request={request} />
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -50,6 +50,11 @@ const CountViewer = lazy(() => import("./CountViewer"));
const PostViewer = lazy(() =>
import("./PostViewer").then((m) => ({ default: m.PostViewer })),
);
const PublishHistoryViewer = lazy(() =>
import("./PublishHistoryViewer").then((m) => ({
default: m.PublishHistoryViewer,
})),
);
// Loading fallback component
function ViewerLoading() {
@@ -194,6 +199,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
case "debug":
content = <DebugViewer />;
break;
case "log":
content = <PublishHistoryViewer />;
break;
case "conn":
content = <ConnViewer />;
break;

View File

@@ -1,40 +1,25 @@
import { ActionRunner } from "applesauce-actions";
import eventStore from "./event-store";
import { EventFactory } from "applesauce-core/event-factory";
import pool from "./relay-pool";
import { relayListCache } from "./relay-list-cache";
import { getSeenRelays } from "applesauce-core/helpers/relays";
import type { NostrEvent } from "nostr-tools/core";
import accountManager from "./accounts";
import { publishingService } from "./publishing";
/**
* 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
* Uses the unified PublishingService for tracking and per-relay status.
*
* @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 result = await publishingService.publish(event, { mode: "outbox" });
// 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) : [];
}
// If still no relays, throw error
if (relays.length === 0) {
// Throw if all relays failed (maintain backwards compatibility)
if (result.status === "failed") {
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();
@@ -56,6 +41,13 @@ accountManager.active$.subscribe((account) => {
factory.setSigner(account?.signer || undefined);
});
/**
* Publishes a Nostr event to specific relays
* Uses the unified PublishingService for tracking and per-relay status.
*
* @param event - The signed Nostr event to publish
* @param relays - Specific relay URLs to publish to
*/
export async function publishEventToRelays(
event: NostrEvent,
relays: string[],
@@ -67,9 +59,13 @@ export async function publishEventToRelays(
);
}
// Publish to relay pool
await pool.publish(relays, event);
const result = await publishingService.publish(event, {
mode: "explicit",
relays,
});
// Add to EventStore for immediate local availability
eventStore.add(event);
// Throw if all relays failed (maintain backwards compatibility)
if (result.status === "failed") {
throw new Error("Failed to publish to any relay.");
}
}

View File

@@ -5,7 +5,7 @@ import type { UnsignedEvent } from "nostr-tools/pure";
// Mock dependencies before importing the service
vi.mock("./relay-pool", () => ({
default: {
publish: vi.fn().mockResolvedValue(undefined),
publish: vi.fn().mockResolvedValue([]),
},
}));
@@ -97,6 +97,7 @@ function createMockUnsignedEvent(
overrides: Partial<UnsignedEvent> = {},
): UnsignedEvent {
return {
pubkey: "test-pubkey",
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
@@ -153,10 +154,11 @@ describe("PublishingService", () => {
});
it("should handle relay failures gracefully", async () => {
vi.mocked(pool.publish).mockImplementation(async (relays) => {
if (relays.includes("wss://relay1.com/")) {
vi.mocked(pool.publish).mockImplementation(async (relays: any) => {
if (Array.isArray(relays) && relays.includes("wss://relay1.com/")) {
throw new Error("Connection failed");
}
return [];
});
const { publishingService } = await import("./publishing");

View File

@@ -24,7 +24,8 @@ export type AppId =
| "wallet"
| "zap"
| "post"
| "win";
| "win"
| "log";
export interface WindowInstance {
id: string;

View File

@@ -127,11 +127,23 @@ export const manPages: Record<string, ManPageEntry> = {
description:
"Display the current application state for debugging purposes. Shows windows, workspaces, active account, and other internal state in a formatted view.",
examples: ["debug View current application state"],
seeAlso: ["help"],
seeAlso: ["help", "log"],
appId: "debug",
category: "System",
defaultProps: {},
},
log: {
name: "log",
section: "1",
synopsis: "log",
description:
"View publishing history and activity log. Shows all sign and publish requests with per-relay status tracking. Useful for debugging publishing issues and verifying event delivery.",
examples: ["log View recent publishing activity"],
seeAlso: ["debug", "post"],
appId: "log",
category: "System",
defaultProps: {},
},
man: {
name: "man",
section: "1",