refactor: extract hooks and create generic Composer component

- Extract useRelaySelection hook for relay list management and status tracking
- Extract useEventPublisher hook for event signing and publishing
- Create generic Composer component that adapts UI based on schema
- Refactor PostViewer to use Composer with NOTE_SCHEMA

This refactoring separates concerns and makes it easier to create
composers for other event kinds (issues, articles, comments, etc.)
by simply providing the appropriate schema.

https://claude.ai/code/session_01WpZc66saVdASHKrrnz3Tme
This commit is contained in:
Claude
2026-01-29 08:54:46 +00:00
parent 7dd89b7e17
commit 78dcfe4edd
5 changed files with 1302 additions and 806 deletions

View File

@@ -1,836 +1,108 @@
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";
/**
* PostViewer - Note (kind 1) composer using generic Composer
*
* Uses the schema-driven Composer component with NOTE_SCHEMA
* to compose and publish short text notes.
*/
import { useCallback } from "react";
import type { NostrEvent } from "nostr-tools";
import { Composer, type ComposerInput } from "@/components/composer";
import { Kind1Renderer } from "@/components/nostr/kinds";
import { NOTE_SCHEMA } from "@/lib/composer/schemas";
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 } from "./editor/RichEditor";
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 { NoteBlueprint } from "applesauce-common/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";
// Per-relay publish status
type RelayStatus = "pending" | "publishing" | "success" | "error";
interface RelayPublishState {
url: string;
status: RelayStatus;
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();
const { signer } = useAccount();
const { settings } = 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 RelayStatus,
})),
);
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 RelayStatus,
}));
return newRelays.length > 0 ? [...prev, ...newRelays] : prev;
});
}
} catch (err) {
console.error("Failed to load draft:", err);
// Build the kind 1 note event
const handleBuildEvent = useCallback(
async (input: ComposerInput): Promise<NostrEvent> => {
if (!signer) {
throw new Error("No signer available");
}
} else {
draftLoadedRef.current = true;
}
}, [pubkey, windowId]);
// Save draft to localStorage on content change
const saveDraft = useCallback(() => {
if (!pubkey || !editorRef.current) return;
// Create event factory with signer
const factory = new EventFactory();
factory.setSigner(signer);
const content = editorRef.current.getContent();
const editorState = editorRef.current.getJSON();
// Use NoteBlueprint - it auto-extracts hashtags, mentions, and quotes from content!
const draft = await factory.create(NoteBlueprint, input.content, {
emojis: input.emojiTags.map((e) => ({
shortcode: e.shortcode,
url: e.url,
})),
});
const draftKey = windowId
? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}`
: `${DRAFT_STORAGE_KEY}-${pubkey}`;
// Add tags that applesauce doesn't handle yet
const additionalTags: string[][] = [];
if (!content.trim()) {
// Clear draft if empty
localStorage.removeItem(draftKey);
return;
}
// Add subject tag if title provided
if (input.title) {
additionalTags.push(["subject", input.title]);
}
// Identify added relays (those not in writeRelays)
const addedRelays = relayStates
.filter((r) => !writeRelays.includes(r.url))
.map((r) => r.url);
// Add a tags for address references (naddr - not yet supported by applesauce)
for (const addr of input.addressRefs) {
additionalTags.push([
"a",
`${addr.kind}:${addr.pubkey}:${addr.identifier}`,
]);
}
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(),
};
// Add client tag (if enabled)
if (settings?.post?.includeClientTag) {
additionalTags.push(GRIMOIRE_CLIENT_TAG);
}
try {
localStorage.setItem(draftKey, JSON.stringify(draft));
} catch (err) {
console.error("Failed to save draft:", err);
}
}, [pubkey, windowId, selectedRelays, relayStates, writeRelays]);
// Add imeta tags for blob attachments (NIP-92)
for (const blob of input.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);
}
// Debounced draft save on editor changes
const draftSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
// Merge additional tags with blueprint tags
draft.tags.push(...additionalTags);
// Sign and return the event
return factory.sign(draft);
},
[signer, settings?.post?.includeClientTag],
);
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;
}
try {
// 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);
// 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);
setRelayStates((prev) =>
prev.map((r) =>
r.url === relayUrl
? {
...r,
status: "error" as RelayStatus,
error:
error instanceof Error ? error.message : "Unknown 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,
})),
});
// 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
try {
// Store the signed event for potential retries
setLastPublishedEvent(event);
// 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,
),
);
// Publish to each relay individually to track status
const publishPromises = selected.map(async (relayUrl) => {
try {
await pool.publish([relayUrl], event);
// 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);
// 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 };
}
});
// 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`,
);
}
} 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) {
console.error("Failed to publish:", error);
toast.error(
error instanceof Error ? error.message : "Failed to publish note",
);
// 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);
}
},
[canSign, signer, pubkey, selectedRelays, settings],
);
// 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 RelayStatus },
]);
// 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) {
// Render preview of published event
const renderPreview = useCallback((event: NostrEvent) => {
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 className="rounded-lg border border-border bg-muted/10 p-4">
<Kind1Renderer event={event} depth={0} />
</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>
<Composer
schema={NOTE_SCHEMA}
windowId={windowId}
onBuildEvent={handleBuildEvent}
renderPreview={renderPreview}
/>
);
}

