Files
grimoire/src/components/PostViewer.tsx
Alejandro 80a421b9fe Centralize relay publishing via PublishService (#211)
* feat: centralize publish flow with RxJS-based PublishService

Create a unified PublishService that:
- Provides consistent relay selection (outbox + state + hints + fallbacks)
- Emits RxJS observables for per-relay status updates
- Handles EventStore integration automatically
- Supports both fire-and-forget and observable-based publishing

Refactor all publish locations to use the centralized service:
- hub.ts: Use PublishService for ActionRunner publish
- delete-event.ts: Use PublishService (fixes missing eventStore.add)
- publish-spell.ts: Use PublishService with relay hint support
- PostViewer.tsx: Use publishWithUpdates() for per-relay UI tracking

This lays the groundwork for the event log feature by providing
observable hooks into all publish operations.

* feat: add LOG command for relay event introspection

Add an ephemeral event log system that tracks relay operations:

- EventLogService (src/services/event-log.ts):
  - Subscribes to PublishService for PUBLISH events with per-relay status
  - Monitors relay pool for CONNECT/DISCONNECT events
  - Tracks AUTH challenges and results
  - Captures NOTICE messages from relays
  - Uses RxJS BehaviorSubject for reactive updates
  - Circular buffer with configurable max entries (default 500)

- useEventLog hook (src/hooks/useEventLog.ts):
  - React hook for filtering and accessing log entries
  - Filter by type, relay, or limit
  - Retry failed relays directly from the hook

- EventLogViewer component (src/components/EventLogViewer.tsx):
  - Tab-based filtering (All/Publish/Connect/Auth/Notice)
  - Expandable PUBLISH entries showing per-relay status
  - Click to retry failed relays
  - Auto-scroll to new entries (pause on scroll)
  - Clear log button

- LOG command accessible via Cmd+K palette

* fix: prevent duplicate log entries and check relay OK response

- EventLogService: Check for existing entry before creating new one
  when handling publish events (prevents duplicates from start/complete)
- PublishService: Check response.ok from pool.publish() to detect
  relay rejections instead of assuming success on resolve
- Update test mock to return proper publish response format

* feat: keep relay selection in call site, compact logs

* chore: cleanup

* fix: make Timestamp component locale-aware via formatTimestamp

Timestamp was hardcoded to "es" locale. Now uses formatTimestamp()
from useLocale.ts for consistent locale-aware time formatting.
Added Timestamp to CLAUDE.md shared components documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: improve event-log reliability, add ERROR type and per-relay timing

Service improvements:
- Fix notice$ duplicate logging with per-relay dedup tracking
- Remove dead Array.isArray code path (notice$ emits strings)
- Increase relay poll interval from 1s to 5s
- Clean publishIdToEntryId map on terminal state, not just overflow
- Immutable entry updates (spread instead of in-place mutation)
- Extract NewEntry<T>/AddEntryInput helper types for clean addEntry signature
- Clear lastNoticePerRelay on log clear

New capabilities:
- ERROR log type: subscribes to relay.error$ for connection failure reasons
- RelayStatusEntry with updatedAt timestamp for per-relay response timing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: improve EventLogViewer with virtualization, timing, and error display

- Virtualize log list with react-virtuoso for 500-entry buffer performance
- Add ErrorEntry renderer for new ERROR log type (AlertTriangle icon)
- Show per-relay response time (e.g. "142ms", "2.3s") in publish details
- Make all entry types expandable (connect/disconnect now have details)
- Show absolute timestamp in all expanded detail views
- Group ERROR events under Connect tab filter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent duplicate PUBLISH log entries from completion event

PublishService emits publish$ twice: once at start, once on completion.
The eager publishIdToEntryId cleanup in handleStatusUpdate fired before
the completion emission, causing handlePublishEvent to create a second
entry. Removed eager cleanup — overflow eviction is sufficient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-04 17:35:44 +01:00

818 lines
26 KiB
TypeScript

import { useState, useRef, useCallback, useMemo, useEffect } from "react";
import {
Paperclip,
Send,
Loader2,
Check,
X,
RotateCcw,
Settings,
Server,
ServerOff,
Plus,
Circle,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { Input } from "./ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
} from "./ui/dropdown-menu";
import { useAccount } from "@/hooks/useAccount";
import { useProfileSearch } from "@/hooks/useProfileSearch";
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
import { useRelayState } from "@/hooks/useRelayState";
import { useSettings } from "@/hooks/useSettings";
import {
RichEditor,
type RichEditorHandle,
type BlobAttachment,
type EmojiTag,
} from "./editor/RichEditor";
import { RelayLink } from "./nostr/RelayLink";
import { Kind1Renderer } from "./nostr/kinds";
import pool from "@/services/relay-pool";
import publishService, {
type RelayPublishStatus,
} from "@/services/publish-service";
import { EventFactory } from "applesauce-core/event-factory";
import { NoteBlueprint } from "@/lib/blueprints";
import { useGrimoire } from "@/core/state";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { normalizeRelayURL } from "@/lib/relay-url";
import { use$ } from "applesauce-react/hooks";
import { getAuthIcon } from "@/lib/relay-status-utils";
import { GRIMOIRE_CLIENT_TAG } from "@/constants/app";
interface RelayPublishState {
url: string;
status: RelayPublishStatus;
error?: string;
}
// Storage keys
const DRAFT_STORAGE_KEY = "grimoire-post-draft";
interface PostViewerProps {
windowId?: string;
}
export function PostViewer({ windowId }: PostViewerProps = {}) {
const { pubkey, canSign, signer } = useAccount();
const { searchProfiles } = useProfileSearch();
const { searchEmojis } = useEmojiSearch();
const { state } = useGrimoire();
const { getRelay } = useRelayState();
const { settings, updateSetting } = useSettings();
// Editor ref for programmatic control
const editorRef = useRef<RichEditorHandle>(null);
// Publish state
const [isPublishing, setIsPublishing] = useState(false);
const [relayStates, setRelayStates] = useState<RelayPublishState[]>([]);
const [selectedRelays, setSelectedRelays] = useState<Set<string>>(new Set());
const [isEditorEmpty, setIsEditorEmpty] = useState(true);
const [lastPublishedEvent, setLastPublishedEvent] = useState<any>(null);
const [showPublishedPreview, setShowPublishedPreview] = useState(false);
const [newRelayInput, setNewRelayInput] = useState("");
// Get relay pool state for connection status
const relayPoolMap = use$(pool.relays$);
// Get active account's write relays from Grimoire state, fallback to aggregators
const writeRelays = useMemo(() => {
if (!state.activeAccount?.relays) return AGGREGATOR_RELAYS;
const userWriteRelays = state.activeAccount.relays
.filter((r) => r.write)
.map((r) => r.url);
return userWriteRelays.length > 0 ? userWriteRelays : AGGREGATOR_RELAYS;
}, [state.activeAccount?.relays]);
// Update relay states when write relays change
const updateRelayStates = useCallback(() => {
setRelayStates(
writeRelays.map((url) => ({
url,
status: "pending" as RelayPublishStatus,
})),
);
setSelectedRelays(new Set(writeRelays));
}, [writeRelays]);
// Initialize selected relays when write relays change
useEffect(() => {
if (writeRelays.length > 0) {
updateRelayStates();
}
}, [writeRelays, updateRelayStates]);
// Track if draft has been loaded to prevent re-runs
const draftLoadedRef = useRef(false);
// Load draft from localStorage on mount
useEffect(() => {
if (!pubkey || draftLoadedRef.current) return;
const draftKey = windowId
? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}`
: `${DRAFT_STORAGE_KEY}-${pubkey}`;
const savedDraft = localStorage.getItem(draftKey);
if (savedDraft) {
try {
const draft = JSON.parse(savedDraft);
draftLoadedRef.current = true;
// Restore editor content with retry logic for editor readiness
if (draft.editorState) {
const trySetContent = (attempts = 0) => {
if (editorRef.current) {
editorRef.current.setContent(draft.editorState);
} else if (attempts < 10) {
// Retry up to 10 times with 50ms intervals (500ms total)
setTimeout(() => trySetContent(attempts + 1), 50);
}
};
// Start trying after a short delay to let editor mount
setTimeout(() => trySetContent(), 50);
}
// Restore selected relays
if (draft.selectedRelays && Array.isArray(draft.selectedRelays)) {
setSelectedRelays(new Set(draft.selectedRelays));
}
// Restore added relays (relays not in writeRelays)
if (draft.addedRelays && Array.isArray(draft.addedRelays)) {
setRelayStates((prev) => {
const currentRelayUrls = new Set(prev.map((r) => r.url));
const newRelays = draft.addedRelays
.filter((url: string) => !currentRelayUrls.has(url))
.map((url: string) => ({
url,
status: "pending" as RelayPublishStatus,
}));
return newRelays.length > 0 ? [...prev, ...newRelays] : prev;
});
}
} catch (err) {
console.error("Failed to load draft:", err);
}
} else {
draftLoadedRef.current = true;
}
}, [pubkey, windowId]);
// Save draft to localStorage on content change
const saveDraft = useCallback(() => {
if (!pubkey || !editorRef.current) return;
const content = editorRef.current.getContent();
const editorState = editorRef.current.getJSON();
const draftKey = windowId
? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}`
: `${DRAFT_STORAGE_KEY}-${pubkey}`;
if (!content.trim()) {
// Clear draft if empty
localStorage.removeItem(draftKey);
return;
}
// Identify added relays (those not in writeRelays)
const addedRelays = relayStates
.filter((r) => !writeRelays.includes(r.url))
.map((r) => r.url);
const draft = {
editorState, // Full editor JSON state (preserves blobs, emojis, formatting)
selectedRelays: Array.from(selectedRelays), // Selected relay URLs
addedRelays, // Custom relays added by user
timestamp: Date.now(),
};
try {
localStorage.setItem(draftKey, JSON.stringify(draft));
} catch (err) {
console.error("Failed to save draft:", err);
}
}, [pubkey, windowId, selectedRelays, relayStates, writeRelays]);
// Debounced draft save on editor changes
const draftSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const handleEditorChange = useCallback(() => {
// Update empty state immediately
if (editorRef.current) {
setIsEditorEmpty(editorRef.current.isEmpty());
}
// Debounce draft save (500ms)
if (draftSaveTimeoutRef.current) {
clearTimeout(draftSaveTimeoutRef.current);
}
draftSaveTimeoutRef.current = setTimeout(() => {
saveDraft();
}, 500);
}, [saveDraft]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (draftSaveTimeoutRef.current) {
clearTimeout(draftSaveTimeoutRef.current);
}
};
}, []);
// Blossom upload for attachments
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
accept: "image/*,video/*,audio/*",
onSuccess: (results) => {
if (results.length > 0 && editorRef.current) {
const { blob, server } = results[0];
editorRef.current.insertBlob({
url: blob.url,
sha256: blob.sha256,
mimeType: blob.type,
size: blob.size,
server,
});
editorRef.current.focus();
}
},
});
// Toggle relay selection
const toggleRelay = useCallback((url: string) => {
setSelectedRelays((prev) => {
const next = new Set(prev);
if (next.has(url)) {
next.delete(url);
} else {
next.add(url);
}
return next;
});
}, []);
// Retry publishing to a specific relay
const retryRelay = useCallback(
async (relayUrl: string) => {
// Reuse the last published event instead of recreating it
if (!lastPublishedEvent) {
toast.error("No event to retry");
return;
}
// Update status to publishing
setRelayStates((prev) =>
prev.map((r) =>
r.url === relayUrl
? { ...r, status: "publishing" as RelayPublishStatus }
: r,
),
);
// Retry via PublishService (skipEventStore since it's already in store)
const result = await publishService.retryRelays(lastPublishedEvent, [
relayUrl,
]);
if (result.ok) {
setRelayStates((prev) =>
prev.map((r) =>
r.url === relayUrl
? {
...r,
status: "success" as RelayPublishStatus,
error: undefined,
}
: r,
),
);
toast.success(`Published to ${relayUrl.replace(/^wss?:\/\//, "")}`);
} else {
const error = result.failed[0]?.error || "Unknown error";
setRelayStates((prev) =>
prev.map((r) =>
r.url === relayUrl
? {
...r,
status: "error" as RelayPublishStatus,
error,
}
: r,
),
);
toast.error(
`Failed to publish to ${relayUrl.replace(/^wss?:\/\//, "")}`,
);
}
},
[lastPublishedEvent],
);
// Publish to selected relays with per-relay status tracking
const handlePublish = useCallback(
async (
content: string,
emojiTags: EmojiTag[],
blobAttachments: BlobAttachment[],
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>,
) => {
if (!canSign || !signer || !pubkey) {
toast.error("Please log in to publish");
return;
}
if (!content.trim()) {
toast.error("Cannot publish empty note");
return;
}
const selected = Array.from(selectedRelays);
if (selected.length === 0) {
toast.error("Please select at least one relay");
return;
}
setIsPublishing(true);
// Create and sign event first
let event;
try {
// Create event factory with signer
const factory = new EventFactory();
factory.setSigner(signer);
// Use NoteBlueprint - it auto-extracts hashtags, mentions, and quotes from content!
const draft = await factory.create(NoteBlueprint, content.trim(), {
emojis: emojiTags.map((e) => ({
shortcode: e.shortcode,
url: e.url,
address: e.address,
})),
});
// Add tags that applesauce doesn't handle yet
const additionalTags: string[][] = [];
// Add a tags for address references (naddr - not yet supported by applesauce)
for (const addr of addressRefs) {
additionalTags.push([
"a",
`${addr.kind}:${addr.pubkey}:${addr.identifier}`,
]);
}
// Add client tag (if enabled)
if (settings?.post?.includeClientTag) {
additionalTags.push(GRIMOIRE_CLIENT_TAG);
}
// Add imeta tags for blob attachments (NIP-92)
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}`);
}
additionalTags.push(imetaTag);
}
// Merge additional tags with blueprint tags
draft.tags.push(...additionalTags);
// Sign the event
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",
);
setIsPublishing(false);
return; // Don't destroy the post, let user try again
}
// Signing succeeded, now publish to relays
// Store the signed event for potential retries
setLastPublishedEvent(event);
// Use PublishService with status updates
const { updates$, result } = publishService.publishWithUpdates(
event,
selected,
);
// Subscribe to per-relay status updates for UI
const subscription = updates$.subscribe((update) => {
setRelayStates((prev) =>
prev.map((r) =>
r.url === update.relay
? {
...r,
status: update.status,
error: update.error,
}
: r,
),
);
});
try {
// Wait for publish to complete
const publishResult = await result;
// Unsubscribe from updates
subscription.unsubscribe();
const successCount = publishResult.successful.length;
if (publishResult.ok) {
// 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`,
);
}
} 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.",
);
}
} catch (error) {
subscription.unsubscribe();
console.error("Failed to publish:", error);
toast.error(
error instanceof Error ? error.message : "Failed to publish note",
);
// Reset relay states to error on publishing error
setRelayStates((prev) =>
prev.map((r) => ({
...r,
status: "error" as RelayPublishStatus,
error: error instanceof Error ? error.message : "Unknown error",
})),
);
} finally {
setIsPublishing(false);
}
},
[canSign, signer, pubkey, selectedRelays, settings, windowId],
);
// Handle file paste
const handleFilePaste = useCallback(
(files: File[]) => {
if (files.length > 0) {
// For pasted files, trigger upload dialog
openUpload();
}
},
[openUpload],
);
// Reset form to compose another post
const handleReset = useCallback(() => {
setShowPublishedPreview(false);
setLastPublishedEvent(null);
updateRelayStates();
editorRef.current?.clear();
editorRef.current?.focus();
}, [updateRelayStates]);
// Discard draft and clear editor
const handleDiscard = useCallback(() => {
editorRef.current?.clear();
if (pubkey) {
const draftKey = windowId
? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}`
: `${DRAFT_STORAGE_KEY}-${pubkey}`;
localStorage.removeItem(draftKey);
}
editorRef.current?.focus();
}, [pubkey, windowId]);
// Check if input looks like a valid relay URL
const isValidRelayInput = useCallback((input: string): boolean => {
const trimmed = input.trim();
if (!trimmed) return false;
// Allow relay URLs with or without protocol
// Must have at least a domain part (e.g., "relay.com" or "wss://relay.com")
const urlPattern =
/^(wss?:\/\/)?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}(:[0-9]{1,5})?(\/.*)?$/;
return urlPattern.test(trimmed);
}, []);
// Add new relay to the list
const handleAddRelay = useCallback(() => {
const trimmed = newRelayInput.trim();
if (!trimmed || !isValidRelayInput(trimmed)) return;
try {
// Normalize the URL (adds wss:// if needed)
const normalizedUrl = normalizeRelayURL(trimmed);
// Check if already in list
const alreadyExists = relayStates.some((r) => r.url === normalizedUrl);
if (alreadyExists) {
toast.error("Relay already in list");
return;
}
// Add to relay states
setRelayStates((prev) => [
...prev,
{ url: normalizedUrl, status: "pending" as RelayPublishStatus },
]);
// Select the new relay
setSelectedRelays((prev) => new Set([...prev, normalizedUrl]));
// Clear input
setNewRelayInput("");
} catch (error) {
console.error("Failed to add relay:", error);
toast.error(error instanceof Error ? error.message : "Invalid relay URL");
}
}, [newRelayInput, isValidRelayInput, relayStates]);
// Show login prompt if not logged in
if (!canSign) {
return (
<div className="h-full flex items-center justify-center p-8">
<div className="max-w-md text-center space-y-4">
<p className="text-muted-foreground">
You need to be logged in to post notes.
</p>
<p className="text-sm text-muted-foreground">
Click the user icon in the top right to log in.
</p>
</div>
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto space-y-4 p-4">
{!showPublishedPreview ? (
<>
{/* Editor */}
<div>
<RichEditor
ref={editorRef}
placeholder="What's on your mind?"
onSubmit={handlePublish}
onChange={handleEditorChange}
searchProfiles={searchProfiles}
searchEmojis={searchEmojis}
onFilePaste={handleFilePaste}
autoFocus
minHeight={150}
maxHeight={400}
/>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
{/* Upload button */}
<Button
variant="outline"
size="icon"
onClick={() => openUpload()}
disabled={isPublishing}
title="Upload image/video"
>
<Paperclip className="h-4 w-4" />
</Button>
{/* Settings dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
disabled={isPublishing}
title="Post settings"
>
<Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuCheckboxItem
checked={settings?.post?.includeClientTag ?? true}
onCheckedChange={(checked) =>
updateSetting("post", "includeClientTag", checked)
}
>
Include client tag
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Spacer */}
<div className="flex-1" />
{/* Discard button */}
<Button
variant="outline"
onClick={handleDiscard}
disabled={isPublishing || isEditorEmpty}
>
Discard
</Button>
{/* Publish button */}
<Button
onClick={() => editorRef.current?.submit()}
disabled={
isPublishing || selectedRelays.size === 0 || isEditorEmpty
}
className="gap-2 w-32"
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4" />
Publish
</>
)}
</Button>
</div>
</>
) : (
<>
{/* Published event preview */}
{lastPublishedEvent && (
<div className="rounded-lg border border-border bg-muted/10 p-4">
<Kind1Renderer event={lastPublishedEvent} depth={0} />
</div>
)}
{/* Reset button */}
<div className="flex justify-center">
<Button variant="outline" onClick={handleReset} className="gap-2">
<RotateCcw className="h-4 w-4" />
Compose Another Post
</Button>
</div>
</>
)}
{/* Relay selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Relays ({selectedRelays.size} selected)
</span>
</div>
<div className="space-y-1 max-h-64 overflow-y-auto">
{relayStates.map((relay) => {
// Get relay connection state from pool
const poolRelay = relayPoolMap?.get(relay.url);
const isConnected = poolRelay?.connected ?? false;
// Get relay state for auth status
const relayState = getRelay(relay.url);
const authIcon = getAuthIcon(relayState);
return (
<div
key={relay.url}
className="flex items-center justify-between gap-3 py-1"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Checkbox
id={relay.url}
checked={selectedRelays.has(relay.url)}
onCheckedChange={() => toggleRelay(relay.url)}
disabled={isPublishing || showPublishedPreview}
/>
{/* Connectivity status icon */}
{isConnected ? (
<Server className="h-3 w-3 text-green-500 flex-shrink-0" />
) : (
<ServerOff className="h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
{/* Auth status icon */}
<div className="flex-shrink-0" title={authIcon.label}>
{authIcon.icon}
</div>
<label
htmlFor={relay.url}
className="cursor-pointer truncate flex-1"
onClick={(e) => e.preventDefault()}
>
<RelayLink
url={relay.url}
write={true}
showInboxOutbox={false}
className="text-sm"
/>
</label>
</div>
{/* Status indicator */}
<div className="flex-shrink-0 w-6 flex items-center justify-center">
{relay.status === "pending" && (
<Circle className="h-4 w-4 text-muted-foreground" />
)}
{relay.status === "publishing" && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{relay.status === "success" && (
<Check className="h-4 w-4 text-green-500" />
)}
{relay.status === "error" && (
<button
onClick={() => retryRelay(relay.url)}
disabled={isPublishing}
className="p-0.5 rounded hover:bg-red-500/10 transition-colors"
title={`${relay.error || "Failed to publish"}. Click to retry.`}
>
<X className="h-4 w-4 text-red-500" />
</button>
)}
</div>
</div>
);
})}
</div>
{/* Add relay input */}
{!showPublishedPreview && (
<div className="flex items-center gap-2 pt-2">
<Input
type="text"
placeholder="relay.example.com"
value={newRelayInput}
onChange={(e) => setNewRelayInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && isValidRelayInput(newRelayInput)) {
handleAddRelay();
}
}}
disabled={isPublishing}
className="flex-1 text-sm"
/>
<Button
size="icon"
variant="outline"
onClick={handleAddRelay}
disabled={isPublishing || !isValidRelayInput(newRelayInput)}
title="Add relay"
>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
</div>
{/* Upload dialog */}
{uploadDialog}
</div>
</div>
);
}