import { useState, useRef, useCallback, useMemo, useEffect } from "react"; import { Paperclip, Send, Loader2, Check, X, RotateCcw, Settings, Server, ServerOff, Plus, Circle, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "./ui/button"; import { Checkbox } from "./ui/checkbox"; import { Input } from "./ui/input"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuCheckboxItem, } from "./ui/dropdown-menu"; import { useAccount } from "@/hooks/useAccount"; import { useProfileSearch } from "@/hooks/useProfileSearch"; import { useEmojiSearch } from "@/hooks/useEmojiSearch"; import { useBlossomUpload } from "@/hooks/useBlossomUpload"; import { useRelayState } from "@/hooks/useRelayState"; import { useSettings } from "@/hooks/useSettings"; import { RichEditor, type RichEditorHandle, type BlobAttachment, type EmojiTag, } from "./editor/RichEditor"; import { RelayLink } from "./nostr/RelayLink"; import { Kind1Renderer } from "./nostr/kinds"; import pool from "@/services/relay-pool"; import publishService, { type RelayPublishStatus, } from "@/services/publish-service"; import { EventFactory } from "applesauce-core/event-factory"; import { NoteBlueprint } from "@/lib/blueprints"; import { useGrimoire } from "@/core/state"; import { AGGREGATOR_RELAYS } from "@/services/loaders"; import { normalizeRelayURL } from "@/lib/relay-url"; import { use$ } from "applesauce-react/hooks"; import { getAuthIcon } from "@/lib/relay-status-utils"; import { GRIMOIRE_CLIENT_TAG } from "@/constants/app"; interface RelayPublishState { url: string; status: RelayPublishStatus; error?: string; } // Storage keys const DRAFT_STORAGE_KEY = "grimoire-post-draft"; interface PostViewerProps { windowId?: string; } export function PostViewer({ windowId }: PostViewerProps = {}) { const { pubkey, canSign, signer } = useAccount(); const { searchProfiles } = useProfileSearch(); const { searchEmojis } = useEmojiSearch(); const { state } = useGrimoire(); const { getRelay } = useRelayState(); const { settings, updateSetting } = useSettings(); // Editor ref for programmatic control const editorRef = useRef(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 RelayPublishStatus, })), ); setSelectedRelays(new Set(writeRelays)); }, [writeRelays]); // Initialize selected relays when write relays change useEffect(() => { if (writeRelays.length > 0) { updateRelayStates(); } }, [writeRelays, updateRelayStates]); // Track if draft has been loaded to prevent re-runs const draftLoadedRef = useRef(false); // Load draft from localStorage on mount useEffect(() => { if (!pubkey || draftLoadedRef.current) return; const draftKey = windowId ? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}` : `${DRAFT_STORAGE_KEY}-${pubkey}`; const savedDraft = localStorage.getItem(draftKey); if (savedDraft) { try { const draft = JSON.parse(savedDraft); draftLoadedRef.current = true; // Restore editor content with retry logic for editor readiness if (draft.editorState) { const trySetContent = (attempts = 0) => { if (editorRef.current) { editorRef.current.setContent(draft.editorState); } else if (attempts < 10) { // Retry up to 10 times with 50ms intervals (500ms total) setTimeout(() => trySetContent(attempts + 1), 50); } }; // Start trying after a short delay to let editor mount setTimeout(() => trySetContent(), 50); } // Restore selected relays if (draft.selectedRelays && Array.isArray(draft.selectedRelays)) { setSelectedRelays(new Set(draft.selectedRelays)); } // Restore added relays (relays not in writeRelays) if (draft.addedRelays && Array.isArray(draft.addedRelays)) { setRelayStates((prev) => { const currentRelayUrls = new Set(prev.map((r) => r.url)); const newRelays = draft.addedRelays .filter((url: string) => !currentRelayUrls.has(url)) .map((url: string) => ({ url, status: "pending" as RelayPublishStatus, })); return newRelays.length > 0 ? [...prev, ...newRelays] : prev; }); } } catch (err) { console.error("Failed to load draft:", err); } } else { draftLoadedRef.current = true; } }, [pubkey, windowId]); // Save draft to localStorage on content change const saveDraft = useCallback(() => { if (!pubkey || !editorRef.current) return; const content = editorRef.current.getContent(); const editorState = editorRef.current.getJSON(); const draftKey = windowId ? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}` : `${DRAFT_STORAGE_KEY}-${pubkey}`; if (!content.trim()) { // Clear draft if empty localStorage.removeItem(draftKey); return; } // Identify added relays (those not in writeRelays) const addedRelays = relayStates .filter((r) => !writeRelays.includes(r.url)) .map((r) => r.url); const draft = { editorState, // Full editor JSON state (preserves blobs, emojis, formatting) selectedRelays: Array.from(selectedRelays), // Selected relay URLs addedRelays, // Custom relays added by user timestamp: Date.now(), }; try { localStorage.setItem(draftKey, JSON.stringify(draft)); } catch (err) { console.error("Failed to save draft:", err); } }, [pubkey, windowId, selectedRelays, relayStates, writeRelays]); // Debounced draft save on editor changes const draftSaveTimeoutRef = useRef | null>( null, ); const handleEditorChange = useCallback(() => { // Update empty state immediately if (editorRef.current) { setIsEditorEmpty(editorRef.current.isEmpty()); } // Debounce draft save (500ms) if (draftSaveTimeoutRef.current) { clearTimeout(draftSaveTimeoutRef.current); } draftSaveTimeoutRef.current = setTimeout(() => { saveDraft(); }, 500); }, [saveDraft]); // Cleanup timeout on unmount useEffect(() => { return () => { if (draftSaveTimeoutRef.current) { clearTimeout(draftSaveTimeoutRef.current); } }; }, []); // Blossom upload for attachments const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({ accept: "image/*,video/*,audio/*", onSuccess: (results) => { if (results.length > 0 && editorRef.current) { const { blob, server } = results[0]; editorRef.current.insertBlob({ url: blob.url, sha256: blob.sha256, mimeType: blob.type, size: blob.size, server, }); editorRef.current.focus(); } }, }); // Toggle relay selection const toggleRelay = useCallback((url: string) => { setSelectedRelays((prev) => { const next = new Set(prev); if (next.has(url)) { next.delete(url); } else { next.add(url); } return next; }); }, []); // Retry publishing to a specific relay const retryRelay = useCallback( async (relayUrl: string) => { // Reuse the last published event instead of recreating it if (!lastPublishedEvent) { toast.error("No event to retry"); return; } // Update status to publishing setRelayStates((prev) => prev.map((r) => r.url === relayUrl ? { ...r, status: "publishing" as RelayPublishStatus } : r, ), ); // Retry via PublishService (skipEventStore since it's already in store) const result = await publishService.retryRelays(lastPublishedEvent, [ relayUrl, ]); if (result.ok) { setRelayStates((prev) => prev.map((r) => r.url === relayUrl ? { ...r, status: "success" as RelayPublishStatus, error: undefined, } : r, ), ); toast.success(`Published to ${relayUrl.replace(/^wss?:\/\//, "")}`); } else { const error = result.failed[0]?.error || "Unknown error"; setRelayStates((prev) => prev.map((r) => r.url === relayUrl ? { ...r, status: "error" as RelayPublishStatus, error, } : r, ), ); toast.error( `Failed to publish to ${relayUrl.replace(/^wss?:\/\//, "")}`, ); } }, [lastPublishedEvent], ); // Publish to selected relays with per-relay status tracking const handlePublish = useCallback( async ( content: string, emojiTags: EmojiTag[], blobAttachments: BlobAttachment[], addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>, ) => { if (!canSign || !signer || !pubkey) { toast.error("Please log in to publish"); return; } if (!content.trim()) { toast.error("Cannot publish empty note"); return; } const selected = Array.from(selectedRelays); if (selected.length === 0) { toast.error("Please select at least one relay"); return; } setIsPublishing(true); // Create and sign event first let event; try { // Create event factory with signer const factory = new EventFactory(); factory.setSigner(signer); // Use NoteBlueprint - it auto-extracts hashtags, mentions, and quotes from content! const draft = await factory.create(NoteBlueprint, content.trim(), { emojis: emojiTags.map((e) => ({ shortcode: e.shortcode, url: e.url, address: e.address, })), }); // Add tags that applesauce doesn't handle yet const additionalTags: string[][] = []; // Add a tags for address references (naddr - not yet supported by applesauce) for (const addr of addressRefs) { additionalTags.push([ "a", `${addr.kind}:${addr.pubkey}:${addr.identifier}`, ]); } // Add client tag (if enabled) if (settings?.post?.includeClientTag) { additionalTags.push(GRIMOIRE_CLIENT_TAG); } // Add imeta tags for blob attachments (NIP-92) for (const blob of blobAttachments) { const imetaTag = [ "imeta", `url ${blob.url}`, `m ${blob.mimeType}`, `x ${blob.sha256}`, `size ${blob.size}`, ]; if (blob.server) { imetaTag.push(`server ${blob.server}`); } additionalTags.push(imetaTag); } // Merge additional tags with blueprint tags draft.tags.push(...additionalTags); // Sign the event event = await factory.sign(draft); } catch (error) { // Signing failed - user might have rejected it console.error("Failed to sign event:", error); toast.error( error instanceof Error ? error.message : "Failed to sign note", ); setIsPublishing(false); return; // Don't destroy the post, let user try again } // Signing succeeded, now publish to relays // Store the signed event for potential retries setLastPublishedEvent(event); // Use PublishService with status updates const { updates$, result } = publishService.publishWithUpdates( event, selected, ); // Subscribe to per-relay status updates for UI const subscription = updates$.subscribe((update) => { setRelayStates((prev) => prev.map((r) => r.url === update.relay ? { ...r, status: update.status, error: update.error, } : r, ), ); }); try { // Wait for publish to complete const publishResult = await result; // Unsubscribe from updates subscription.unsubscribe(); const successCount = publishResult.successful.length; if (publishResult.ok) { // Clear draft from localStorage if (pubkey) { const draftKey = windowId ? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}` : `${DRAFT_STORAGE_KEY}-${pubkey}`; localStorage.removeItem(draftKey); } // Clear editor content editorRef.current?.clear(); // Show published preview setShowPublishedPreview(true); // Show success toast if (successCount === selected.length) { toast.success( `Published to all ${selected.length} relay${selected.length > 1 ? "s" : ""}`, ); } else { toast.warning( `Published to ${successCount} of ${selected.length} relays`, ); } } else { // All relays failed - keep the editor visible with content toast.error( "Failed to publish to any relay. Please check your relay connections and try again.", ); } } catch (error) { subscription.unsubscribe(); console.error("Failed to publish:", error); toast.error( error instanceof Error ? error.message : "Failed to publish note", ); // Reset relay states to error on publishing error setRelayStates((prev) => prev.map((r) => ({ ...r, status: "error" as RelayPublishStatus, error: error instanceof Error ? error.message : "Unknown error", })), ); } finally { setIsPublishing(false); } }, [canSign, signer, pubkey, selectedRelays, settings, windowId], ); // Handle file paste const handleFilePaste = useCallback( (files: File[]) => { if (files.length > 0) { // For pasted files, trigger upload dialog openUpload(); } }, [openUpload], ); // Reset form to compose another post const handleReset = useCallback(() => { setShowPublishedPreview(false); setLastPublishedEvent(null); updateRelayStates(); editorRef.current?.clear(); editorRef.current?.focus(); }, [updateRelayStates]); // Discard draft and clear editor const handleDiscard = useCallback(() => { editorRef.current?.clear(); if (pubkey) { const draftKey = windowId ? `${DRAFT_STORAGE_KEY}-${pubkey}-${windowId}` : `${DRAFT_STORAGE_KEY}-${pubkey}`; localStorage.removeItem(draftKey); } editorRef.current?.focus(); }, [pubkey, windowId]); // Check if input looks like a valid relay URL const isValidRelayInput = useCallback((input: string): boolean => { const trimmed = input.trim(); if (!trimmed) return false; // Allow relay URLs with or without protocol // Must have at least a domain part (e.g., "relay.com" or "wss://relay.com") const urlPattern = /^(wss?:\/\/)?[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}(:[0-9]{1,5})?(\/.*)?$/; return urlPattern.test(trimmed); }, []); // Add new relay to the list const handleAddRelay = useCallback(() => { const trimmed = newRelayInput.trim(); if (!trimmed || !isValidRelayInput(trimmed)) return; try { // Normalize the URL (adds wss:// if needed) const normalizedUrl = normalizeRelayURL(trimmed); // Check if already in list const alreadyExists = relayStates.some((r) => r.url === normalizedUrl); if (alreadyExists) { toast.error("Relay already in list"); return; } // Add to relay states setRelayStates((prev) => [ ...prev, { url: normalizedUrl, status: "pending" as RelayPublishStatus }, ]); // Select the new relay setSelectedRelays((prev) => new Set([...prev, normalizedUrl])); // Clear input setNewRelayInput(""); } catch (error) { console.error("Failed to add relay:", error); toast.error(error instanceof Error ? error.message : "Invalid relay URL"); } }, [newRelayInput, isValidRelayInput, relayStates]); // Show login prompt if not logged in if (!canSign) { return (

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}
); }