View File

@@ -0,0 +1,746 @@
/**
* Composer - Schema-driven event composition
*
* A generic composer component that adapts its UI based on the provided schema.
* Supports different editor types, metadata fields, and relay strategies.
*/
import {
useState,
useRef,
useCallback,
useEffect,
forwardRef,
useImperativeHandle,
} from "react";
import {
Paperclip,
Send,
Loader2,
Settings,
Server,
ServerOff,
Plus,
Circle,
Check,
X,
RotateCcw,
} from "lucide-react";
import { toast } from "sonner";
import type { NostrEvent } from "nostr-tools";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
} from "@/components/ui/dropdown-menu";
import { useAccount } from "@/hooks/useAccount";
import { useProfileSearch } from "@/hooks/useProfileSearch";
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
import { useBlossomUpload } from "@/hooks/useBlossomUpload";
import { useSettings } from "@/hooks/useSettings";
import { useRelaySelection } from "@/hooks/useRelaySelection";
import { useEventPublisher } from "@/hooks/useEventPublisher";
import {
TextEditor,
type TextEditorHandle,
} from "@/components/editor/TextEditor";
import {
MarkdownEditor,
type MarkdownEditorHandle,
} from "@/components/editor/MarkdownEditor";
import type { BlobAttachment, EmojiTag } from "@/components/editor/core/types";
import { RelayLink } from "@/components/nostr/RelayLink";
import { getAuthIcon } from "@/lib/relay-status-utils";
import type { ComposerSchema, ComposerContext } from "@/lib/composer/schema";
// Generic editor handle type
type EditorHandle = TextEditorHandle | MarkdownEditorHandle;
export interface ComposerProps {
/** Schema defining how to compose this event kind */
schema: ComposerSchema;
/** Context for the composition (reply target, group, etc.) */
context?: ComposerContext;
/** Called when event is created and ready to sign */
onBuildEvent: (input: ComposerInput) => Promise<NostrEvent>;
/** Called after successful publish */
onPublished?: (event: NostrEvent) => void;
/** Render published event preview */
renderPreview?: (event: NostrEvent) => React.ReactNode;
/** Additional class name */
className?: string;
/** Window ID for draft storage */
windowId?: string;
}
export interface ComposerInput {
content: string;
title?: string;
summary?: string;
image?: string;
labels?: string[];
emojiTags: EmojiTag[];
blobAttachments: BlobAttachment[];
addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>;
}
export interface ComposerHandle {
/** Focus the editor */
focus: () => void;
/** Clear the editor */
clear: () => void;
/** Get current content */
getContent: () => string;
/** Check if editor is empty */
isEmpty: () => boolean;
}
export const Composer = forwardRef<ComposerHandle, ComposerProps>(
(
{
schema,
context,
onBuildEvent,
onPublished,
renderPreview,
className = "",
windowId,
},
ref,
) => {
const { pubkey, canSign } = useAccount();
const { searchProfiles } = useProfileSearch();
const { searchEmojis } = useEmojiSearch();
const { settings, updateSetting } = useSettings();
// Editor ref
const editorRef = useRef<EditorHandle>(null);
// Metadata state
const [title, setTitle] = useState("");
const [summary, setSummary] = useState("");
const [image, setImage] = useState("");
const [labels, setLabels] = useState<string[]>([]);
// UI state
const [isEditorEmpty, setIsEditorEmpty] = useState(true);
const [showPublishedPreview, setShowPublishedPreview] = useState(false);
const [newRelayInput, setNewRelayInput] = useState("");
// Relay selection hook
const relaySelection = useRelaySelection({
strategy: schema.relays,
contextRelay: context?.groupRelay,
});
// Event publisher hook
const publisher = useEventPublisher();
// Expose handle methods
useImperativeHandle(
ref,
() => ({
focus: () => editorRef.current?.focus(),
clear: () => {
editorRef.current?.clear();
setTitle("");
setSummary("");
setImage("");
setLabels([]);
},
getContent: () => editorRef.current?.getContent() || "",
isEmpty: () => editorRef.current?.isEmpty() ?? true,
}),
[],
);
// Draft storage key
const getDraftKey = useCallback(() => {
if (!schema.drafts.supported || !schema.drafts.storageKey) return null;
return schema.drafts.storageKey({
...context,
windowId,
});
}, [schema.drafts, context, windowId]);
// Track if draft has been loaded
const draftLoadedRef = useRef(false);
// Load draft from localStorage on mount
useEffect(() => {
if (!pubkey || draftLoadedRef.current) return;
const draftKey = getDraftKey();
if (!draftKey) {
draftLoadedRef.current = true;
return;
}
const savedDraft = localStorage.getItem(draftKey);
if (savedDraft) {
try {
const draft = JSON.parse(savedDraft);
draftLoadedRef.current = true;
// Restore editor content with retry logic
if (draft.editorState) {
const trySetContent = (attempts = 0) => {
if (editorRef.current && "setContent" in editorRef.current) {
(editorRef.current as TextEditorHandle).setContent(
draft.editorState,
);
} else if (attempts < 10) {
setTimeout(() => trySetContent(attempts + 1), 50);
}
};
setTimeout(() => trySetContent(), 50);
}
// Restore metadata
if (draft.title) setTitle(draft.title);
if (draft.summary) setSummary(draft.summary);
if (draft.image) setImage(draft.image);
if (draft.labels) setLabels(draft.labels);
// Restore relays
if (draft.selectedRelays && draft.addedRelays) {
relaySelection.restoreRelayStates(
draft.selectedRelays,
draft.addedRelays,
);
}
} catch (err) {
console.error("Failed to load draft:", err);
}
} else {
draftLoadedRef.current = true;
}
}, [pubkey, getDraftKey, relaySelection]);
// Save draft to localStorage
const saveDraft = useCallback(() => {
if (!pubkey || !editorRef.current) return;
const draftKey = getDraftKey();
if (!draftKey) return;
const content = editorRef.current.getContent();
if (!content.trim() && !title && !summary) {
localStorage.removeItem(draftKey);
return;
}
const draft = {
editorState:
"getJSON" in editorRef.current
? (editorRef.current as TextEditorHandle).getJSON()
: undefined,
title,
summary,
image,
labels,
selectedRelays: Array.from(relaySelection.selectedRelays),
addedRelays: relaySelection.getAddedRelays(),
timestamp: Date.now(),
};
try {
localStorage.setItem(draftKey, JSON.stringify(draft));
} catch (err) {
console.error("Failed to save draft:", err);
}
}, [pubkey, getDraftKey, title, summary, image, labels, relaySelection]);
// Debounced draft save
const draftSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const handleEditorChange = useCallback(() => {
if (editorRef.current) {
setIsEditorEmpty(editorRef.current.isEmpty());
}
if (draftSaveTimeoutRef.current) {
clearTimeout(draftSaveTimeoutRef.current);
}
draftSaveTimeoutRef.current = setTimeout(() => {
saveDraft();
}, 500);
}, [saveDraft]);
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();
}
},
});
// Handle publish
const handlePublish = useCallback(
async (
content: string,
emojiTags: EmojiTag[],
blobAttachments: BlobAttachment[],
addressRefs: Array<{
kind: number;
pubkey: string;
identifier: string;
}>,
) => {
if (!canSign || !pubkey) {
toast.error("Please log in to publish");
return;
}
if (!content.trim() && schema.metadata.title?.required && !title) {
toast.error("Please fill in required fields");
return;
}
const selectedUrls = Array.from(relaySelection.selectedRelays);
if (selectedUrls.length === 0) {
toast.error("Please select at least one relay");
return;
}
try {
// Build the event using the provided callback
const input: ComposerInput = {
content: content.trim(),
title: title || undefined,
summary: summary || undefined,
image: image || undefined,
labels,
emojiTags,
blobAttachments,
addressRefs,
};
const event = await onBuildEvent(input);
// Publish with status tracking
const result = await publisher.publishEvent(
event,
selectedUrls,
relaySelection.updateRelayStatus,
);
if (result.success) {
// Clear draft
const draftKey = getDraftKey();
if (draftKey) {
localStorage.removeItem(draftKey);
}
// Clear editor and metadata
editorRef.current?.clear();
setTitle("");
setSummary("");
setImage("");
setLabels([]);
// Show preview
setShowPublishedPreview(true);
// Notify parent
onPublished?.(event);
}
} catch (error) {
console.error("Failed to create/publish event:", error);
toast.error(
error instanceof Error ? error.message : "Failed to publish",
);
}
},
[
canSign,
pubkey,
schema.metadata.title?.required,
title,
summary,
image,
labels,
relaySelection,
onBuildEvent,
publisher,
getDraftKey,
onPublished,
],
);
// Handle file paste
const handleFilePaste = useCallback(
(files: File[]) => {
if (files.length > 0) {
openUpload();
}
},
[openUpload],
);
// Reset to compose another
const handleReset = useCallback(() => {
setShowPublishedPreview(false);
publisher.clearLastEvent();
relaySelection.resetRelayStates();
editorRef.current?.clear();
setTitle("");
setSummary("");
setImage("");
setLabels([]);
editorRef.current?.focus();
}, [publisher, relaySelection]);
// Discard draft
const handleDiscard = useCallback(() => {
editorRef.current?.clear();
setTitle("");
setSummary("");
setImage("");
setLabels([]);
const draftKey = getDraftKey();
if (draftKey) {
localStorage.removeItem(draftKey);
}
editorRef.current?.focus();
}, [getDraftKey]);
// Add relay
const handleAddRelay = useCallback(() => {
const success = relaySelection.addRelay(newRelayInput);
if (success) {
setNewRelayInput("");
}
}, [newRelayInput, relaySelection]);
// Handle relay retry
const handleRetryRelay = useCallback(
(relayUrl: string) => {
publisher.retryRelay(relayUrl, relaySelection.updateRelayStatus);
},
[publisher, relaySelection],
);
// 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 {schema.name.toLowerCase()}.
</p>
<p className="text-sm text-muted-foreground">
Click the user icon in the top right to log in.
</p>
</div>
</div>
);
}
// Determine which editor to render
const renderEditor = () => {
const editorProps = {
placeholder:
schema.content.placeholder ||
`Write your ${schema.name.toLowerCase()}...`,
onSubmit: handlePublish,
onChange: handleEditorChange,
searchProfiles,
searchEmojis,
onFilePaste: handleFilePaste,
autoFocus: true,
minHeight: schema.content.editor === "markdown" ? 200 : 150,
maxHeight: schema.content.editor === "markdown" ? 600 : 400,
};
if (schema.content.editor === "markdown") {
return (
<MarkdownEditor
ref={editorRef as React.Ref<MarkdownEditorHandle>}
{...editorProps}
/>
);
}
return (
<TextEditor
ref={editorRef as React.Ref<TextEditorHandle>}
{...editorProps}
/>
);
};
// Render title field if configured
const renderTitleField = () => {
if (!schema.metadata.title) return null;
return (
<Input
type="text"
placeholder={
schema.metadata.title.placeholder ||
`${schema.metadata.title.label}...`
}
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={publisher.isPublishing || showPublishedPreview}
className="text-lg font-medium"
/>
);
};
// Check if form is empty
const isFormEmpty = isEditorEmpty && !title && !summary && !image;
return (
<div className={`h-full overflow-y-auto ${className}`}>
<div className="max-w-2xl mx-auto space-y-4 p-4">
{!showPublishedPreview ? (
<>
{/* Title field */}
{renderTitleField()}
{/* Editor */}
<div>{renderEditor()}</div>
{/* Action buttons */}
<div className="flex items-center gap-2">
{/* Upload button */}
{schema.media.allowed && (
<Button
variant="outline"
size="icon"
onClick={() => openUpload()}
disabled={publisher.isPublishing}
title="Upload image/video"
>
<Paperclip className="h-4 w-4" />
</Button>
)}
{/* Settings dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
disabled={publisher.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={publisher.isPublishing || isFormEmpty}
>
Discard
</Button>
{/* Publish button */}
<Button
onClick={() => editorRef.current?.submit()}
disabled={
publisher.isPublishing ||
relaySelection.selectedRelays.size === 0 ||
isFormEmpty
}
className="gap-2 w-32"
>
{publisher.isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4" />
Publish
</>
)}
</Button>
</div>
</>
) : (
<>
{/* Published event preview */}
{publisher.lastPublishedEvent &&
renderPreview?.(publisher.lastPublishedEvent)}
{/* Reset button */}
<div className="flex justify-center">
<Button
variant="outline"
onClick={handleReset}
className="gap-2"
>
<RotateCcw className="h-4 w-4" />
Compose Another {schema.name}
</Button>
</div>
</>
)}
{/* Relay selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Relays ({relaySelection.selectedRelays.size} selected)
</span>
</div>
<div className="space-y-1 max-h-64 overflow-y-auto">
{relaySelection.relayStates.map((relay) => {
const isConnected = relaySelection.getConnectionStatus(
relay.url,
);
const relayState = relaySelection.getRelayAuthState(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={relaySelection.selectedRelays.has(relay.url)}
onCheckedChange={() =>
relaySelection.toggleRelay(relay.url)
}
disabled={
publisher.isPublishing || showPublishedPreview
}
/>
{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" />
)}
<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>
<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={() => handleRetryRelay(relay.url)}
disabled={publisher.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" &&
relaySelection.isValidRelayInput(newRelayInput)
) {
handleAddRelay();
}
}}
disabled={publisher.isPublishing}
className="flex-1 text-sm"
/>
<Button
size="icon"
variant="outline"
onClick={handleAddRelay}
disabled={
publisher.isPublishing ||
!relaySelection.isValidRelayInput(newRelayInput)
}
title="Add relay"
>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
</div>
{/* Upload dialog */}
{uploadDialog}
</div>
</div>
);
},
);
Composer.displayName = "Composer";

