mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 10:11:12 +02:00
feat: keep relay selection in call site, compact logs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user