diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index baf8988..6a81388 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -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(null); - - // Publish state - const [isPublishing, setIsPublishing] = useState(false); - const [relayStates, setRelayStates] = useState([]); - const [selectedRelays, setSelectedRelays] = useState>(new Set()); - const [isEditorEmpty, setIsEditorEmpty] = useState(true); - const [lastPublishedEvent, setLastPublishedEvent] = useState(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 => { + 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 | 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 ( -
-
-

- You need to be logged in to post notes. -

-

- Click the user icon in the top right to log in. -

-
+
+
); - } + }, []); return ( -
-
- {!showPublishedPreview ? ( - <> - {/* Editor */} -
- -
- - {/* Action buttons */} -
- {/* Upload button */} - - - {/* Settings dropdown */} - - - - - - - updateSetting("post", "includeClientTag", checked) - } - > - Include client tag - - - - - {/* Spacer */} -
- - {/* Discard button */} - - - {/* Publish button */} - -
- - ) : ( - <> - {/* Published event preview */} - {lastPublishedEvent && ( -
- -
- )} - - {/* Reset button */} -
- -
- - )} - - {/* Relay selection */} -
-
- - Relays ({selectedRelays.size} selected) - -
- -
- {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 ( -
-
- toggleRelay(relay.url)} - disabled={isPublishing || showPublishedPreview} - /> - {/* Connectivity status icon */} - {isConnected ? ( - - ) : ( - - )} - {/* Auth status icon */} -
- {authIcon.icon} -
- -
- - {/* Status indicator */} -
- {relay.status === "pending" && ( - - )} - {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 */} - {uploadDialog} -
-
+ ); } diff --git a/src/components/composer/Composer.tsx b/src/components/composer/Composer.tsx new file mode 100644 index 0000000..0bcd9e8 --- /dev/null +++ b/src/components/composer/Composer.tsx @@ -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; + /** 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( + ( + { + 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(null); + + // Metadata state + const [title, setTitle] = useState(""); + const [summary, setSummary] = useState(""); + const [image, setImage] = useState(""); + const [labels, setLabels] = useState([]); + + // 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 | 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 ( +
+
+

+ You need to be logged in to {schema.name.toLowerCase()}. +

+

+ Click the user icon in the top right to log in. +

+
+
+ ); + } + + // 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 ( + } + {...editorProps} + /> + ); + } + + return ( + } + {...editorProps} + /> + ); + }; + + // Render title field if configured + const renderTitleField = () => { + if (!schema.metadata.title) return null; + + return ( + 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 ( +
+
+ {!showPublishedPreview ? ( + <> + {/* Title field */} + {renderTitleField()} + + {/* Editor */} +
{renderEditor()}
+ + {/* Action buttons */} +
+ {/* Upload button */} + {schema.media.allowed && ( + + )} + + {/* Settings dropdown */} + + + + + + + updateSetting("post", "includeClientTag", checked) + } + > + Include client tag + + + + + {/* Spacer */} +
+ + {/* Discard button */} + + + {/* Publish button */} + +
+ + ) : ( + <> + {/* Published event preview */} + {publisher.lastPublishedEvent && + renderPreview?.(publisher.lastPublishedEvent)} + + {/* Reset button */} +
+ +
+ + )} + + {/* Relay selection */} +
+
+ + Relays ({relaySelection.selectedRelays.size} selected) + +
+ +
+ {relaySelection.relayStates.map((relay) => { + const isConnected = relaySelection.getConnectionStatus( + relay.url, + ); + const relayState = relaySelection.getRelayAuthState(relay.url); + const authIcon = getAuthIcon(relayState); + + return ( +
+
+ + relaySelection.toggleRelay(relay.url) + } + disabled={ + publisher.isPublishing || showPublishedPreview + } + /> + {isConnected ? ( + + ) : ( + + )} +
+ {authIcon.icon} +
+ +
+ +
+ {relay.status === "pending" && ( + + )} + {relay.status === "publishing" && ( + + )} + {relay.status === "success" && ( + + )} + {relay.status === "error" && ( + + )} +
+
+ ); + })} +
+ + {/* Add relay input */} + {!showPublishedPreview && ( +
+ setNewRelayInput(e.target.value)} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + relaySelection.isValidRelayInput(newRelayInput) + ) { + handleAddRelay(); + } + }} + disabled={publisher.isPublishing} + className="flex-1 text-sm" + /> + +
+ )} +
+ + {/* Upload dialog */} + {uploadDialog} +
+
+ ); + }, +); + +Composer.displayName = "Composer"; diff --git a/src/components/composer/index.ts b/src/components/composer/index.ts new file mode 100644 index 0000000..39fcad8 --- /dev/null +++ b/src/components/composer/index.ts @@ -0,0 +1,10 @@ +/** + * Composer components + */ + +export { + Composer, + type ComposerProps, + type ComposerHandle, + type ComposerInput, +} from "./Composer"; diff --git a/src/hooks/useEventPublisher.ts b/src/hooks/useEventPublisher.ts new file mode 100644 index 0000000..37f21c4 --- /dev/null +++ b/src/hooks/useEventPublisher.ts @@ -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; + /** Retry publishing to a specific relay */ + retryRelay: ( + relayUrl: string, + onRelayStatus: (url: string, status: RelayStatus, error?: string) => void, + ) => Promise; + /** Clear the last published event */ + clearLastEvent: () => void; +} + +export function useEventPublisher(): UseEventPublisherResult { + const { canSign } = useAccount(); + const [isPublishing, setIsPublishing] = useState(false); + const [lastPublishedEvent, setLastPublishedEvent] = + useState(null); + + // Use ref to track the event for retry without stale closure + const lastEventRef = useRef(null); + + // Publish a signed event to relays + const publishEvent = useCallback( + async ( + event: NostrEvent, + relayUrls: string[], + onRelayStatus: (url: string, status: RelayStatus, error?: string) => void, + ): Promise => { + 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 => { + 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, + }; +} diff --git a/src/hooks/useRelaySelection.ts b/src/hooks/useRelaySelection.ts new file mode 100644 index 0000000..c710dd1 --- /dev/null +++ b/src/hooks/useRelaySelection.ts @@ -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; + /** 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["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([]); + const [selectedRelays, setSelectedRelays] = useState>(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, + }; +}