View File

@@ -0,0 +1,10 @@
/**
* Composer components
*/
export {
Composer,
type ComposerProps,
type ComposerHandle,
type ComposerInput,
} from "./Composer";

View File

@@ -0,0 +1,187 @@
/**
* useEventPublisher - Event publishing hook
*
* Handles event signing and publishing with per-relay status tracking.
* Works with useRelaySelection for relay management.
*/
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
import type { NostrEvent } from "nostr-tools";
import { useAccount } from "@/hooks/useAccount";
import pool from "@/services/relay-pool";
import eventStore from "@/services/event-store";
import type { RelayStatus } from "@/hooks/useRelaySelection";
export interface PublishResult {
success: boolean;
successCount: number;
totalCount: number;
event: NostrEvent | null;
}
export interface UseEventPublisherResult {
/** Whether currently publishing */
isPublishing: boolean;
/** Last published event (for retries and preview) */
lastPublishedEvent: NostrEvent | null;
/** Publish a signed event to relays */
publishEvent: (
event: NostrEvent,
relayUrls: string[],
onRelayStatus: (url: string, status: RelayStatus, error?: string) => void,
) => Promise<PublishResult>;
/** Retry publishing to a specific relay */
retryRelay: (
relayUrl: string,
onRelayStatus: (url: string, status: RelayStatus, error?: string) => void,
) => Promise<boolean>;
/** Clear the last published event */
clearLastEvent: () => void;
}
export function useEventPublisher(): UseEventPublisherResult {
const { canSign } = useAccount();
const [isPublishing, setIsPublishing] = useState(false);
const [lastPublishedEvent, setLastPublishedEvent] =
useState<NostrEvent | null>(null);
// Use ref to track the event for retry without stale closure
const lastEventRef = useRef<NostrEvent | null>(null);
// Publish a signed event to relays
const publishEvent = useCallback(
async (
event: NostrEvent,
relayUrls: string[],
onRelayStatus: (url: string, status: RelayStatus, error?: string) => void,
): Promise<PublishResult> => {
if (!canSign) {
toast.error("Please log in to publish");
return { success: false, successCount: 0, totalCount: 0, event: null };
}
if (relayUrls.length === 0) {
toast.error("Please select at least one relay");
return { success: false, successCount: 0, totalCount: 0, event: null };
}
setIsPublishing(true);
// Store the signed event for potential retries
setLastPublishedEvent(event);
lastEventRef.current = event;
// Update relay states - set all to publishing
for (const url of relayUrls) {
onRelayStatus(url, "publishing");
}
try {
// Publish to each relay individually to track status
const publishPromises = relayUrls.map(async (relayUrl) => {
try {
await pool.publish([relayUrl], event);
onRelayStatus(relayUrl, "success");
return { success: true, relayUrl };
} catch (error) {
console.error(`Failed to publish to ${relayUrl}:`, error);
onRelayStatus(
relayUrl,
"error",
error instanceof Error ? error.message : "Unknown error",
);
return { success: false, relayUrl };
}
});
// Wait for all publishes to complete
const results = await Promise.allSettled(publishPromises);
// Count successes
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);
// Show success toast
if (successCount === relayUrls.length) {
toast.success(
`Published to all ${relayUrls.length} relay${relayUrls.length > 1 ? "s" : ""}`,
);
} else {
toast.warning(
`Published to ${successCount} of ${relayUrls.length} relays`,
);
}
} else {
// All relays failed
toast.error(
"Failed to publish to any relay. Please check your relay connections and try again.",
);
}
return {
success: successCount > 0,
successCount,
totalCount: relayUrls.length,
event,
};
} finally {
setIsPublishing(false);
}
},
[canSign],
);
// Retry publishing to a specific relay
const retryRelay = useCallback(
async (
relayUrl: string,
onRelayStatus: (url: string, status: RelayStatus, error?: string) => void,
): Promise<boolean> => {
const event = lastEventRef.current;
if (!event) {
toast.error("No event to retry");
return false;
}
try {
onRelayStatus(relayUrl, "publishing");
await pool.publish([relayUrl], event);
onRelayStatus(relayUrl, "success");
toast.success(`Published to ${relayUrl.replace(/^wss?:\/\//, "")}`);
return true;
} catch (error) {
console.error(`Failed to retry publish to ${relayUrl}:`, error);
onRelayStatus(
relayUrl,
"error",
error instanceof Error ? error.message : "Unknown error",
);
toast.error(
`Failed to publish to ${relayUrl.replace(/^wss?:\/\//, "")}`,
);
return false;
}
},
[],
);
// Clear the last published event
const clearLastEvent = useCallback(() => {
setLastPublishedEvent(null);
lastEventRef.current = null;
}, []);
return {
isPublishing,
lastPublishedEvent,
publishEvent,
retryRelay,
clearLastEvent,
};
}

