diff --git a/src/actions/delete-event.ts b/src/actions/delete-event.ts index 7b19cbd..7b5d040 100644 --- a/src/actions/delete-event.ts +++ b/src/actions/delete-event.ts @@ -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"); + } } } diff --git a/src/actions/publish-spell.test.ts b/src/actions/publish-spell.test.ts index 078f3ce..18b4ff5 100644 --- a/src/actions/publish-spell.test.ts +++ b/src/actions/publish-spell.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { PublishSpellAction } from "./publish-spell"; import accountManager from "@/services/accounts"; -import pool from "@/services/relay-pool"; +import { 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( diff --git a/src/actions/publish-spell.ts b/src/actions/publish-spell.ts index 0a56152..0e6e0eb 100644 --- a/src/actions/publish-spell.ts +++ b/src/actions/publish-spell.ts @@ -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"); + } } } diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index 449f309..94ae768 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -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 diff --git a/src/components/PublishHistoryViewer.tsx b/src/components/PublishHistoryViewer.tsx new file mode 100644 index 0000000..b0a9f49 --- /dev/null +++ b/src/components/PublishHistoryViewer.tsx @@ -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 ; + case "failed": + return ; + case "partial": + return ; + case "pending": + return ; + default: + return null; + } +} + +function SignRequestRow({ request }: { request: SignRequest }) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
setExpanded(!expanded)} + > + {expanded ? ( + + ) : ( + + )} + + + Kind {request.unsignedEvent.kind} + + + {formatDistanceToNow(request.timestamp, { addSuffix: true })} + + {request.duration && ( + + {request.duration}ms + + )} +
+ {expanded && ( +
+
+
+ ID: + {request.id} +
+ {request.signedEvent && ( +
+ Event ID: + + {request.signedEvent.id} + +
+ )} + {request.error && ( +
+ Error: + {request.error} +
+ )} +
+ Content: + + {request.unsignedEvent.content.slice(0, 100)} + {request.unsignedEvent.content.length > 100 && "..."} + +
+
+
+ )} +
+ ); +} + +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 ( +
+
setExpanded(!expanded)} + > + {expanded ? ( + + ) : ( + + )} + + Kind {request.event.kind} + + {relayStats.success}/{relayStats.total} relays + + + {formatDistanceToNow(request.timestamp, { addSuffix: true })} + + {request.duration && ( + + {request.duration}ms + + )} +
+ {expanded && ( +
+
+
+ Event ID: + {request.eventId} +
+
+ Mode: + {request.relayMode.mode} +
+
+ + Relay Results: + +
+ {Object.entries(request.relayResults).map(([relay, result]) => ( +
+ + + {result.error && ( + + {result.error} + + )} + {result.completedAt && result.startedAt && ( + + {result.completedAt - result.startedAt}ms + + )} +
+ ))} +
+
+
+
+ )} +
+ ); +} + +export function PublishHistoryViewer() { + const { signHistory, publishHistory, stats, clearAllHistory } = + usePublishing(); + const [tab, setTab] = useState<"publish" | "sign">("publish"); + + return ( +
+ {/* Header */} +
+

Publishing Activity

+ +
+ + {/* Stats */} +
+
+
{stats.totalPublishRequests}
+
Total Publishes
+
+
+
+ {stats.successfulPublishes} +
+
Successful
+
+
+
+ {stats.partialPublishes} +
+
Partial
+
+
+
+ {stats.failedPublishes} +
+
Failed
+
+
+ + {/* Tabs */} +
+ + +
+ + {/* Content */} +
+ {tab === "publish" ? ( + publishHistory.length === 0 ? ( +
+ No publish history yet. Events you publish will appear here. +
+ ) : ( +
+ {publishHistory.map((request) => ( + + ))} +
+ ) + ) : signHistory.length === 0 ? ( +
+ No sign history yet. Events you sign will appear here. +
+ ) : ( +
+ {signHistory.map((request) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index fa366d9..622b8e0 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -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 = ; break; + case "log": + content = ; + break; case "conn": content = ; break; diff --git a/src/services/hub.ts b/src/services/hub.ts index cb88765..5d920dc 100644 --- a/src/services/hub.ts +++ b/src/services/hub.ts @@ -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 { - // 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."); + } } diff --git a/src/services/publishing.test.ts b/src/services/publishing.test.ts index ac37d2d..bec2eeb 100644 --- a/src/services/publishing.test.ts +++ b/src/services/publishing.test.ts @@ -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 { 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"); diff --git a/src/types/app.ts b/src/types/app.ts index 779ff89..a25ad59 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -24,7 +24,8 @@ export type AppId = | "wallet" | "zap" | "post" - | "win"; + | "win" + | "log"; export interface WindowInstance { id: string; diff --git a/src/types/man.ts b/src/types/man.ts index 139b568..3a65356 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -127,11 +127,23 @@ export const manPages: Record = { 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",