mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 08:57:04 +02:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
276
src/components/PublishHistoryViewer.tsx
Normal file
276
src/components/PublishHistoryViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -24,7 +24,8 @@ export type AppId =
|
||||
| "wallet"
|
||||
| "zap"
|
||||
| "post"
|
||||
| "win";
|
||||
| "win"
|
||||
| "log";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user