From a34179a669deb4ce53395c1e77fed8b247f55edb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 09:30:03 +0000 Subject: [PATCH] feat(post): add relay connectivity status, input widget, and improve error handling - Show relay connectivity status (Server/ServerOff icons) next to each relay in list - Add input widget to add custom relays during post composition - Validates relay URLs (supports with/without wss:// prefix) - Normalizes URLs automatically (defaults to wss://) - Shows Plus button enabled only when input is valid - Press Enter or click Plus to add relay - Improve signing failure handling: draft is only cleared after successful publish - New relay input widget disabled during preview mode - Connect to relay pool state using use$() hook for reactive connectivity status Technical details: - Import Server, ServerOff, Plus icons and Input component - Add newRelayInput state for relay URL input - Use pool.relays$ observable to get real-time connection state - Create isValidRelayInput() validator with URL pattern matching - Create handleAddRelay() to normalize and add relays to list - Update relay list rendering to show connectivity icons - Add input + button widget after relay list --- src/components/PostViewer.tsx | 186 ++++++++++++++++++++++++++-------- 1 file changed, 142 insertions(+), 44 deletions(-) diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index ed5d382..a6413ce 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -7,10 +7,14 @@ import { X, RotateCcw, Settings, + Server, + ServerOff, + Plus, } 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, @@ -30,6 +34,8 @@ import eventStore from "@/services/event-store"; import { EventFactory } from "applesauce-core/event-factory"; import { useGrimoire } from "@/core/state"; import { AGGREGATOR_RELAYS } from "@/services/loaders"; +import { normalizeRelayURL } from "@/lib/relay-url"; +import { use$ } from "applesauce-react/hooks"; // Per-relay publish status type RelayStatus = "pending" | "publishing" | "success" | "error"; @@ -60,6 +66,10 @@ export function PostViewer() { const [lastPublishedEvent, setLastPublishedEvent] = useState(null); const [showPublishedPreview, setShowPublishedPreview] = useState(false); const [includeClientTag, setIncludeClientTag] = useState(true); + 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(() => { @@ -439,6 +449,54 @@ export function PostViewer() { editorRef.current?.focus(); }, [pubkey]); + // 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 RelayStatus }, + ]); + + // Select the new relay + setSelectedRelays((prev) => new Set([...prev, normalizedUrl])); + + // Clear input + setNewRelayInput(""); + + toast.success(`Added ${normalizedUrl.replace(/^wss?:\/\//, "")}`); + } 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 ( @@ -572,54 +630,94 @@ export function PostViewer() {
- {relayStates.map((relay) => ( -
-
- toggleRelay(relay.url)} - disabled={isPublishing || showPublishedPreview} - /> - -
+ {relayStates.map((relay) => { + // Get relay connection state from pool + const poolRelay = relayPoolMap?.get(relay.url); + const isConnected = poolRelay?.connected ?? false; - {/* Status indicator */} -
- {relay.status === "publishing" && ( - - )} - {relay.status === "success" && ( - - )} - {relay.status === "error" && ( - - )} + + +
+ + {/* Status indicator */} +
+ {relay.status === "publishing" && ( + + )} + {relay.status === "success" && ( + + )} + {relay.status === "error" && ( + + )} +
-
- ))} + ); + })} + + {/* Add relay input */} + {!showPublishedPreview && ( +
+ setNewRelayInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && isValidRelayInput(newRelayInput)) { + handleAddRelay(); + } + }} + disabled={isPublishing} + className="flex-1 text-sm" + /> + +
+ )} {/* Upload dialog */}