diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index 53696b5..c4d1250 100644 --- a/src/components/ChatViewer.tsx +++ b/src/components/ChatViewer.tsx @@ -44,12 +44,9 @@ import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu"; import { useGrimoire } from "@/core/state"; import { Button } from "./ui/button"; import LoginDialog from "./nostr/LoginDialog"; -import { - MentionEditor, - type MentionEditorHandle, - type EmojiTag, - type BlobAttachment, -} from "./editor/MentionEditor"; +import { NostrEditor, type NostrEditorHandle } from "./editor/NostrEditor"; +import type { EmojiTag, BlobAttachment } from "./editor/types"; +import { createNostrSuggestions } from "./editor/suggestions"; import { useProfileSearch } from "@/hooks/useProfileSearch"; import { useEmojiSearch } from "@/hooks/useEmojiSearch"; import { useCopy } from "@/hooks/useCopy"; @@ -459,8 +456,8 @@ export function ChatViewer({ // Copy chat identifier to clipboard const { copy: copyChatId, copied: chatIdCopied } = useCopy(); - // Ref to MentionEditor for programmatic submission - const editorRef = useRef(null); + // Ref to NostrEditor for programmatic submission + const editorRef = useRef(null); // Blossom upload hook for file attachments const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({ @@ -688,6 +685,18 @@ export function ChatViewer({ [conversation, canSign, isSending, adapter, pubkey, signer], ); + // Create suggestions configuration for NostrEditor + const suggestions = useMemo( + () => + createNostrSuggestions({ + searchProfiles, + searchEmojis, + searchCommands, + onCommandExecute: handleCommandExecute, + }), + [searchProfiles, searchEmojis, searchCommands, handleCommandExecute], + ); + // Handle reply button click const handleReply = useCallback((messageId: string) => { setReplyTo(messageId); @@ -1089,16 +1098,21 @@ export function ChatViewer({ - { - if (content.trim()) { - handleSend(content, replyTo, emojiTags, blobAttachments); + suggestions={suggestions} + submitBehavior="enter" + variant="inline" + blobPreview="compact" + onSubmit={(content) => { + if (content.text.trim()) { + handleSend( + content.text, + replyTo, + content.emojiTags, + content.blobAttachments, + ); } }} className="flex-1 min-w-0" diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx new file mode 100644 index 0000000..f8032ef --- /dev/null +++ b/src/components/PostViewer.tsx @@ -0,0 +1,309 @@ +import { useRef, useMemo, useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { NostrEditor, type NostrEditorHandle } from "./editor/NostrEditor"; +import { createNostrSuggestions } from "./editor/suggestions"; +import { useProfileSearch } from "@/hooks/useProfileSearch"; +import { useEmojiSearch } from "@/hooks/useEmojiSearch"; +import { useBlossomUpload } from "@/hooks/useBlossomUpload"; +import { useAccount } from "@/hooks/useAccount"; +import { Loader2, Paperclip, CheckCircle2 } from "lucide-react"; +import { toast } from "sonner"; +import { hub } from "@/services/hub"; +import { NoteBlueprint } from "applesauce-common/blueprints"; +import type { SerializedContent } from "./editor/types"; +import { lastValueFrom } from "rxjs"; +import type { ActionContext } from "applesauce-actions"; +import { useEventStore } from "applesauce-react/hooks"; +import { addressLoader, profileLoader } from "@/services/loaders"; + +// Draft storage key prefix +const DRAFT_STORAGE_PREFIX = "grimoire:post-draft:"; + +// Action builder for creating a short text note +function CreateNoteAction(content: SerializedContent) { + return async ({ factory, sign, publish }: ActionContext) => { + // Build the note using NoteBlueprint + const draft = await factory.create(NoteBlueprint, content.text); + + // Add emoji tags if any custom emojis were used + for (const emoji of content.emojiTags) { + draft.tags.push(["emoji", emoji.shortcode, emoji.url]); + } + + // Add imeta tags for media attachments + for (const blob of content.blobAttachments) { + const imetaValues = [`url ${blob.url}`, `x ${blob.sha256}`]; + if (blob.mimeType) imetaValues.push(`m ${blob.mimeType}`); + if (blob.size) imetaValues.push(`size ${blob.size}`); + draft.tags.push(["imeta", ...imetaValues]); + } + + // Sign and publish the event + const event = await sign(draft); + await publish(event); + }; +} + +export function PostViewer() { + const { pubkey, canSign } = useAccount(); + const eventStore = useEventStore(); + const { searchProfiles, service: profileService } = useProfileSearch(); + const { searchEmojis } = useEmojiSearch(); + const editorRef = useRef(null); + const [isPublishing, setIsPublishing] = useState(false); + const [isPublished, setIsPublished] = useState(false); + + // Use pubkey as draft key - one draft per account, persists across reloads + const draftKey = pubkey ? `${DRAFT_STORAGE_PREFIX}${pubkey}` : null; + + // Track if editor is mounted and draft is loaded + const [editorReady, setEditorReady] = useState(false); + const draftLoadedRef = useRef(false); + + // Callback when editor mounts - triggers draft loading + const handleEditorReady = useCallback(() => { + setEditorReady(true); + }, []); + + // Load draft from localStorage after editor is ready + useEffect(() => { + if ( + draftLoadedRef.current || + !draftKey || + !editorReady || + !editorRef.current + ) + return; + draftLoadedRef.current = true; + + try { + const savedDraft = localStorage.getItem(draftKey); + if (savedDraft) { + const parsed = JSON.parse(savedDraft); + // Use setContent to load draft after editor is mounted + editorRef.current.setContent(parsed); + } + } catch (error) { + console.warn("[PostViewer] Failed to load draft:", error); + } + }, [draftKey, editorReady]); + + // Save draft to localStorage when content changes (uses full TipTap JSON) + const saveDraft = useCallback(() => { + if (!draftKey || !editorRef.current) return; + try { + const json = editorRef.current.getJSON(); + const text = editorRef.current.getContent(); + if (text.trim()) { + localStorage.setItem(draftKey, JSON.stringify(json)); + } else { + localStorage.removeItem(draftKey); + } + } catch (error) { + // localStorage might be full or disabled + console.warn("[PostViewer] Failed to save draft:", error); + } + }, [draftKey]); + + // Clear draft from localStorage + const clearDraft = useCallback(() => { + if (!draftKey) return; + try { + localStorage.removeItem(draftKey); + } catch (error) { + console.warn("[PostViewer] Failed to clear draft:", error); + } + }, [draftKey]); + + // Load contacts and their profiles + useEffect(() => { + if (!pubkey) return; + + // Load contacts list (kind 3) + const contactsSubscription = addressLoader({ + kind: 3, + pubkey, + identifier: "", + }).subscribe(); + + // Watch for contacts event and load profiles + const storeSubscription = eventStore + .replaceable(3, pubkey, "") + .subscribe((contactsEvent) => { + if (!contactsEvent) return; + + // Extract pubkeys from p tags + const contactPubkeys = contactsEvent.tags + .filter((tag) => tag[0] === "p" && tag[1]) + .map((tag) => tag[1]); + + // Load profiles for all contacts (batched by profileLoader) + for (const contactPubkey of contactPubkeys) { + profileLoader({ + kind: 0, + pubkey: contactPubkey, + identifier: "", + }).subscribe({ + next: (event) => { + // Add loaded profile to search service + profileService.addProfiles([event]); + }, + }); + } + }); + + return () => { + contactsSubscription.unsubscribe(); + storeSubscription.unsubscribe(); + }; + }, [pubkey, eventStore, profileService]); + + // 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(); + } + }, + }); + + // Create suggestions for the editor + const suggestions = useMemo( + () => + createNostrSuggestions({ + searchProfiles, + searchEmojis, + }), + [searchProfiles, searchEmojis], + ); + + // Handle publishing the post + const handlePublish = useCallback( + async (content: SerializedContent) => { + if (!canSign || !pubkey) { + toast.error("Please sign in to post"); + return; + } + + if (!content.text.trim()) { + toast.error("Please write something to post"); + return; + } + + setIsPublishing(true); + try { + // Execute the action (builds, signs, and publishes) + await lastValueFrom(hub.exec(CreateNoteAction, content)); + + toast.success("Post published!"); + setIsPublished(true); + editorRef.current?.clear(); + clearDraft(); // Clear draft after successful publish + } catch (error) { + console.error("[PostViewer] Failed to publish:", error); + toast.error( + error instanceof Error ? error.message : "Failed to publish post", + ); + } finally { + setIsPublishing(false); + } + }, + [canSign, pubkey, clearDraft], + ); + + // Handle submit button click + const handleSubmitClick = useCallback(() => { + if (editorRef.current) { + const content = editorRef.current.getSerializedContent(); + handlePublish(content); + } + }, [handlePublish]); + + // Handle content change - save draft and reset published state + const handleChange = useCallback(() => { + if (isPublished) { + setIsPublished(false); + } + saveDraft(); + }, [isPublished, saveDraft]); + + if (!canSign) { + return ( +
+
+

