diff --git a/src/components/BlossomUploadDialog.tsx b/src/components/BlossomUploadDialog.tsx index 9a6d461..e0af497 100644 --- a/src/components/BlossomUploadDialog.tsx +++ b/src/components/BlossomUploadDialog.tsx @@ -54,6 +54,8 @@ interface BlossomUploadDialogProps { onError?: (error: Error) => void; /** File types to accept (e.g., "image/*,video/*,audio/*") */ accept?: string; + /** Optional initial files to pre-select (e.g., from drag-and-drop) */ + initialFiles?: File[]; } /** @@ -72,6 +74,7 @@ export function BlossomUploadDialog({ onCancel, onError, accept = "image/*,video/*,audio/*", + initialFiles, }: BlossomUploadDialogProps) { const eventStore = useEventStore(); const activeAccount = use$(accountManager.active$); @@ -96,14 +99,29 @@ export function BlossomUploadDialog({ // Reset state when dialog opens useEffect(() => { if (open) { - setSelectedFile(null); - setPreviewUrl(null); + // If initial files provided, set the first one + if (initialFiles && initialFiles.length > 0) { + const file = initialFiles[0]; + setSelectedFile(file); + + // Create preview URL for images/video + if (file.type.startsWith("image/") || file.type.startsWith("video/")) { + const url = URL.createObjectURL(file); + setPreviewUrl(url); + } else { + setPreviewUrl(null); + } + } else { + setSelectedFile(null); + setPreviewUrl(null); + } + setUploadResults([]); setUploadErrors([]); setUploading(false); setUsingFallback(false); } - }, [open]); + }, [open, initialFiles]); // Helper to set fallback servers const applyFallbackServers = useCallback(() => { diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 53696b5..3d12ea2 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -1078,7 +1078,7 @@ export function ChatViewer({ variant="ghost" size="icon" className="flex-shrink-0 size-7 text-muted-foreground hover:text-foreground" - onClick={openUpload} + onClick={() => openUpload()} disabled={isSending} > @@ -1096,6 +1096,10 @@ export function ChatViewer({ searchEmojis={searchEmojis} searchCommands={searchCommands} onCommandExecute={handleCommandExecute} + onFilePaste={(files) => { + // Open upload dialog with pasted files + openUpload(files); + }} onSubmit={(content, emojiTags, blobAttachments) => { if (content.trim()) { handleSend(content, replyTo, emojiTags, blobAttachments); diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx new file mode 100644 index 0000000..449f309 --- /dev/null +++ b/src/components/PostViewer.tsx @@ -0,0 +1,866 @@ +import { useState, useRef, useCallback, useMemo, useEffect } from "react"; +import { + Paperclip, + Send, + Loader2, + Check, + X, + RotateCcw, + Settings, + Server, + ServerOff, + Plus, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; +import { Input } from "./ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + 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 { 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 { 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"; +const SETTINGS_STORAGE_KEY = "grimoire-post-settings"; + +interface PostSettings { + includeClientTag: boolean; +} + +const DEFAULT_SETTINGS: PostSettings = { + includeClientTag: true, +}; + +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(); + + // 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(""); + + // Load settings from localStorage + const [settings, setSettings] = useState(() => { + try { + const stored = localStorage.getItem(SETTINGS_STORAGE_KEY); + return stored ? JSON.parse(stored) : DEFAULT_SETTINGS; + } catch { + return DEFAULT_SETTINGS; + } + }); + + // Get relay pool state for connection status + const relayPoolMap = use$(pool.relays$); + + // Persist settings to localStorage when they change + useEffect(() => { + try { + localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + } catch (err) { + console.error("Failed to save post settings:", err); + } + }, [settings]); + + // Update a single setting + const updateSetting = useCallback( + (key: K, value: PostSettings[K]) => { + setSettings((prev) => ({ ...prev, [key]: value })); + }, + [], + ); + + // 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]); + + // Load draft from localStorage on mount + useEffect(() => { + if (!pubkey) 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); + + // Restore editor content + if (editorRef.current && draft.editorState) { + // Use setTimeout to ensure editor is fully mounted + setTimeout(() => { + if (editorRef.current && draft.editorState) { + editorRef.current.setContent(draft.editorState); + } + }, 100); + } + + // 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)) { + const currentRelayUrls = new Set(relayStates.map((r) => r.url)); + const newRelays = draft.addedRelays + .filter((url: string) => !currentRelayUrls.has(url)) + .map((url: string) => ({ + url, + status: "pending" as RelayStatus, + })); + if (newRelays.length > 0) { + setRelayStates((prev) => [...prev, ...newRelays]); + } + } + } catch (err) { + console.error("Failed to load draft:", err); + } + } + }, [pubkey, windowId, relayStates]); + + // 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; + } + + 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[], + mentions: string[], + eventRefs: string[], + 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); + + // Build tags array + const tags: string[][] = []; + + // Add p tags for mentions + for (const pubkey of mentions) { + tags.push(["p", pubkey]); + } + + // Add e tags for event references + for (const eventId of eventRefs) { + tags.push(["e", eventId]); + } + + // Add a tags for address references + for (const addr of addressRefs) { + tags.push(["a", `${addr.kind}:${addr.pubkey}:${addr.identifier}`]); + } + + // Add client tag (if enabled) + if (settings.includeClientTag) { + tags.push(GRIMOIRE_CLIENT_TAG); + } + + // Add emoji tags + for (const emoji of emojiTags) { + tags.push(["emoji", emoji.shortcode, emoji.url]); + } + + // Add blob attachment tags (imeta) + 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}`); + } + tags.push(imetaTag); + } + + // Create and sign event (kind 1 note) + const draft = await factory.build({ + kind: 1, + content: content.trim(), + tags, + }); + 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) { + 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("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 === "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/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 0ad2c71..fa366d9 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -47,6 +47,9 @@ const ZapWindow = lazy(() => import("./ZapWindow").then((m) => ({ default: m.ZapWindow })), ); const CountViewer = lazy(() => import("./CountViewer")); +const PostViewer = lazy(() => + import("./PostViewer").then((m) => ({ default: m.PostViewer })), +); // Loading fallback component function ViewerLoading() { @@ -241,6 +244,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { /> ); break; + case "post": + content = ; + break; default: content = (
diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index aa92bc1..013ed50 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -31,6 +31,8 @@ import type { ProfileSearchResult } from "@/services/profile-search"; import type { EmojiSearchResult } from "@/services/emoji-search"; import type { ChatAction } from "@/types/chat-actions"; import { nip19 } from "nostr-tools"; +import { NostrPasteHandler } from "./extensions/nostr-paste-handler"; +import { FilePasteHandler } from "./extensions/file-paste-handler"; /** * Represents an emoji tag for NIP-30 @@ -66,6 +68,12 @@ export interface SerializedContent { emojiTags: EmojiTag[]; /** Blob attachments for imeta tags (NIP-92) */ blobAttachments: BlobAttachment[]; + /** Mentioned pubkeys for p tags */ + mentions: string[]; + /** Referenced event IDs for e tags (from note/nevent) */ + eventRefs: string[]; + /** Referenced addresses for a tags (from naddr) */ + addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>; } export interface MentionEditorProps { @@ -79,6 +87,7 @@ export interface MentionEditorProps { searchEmojis?: (query: string) => Promise; searchCommands?: (query: string) => Promise; onCommandExecute?: (action: ChatAction) => Promise; + onFilePaste?: (files: File[]) => void; autoFocus?: boolean; className?: string; } @@ -276,6 +285,89 @@ function formatBlobSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } +// Create nostr event preview node for nevent/naddr/note/npub/nprofile +const NostrEventPreview = Node.create({ + name: "nostrEventPreview", + group: "inline", + inline: true, + atom: true, + + addAttributes() { + return { + type: { default: null }, // 'note' | 'nevent' | 'naddr' + data: { default: null }, // Decoded bech32 data (varies by type) + }; + }, + + parseHTML() { + return [ + { + tag: 'span[data-nostr-preview="true"]', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes(HTMLAttributes, { "data-nostr-preview": "true" }), + ]; + }, + + renderText({ node }) { + // Serialize back to nostr: URI for plain text export + const { type, data } = node.attrs; + try { + if (type === "note") { + return `nostr:${nip19.noteEncode(data)}`; + } else if (type === "nevent") { + return `nostr:${nip19.neventEncode(data)}`; + } else if (type === "naddr") { + return `nostr:${nip19.naddrEncode(data)}`; + } + } catch (err) { + console.error("[NostrEventPreview] Failed to encode:", err); + } + return ""; + }, + + addNodeView() { + return ({ node }) => { + const { type, data } = node.attrs; + + // Create wrapper span + const dom = document.createElement("span"); + dom.className = + "nostr-event-preview inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/30 text-xs align-middle"; + dom.contentEditable = "false"; + + // Type label + const typeLabel = document.createElement("span"); + typeLabel.className = "text-primary font-medium"; + + // Content label + const contentLabel = document.createElement("span"); + contentLabel.className = "text-muted-foreground truncate max-w-[140px]"; + + if (type === "note" || type === "nevent") { + // event + short ID + typeLabel.textContent = "event"; + contentLabel.textContent = + type === "note" ? data.slice(0, 8) : data.id.slice(0, 8); + } else if (type === "naddr") { + // address + d identifier (or short pubkey if no identifier) + typeLabel.textContent = "address"; + contentLabel.textContent = data.identifier || data.pubkey.slice(0, 8); + } + + dom.appendChild(typeLabel); + dom.appendChild(contentLabel); + + return { dom }; + }; + }, +}); + export const MentionEditor = forwardRef< MentionEditorHandle, MentionEditorProps @@ -288,6 +380,7 @@ export const MentionEditor = forwardRef< searchEmojis, searchCommands, onCommandExecute, + onFilePaste, autoFocus = false, className = "", }, @@ -632,6 +725,23 @@ export const MentionEditor = forwardRef< }); } } + } else if (child.type === "nostrEventPreview") { + // Nostr event preview - serialize back to nostr: URI + const { type, data } = child.attrs; + try { + if (type === "note") { + text += `nostr:${nip19.noteEncode(data)}`; + } else if (type === "nevent") { + text += `nostr:${nip19.neventEncode(data)}`; + } else if (type === "naddr") { + text += `nostr:${nip19.naddrEncode(data)}`; + } + } catch (err) { + console.error( + "[MentionEditor] Failed to serialize nostr preview:", + err, + ); + } } }); text += "\n"; @@ -642,6 +752,9 @@ export const MentionEditor = forwardRef< text: text.trim(), emojiTags, blobAttachments, + mentions: [], + eventRefs: [], + addressRefs: [], }; }, [], @@ -736,6 +849,14 @@ export const MentionEditor = forwardRef< }), // Add blob attachment extension for media previews BlobAttachmentNode, + // Add nostr event preview extension for bech32 links + NostrEventPreview, + // Add paste handler to transform bech32 strings into previews + NostrPasteHandler, + // Add file paste handler for clipboard file uploads + FilePasteHandler.configure({ + onFilePaste, + }), ]; // Add emoji extension if search is provided @@ -813,6 +934,7 @@ export const MentionEditor = forwardRef< emojiSuggestion, slashCommandSuggestion, onCommandExecute, + onFilePaste, placeholder, ]); @@ -834,7 +956,15 @@ export const MentionEditor = forwardRef< clear: () => editor?.commands.clearContent(), getContent: () => editor?.getText() || "", getSerializedContent: () => { - if (!editor) return { text: "", emojiTags: [], blobAttachments: [] }; + if (!editor) + return { + text: "", + emojiTags: [], + blobAttachments: [], + mentions: [], + eventRefs: [], + addressRefs: [], + }; return serializeContent(editor); }, isEmpty: () => editor?.isEmpty ?? true, diff --git a/src/components/editor/RichEditor.tsx b/src/components/editor/RichEditor.tsx new file mode 100644 index 0000000..acb5f23 --- /dev/null +++ b/src/components/editor/RichEditor.tsx @@ -0,0 +1,617 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useCallback, + useRef, +} from "react"; +import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react"; +import { Extension } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import Mention from "@tiptap/extension-mention"; +import Placeholder from "@tiptap/extension-placeholder"; +import type { SuggestionOptions } from "@tiptap/suggestion"; +import tippy from "tippy.js"; +import type { Instance as TippyInstance } from "tippy.js"; +import "tippy.js/dist/tippy.css"; +import { + ProfileSuggestionList, + type ProfileSuggestionListHandle, +} from "./ProfileSuggestionList"; +import { + EmojiSuggestionList, + type EmojiSuggestionListHandle, +} from "./EmojiSuggestionList"; +import type { ProfileSearchResult } from "@/services/profile-search"; +import type { EmojiSearchResult } from "@/services/emoji-search"; +import { nip19 } from "nostr-tools"; +import { NostrPasteHandler } from "./extensions/nostr-paste-handler"; +import { FilePasteHandler } from "./extensions/file-paste-handler"; +import { BlobAttachmentRichNode } from "./extensions/blob-attachment-rich"; +import { NostrEventPreviewRichNode } from "./extensions/nostr-event-preview-rich"; +import type { + EmojiTag, + BlobAttachment, + SerializedContent, +} from "./MentionEditor"; + +export interface RichEditorProps { + placeholder?: string; + onSubmit?: ( + content: string, + emojiTags: EmojiTag[], + blobAttachments: BlobAttachment[], + mentions: string[], + eventRefs: string[], + addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>, + ) => void; + onChange?: () => void; + searchProfiles: (query: string) => Promise; + searchEmojis?: (query: string) => Promise; + onFilePaste?: (files: File[]) => void; + autoFocus?: boolean; + className?: string; + /** Minimum height in pixels */ + minHeight?: number; + /** Maximum height in pixels */ + maxHeight?: number; +} + +export interface RichEditorHandle { + focus: () => void; + clear: () => void; + getContent: () => string; + getSerializedContent: () => SerializedContent; + isEmpty: () => boolean; + submit: () => void; + /** Insert text at the current cursor position */ + insertText: (text: string) => void; + /** Insert a blob attachment with rich preview */ + insertBlob: (blob: BlobAttachment) => void; + /** Get editor state as JSON (for persistence) */ + getJSON: () => any; + /** Set editor content from JSON (for restoration) */ + setContent: (json: any) => void; +} + +// Create emoji extension by extending Mention with a different name and custom node view +const EmojiMention = Mention.extend({ + name: "emoji", + + // Add custom attributes for emoji (url and source) + addAttributes() { + return { + ...this.parent?.(), + url: { + default: null, + parseHTML: (element) => element.getAttribute("data-url"), + renderHTML: (attributes) => { + if (!attributes.url) return {}; + return { "data-url": attributes.url }; + }, + }, + source: { + default: null, + parseHTML: (element) => element.getAttribute("data-source"), + renderHTML: (attributes) => { + if (!attributes.source) return {}; + return { "data-source": attributes.source }; + }, + }, + }; + }, + + // Override renderText to return empty string (nodeView handles display) + renderText({ node }) { + // Return the emoji character for unicode, or empty for custom + // This is what gets copied to clipboard + if (node.attrs.source === "unicode") { + return node.attrs.url || ""; + } + return `:${node.attrs.id}:`; + }, + + addNodeView() { + return ({ node }) => { + const { url, source, id } = node.attrs; + const isUnicode = source === "unicode"; + + // Create wrapper span + const dom = document.createElement("span"); + dom.className = "emoji-node"; + dom.setAttribute("data-emoji", id || ""); + + if (isUnicode && url) { + // Unicode emoji - render as text span + const span = document.createElement("span"); + span.className = "emoji-unicode"; + span.textContent = url; + span.title = `:${id}:`; + dom.appendChild(span); + } else if (url) { + // Custom emoji - render as image + const img = document.createElement("img"); + img.src = url; + img.alt = `:${id}:`; + img.title = `:${id}:`; + img.className = "emoji-image"; + img.draggable = false; + img.onerror = () => { + // Fallback to shortcode if image fails to load + dom.textContent = `:${id}:`; + }; + dom.appendChild(img); + } else { + // Fallback if no url - show shortcode + dom.textContent = `:${id}:`; + } + + return { + dom, + }; + }; + }, +}); + +/** + * Serialize editor content to plain text with nostr: URIs + */ +function serializeContent(editor: any): SerializedContent { + const emojiTags: EmojiTag[] = []; + const blobAttachments: BlobAttachment[] = []; + const mentions = new Set(); + const eventRefs = new Set(); + const addressRefs: Array<{ + kind: number; + pubkey: string; + identifier: string; + }> = []; + const seenEmojis = new Set(); + const seenBlobs = new Set(); + const seenAddrs = new Set(); + + // Get plain text representation + const text = editor.getText(); + + // Walk the document to collect emoji, blob, mention, and event data + editor.state.doc.descendants((node: any) => { + if (node.type.name === "emoji") { + const { id, url, source } = node.attrs; + // Only add custom emojis (not unicode) and avoid duplicates + if (source !== "unicode" && !seenEmojis.has(id)) { + seenEmojis.add(id); + emojiTags.push({ shortcode: id, url }); + } + } else if (node.type.name === "blobAttachment") { + const { url, sha256, mimeType, size, server } = node.attrs; + // Avoid duplicates + if (!seenBlobs.has(sha256)) { + seenBlobs.add(sha256); + blobAttachments.push({ url, sha256, mimeType, size, server }); + } + } else if (node.type.name === "mention") { + // Extract pubkey from @mentions for p tags + const { id } = node.attrs; + if (id) { + mentions.add(id); + } + } else if (node.type.name === "nostrEventPreview") { + // Extract event/address references for e/a tags + const { type, data } = node.attrs; + if (type === "note" && data) { + eventRefs.add(data); + } else if (type === "nevent" && data?.id) { + eventRefs.add(data.id); + } else if (type === "naddr" && data) { + const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`; + if (!seenAddrs.has(addrKey)) { + seenAddrs.add(addrKey); + addressRefs.push({ + kind: data.kind, + pubkey: data.pubkey, + identifier: data.identifier || "", + }); + } + } + } + }); + + return { + text, + emojiTags, + blobAttachments, + mentions: Array.from(mentions), + eventRefs: Array.from(eventRefs), + addressRefs, + }; +} + +export const RichEditor = forwardRef( + ( + { + placeholder = "Write your note...", + onSubmit, + onChange, + searchProfiles, + searchEmojis, + onFilePaste, + autoFocus = false, + className = "", + minHeight = 200, + maxHeight = 600, + }, + ref, + ) => { + // Ref to access handleSubmit from keyboard shortcuts + const handleSubmitRef = useRef<(editor: any) => void>(() => {}); + + // Create mention suggestion configuration for @ mentions + const mentionSuggestion: Omit = useMemo( + () => ({ + char: "@", + allowSpaces: false, + items: async ({ query }) => { + return await searchProfiles(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; + + return { + onStart: (props) => { + component = new ReactRenderer(ProfileSuggestionList, { + props: { items: [], command: props.command }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy("body", { + getReferenceClientRect: props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + theme: "mention", + }); + }, + + onUpdate(props) { + component.updateProps({ + items: props.items, + command: props.command, + }); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect as () => DOMRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup[0].hide(); + return true; + } + return component.ref?.onKeyDown(props.event) || false; + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + }, + }; + }, + }), + [searchProfiles], + ); + + // Create emoji suggestion configuration for : emojis + const emojiSuggestion: Omit | undefined = + useMemo(() => { + if (!searchEmojis) return undefined; + + return { + char: ":", + allowSpaces: false, + items: async ({ query }) => { + return await searchEmojis(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; + + return { + onStart: (props) => { + component = new ReactRenderer(EmojiSuggestionList, { + props: { items: [], command: props.command }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy("body", { + getReferenceClientRect: props.clientRect as () => DOMRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + theme: "mention", + }); + }, + + onUpdate(props) { + component.updateProps({ + items: props.items, + command: props.command, + }); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect as () => DOMRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup[0].hide(); + return true; + } + return component.ref?.onKeyDown(props.event) || false; + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + }, + }; + }, + }; + }, [searchEmojis]); + + // Handle submit + const handleSubmit = useCallback( + (editorInstance: any) => { + if (editorInstance.isEmpty) { + return; + } + + const serialized = serializeContent(editorInstance); + + if (onSubmit) { + onSubmit( + serialized.text, + serialized.emojiTags, + serialized.blobAttachments, + serialized.mentions, + serialized.eventRefs, + serialized.addressRefs, + ); + // Don't clear content here - let the parent component decide when to clear + } + }, + [onSubmit], + ); + + // Keep ref updated with latest handleSubmit + handleSubmitRef.current = handleSubmit; + + // Build extensions array + const extensions = useMemo(() => { + // Custom extension for keyboard shortcuts + const SubmitShortcut = Extension.create({ + name: "submitShortcut", + addKeyboardShortcuts() { + return { + // Ctrl/Cmd+Enter submits + "Mod-Enter": ({ editor }) => { + handleSubmitRef.current(editor); + return true; + }, + // Plain Enter creates a new line (default behavior) + }; + }, + }); + + const exts = [ + SubmitShortcut, + StarterKit.configure({ + // Enable paragraph, hardBreak, etc. for multi-line + hardBreak: { + keepMarks: false, + }, + }), + Mention.extend({ + renderText({ node }) { + // Serialize to nostr: URI for plain text export + try { + return `nostr:${nip19.npubEncode(node.attrs.id)}`; + } catch (err) { + console.error("[Mention] Failed to encode pubkey:", err); + return `@${node.attrs.label}`; + } + }, + }).configure({ + HTMLAttributes: { + class: "mention", + }, + suggestion: { + ...mentionSuggestion, + command: ({ editor, range, props }: any) => { + // props is the ProfileSearchResult + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: "mention", + attrs: { + id: props.pubkey, + label: props.displayName, + }, + }, + { type: "text", text: " " }, + ]) + .run(); + }, + }, + renderLabel({ node }) { + return `@${node.attrs.label}`; + }, + }), + Placeholder.configure({ + placeholder, + }), + // Add blob attachment extension for full-size media previews + BlobAttachmentRichNode, + // Add nostr event preview extension for full event rendering + NostrEventPreviewRichNode, + // Add paste handler to transform bech32 strings into previews + NostrPasteHandler, + // Add file paste handler for clipboard file uploads + FilePasteHandler.configure({ + onFilePaste, + }), + ]; + + // Add emoji extension if search is provided + if (emojiSuggestion) { + exts.push( + EmojiMention.configure({ + HTMLAttributes: { + class: "emoji", + }, + suggestion: { + ...emojiSuggestion, + command: ({ editor, range, props }: any) => { + // props is the EmojiSearchResult + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: "emoji", + attrs: { + id: props.shortcode, + label: props.shortcode, + url: props.url, + source: props.source, + }, + }, + { type: "text", text: " " }, + ]) + .run(); + }, + }, + }), + ); + } + + return exts; + }, [mentionSuggestion, emojiSuggestion, onFilePaste, placeholder]); + + const editor = useEditor({ + extensions, + editorProps: { + attributes: { + class: "prose prose-sm max-w-none focus:outline-none", + style: `min-height: ${minHeight}px; max-height: ${maxHeight}px; overflow-y: auto;`, + }, + }, + autofocus: autoFocus, + onUpdate: () => { + onChange?.(); + }, + }); + + // Expose editor methods + useImperativeHandle( + ref, + () => ({ + focus: () => editor?.commands.focus(), + clear: () => editor?.commands.clearContent(), + getContent: () => editor?.getText() || "", + getSerializedContent: () => { + if (!editor) + return { + text: "", + emojiTags: [], + blobAttachments: [], + mentions: [], + eventRefs: [], + addressRefs: [], + }; + return serializeContent(editor); + }, + isEmpty: () => editor?.isEmpty ?? true, + submit: () => { + if (editor) { + handleSubmit(editor); + } + }, + insertText: (text: string) => { + editor?.commands.insertContent(text); + }, + insertBlob: (blob: BlobAttachment) => { + editor?.commands.insertContent({ + type: "blobAttachment", + attrs: blob, + }); + }, + getJSON: () => { + return editor?.getJSON() || null; + }, + setContent: (json: any) => { + if (editor && json) { + editor.commands.setContent(json); + } + }, + }), + [editor, handleSubmit], + ); + + // Handle submit on Ctrl/Cmd+Enter + useEffect(() => { + if (!editor) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { + event.preventDefault(); + handleSubmit(editor); + } + }; + + editor.view.dom.addEventListener("keydown", handleKeyDown); + return () => { + editor.view.dom.removeEventListener("keydown", handleKeyDown); + }; + }, [editor, handleSubmit]); + + if (!editor) { + return null; + } + + return ( +
+ +
+ ); + }, +); + +RichEditor.displayName = "RichEditor"; diff --git a/src/components/editor/extensions/blob-attachment-rich.ts b/src/components/editor/extensions/blob-attachment-rich.ts new file mode 100644 index 0000000..f193979 --- /dev/null +++ b/src/components/editor/extensions/blob-attachment-rich.ts @@ -0,0 +1,49 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { BlobAttachmentRich } from "../node-views/BlobAttachmentRich"; + +/** + * Rich blob attachment node for long-form editors + * + * Uses React components to render full-size image/video previews + */ +export const BlobAttachmentRichNode = Node.create({ + name: "blobAttachment", + group: "block", + inline: false, + atom: true, + + addAttributes() { + return { + url: { default: null }, + sha256: { default: null }, + mimeType: { default: null }, + size: { default: null }, + server: { default: null }, + }; + }, + + parseHTML() { + return [ + { + tag: 'div[data-blob-attachment="true"]', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(HTMLAttributes, { "data-blob-attachment": "true" }), + ]; + }, + + renderText({ node }) { + // Serialize to URL for plain text export + return node.attrs.url || ""; + }, + + addNodeView() { + return ReactNodeViewRenderer(BlobAttachmentRich); + }, +}); diff --git a/src/components/editor/extensions/file-paste-handler.ts b/src/components/editor/extensions/file-paste-handler.ts new file mode 100644 index 0000000..2148f10 --- /dev/null +++ b/src/components/editor/extensions/file-paste-handler.ts @@ -0,0 +1,54 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +/** + * File paste handler extension to intercept file pastes and trigger upload + * + * Handles clipboard paste events with files (e.g., pasting images from clipboard) + * and triggers a callback to open the upload dialog. + */ +export const FilePasteHandler = Extension.create<{ + onFilePaste?: (files: File[]) => void; +}>({ + name: "filePasteHandler", + + addOptions() { + return { + onFilePaste: undefined, + }; + }, + + addProseMirrorPlugins() { + const onFilePaste = this.options.onFilePaste; + + return [ + new Plugin({ + key: new PluginKey("filePasteHandler"), + + props: { + handlePaste: (_view, event) => { + // Handle paste events with files (e.g., pasting images from clipboard) + const files = event.clipboardData?.files; + if (!files || files.length === 0) return false; + + // Check if files are images, videos, or audio + const validFiles = Array.from(files).filter((file) => + file.type.match(/^(image|video|audio)\//), + ); + + if (validFiles.length === 0) return false; + + // Trigger the file paste callback + if (onFilePaste) { + onFilePaste(validFiles); + event.preventDefault(); + return true; // Prevent default paste behavior + } + + return false; + }, + }, + }), + ]; + }, +}); diff --git a/src/components/editor/extensions/nostr-event-preview-rich.ts b/src/components/editor/extensions/nostr-event-preview-rich.ts new file mode 100644 index 0000000..8d4c5f3 --- /dev/null +++ b/src/components/editor/extensions/nostr-event-preview-rich.ts @@ -0,0 +1,59 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { NostrEventPreviewRich } from "../node-views/NostrEventPreviewRich"; +import { nip19 } from "nostr-tools"; + +/** + * Rich Nostr event preview node for long-form editors + * + * Uses React components to render full event previews with KindRenderer + */ +export const NostrEventPreviewRichNode = Node.create({ + name: "nostrEventPreview", + group: "block", + inline: false, + atom: true, + + addAttributes() { + return { + type: { default: null }, // 'note' | 'nevent' | 'naddr' + data: { default: null }, // Decoded bech32 data (varies by type) + }; + }, + + parseHTML() { + return [ + { + tag: 'div[data-nostr-preview="true"]', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes(HTMLAttributes, { "data-nostr-preview": "true" }), + ]; + }, + + renderText({ node }) { + // Serialize back to nostr: URI for plain text export + const { type, data } = node.attrs; + try { + if (type === "note") { + return `nostr:${nip19.noteEncode(data)}`; + } else if (type === "nevent") { + return `nostr:${nip19.neventEncode(data)}`; + } else if (type === "naddr") { + return `nostr:${nip19.naddrEncode(data)}`; + } + } catch (err) { + console.error("[NostrEventPreviewRich] Failed to encode:", err); + } + return ""; + }, + + addNodeView() { + return ReactNodeViewRenderer(NostrEventPreviewRich); + }, +}); diff --git a/src/components/editor/extensions/nostr-paste-handler.ts b/src/components/editor/extensions/nostr-paste-handler.ts new file mode 100644 index 0000000..08d6986 --- /dev/null +++ b/src/components/editor/extensions/nostr-paste-handler.ts @@ -0,0 +1,175 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; +import { nip19 } from "nostr-tools"; +import eventStore from "@/services/event-store"; +import { getProfileContent } from "applesauce-core/helpers"; +import { getDisplayName } from "@/lib/nostr-utils"; + +/** + * Helper to get display name for a pubkey (synchronous lookup from cache) + */ +function getDisplayNameForPubkey(pubkey: string): string { + try { + // Try to get profile from event store (check if it's a BehaviorSubject with .value) + const profile$ = eventStore.replaceable(0, pubkey) as any; + if (profile$ && profile$.value) { + const profileEvent = profile$.value; + if (profileEvent) { + const content = getProfileContent(profileEvent); + if (content) { + // Use the Grimoire helper which handles fallbacks + return getDisplayName(pubkey, content); + } + } + } + } catch (err) { + // Ignore errors, fall through to default + console.debug( + "[NostrPasteHandler] Could not get profile for", + pubkey.slice(0, 8), + err, + ); + } + // Fallback to short pubkey + return pubkey.slice(0, 8); +} + +/** + * Paste handler extension to transform bech32 strings into preview nodes + * + * Detects and transforms: + * - npub/nprofile → @mention nodes + * - note/nevent/naddr → nostrEventPreview nodes + */ +export const NostrPasteHandler = Extension.create({ + name: "nostrPasteHandler", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("nostrPasteHandler"), + + props: { + handlePaste: (view, event) => { + const text = event.clipboardData?.getData("text/plain"); + if (!text) return false; + + // Regex to detect nostr bech32 strings (with or without nostr: prefix) + const bech32Regex = + /(?:nostr:)?(npub1[\w]{58,}|note1[\w]{58,}|nevent1[\w]+|naddr1[\w]+|nprofile1[\w]+)/g; + const matches = Array.from(text.matchAll(bech32Regex)); + + if (matches.length === 0) return false; // No bech32 found, use default paste + + // Build content with text and preview nodes + const nodes: any[] = []; + let lastIndex = 0; + + for (const match of matches) { + const matchedText = match[0]; + const matchIndex = match.index!; + const bech32 = match[1]; // The bech32 without nostr: prefix + + // Add text before this match + if (lastIndex < matchIndex) { + const textBefore = text.slice(lastIndex, matchIndex); + if (textBefore) { + nodes.push(view.state.schema.text(textBefore)); + } + } + + // Try to decode bech32 and create preview node + try { + const decoded = nip19.decode(bech32); + + // For npub/nprofile, create regular mention nodes (reuse existing infrastructure) + if (decoded.type === "npub") { + const pubkey = decoded.data as string; + const displayName = getDisplayNameForPubkey(pubkey); + nodes.push( + view.state.schema.nodes.mention.create({ + id: pubkey, + label: displayName, + }), + ); + } else if (decoded.type === "nprofile") { + const pubkey = (decoded.data as any).pubkey; + const displayName = getDisplayNameForPubkey(pubkey); + nodes.push( + view.state.schema.nodes.mention.create({ + id: pubkey, + label: displayName, + }), + ); + } else if (decoded.type === "note") { + nodes.push( + view.state.schema.nodes.nostrEventPreview.create({ + type: "note", + data: decoded.data, + }), + ); + } else if (decoded.type === "nevent") { + nodes.push( + view.state.schema.nodes.nostrEventPreview.create({ + type: "nevent", + data: decoded.data, + }), + ); + } else if (decoded.type === "naddr") { + nodes.push( + view.state.schema.nodes.nostrEventPreview.create({ + type: "naddr", + data: decoded.data, + }), + ); + } + + // Add space after preview node + nodes.push(view.state.schema.text(" ")); + } catch (err) { + // Invalid bech32, insert as plain text + console.warn( + "[NostrPasteHandler] Failed to decode:", + bech32, + err, + ); + nodes.push(view.state.schema.text(matchedText)); + } + + lastIndex = matchIndex + matchedText.length; + } + + // Add remaining text after last match + if (lastIndex < text.length) { + const textAfter = text.slice(lastIndex); + if (textAfter) { + nodes.push(view.state.schema.text(textAfter)); + } + } + + // Insert all nodes at cursor position + if (nodes.length > 0) { + const { tr } = view.state; + const { from } = view.state.selection; + + // Insert content and track position + let insertPos = from; + nodes.forEach((node) => { + tr.insert(insertPos, node); + insertPos += node.nodeSize; + }); + + // Move cursor to end of inserted content + tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos))); + + view.dispatch(tr); + return true; // Prevent default paste + } + + return false; + }, + }, + }), + ]; + }, +}); diff --git a/src/components/editor/node-views/BlobAttachmentRich.tsx b/src/components/editor/node-views/BlobAttachmentRich.tsx new file mode 100644 index 0000000..a860c36 --- /dev/null +++ b/src/components/editor/node-views/BlobAttachmentRich.tsx @@ -0,0 +1,123 @@ +import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react"; +import { X, FileIcon, Music, Film } from "lucide-react"; + +function formatBlobSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +/** + * Rich preview component for blob attachments in the editor + * + * Shows full-size images and videos with remove button + */ +export function BlobAttachmentRich({ node, deleteNode }: ReactNodeViewProps) { + const { url, mimeType, size } = node.attrs as { + url: string; + sha256: string; + mimeType: string; + size: number; + server: string; + }; + + const isImage = mimeType?.startsWith("image/"); + const isVideo = mimeType?.startsWith("video/"); + const isAudio = mimeType?.startsWith("audio/"); + + return ( + +
+ {isImage && url && ( +
+ attachment + {deleteNode && ( + + )} +
+ )} + + {isVideo && url && ( +
+
+ )} + + {isAudio && url && ( +
+
+ +
+
+
+ {deleteNode && ( + + )} +
+ )} + + {!isImage && !isVideo && !isAudio && ( +
+
+ {isVideo ? ( + + ) : ( + + )} +
+
+

{url}

+

+ {mimeType || "Unknown"} • {formatBlobSize(size || 0)} +

+
+ {deleteNode && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/components/editor/node-views/NostrEventPreviewRich.tsx b/src/components/editor/node-views/NostrEventPreviewRich.tsx new file mode 100644 index 0000000..b97e22a --- /dev/null +++ b/src/components/editor/node-views/NostrEventPreviewRich.tsx @@ -0,0 +1,66 @@ +import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react"; +import { X } from "lucide-react"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { KindRenderer } from "@/components/nostr/kinds"; +import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; +import { EventCardSkeleton } from "@/components/ui/skeleton"; + +/** + * Rich preview component for Nostr events in the editor + * + * Uses the feed KindRenderer to show event content inline + */ +export function NostrEventPreviewRich({ + node, + deleteNode, +}: ReactNodeViewProps) { + const { type, data } = node.attrs as { + type: "note" | "nevent" | "naddr"; + data: any; + }; + + // Build pointer for useNostrEvent hook + let pointer: EventPointer | AddressPointer | string | null = null; + + if (type === "note") { + pointer = data; // Just the event ID + } else if (type === "nevent") { + pointer = { + id: data.id, + relays: data.relays || [], + author: data.author, + kind: data.kind, + } as EventPointer; + } else if (type === "naddr") { + pointer = { + kind: data.kind, + pubkey: data.pubkey, + identifier: data.identifier || "", + relays: data.relays || [], + } as AddressPointer; + } + + // Fetch the event (only if we have a valid pointer) + const event = useNostrEvent(pointer || undefined); + + return ( + +
+ {!event ? ( + + ) : ( + + )} +
+ {deleteNode && ( + + )} +
+ ); +} diff --git a/src/hooks/useBlossomUpload.tsx b/src/hooks/useBlossomUpload.tsx index aee53a5..a6b83c8 100644 --- a/src/hooks/useBlossomUpload.tsx +++ b/src/hooks/useBlossomUpload.tsx @@ -14,8 +14,8 @@ export interface UseBlossomUploadOptions { } export interface UseBlossomUploadReturn { - /** Open the upload dialog */ - open: () => void; + /** Open the upload dialog, optionally with pre-selected files */ + open: (files?: File[]) => void; /** Close the upload dialog */ close: () => void; /** Whether the dialog is currently open */ @@ -50,9 +50,20 @@ export function useBlossomUpload( options: UseBlossomUploadOptions = {}, ): UseBlossomUploadReturn { const [isOpen, setIsOpen] = useState(false); + const [initialFiles, setInitialFiles] = useState( + undefined, + ); - const open = useCallback(() => setIsOpen(true), []); - const close = useCallback(() => setIsOpen(false), []); + const open = useCallback((files?: File[]) => { + setInitialFiles(files); + setIsOpen(true); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + // Clear initial files when closing + setInitialFiles(undefined); + }, []); const handleSuccess = useCallback( (results: UploadResult[]) => { @@ -84,9 +95,17 @@ export function useBlossomUpload( onCancel={handleCancel} onError={handleError} accept={options.accept} + initialFiles={initialFiles} /> ), - [isOpen, handleSuccess, handleCancel, handleError, options.accept], + [ + isOpen, + handleSuccess, + handleCancel, + handleError, + options.accept, + initialFiles, + ], ); return { open, close, isOpen, dialog }; diff --git a/src/index.css b/src/index.css index 1741435..3b498e5 100644 --- a/src/index.css +++ b/src/index.css @@ -414,6 +414,25 @@ body.animating-layout vertical-align: middle; } +/* Nostr event preview styles */ +.ProseMirror .nostr-event-preview { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.375rem; + background-color: hsl(var(--primary) / 0.1); + border: 1px solid hsl(var(--primary) / 0.3); + border-radius: 0.25rem; + font-size: 0.75rem; + vertical-align: middle; + cursor: default; + transition: background-color 0.2s; +} + +.ProseMirror .nostr-event-preview:hover { + background-color: hsl(var(--primary) / 0.15); +} + /* Hide scrollbar utility */ .hide-scrollbar { scrollbar-width: none; /* Firefox */ @@ -423,3 +442,13 @@ body.animating-layout .hide-scrollbar::-webkit-scrollbar { display: none; /* Chrome/Safari/Opera */ } + +/* Hide scrollbar in RichEditor */ +.rich-editor .ProseMirror { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE/Edge */ +} + +.rich-editor .ProseMirror::-webkit-scrollbar { + display: none; /* Chrome/Safari/Opera */ +} diff --git a/src/types/app.ts b/src/types/app.ts index b7d1e88..779ff89 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -23,6 +23,7 @@ export type AppId = | "blossom" | "wallet" | "zap" + | "post" | "win"; export interface WindowInstance { diff --git a/src/types/man.ts b/src/types/man.ts index 9031732..139b568 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -843,4 +843,16 @@ export const manPages: Record = { category: "Nostr", defaultProps: {}, }, + post: { + name: "post", + section: "1", + synopsis: "post", + description: + "Compose and publish a Nostr note (kind 1). Features a rich text editor with @mentions, :emoji: autocomplete, and image/video attachments. Select which relays to publish to, with write relays pre-selected by default. Track per-relay publish status (loading/success/error).", + examples: ["post Open post composer"], + seeAlso: ["req", "profile", "blossom"], + appId: "post", + category: "Nostr", + defaultProps: {}, + }, };