View File

@@ -0,0 +1,281 @@
/**
* useRelaySelection - Relay selection hook for publishing
*
* Handles relay list management, selection state, and status tracking
* for publishing events to multiple relays.
*/
import { useState, useCallback, useMemo, useEffect } from "react";
import { toast } from "sonner";
import { use$ } from "applesauce-react/hooks";
import { useGrimoire } from "@/core/state";
import { useRelayState } from "@/hooks/useRelayState";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { normalizeRelayURL } from "@/lib/relay-url";
import pool from "@/services/relay-pool";
import type { RelayStrategy } from "@/lib/composer/schema";
// Per-relay publish status
export type RelayStatus = "pending" | "publishing" | "success" | "error";
export interface RelayPublishState {
url: string;
status: RelayStatus;
error?: string;
}
export interface RelaySelectionOptions {
/** Relay strategy from schema */
strategy?: RelayStrategy;
/** Address hints for address-bound events */
addressHints?: string[];
/** Context relay (for groups) */
contextRelay?: string;
}
export interface UseRelaySelectionResult {
/** Current relay states */
relayStates: RelayPublishState[];
/** Set of selected relay URLs */
selectedRelays: Set<string>;
/** Toggle a relay's selection */
toggleRelay: (url: string) => void;
/** Add a new relay to the list */
addRelay: (url: string) => boolean;
/** Check if input looks like a valid relay URL */
isValidRelayInput: (input: string) => boolean;
/** Reset relay states to pending */
resetRelayStates: () => void;
/** Update status for a specific relay */
updateRelayStatus: (url: string, status: RelayStatus, error?: string) => void;
/** Set all selected relays to publishing */
setPublishing: () => void;
/** Get relay pool connection status */
getConnectionStatus: (url: string) => boolean;
/** Get relay auth state */
getRelayAuthState: (
url: string,
) => ReturnType<typeof useRelayState>["getRelay"] extends (
url: string,
) => infer R
? R
: never;
/** User's write relays (source list) */
writeRelays: string[];
/** Get added relays (not in writeRelays) */
getAddedRelays: () => string[];
/** Restore relay states (for draft loading) */
restoreRelayStates: (selectedUrls: string[], addedUrls: string[]) => void;
}
export function useRelaySelection(
options: RelaySelectionOptions = {},
): UseRelaySelectionResult {
const { state } = useGrimoire();
const { getRelay } = useRelayState();
// Get relay pool state for connection status
const relayPoolMap = use$(pool.relays$);
// Determine write relays based on strategy
const writeRelays = useMemo(() => {
const { strategy, addressHints, contextRelay } = options;
// Context-only strategy uses only the context relay
if (strategy?.type === "context-only" && contextRelay) {
return [contextRelay];
}
// Get user's write relays from account
const userWriteRelays =
state.activeAccount?.relays?.filter((r) => r.write).map((r) => r.url) ||
[];
// Address-hints strategy: use hints with user outbox fallback
if (strategy?.type === "address-hints" && addressHints?.length) {
return addressHints.length > 0 ? addressHints : userWriteRelays;
}
// Default: user-outbox or aggregator fallback
return userWriteRelays.length > 0 ? userWriteRelays : AGGREGATOR_RELAYS;
}, [state.activeAccount?.relays, options]);
// Relay states
const [relayStates, setRelayStates] = useState<RelayPublishState[]>([]);
const [selectedRelays, setSelectedRelays] = useState<Set<string>>(new Set());
// Initialize/update relay states when write relays change
useEffect(() => {
if (writeRelays.length > 0) {
setRelayStates(
writeRelays.map((url) => ({
url,
status: "pending" as RelayStatus,
})),
);
setSelectedRelays(new Set(writeRelays));
}
}, [writeRelays]);
// 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;
});
}, []);
// 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
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 a new relay to the list
const addRelay = useCallback(
(input: string): boolean => {
const trimmed = input.trim();
if (!trimmed || !isValidRelayInput(trimmed)) return false;
try {
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 false;
}
// Add to relay states
setRelayStates((prev) => [
...prev,
{ url: normalizedUrl, status: "pending" as RelayStatus },
]);
// Select the new relay
setSelectedRelays((prev) => new Set([...prev, normalizedUrl]));
return true;
} catch (error) {
console.error("Failed to add relay:", error);
toast.error(
error instanceof Error ? error.message : "Invalid relay URL",
);
return false;
}
},
[isValidRelayInput, relayStates],
);
// Reset relay states to pending
const resetRelayStates = useCallback(() => {
setRelayStates(
writeRelays.map((url) => ({
url,
status: "pending" as RelayStatus,
})),
);
setSelectedRelays(new Set(writeRelays));
}, [writeRelays]);
// Update status for a specific relay
const updateRelayStatus = useCallback(
(url: string, status: RelayStatus, error?: string) => {
setRelayStates((prev) =>
prev.map((r) =>
r.url === url ? { ...r, status, error: error ?? r.error } : r,
),
);
},
[],
);
// Set all selected relays to publishing
const setPublishing = useCallback(() => {
const selected = Array.from(selectedRelays);
setRelayStates((prev) =>
prev.map((r) =>
selected.includes(r.url)
? { ...r, status: "publishing" as RelayStatus }
: r,
),
);
}, [selectedRelays]);
// Get relay connection status
const getConnectionStatus = useCallback(
(url: string): boolean => {
const poolRelay = relayPoolMap?.get(url);
return poolRelay?.connected ?? false;
},
[relayPoolMap],
);
// Get relay auth state
const getRelayAuthState = useCallback(
(url: string) => {
return getRelay(url);
},
[getRelay],
);
// Get added relays (not in writeRelays)
const getAddedRelays = useCallback(() => {
return relayStates
.filter((r) => !writeRelays.includes(r.url))
.map((r) => r.url);
}, [relayStates, writeRelays]);
// Restore relay states (for draft loading)
const restoreRelayStates = useCallback(
(selectedUrls: string[], addedUrls: string[]) => {
// Set selected relays
if (selectedUrls.length > 0) {
setSelectedRelays(new Set(selectedUrls));
}
// Add custom relays that aren't in the current list
if (addedUrls.length > 0) {
setRelayStates((prev) => {
const currentUrls = new Set(prev.map((r) => r.url));
const newRelays = addedUrls
.filter((url) => !currentUrls.has(url))
.map((url) => ({
url,
status: "pending" as RelayStatus,
}));
return newRelays.length > 0 ? [...prev, ...newRelays] : prev;
});
}
},
[],
);
return {
relayStates,
selectedRelays,
toggleRelay,
addRelay,
isValidRelayInput,
resetRelayStates,
updateRelayStatus,
setPublishing,
getConnectionStatus,
getRelayAuthState,
writeRelays,
getAddedRelays,
restoreRelayStates,
};
}