Sign in to post

+

+ You need to be signed in with a signing-capable account to create + posts. +

+
+
+ ); + } + + return ( +
+ + +
+ + +
+ {isPublished && ( + + + Published + + )} + +
+
+ + {uploadDialog} +
+ ); +} + +export default PostViewer; diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 0ad2c71..36946bf 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..4c9f357 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -1,79 +1,36 @@ -import { - forwardRef, - useEffect, - useImperativeHandle, - useMemo, - useCallback, - useRef, -} from "react"; -import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react"; -import { Extension, Node, mergeAttributes } 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 { - SlashCommandSuggestionList, - type SlashCommandSuggestionListHandle, -} from "./SlashCommandSuggestionList"; +/** + * MentionEditor - Backward compatibility wrapper around NostrEditor + * + * This file provides the legacy MentionEditor API while using NostrEditor internally. + * New code should import from NostrEditor and use the new API directly. + * + * @deprecated Use NostrEditor from "./NostrEditor" instead + */ + +import { forwardRef, useMemo, useCallback } from "react"; +import { NostrEditor, type NostrEditorProps } from "./NostrEditor"; +import { createNostrSuggestions } from "./suggestions"; 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"; + +// Re-export types from the new location for backward compatibility +export type { + EmojiTag, + BlobAttachment, + SerializedContent, + NostrEditorHandle as MentionEditorHandle, +} from "./types"; /** - * Represents an emoji tag for NIP-30 + * @deprecated Use NostrEditorProps instead */ -export interface EmojiTag { - shortcode: string; - url: string; -} - -/** - * Represents a blob attachment for imeta tags (NIP-92) - */ -export interface BlobAttachment { - /** The URL of the blob */ - url: string; - /** SHA256 hash of the blob content */ - sha256: string; - /** MIME type of the blob */ - mimeType?: string; - /** Size in bytes */ - size?: number; - /** Blossom server URL */ - server?: string; -} - -/** - * Result of serializing editor content - */ -export interface SerializedContent { - /** The text content with mentions as nostr: URIs and emoji as :shortcode: */ - text: string; - /** Emoji tags to include in the event (NIP-30) */ - emojiTags: EmojiTag[]; - /** Blob attachments for imeta tags (NIP-92) */ - blobAttachments: BlobAttachment[]; -} - export interface MentionEditorProps { placeholder?: string; onSubmit?: ( content: string, - emojiTags: EmojiTag[], - blobAttachments: BlobAttachment[], + emojiTags: import("./types").EmojiTag[], + blobAttachments: import("./types").BlobAttachment[], ) => void; searchProfiles: (query: string) => Promise; searchEmojis?: (query: string) => Promise; @@ -83,201 +40,32 @@ export interface MentionEditorProps { className?: string; } -export interface MentionEditorHandle { - 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; -} - -// 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, - }; - }; - }, -}); - -// Create blob attachment extension for media previews -const BlobAttachmentNode = Node.create({ - name: "blobAttachment", - group: "inline", - inline: true, - atom: true, - - addAttributes() { - return { - url: { default: null }, - sha256: { default: null }, - mimeType: { default: null }, - size: { default: null }, - server: { default: null }, - }; - }, - - parseHTML() { - return [ - { - tag: 'span[data-blob-attachment="true"]', - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - "span", - mergeAttributes(HTMLAttributes, { "data-blob-attachment": "true" }), - ]; - }, - - addNodeView() { - return ({ node }) => { - const { url, mimeType, size } = node.attrs; - - // Create wrapper span - const dom = document.createElement("span"); - dom.className = - "blob-attachment inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 border border-border text-xs align-middle"; - dom.contentEditable = "false"; - - const isImage = mimeType?.startsWith("image/"); - const isVideo = mimeType?.startsWith("video/"); - const isAudio = mimeType?.startsWith("audio/"); - - if (isImage && url) { - // Show image thumbnail - const img = document.createElement("img"); - img.src = url; - img.alt = "attachment"; - img.className = "h-4 w-4 object-cover rounded"; - img.draggable = false; - dom.appendChild(img); - } else { - // Show icon based on type - const icon = document.createElement("span"); - icon.className = "text-muted-foreground"; - if (isVideo) { - icon.textContent = "🎬"; - } else if (isAudio) { - icon.textContent = "🎵"; - } else { - icon.textContent = "📎"; - } - dom.appendChild(icon); - } - - // Add type label - const label = document.createElement("span"); - label.className = "text-muted-foreground truncate max-w-[80px]"; - if (isImage) { - label.textContent = "image"; - } else if (isVideo) { - label.textContent = "video"; - } else if (isAudio) { - label.textContent = "audio"; - } else { - label.textContent = "file"; - } - dom.appendChild(label); - - // Add size if available - if (size) { - const sizeEl = document.createElement("span"); - sizeEl.className = "text-muted-foreground/70"; - sizeEl.textContent = formatBlobSize(size); - dom.appendChild(sizeEl); - } - - return { dom }; - }; - }, -}); - -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`; -} - +/** + * MentionEditor - Legacy chat composer component + * + * @deprecated Use NostrEditor instead with the new API: + * + * ```tsx + * import { NostrEditor } from "./editor/NostrEditor"; + * import { createNostrSuggestions } from "./editor/suggestions"; + * + * const suggestions = createNostrSuggestions({ + * searchProfiles, + * searchEmojis, + * searchCommands, + * onCommandExecute, + * }); + * + * handleSend(content.text, content.emojiTags, content.blobAttachments)} + * /> + * ``` + */ export const MentionEditor = forwardRef< - MentionEditorHandle, + import("./types").NostrEditorHandle, MentionEditorProps >( ( @@ -293,603 +81,40 @@ export const MentionEditor = forwardRef< }, ref, ) => { - // Ref to access handleSubmit from suggestion plugins (defined early so useMemo can access it) - 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[]; - let editorRef: any; - - return { - onStart: (props) => { - editorRef = props.editor; - component = new ReactRenderer(ProfileSuggestionList, { - props: { - items: props.items, - command: props.command, - onClose: () => { - popup[0]?.hide(); - }, - }, - 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", - }); - }, - - 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; - } - - // Ctrl/Cmd+Enter submits the message - if ( - props.event.key === "Enter" && - (props.event.ctrlKey || props.event.metaKey) - ) { - popup[0]?.hide(); - handleSubmitRef.current(editorRef); - return true; - } - - return component.ref?.onKeyDown(props.event) ?? false; - }, - - onExit() { - popup[0]?.destroy(); - component.destroy(); - }, - }; - }, - }), - [searchProfiles], - ); - - // Create emoji suggestion configuration for : emoji - const emojiSuggestion: Omit | null = useMemo( + // Create suggestions configuration + const suggestions = useMemo( () => - searchEmojis - ? { - char: ":", - allowSpaces: false, - items: async ({ query }) => { - return await searchEmojis(query); - }, - render: () => { - let component: ReactRenderer; - let popup: TippyInstance[]; - let editorRef: any; - - return { - onStart: (props) => { - editorRef = props.editor; - component = new ReactRenderer(EmojiSuggestionList, { - props: { - items: props.items, - command: props.command, - onClose: () => { - popup[0]?.hide(); - }, - }, - 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", - }); - }, - - 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; - } - - // Ctrl/Cmd+Enter submits the message - if ( - props.event.key === "Enter" && - (props.event.ctrlKey || props.event.metaKey) - ) { - popup[0]?.hide(); - handleSubmitRef.current(editorRef); - return true; - } - - return component.ref?.onKeyDown(props.event) ?? false; - }, - - onExit() { - popup[0]?.destroy(); - component.destroy(); - }, - }; - }, - } - : null, - [searchEmojis], + createNostrSuggestions({ + searchProfiles, + searchEmojis, + searchCommands, + onCommandExecute, + }), + [searchProfiles, searchEmojis, searchCommands, onCommandExecute], ); - // Create slash command suggestion configuration for / commands - // Only triggers when / is at the very beginning of the input - const slashCommandSuggestion: Omit | null = - useMemo( - () => - searchCommands - ? { - char: "/", - allowSpaces: false, - // Only allow slash commands at the start of input (position 1 in TipTap = first char) - allow: ({ range }) => range.from === 1, - items: async ({ query }) => { - return await searchCommands(query); - }, - render: () => { - let component: ReactRenderer; - let popup: TippyInstance[]; - let editorRef: any; - - return { - onStart: (props) => { - editorRef = props.editor; - component = new ReactRenderer( - SlashCommandSuggestionList, - { - props: { - items: props.items, - command: props.command, - onClose: () => { - popup[0]?.hide(); - }, - }, - 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: "top-start", - }); - }, - - 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; - } - - // Ctrl/Cmd+Enter submits the message - if ( - props.event.key === "Enter" && - (props.event.ctrlKey || props.event.metaKey) - ) { - popup[0]?.hide(); - handleSubmitRef.current(editorRef); - return true; - } - - return component.ref?.onKeyDown(props.event) ?? false; - }, - - onExit() { - popup[0]?.destroy(); - component.destroy(); - }, - }; - }, - } - : null, - [searchCommands], - ); - - // Helper function to serialize editor content with mentions, emojis, and blobs - const serializeContent = useCallback( - (editorInstance: any): SerializedContent => { - let text = ""; - const emojiTags: EmojiTag[] = []; - const blobAttachments: BlobAttachment[] = []; - const seenEmojis = new Set(); - const seenBlobs = new Set(); - const json = editorInstance.getJSON(); - - json.content?.forEach((node: any) => { - if (node.type === "paragraph") { - node.content?.forEach((child: any) => { - if (child.type === "text") { - text += child.text; - } else if (child.type === "hardBreak") { - // Preserve newlines from Shift+Enter - text += "\n"; - } else if (child.type === "mention") { - const pubkey = child.attrs?.id; - if (pubkey) { - try { - const npub = nip19.npubEncode(pubkey); - text += `nostr:${npub}`; - } catch { - // Fallback to display name if encoding fails - text += `@${child.attrs?.label || "unknown"}`; - } - } - } else if (child.type === "emoji") { - const shortcode = child.attrs?.id; - const url = child.attrs?.url; - const source = child.attrs?.source; - - if (source === "unicode" && url) { - // Unicode emoji - output the actual character - text += url; - } else if (shortcode) { - // Custom emoji - output :shortcode: and add tag - text += `:${shortcode}:`; - - if (url && !seenEmojis.has(shortcode)) { - seenEmojis.add(shortcode); - emojiTags.push({ shortcode, url }); - } - } - } else if (child.type === "blobAttachment") { - // Blob attachment - output URL and track for imeta tag - const { url, sha256, mimeType, size, server } = child.attrs; - if (url) { - text += url; - // Add to blob attachments for imeta tags (dedupe by sha256) - if (sha256 && !seenBlobs.has(sha256)) { - seenBlobs.add(sha256); - blobAttachments.push({ - url, - sha256, - mimeType: mimeType || undefined, - size: size || undefined, - server: server || undefined, - }); - } - } - } - }); - text += "\n"; - } - }); - - return { - text: text.trim(), - emojiTags, - blobAttachments, - }; - }, - [], - ); - - // Helper function to handle submission - const handleSubmit = useCallback( - (editorInstance: any) => { - if (!editorInstance || !onSubmit) return; - - const { text, emojiTags, blobAttachments } = - serializeContent(editorInstance); - if (text) { - onSubmit(text, emojiTags, blobAttachments); - editorInstance.commands.clearContent(); + // Adapt the old onSubmit signature to the new one + const handleSubmit = useCallback>( + (content) => { + if (onSubmit) { + onSubmit(content.text, content.emojiTags, content.blobAttachments); } }, - [onSubmit, serializeContent], + [onSubmit], ); - // Keep ref updated with latest handleSubmit - handleSubmitRef.current = handleSubmit; - - // Build extensions array - const extensions = useMemo(() => { - // Detect mobile devices (touch support) - const isMobile = "ontouchstart" in window || navigator.maxTouchPoints > 0; - - // Custom extension for keyboard shortcuts (runs before suggestion plugins) - const SubmitShortcut = Extension.create({ - name: "submitShortcut", - addKeyboardShortcuts() { - return { - // Ctrl/Cmd+Enter always submits - "Mod-Enter": ({ editor }) => { - handleSubmitRef.current(editor); - return true; - }, - // Plain Enter behavior depends on device - Enter: ({ editor }) => { - if (isMobile) { - // On mobile, Enter inserts a newline (hardBreak) - return editor.commands.setHardBreak(); - } else { - // On desktop, Enter submits the message - handleSubmitRef.current(editor); - return true; - } - }, - }; - }, - }); - - const exts = [ - SubmitShortcut, - StarterKit.configure({ - // Shift+Enter inserts hard break (newline) - hardBreak: { - keepMarks: false, - }, - }), - Mention.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 media previews - BlobAttachmentNode, - ]; - - // 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(); - }, - }, - // Note: renderLabel is not used when nodeView is defined - }), - ); - } - - // Add slash command extension if search is provided - if (slashCommandSuggestion) { - const SlashCommand = Mention.extend({ - name: "slashCommand", - }); - - exts.push( - SlashCommand.configure({ - HTMLAttributes: { - class: "slash-command", - }, - suggestion: { - ...slashCommandSuggestion, - command: ({ editor, props }: any) => { - // props is the ChatAction - // Execute the command immediately and clear the editor - editor.commands.clearContent(); - if (onCommandExecute) { - // Execute action asynchronously - onCommandExecute(props).catch((error) => { - console.error( - "[MentionEditor] Command execution failed:", - error, - ); - }); - } - }, - }, - renderLabel({ node }) { - return `/${node.attrs.label}`; - }, - }), - ); - } - - return exts; - }, [ - mentionSuggestion, - emojiSuggestion, - slashCommandSuggestion, - onCommandExecute, - placeholder, - ]); - - const editor = useEditor({ - extensions, - editorProps: { - attributes: { - class: "prose prose-sm max-w-none focus:outline-none text-sm", - }, - }, - autofocus: autoFocus, - }); - - // Expose editor methods - useImperativeHandle( - ref, - () => ({ - focus: () => editor?.commands.focus(), - clear: () => editor?.commands.clearContent(), - getContent: () => editor?.getText() || "", - getSerializedContent: () => { - if (!editor) return { text: "", emojiTags: [], blobAttachments: [] }; - return serializeContent(editor); - }, - isEmpty: () => editor?.isEmpty ?? true, - submit: () => { - if (editor) { - handleSubmit(editor); - } - }, - insertText: (text: string) => { - if (editor) { - editor.chain().focus().insertContent(text).run(); - } - }, - insertBlob: (blob: BlobAttachment) => { - if (editor) { - editor - .chain() - .focus() - .insertContent([ - { - type: "blobAttachment", - attrs: { - url: blob.url, - sha256: blob.sha256, - mimeType: blob.mimeType, - size: blob.size, - server: blob.server, - }, - }, - { type: "text", text: " " }, - ]) - .run(); - } - }, - }), - [editor, serializeContent, handleSubmit], - ); - - // Cleanup on unmount - useEffect(() => { - return () => { - editor?.destroy(); - }; - }, [editor]); - - if (!editor) { - return null; - } - return ( -
- -
+ ); }, ); diff --git a/src/components/editor/NostrEditor.tsx b/src/components/editor/NostrEditor.tsx new file mode 100644 index 0000000..c7ff575 --- /dev/null +++ b/src/components/editor/NostrEditor.tsx @@ -0,0 +1,876 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useCallback, + useRef, +} from "react"; +import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react"; +import { + Extension, + Node, + mergeAttributes, + type AnyExtension, +} 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 { nip19 } from "nostr-tools"; +import { cn } from "@/lib/utils"; +import type { + NostrEditorHandle, + SerializedContent, + BlobAttachment, + EmojiTag, + SuggestionConfig, + SubmitBehavior, + EditorVariant, + BlobPreviewStyle, + SuggestionListHandle, +} from "./types"; + +// Re-export handle type for consumers +export type { NostrEditorHandle }; + +export interface NostrEditorProps { + /** Placeholder text when editor is empty */ + placeholder?: string; + /** Initial content (plain text or TipTap JSON) */ + initialContent?: string | object; + /** Called when content is submitted */ + onSubmit?: (content: SerializedContent) => void; + /** Called when content changes */ + onChange?: (content: SerializedContent) => void; + /** Called when editor is ready (mounted and initialized) */ + onReady?: () => void; + /** Submit behavior: 'enter' (chat), 'ctrl-enter' (post), 'button-only' (external button) */ + submitBehavior?: SubmitBehavior; + /** Layout variant: 'inline' (chat), 'multiline' (auto-expand), 'full' (fixed height) */ + variant?: EditorVariant; + /** Minimum lines for multiline/full variants */ + minLines?: number; + /** Maximum lines for multiline variant (auto-expand limit) */ + maxLines?: number; + /** Blob preview style: 'compact' (pill), 'card' (thumbnail), 'gallery' (full-width) */ + blobPreview?: BlobPreviewStyle; + /** Suggestion configurations */ + suggestions?: SuggestionConfig[]; + /** Auto-focus on mount */ + autoFocus?: boolean; + /** Additional CSS classes */ + className?: string; +} + +// Create emoji extension by extending Mention with a different name and custom node view +const EmojiMention = Mention.extend({ + name: "emoji", + + 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 }; + }, + }, + }; + }, + + renderText({ node }) { + 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"; + + const dom = document.createElement("span"); + dom.className = "emoji-node"; + dom.setAttribute("data-emoji", id || ""); + + if (isUnicode && url) { + const span = document.createElement("span"); + span.className = "emoji-unicode"; + span.textContent = url; + span.title = `:${id}:`; + dom.appendChild(span); + } else if (url) { + const img = document.createElement("img"); + img.src = url; + img.alt = `:${id}:`; + img.title = `:${id}:`; + img.className = "emoji-image"; + img.draggable = false; + img.onerror = () => { + dom.textContent = `:${id}:`; + }; + dom.appendChild(img); + } else { + dom.textContent = `:${id}:`; + } + + return { dom }; + }; + }, +}); + +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`; +} + +/** + * Create blob attachment node with configurable preview style + */ +function createBlobAttachmentNode(previewStyle: BlobPreviewStyle) { + return Node.create({ + name: "blobAttachment", + group: previewStyle === "compact" ? "inline" : "block", + inline: previewStyle === "compact", + atom: true, + + addAttributes() { + return { + url: { default: null }, + sha256: { default: null }, + mimeType: { default: null }, + size: { default: null }, + server: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-blob-attachment="true"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes(HTMLAttributes, { "data-blob-attachment": "true" }), + ]; + }, + + addNodeView() { + return ({ node }) => { + const { url, mimeType, size } = node.attrs; + const isImage = mimeType?.startsWith("image/"); + const isVideo = mimeType?.startsWith("video/"); + const isAudio = mimeType?.startsWith("audio/"); + + const dom = document.createElement( + previewStyle === "compact" ? "span" : "div", + ); + dom.contentEditable = "false"; + + if (previewStyle === "compact") { + // Compact: small inline pill + dom.className = + "blob-attachment inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 border border-border text-xs align-middle"; + + if (isImage && url) { + const img = document.createElement("img"); + img.src = url; + img.alt = "attachment"; + img.className = "h-4 w-4 object-cover rounded"; + img.draggable = false; + dom.appendChild(img); + } else { + const icon = document.createElement("span"); + icon.className = "text-muted-foreground"; + icon.textContent = isVideo ? "🎬" : isAudio ? "🎵" : "📎"; + dom.appendChild(icon); + } + + const label = document.createElement("span"); + label.className = "text-muted-foreground truncate max-w-[80px]"; + label.textContent = isImage + ? "image" + : isVideo + ? "video" + : isAudio + ? "audio" + : "file"; + dom.appendChild(label); + + if (size) { + const sizeEl = document.createElement("span"); + sizeEl.className = "text-muted-foreground/70"; + sizeEl.textContent = formatBlobSize(size); + dom.appendChild(sizeEl); + } + } else if (previewStyle === "card") { + // Card: medium thumbnail card + dom.className = + "blob-attachment-card my-2 inline-flex items-center gap-3 p-2 rounded-lg bg-muted/30 border border-border max-w-xs"; + + if (isImage && url) { + const img = document.createElement("img"); + img.src = url; + img.alt = "attachment"; + img.className = "h-16 w-16 object-cover rounded"; + img.draggable = false; + dom.appendChild(img); + } else { + const iconWrapper = document.createElement("div"); + iconWrapper.className = + "h-16 w-16 flex items-center justify-center bg-muted rounded"; + const icon = document.createElement("span"); + icon.className = "text-2xl"; + icon.textContent = isVideo ? "🎬" : isAudio ? "🎵" : "📎"; + iconWrapper.appendChild(icon); + dom.appendChild(iconWrapper); + } + + const info = document.createElement("div"); + info.className = "flex flex-col gap-0.5 min-w-0"; + + const typeLabel = document.createElement("span"); + typeLabel.className = "text-sm font-medium capitalize"; + typeLabel.textContent = isImage + ? "Image" + : isVideo + ? "Video" + : isAudio + ? "Audio" + : "File"; + info.appendChild(typeLabel); + + if (size) { + const sizeEl = document.createElement("span"); + sizeEl.className = "text-xs text-muted-foreground"; + sizeEl.textContent = formatBlobSize(size); + info.appendChild(sizeEl); + } + + dom.appendChild(info); + } else { + // Gallery: full-width preview + dom.className = "blob-attachment-gallery my-2 w-full"; + + if (isImage && url) { + const img = document.createElement("img"); + img.src = url; + img.alt = "attachment"; + img.className = "max-w-full max-h-64 rounded-lg object-contain"; + img.draggable = false; + dom.appendChild(img); + } else if (isVideo && url) { + const video = document.createElement("video"); + video.src = url; + video.className = "max-w-full max-h-64 rounded-lg"; + video.controls = true; + dom.appendChild(video); + } else if (isAudio && url) { + const audio = document.createElement("audio"); + audio.src = url; + audio.className = "w-full"; + audio.controls = true; + dom.appendChild(audio); + } else { + const fileCard = document.createElement("div"); + fileCard.className = + "inline-flex items-center gap-2 p-3 rounded-lg bg-muted/30 border border-border"; + const icon = document.createElement("span"); + icon.className = "text-xl"; + icon.textContent = "📎"; + fileCard.appendChild(icon); + const label = document.createElement("span"); + label.className = "text-sm"; + label.textContent = size + ? `File (${formatBlobSize(size)})` + : "File"; + fileCard.appendChild(label); + dom.appendChild(fileCard); + } + } + + return { dom }; + }; + }, + }); +} + +/** + * Create a TipTap suggestion configuration from our SuggestionConfig + * + * Design: TipTap's `items()` function has buggy async handling where results + * arrive out of order. We bypass this entirely by: + * - Using `items()` only as a sync no-op (returns empty array) + * - Doing all async search in `onUpdate` when query changes + * - Updating the component directly when results arrive + * + * This keeps all search logic in one place and avoids TipTap's race conditions. + */ +function createSuggestionConfig( + config: SuggestionConfig, + handleSubmitRef: React.MutableRefObject<(editor: unknown) => void>, +): Omit, "editor"> { + return { + char: config.char, + allowSpaces: config.allowSpaces ?? false, + allow: config.allow, + // Don't use items() for async work - TipTap's async handling is buggy + // We do our own search in onUpdate instead + items: () => [], + render: () => { + let component: ReactRenderer | null = null; + let popup: TippyInstance[] | null = null; + let editorRef: unknown = null; + let currentQuery = ""; + let searchCounter = 0; + + // Async search with race condition protection + const doSearch = async (query: string) => { + const thisSearch = ++searchCounter; + const results = await config.search(query); + + // Discard if a newer search was started + if (thisSearch !== searchCounter || !component) return; + + component.updateProps({ items: results }); + }; + + return { + onStart: (props) => { + editorRef = props.editor; + currentQuery = (props as { query?: string }).query ?? ""; + + component = new ReactRenderer(config.component as never, { + props: { + items: [], + command: props.command, + onClose: () => popup?.[0]?.hide(), + }, + 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: config.placement ?? "bottom-start", + zIndex: 100, + }); + + // Trigger initial search + doSearch(currentQuery); + }, + + onUpdate(props) { + const newQuery = (props as { query?: string }).query ?? ""; + + // Search when query changes + if (newQuery !== currentQuery) { + currentQuery = newQuery; + doSearch(newQuery); + } + + // Update command (TipTap regenerates it) + if (component) { + component.updateProps({ command: props.command }); + } + + // Update popup position + if (props.clientRect && popup?.[0]) { + popup[0].setProps({ + getReferenceClientRect: props.clientRect as () => DOMRect, + }); + } + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup?.[0]?.hide(); + return true; + } + + // Ctrl/Cmd+Enter always submits + if ( + props.event.key === "Enter" && + (props.event.ctrlKey || props.event.metaKey) + ) { + popup?.[0]?.hide(); + handleSubmitRef.current(editorRef); + return true; + } + + return component?.ref?.onKeyDown(props.event) ?? false; + }, + + onExit() { + popup?.[0]?.destroy(); + component?.destroy(); + component = null; + popup = null; + currentQuery = ""; + searchCounter = 0; + }, + }; + }, + }; +} + +export const NostrEditor = forwardRef( + ( + { + placeholder = "Type a message...", + initialContent, + onSubmit, + onChange, + onReady, + submitBehavior = "enter", + variant = "inline", + minLines = 1, + maxLines = 10, + blobPreview = "compact", + suggestions = [], + autoFocus = false, + className = "", + }, + ref, + ) => { + const handleSubmitRef = useRef<(editor: unknown) => void>(() => {}); + + // Helper function to serialize editor content + const serializeContent = useCallback( + (editorInstance: { + getJSON: () => { content?: unknown[] }; + getText: () => string; + }): SerializedContent => { + let text = ""; + const emojiTags: EmojiTag[] = []; + const blobAttachments: BlobAttachment[] = []; + const seenEmojis = new Set(); + const seenBlobs = new Set(); + const json = editorInstance.getJSON(); + + const processNode = (node: Record) => { + if (node.type === "text") { + text += node.text as string; + } else if (node.type === "hardBreak") { + text += "\n"; + } else if (node.type === "mention") { + const attrs = node.attrs as Record; + const pubkey = attrs?.id as string; + if (pubkey) { + try { + const npub = nip19.npubEncode(pubkey); + text += `nostr:${npub}`; + } catch { + text += `@${(attrs?.label as string) || "unknown"}`; + } + } + } else if (node.type === "emoji") { + const attrs = node.attrs as Record; + const shortcode = attrs?.id as string; + const url = attrs?.url as string; + const source = attrs?.source as string; + + if (source === "unicode" && url) { + text += url; + } else if (shortcode) { + text += `:${shortcode}:`; + if (url && !seenEmojis.has(shortcode)) { + seenEmojis.add(shortcode); + emojiTags.push({ shortcode, url }); + } + } + } else if (node.type === "blobAttachment") { + const attrs = node.attrs as Record; + const url = attrs.url as string; + const sha256 = attrs.sha256 as string; + if (url) { + text += url; + if (sha256 && !seenBlobs.has(sha256)) { + seenBlobs.add(sha256); + blobAttachments.push({ + url, + sha256, + mimeType: (attrs.mimeType as string) || undefined, + size: (attrs.size as number) || undefined, + server: (attrs.server as string) || undefined, + }); + } + } + } + }; + + const processContent = (content: unknown[]) => { + for (const node of content) { + const n = node as Record; + if (n.type === "paragraph" || n.type === "doc") { + if (n.content) { + processContent(n.content as unknown[]); + } + if (n.type === "paragraph") { + text += "\n"; + } + } else { + processNode(n); + } + } + }; + + if (json.content) { + processContent(json.content); + } + + return { + text: text.trim(), + emojiTags, + blobAttachments, + }; + }, + [], + ); + + // Helper function to handle submission + const handleSubmit = useCallback( + (editorInstance: unknown) => { + if (!editorInstance || !onSubmit) return; + const editor = editorInstance as { + getJSON: () => { content?: unknown[] }; + getText: () => string; + commands: { clearContent: () => void }; + }; + + const content = serializeContent(editor); + if (content.text) { + onSubmit(content); + editor.commands.clearContent(); + } + }, + [onSubmit, serializeContent], + ); + + handleSubmitRef.current = handleSubmit; + + // Find suggestion configs + const mentionConfig = suggestions.find((s) => s.char === "@"); + const emojiConfig = suggestions.find((s) => s.char === ":"); + const slashConfig = suggestions.find((s) => s.char === "/"); + + // Build extensions array + const extensions = useMemo(() => { + const isMobile = "ontouchstart" in window || navigator.maxTouchPoints > 0; + + // Custom extension for keyboard shortcuts + const SubmitShortcut = Extension.create({ + name: "submitShortcut", + addKeyboardShortcuts() { + return { + "Mod-Enter": ({ editor }) => { + handleSubmitRef.current(editor); + return true; + }, + Enter: ({ editor }) => { + if (submitBehavior === "button-only") { + // Never submit on Enter, always newline + return editor.commands.setHardBreak(); + } else if (submitBehavior === "ctrl-enter") { + // Enter always inserts newline + return editor.commands.setHardBreak(); + } else { + // submitBehavior === 'enter' + if (isMobile) { + return editor.commands.setHardBreak(); + } else { + handleSubmitRef.current(editor); + return true; + } + } + }, + }; + }, + }); + + const exts: AnyExtension[] = [ + SubmitShortcut, + StarterKit.configure({ + hardBreak: { keepMarks: false }, + }), + Placeholder.configure({ placeholder }), + createBlobAttachmentNode(blobPreview), + ]; + + // Add mention extension for @ mentions + if (mentionConfig) { + exts.push( + Mention.configure({ + HTMLAttributes: { class: "mention" }, + suggestion: { + ...createSuggestionConfig(mentionConfig, handleSubmitRef), + command: ({ + editor, + range, + props, + }: { + editor: unknown; + range: unknown; + props: unknown; + }) => { + const result = mentionConfig.onSelect(props as never); + const ed = editor as { + chain: () => { + focus: () => { + insertContentAt: ( + range: unknown, + content: unknown[], + ) => { run: () => void }; + }; + }; + }; + ed.chain() + .focus() + .insertContentAt(range, [ + { type: result.type, attrs: result.attrs }, + { type: "text", text: " " }, + ]) + .run(); + }, + }, + renderLabel({ node }) { + return `@${node.attrs.label}`; + }, + }), + ); + } + + // Add emoji extension + if (emojiConfig) { + exts.push( + EmojiMention.configure({ + HTMLAttributes: { class: "emoji" }, + suggestion: { + ...createSuggestionConfig(emojiConfig, handleSubmitRef), + command: ({ + editor, + range, + props, + }: { + editor: unknown; + range: unknown; + props: unknown; + }) => { + const result = emojiConfig.onSelect(props as never); + const ed = editor as { + chain: () => { + focus: () => { + insertContentAt: ( + range: unknown, + content: unknown[], + ) => { run: () => void }; + }; + }; + }; + ed.chain() + .focus() + .insertContentAt(range, [ + { type: "emoji", attrs: result.attrs }, + { type: "text", text: " " }, + ]) + .run(); + }, + }, + }), + ); + } + + // Add slash command extension + if (slashConfig) { + const SlashCommand = Mention.extend({ name: "slashCommand" }); + exts.push( + SlashCommand.configure({ + HTMLAttributes: { class: "slash-command" }, + suggestion: { + ...createSuggestionConfig(slashConfig, handleSubmitRef), + command: ({ + editor, + props, + }: { + editor: unknown; + props: unknown; + }) => { + const ed = editor as { commands: { clearContent: () => void } }; + if (slashConfig.clearOnSelect !== false) { + ed.commands.clearContent(); + } + if (slashConfig.onExecute) { + slashConfig.onExecute(props as never).catch((error) => { + console.error( + "[NostrEditor] Command execution failed:", + error, + ); + }); + } + }, + }, + renderLabel({ node }) { + return `/${node.attrs.label}`; + }, + }), + ); + } + + return exts; + }, [ + submitBehavior, + placeholder, + blobPreview, + mentionConfig, + emojiConfig, + slashConfig, + ]); + + const editor = useEditor({ + extensions, + content: initialContent, + editorProps: { + attributes: { + class: "prose prose-sm max-w-none focus:outline-none text-sm", + }, + }, + autofocus: autoFocus, + onCreate: () => { + // Notify parent that editor is ready for operations like loading drafts + onReady?.(); + }, + onUpdate: ({ editor }) => { + if (onChange) { + onChange(serializeContent(editor)); + } + }, + }); + + // Expose editor methods + useImperativeHandle( + ref, + () => ({ + focus: () => editor?.commands.focus(), + clear: () => editor?.commands.clearContent(), + getContent: () => editor?.getText() || "", + getSerializedContent: () => { + if (!editor) return { text: "", emojiTags: [], blobAttachments: [] }; + return serializeContent(editor); + }, + getJSON: () => editor?.getJSON() || null, + setContent: (content: string | object) => { + if (editor) { + editor.commands.setContent(content); + } + }, + isEmpty: () => editor?.isEmpty ?? true, + submit: () => { + if (editor) { + handleSubmit(editor); + } + }, + insertText: (text: string) => { + if (editor) { + editor.chain().focus().insertContent(text).run(); + } + }, + insertBlob: (blob: BlobAttachment) => { + if (editor) { + editor + .chain() + .focus() + .insertContent([ + { + type: "blobAttachment", + attrs: { + url: blob.url, + sha256: blob.sha256, + mimeType: blob.mimeType, + size: blob.size, + server: blob.server, + }, + }, + { type: "text", text: " " }, + ]) + .run(); + } + }, + }), + [editor, serializeContent, handleSubmit], + ); + + // Cleanup on unmount + useEffect(() => { + return () => { + editor?.destroy(); + }; + }, [editor]); + + if (!editor) { + return null; + } + + // Inline styles for dynamic height (Tailwind can't do dynamic values) + const getInlineStyles = (): React.CSSProperties => { + const lineHeight = 24; + + switch (variant) { + case "inline": + return {}; + case "multiline": + return { + minHeight: `${Math.max(minLines, 2) * lineHeight}px`, + maxHeight: `${maxLines * lineHeight}px`, + }; + case "full": + return { + height: `${Math.max(minLines, 5) * lineHeight}px`, + }; + default: + return {}; + } + }; + + return ( +
+ +
+ ); + }, +); + +NostrEditor.displayName = "NostrEditor"; diff --git a/src/components/editor/suggestions.tsx b/src/components/editor/suggestions.tsx new file mode 100644 index 0000000..8f4391e --- /dev/null +++ b/src/components/editor/suggestions.tsx @@ -0,0 +1,131 @@ +/** + * Default suggestion configurations for NostrEditor + * + * These provide ready-to-use configurations for common Nostr autocomplete features: + * - Profile mentions (@) + * - Emoji autocomplete (:) + * - Slash commands (/) + */ + +import type { SuggestionConfig } from "./types"; +import type { ProfileSearchResult } from "@/services/profile-search"; +import type { EmojiSearchResult } from "@/services/emoji-search"; +import type { ChatAction } from "@/types/chat-actions"; +import { + ProfileSuggestionList, + type ProfileSuggestionListProps, +} from "./ProfileSuggestionList"; +import { + EmojiSuggestionList, + type EmojiSuggestionListProps, +} from "./EmojiSuggestionList"; +import { + SlashCommandSuggestionList, + type SlashCommandSuggestionListProps, +} from "./SlashCommandSuggestionList"; + +/** + * Create a profile mention suggestion config (@mentions) + */ +export function createProfileSuggestion( + searchProfiles: (query: string) => Promise, +): SuggestionConfig { + return { + char: "@", + allowSpaces: false, + search: searchProfiles, + component: + ProfileSuggestionList as React.ComponentType, + onSelect: (profile) => ({ + type: "mention", + attrs: { + id: profile.pubkey, + label: profile.displayName, + }, + }), + placement: "bottom-start", + }; +} + +/** + * Create an emoji suggestion config (:emoji:) + */ +export function createEmojiSuggestion( + searchEmojis: (query: string) => Promise, +): SuggestionConfig { + return { + char: ":", + allowSpaces: false, + search: searchEmojis, + component: + EmojiSuggestionList as React.ComponentType, + onSelect: (emoji) => ({ + type: "emoji", + attrs: { + id: emoji.shortcode, + label: emoji.shortcode, + url: emoji.url, + source: emoji.source, + }, + }), + placement: "bottom-start", + }; +} + +/** + * Create a slash command suggestion config (/commands) + */ +export function createSlashCommandSuggestion( + searchCommands: (query: string) => Promise, + onExecute: (action: ChatAction) => Promise, +): SuggestionConfig { + return { + char: "/", + allowSpaces: false, + // Only allow at the start of input + allow: ({ range }) => range.from === 1, + search: searchCommands, + component: + SlashCommandSuggestionList as React.ComponentType, + onSelect: (action) => ({ + type: "slashCommand", + attrs: { + id: action.name, + label: action.name, + }, + }), + onExecute, + clearOnSelect: true, + placement: "top-start", + }; +} + +/** + * Helper to create all standard Nostr editor suggestions + */ +export function createNostrSuggestions(options: { + searchProfiles: (query: string) => Promise; + searchEmojis?: (query: string) => Promise; + searchCommands?: (query: string) => Promise; + onCommandExecute?: (action: ChatAction) => Promise; +}): SuggestionConfig[] { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const suggestions: SuggestionConfig[] = [ + createProfileSuggestion(options.searchProfiles), + ]; + + if (options.searchEmojis) { + suggestions.push(createEmojiSuggestion(options.searchEmojis)); + } + + if (options.searchCommands && options.onCommandExecute) { + suggestions.push( + createSlashCommandSuggestion( + options.searchCommands, + options.onCommandExecute, + ), + ); + } + + return suggestions; +} diff --git a/src/components/editor/types.ts b/src/components/editor/types.ts new file mode 100644 index 0000000..253da8f --- /dev/null +++ b/src/components/editor/types.ts @@ -0,0 +1,126 @@ +import type { ComponentType } from "react"; + +/** + * Represents an emoji tag for NIP-30 + */ +export interface EmojiTag { + shortcode: string; + url: string; +} + +/** + * Represents a blob attachment for imeta tags (NIP-92) + */ +export interface BlobAttachment { + /** The URL of the blob */ + url: string; + /** SHA256 hash of the blob content */ + sha256: string; + /** MIME type of the blob */ + mimeType?: string; + /** Size in bytes */ + size?: number; + /** Blossom server URL */ + server?: string; +} + +/** + * Result of serializing editor content + */ +export interface SerializedContent { + /** The text content with mentions as nostr: URIs and emoji as :shortcode: */ + text: string; + /** Emoji tags to include in the event (NIP-30) */ + emojiTags: EmojiTag[]; + /** Blob attachments for imeta tags (NIP-92) */ + blobAttachments: BlobAttachment[]; +} + +/** + * Props for suggestion list components + */ +export interface SuggestionListProps { + items: T[]; + command: (item: T) => void; + onClose?: () => void; +} + +/** + * Handle for suggestion list components (keyboard navigation) + */ +export interface SuggestionListHandle { + onKeyDown: (event: KeyboardEvent) => boolean; +} + +/** + * Configuration for a suggestion type + */ +export interface SuggestionConfig { + /** Trigger character (e.g., "@", ":", "/") */ + char: string; + /** Search function to find suggestions */ + search: (query: string) => Promise; + /** Component to render the suggestion list */ + component: ComponentType< + SuggestionListProps & { ref?: React.Ref } + >; + /** Command to execute when item is selected - transforms item to TipTap node attrs */ + onSelect: (item: T) => { + type: string; + attrs: Record; + }; + /** Whether to allow spaces in the query */ + allowSpaces?: boolean; + /** Custom allow function (e.g., only at start of input) */ + allow?: (props: { range: { from: number; to: number } }) => boolean; + /** Popup placement */ + placement?: "top-start" | "bottom-start"; + /** Optional callback when command is executed (e.g., for slash commands) */ + onExecute?: (item: T) => Promise; + /** Whether selection should clear the trigger text (for slash commands) */ + clearOnSelect?: boolean; +} + +/** + * Submit behavior configuration + */ +export type SubmitBehavior = + | "enter" // Enter submits (desktop chat default), Shift+Enter for newline + | "ctrl-enter" // Only Ctrl/Cmd+Enter submits, Enter inserts newline + | "button-only"; // No keyboard submit, rely on external button + +/** + * Layout variant for the editor + */ +export type EditorVariant = + | "inline" // Single-line chat input (current chat behavior) + | "multiline" // Auto-expanding textarea + | "full"; // Full editor with fixed height and scroll + +/** + * Blob preview style + */ +export type BlobPreviewStyle = + | "compact" // Small inline pill (current chat behavior) + | "card" // Medium card with thumbnail + | "gallery"; // Full-width image gallery + +/** + * Handle exposed by NostrEditor for imperative control + */ +export interface NostrEditorHandle { + focus: () => void; + clear: () => void; + getContent: () => string; + getSerializedContent: () => SerializedContent; + /** Get the full TipTap JSON content (for draft persistence) */ + getJSON: () => object | null; + /** Set content from string or TipTap JSON */ + setContent: (content: string | object) => void; + 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; +} diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 65b694d..e8236f7 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -8,6 +8,7 @@ import { Eye, EyeOff, Zap, + PenSquare, } from "lucide-react"; import accounts from "@/services/accounts"; import { useProfile } from "@/hooks/useProfile"; @@ -145,6 +146,10 @@ export default function UserMenu() { ); } + function openPost() { + addWindow("post", {}, "New Post"); + } + function openWallet() { addWindow("wallet", {}, "Wallet"); } @@ -379,6 +384,13 @@ export default function UserMenu() { > + + + New Post + diff --git a/src/hooks/useEmojiSearch.ts b/src/hooks/useEmojiSearch.ts index 5481671..0c050ab 100644 --- a/src/hooks/useEmojiSearch.ts +++ b/src/hooks/useEmojiSearch.ts @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef } from "react"; +import type { Observable } from "rxjs"; import { EmojiSearchService, type EmojiSearchResult, @@ -8,23 +9,107 @@ import eventStore from "@/services/event-store"; import type { NostrEvent } from "@/types/nostr"; import { useAccount } from "./useAccount"; +export interface UseEmojiSearchOptions { + /** Event to extract context emojis from (e.g., current conversation) */ + contextEvent?: NostrEvent; + /** Custom emoji events to index (kind 10030 or 30030) */ + customEmojiEvents?: NostrEvent[]; + /** Custom observable source for emoji events */ + emojiSource$?: Observable; + /** Whether to include Unicode emojis (default: true) */ + includeUnicode?: boolean; + /** Whether to include user's emoji list from EventStore (default: true) */ + includeUserEmojis?: boolean; + /** Maximum results to return (default: 24) */ + limit?: number; +} + /** * Hook to provide emoji search functionality with automatic indexing - * of Unicode emojis and user's custom emojis from the event store + * of Unicode emojis and user's custom emojis from the event store. + * + * Supports injectable sources for custom emoji sets. + * + * @example + * // Default: Unicode + user's custom emojis + * const { searchEmojis } = useEmojiSearch(); + * + * @example + * // With context event (extracts emoji tags from event) + * const { searchEmojis } = useEmojiSearch({ contextEvent: event }); + * + * @example + * // Custom emoji source only + * const { searchEmojis } = useEmojiSearch({ + * emojiSource$: customEmojis$, + * includeUnicode: false, + * includeUserEmojis: false, + * }); */ -export function useEmojiSearch(contextEvent?: NostrEvent) { +export function useEmojiSearch(options: UseEmojiSearchOptions = {}) { + const { + contextEvent, + customEmojiEvents, + emojiSource$, + includeUnicode = true, + includeUserEmojis = true, + limit = 24, + } = options; + const serviceRef = useRef(null); const { pubkey } = useAccount(); // Create service instance (singleton per component mount) if (!serviceRef.current) { serviceRef.current = new EmojiSearchService(); - // Load Unicode emojis immediately - serviceRef.current.addUnicodeEmojis(UNICODE_EMOJIS); } const service = serviceRef.current; + // Load Unicode emojis if enabled + useEffect(() => { + if (includeUnicode) { + service.addUnicodeEmojis(UNICODE_EMOJIS); + } + }, [includeUnicode, service]); + + // Add custom emoji events if provided + useEffect(() => { + if (customEmojiEvents && customEmojiEvents.length > 0) { + for (const event of customEmojiEvents) { + if (event.kind === 10030) { + service.addUserEmojiList(event); + } else if (event.kind === 30030) { + service.addEmojiSet(event); + } + } + } + }, [customEmojiEvents, service]); + + // Subscribe to custom emoji source if provided + useEffect(() => { + if (!emojiSource$) return; + + const subscription = emojiSource$.subscribe({ + next: (events) => { + for (const event of events) { + if (event.kind === 10030) { + service.addUserEmojiList(event); + } else if (event.kind === 30030) { + service.addEmojiSet(event); + } + } + }, + error: (error) => { + console.error("Failed to load emojis from custom source:", error); + }, + }); + + return () => { + subscription.unsubscribe(); + }; + }, [emojiSource$, service]); + // Add context emojis when context event changes useEffect(() => { if (contextEvent) { @@ -34,7 +119,7 @@ export function useEmojiSearch(contextEvent?: NostrEvent) { // Subscribe to user's emoji list (kind 10030) and emoji sets (kind 30030) useEffect(() => { - if (!pubkey) { + if (!includeUserEmojis || !pubkey) { return; } @@ -96,15 +181,15 @@ export function useEmojiSearch(contextEvent?: NostrEvent) { // Clear custom emojis but keep unicode service.clearCustom(); }; - }, [pubkey, service]); + }, [pubkey, service, includeUserEmojis]); // Memoize search function const searchEmojis = useMemo( () => async (query: string): Promise => { - return await service.search(query, { limit: 24 }); + return await service.search(query, { limit }); }, - [service], + [service, limit], ); return { diff --git a/src/hooks/useProfileSearch.ts b/src/hooks/useProfileSearch.ts index 830d6f9..f77561a 100644 --- a/src/hooks/useProfileSearch.ts +++ b/src/hooks/useProfileSearch.ts @@ -1,26 +1,89 @@ import { useEffect, useMemo, useRef } from "react"; +import type { Observable } from "rxjs"; import { ProfileSearchService, type ProfileSearchResult, } from "@/services/profile-search"; import eventStore from "@/services/event-store"; +import type { NostrEvent } from "@/types/nostr"; + +export interface UseProfileSearchOptions { + /** Initial profiles to index immediately */ + initialProfiles?: NostrEvent[]; + /** Custom observable source for profiles (replaces default EventStore subscription) */ + profileSource$?: Observable; + /** Whether to also include profiles from global EventStore (default: true) */ + includeGlobal?: boolean; + /** Maximum results to return (default: 20) */ + limit?: number; +} /** * Hook to provide profile search functionality with automatic indexing - * of profiles from the event store + * of profiles from the event store. + * + * Supports injectable sources for custom profile sets (e.g., group members only). + * + * @example + * // Default: index all profiles from global EventStore + * const { searchProfiles } = useProfileSearch(); + * + * @example + * // Custom source: only group members + * const { searchProfiles } = useProfileSearch({ + * profileSource$: groupMemberProfiles$, + * includeGlobal: false, + * }); + * + * @example + * // Pre-populate with known profiles + * const { searchProfiles } = useProfileSearch({ + * initialProfiles: knownProfiles, + * }); */ -export function useProfileSearch() { +export function useProfileSearch(options: UseProfileSearchOptions = {}) { + const { + initialProfiles, + profileSource$, + includeGlobal = true, + limit = 20, + } = options; + const serviceRef = useRef(null); // Create service instance (singleton per component mount) if (!serviceRef.current) { serviceRef.current = new ProfileSearchService(); + // Index initial profiles immediately if provided + if (initialProfiles && initialProfiles.length > 0) { + serviceRef.current.addProfiles(initialProfiles); + } } const service = serviceRef.current; - // Subscribe to profile events from the event store + // Subscribe to custom profile source if provided useEffect(() => { + if (!profileSource$) return; + + const subscription = profileSource$.subscribe({ + next: (events) => { + service.addProfiles(events); + }, + error: (error) => { + console.error("Failed to load profiles from custom source:", error); + }, + }); + + return () => { + subscription.unsubscribe(); + }; + }, [profileSource$, service]); + + // Subscribe to global profile events from the event store + useEffect(() => { + if (!includeGlobal) return; + const subscription = eventStore .timeline([{ kinds: [0], limit: 1000 }]) .subscribe({ @@ -36,15 +99,15 @@ export function useProfileSearch() { subscription.unsubscribe(); service.clear(); // Clean up indexed profiles }; - }, [service]); + }, [service, includeGlobal]); // Memoize search function const searchProfiles = useMemo( () => async (query: string): Promise => { - return await service.search(query, { limit: 20 }); + return await service.search(query, { limit }); }, - [service], + [service, limit], ); return { 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..e00708d 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: + "Open a post composer to create and publish a short text note (kind 1). Features profile @mentions autocomplete, custom emoji support, and media attachments via Blossom. Requires a signing-capable account.", + examples: ["post Open post composer"], + seeAlso: ["req", "profile"], + appId: "post", + category: "Nostr", + defaultProps: {}, + }, };