From 0075f9a134b3d00531db18882dc9feb00bdb1ea4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 10:57:38 +0000 Subject: [PATCH 01/13] refactor: extract NostrEditor as generic post composer Refactor the chat composer into a reusable NostrEditor component with configurable behavior for different UI contexts (chat, posts, long-form). Key changes: - Create NostrEditor with configurable props: - submitBehavior: 'enter' | 'ctrl-enter' | 'button-only' - variant: 'inline' | 'multiline' | 'full' - blobPreview: 'compact' | 'card' | 'gallery' - Extract suggestion system into pluggable architecture (SuggestionConfig) - Add helper functions to create standard Nostr suggestions - Update search hooks (useProfileSearch, useEmojiSearch) with injectable sources for custom profile/emoji sets - Convert MentionEditor to backward-compatible wrapper around NostrEditor - Update ChatViewer to use new NostrEditor component This enables building WYSIWYG editors with the same autocomplete features as chat (profile mentions, emoji, uploads) but with different behaviors suitable for long-form posts or notes. --- src/components/ChatViewer.tsx | 46 +- src/components/editor/MentionEditor.tsx | 923 ++---------------------- src/components/editor/NostrEditor.tsx | 859 ++++++++++++++++++++++ src/components/editor/suggestions.tsx | 131 ++++ src/components/editor/types.ts | 122 ++++ src/hooks/useEmojiSearch.ts | 101 ++- src/hooks/useProfileSearch.ts | 75 +- 7 files changed, 1378 insertions(+), 879 deletions(-) create mode 100644 src/components/editor/NostrEditor.tsx create mode 100644 src/components/editor/suggestions.tsx create mode 100644 src/components/editor/types.ts diff --git a/src/components/ChatViewer.tsx b/src/components/ChatViewer.tsx index af29920..5646157 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/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..0713812 --- /dev/null +++ b/src/components/editor/NostrEditor.tsx @@ -0,0 +1,859 @@ +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) */ + initialContent?: string; + /** Called when content is submitted */ + onSubmit?: (content: SerializedContent) => void; + /** Called when content changes */ + onChange?: (content: SerializedContent) => 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 + */ +function createSuggestionConfig( + config: SuggestionConfig, + handleSubmitRef: React.MutableRefObject<(editor: unknown) => void>, +): Omit, "editor"> { + return { + char: config.char, + allowSpaces: config.allowSpaces ?? false, + allow: config.allow, + items: async ({ query }) => { + return await config.search(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; + let editorRef: unknown; + + return { + onStart: (props) => { + editorRef = props.editor; + component = new ReactRenderer(config.component as never, { + 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: config.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 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(); + }, + }; + }, + }; +} + +export const NostrEditor = forwardRef( + ( + { + placeholder = "Type a message...", + initialContent, + onSubmit, + onChange, + 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; + + // 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 (find it in suggestions) + const mentionConfig = suggestions.find((s) => s.char === "@"); + 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 (find it in suggestions) + const emojiConfig = suggestions.find((s) => s.char === ":"); + 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 (find it in suggestions) + const slashConfig = suggestions.find((s) => s.char === "/"); + 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}`; + }, + }), + ); + } + + // Add any additional custom suggestions (not @, :, or /) + const customSuggestions = suggestions.filter( + (s) => !["@", ":", "/"].includes(s.char), + ); + for (const config of customSuggestions) { + const CustomMention = Mention.extend({ + name: `suggestion-${config.char}`, + }); + exts.push( + CustomMention.configure({ + HTMLAttributes: { class: `suggestion-${config.char}` }, + suggestion: { + ...createSuggestionConfig(config, handleSubmitRef), + command: ({ + editor, + range, + props, + }: { + editor: unknown; + range: unknown; + props: unknown; + }) => { + const result = config.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(); + }, + }, + }), + ); + } + + return exts; + }, [suggestions, submitBehavior, placeholder, blobPreview]); + + const editor = useEditor({ + extensions, + content: initialContent, + editorProps: { + attributes: { + class: "prose prose-sm max-w-none focus:outline-none text-sm", + }, + }, + autofocus: autoFocus, + 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); + }, + 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..2a22871 --- /dev/null +++ b/src/components/editor/types.ts @@ -0,0 +1,122 @@ +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; + 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/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 { From 0d70e2dd9277a84a14151ef05f2aef29556e9832 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 14:28:19 +0000 Subject: [PATCH 02/13] feat: add post dialog with NostrEditor to user menu Add a "New Post" action to the user menu that opens a dialog with the new NostrEditor configured for composing kind 1 notes: - Full editor variant with 8 lines minimum - Gallery-style blob previews for media attachments - Button-only submit behavior (no keyboard shortcuts) - Profile and emoji autocomplete via suggestions - Blossom upload integration for media attachments The post action: - Creates kind 1 events using NoteBlueprint from applesauce-common - Adds emoji tags for custom emoji (NIP-30) - Adds imeta tags for media attachments (NIP-92) - Publishes via the global hub action runner --- src/components/PostDialog.tsx | 183 +++++++++++++++++++++++++++++ src/components/nostr/user-menu.tsx | 11 ++ 2 files changed, 194 insertions(+) create mode 100644 src/components/PostDialog.tsx diff --git a/src/components/PostDialog.tsx b/src/components/PostDialog.tsx new file mode 100644 index 0000000..f0cbfec --- /dev/null +++ b/src/components/PostDialog.tsx @@ -0,0 +1,183 @@ +import { useRef, useMemo, useState, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +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 } 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"; + +interface PostDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +// 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 default function PostDialog({ open, onOpenChange }: PostDialogProps) { + const { pubkey, canSign } = useAccount(); + const { searchProfiles } = useProfileSearch(); + const { searchEmojis } = useEmojiSearch(); + const editorRef = useRef(null); + const [isPublishing, setIsPublishing] = useState(false); + + // 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!"); + editorRef.current?.clear(); + onOpenChange(false); + } catch (error) { + console.error("[PostDialog] Failed to publish:", error); + toast.error( + error instanceof Error ? error.message : "Failed to publish post", + ); + } finally { + setIsPublishing(false); + } + }, + [canSign, pubkey, onOpenChange], + ); + + // Handle submit button click + const handleSubmitClick = useCallback(() => { + if (editorRef.current) { + const content = editorRef.current.getSerializedContent(); + handlePublish(content); + } + }, [handlePublish]); + + return ( + <> + + + + New Post + + +
+ + +
+ + + +
+
+
+
+ + {uploadDialog} + + ); +} diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 7bd0fec..62937f2 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"; @@ -42,6 +43,7 @@ import { RelayLink } from "./RelayLink"; import SettingsDialog from "@/components/SettingsDialog"; import LoginDialog from "./LoginDialog"; import ConnectWalletDialog from "@/components/ConnectWalletDialog"; +import PostDialog from "@/components/PostDialog"; import { useState } from "react"; import { useTheme } from "@/lib/themes"; import { toast } from "sonner"; @@ -93,6 +95,7 @@ export default function UserMenu() { const [showLogin, setShowLogin] = useState(false); const [showConnectWallet, setShowConnectWallet] = useState(false); const [showWalletInfo, setShowWalletInfo] = useState(false); + const [showPost, setShowPost] = useState(false); const { themeId, setTheme, availableThemes } = useTheme(); // Calculate monthly donations reactively from DB (last 30 days) @@ -218,6 +221,7 @@ export default function UserMenu() { onOpenChange={setShowConnectWallet} onConnected={openWallet} /> + {/* Wallet Info Dialog */} {nwcConnection && ( @@ -379,6 +383,13 @@ export default function UserMenu() { > + setShowPost(true)} + > + + New Post + From 9d651f99b16d1f632809f23465727f03cbb0d90e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 14:37:07 +0000 Subject: [PATCH 03/13] fix: load contacts profiles and fix autocomplete z-index in PostDialog - Load contacts (kind 3) and fetch their profiles when dialog opens - Profiles are added to the search index for autocomplete - Set tippy z-index to 100 to appear above dialogs (z-50) --- src/components/PostDialog.tsx | 50 +++++++++++++++++++++++++-- src/components/editor/NostrEditor.tsx | 2 ++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/components/PostDialog.tsx b/src/components/PostDialog.tsx index f0cbfec..dc3918c 100644 --- a/src/components/PostDialog.tsx +++ b/src/components/PostDialog.tsx @@ -1,4 +1,4 @@ -import { useRef, useMemo, useState, useCallback } from "react"; +import { useRef, useMemo, useState, useCallback, useEffect } from "react"; import { Dialog, DialogContent, @@ -19,6 +19,8 @@ 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"; interface PostDialogProps { open: boolean; @@ -52,11 +54,55 @@ function CreateNoteAction(content: SerializedContent) { export default function PostDialog({ open, onOpenChange }: PostDialogProps) { const { pubkey, canSign } = useAccount(); - const { searchProfiles } = useProfileSearch(); + const eventStore = useEventStore(); + const { searchProfiles, service: profileService } = useProfileSearch(); const { searchEmojis } = useEmojiSearch(); const editorRef = useRef(null); const [isPublishing, setIsPublishing] = useState(false); + // Load contacts and their profiles when dialog opens + useEffect(() => { + if (!open || !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(); + }; + }, [open, pubkey, eventStore, profileService]); + // Blossom upload for attachments const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({ accept: "image/*,video/*,audio/*", diff --git a/src/components/editor/NostrEditor.tsx b/src/components/editor/NostrEditor.tsx index 0713812..2f50f58 100644 --- a/src/components/editor/NostrEditor.tsx +++ b/src/components/editor/NostrEditor.tsx @@ -350,6 +350,8 @@ function createSuggestionConfig( interactive: true, trigger: "manual", placement: config.placement ?? "bottom-start", + // Ensure popup appears above dialogs (which use z-50) + zIndex: 100, }); }, From 67b6441547254126821351d27570050a72270e1b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 14:46:38 +0000 Subject: [PATCH 04/13] feat: add post command and window-based composer Replace dialog-based PostDialog with PostViewer window: - Create PostViewer component for composing kind 1 notes - Add "post" command to man pages - Add "post" to AppId type - Update WindowRenderer to render PostViewer - Update user menu to open post window instead of dialog - Delete unused PostDialog component Window-based approach avoids z-index issues with autocomplete popups that occurred in the dialog context. --- .../{PostDialog.tsx => PostViewer.tsx} | 148 ++++++++++-------- src/components/WindowRenderer.tsx | 6 + src/components/nostr/user-menu.tsx | 9 +- src/types/app.ts | 1 + src/types/man.ts | 12 ++ 5 files changed, 105 insertions(+), 71 deletions(-) rename src/components/{PostDialog.tsx => PostViewer.tsx} (67%) diff --git a/src/components/PostDialog.tsx b/src/components/PostViewer.tsx similarity index 67% rename from src/components/PostDialog.tsx rename to src/components/PostViewer.tsx index dc3918c..6d2d166 100644 --- a/src/components/PostDialog.tsx +++ b/src/components/PostViewer.tsx @@ -1,10 +1,4 @@ import { useRef, useMemo, useState, useCallback, useEffect } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { NostrEditor, type NostrEditorHandle } from "./editor/NostrEditor"; import { createNostrSuggestions } from "./editor/suggestions"; @@ -12,7 +6,7 @@ import { useProfileSearch } from "@/hooks/useProfileSearch"; import { useEmojiSearch } from "@/hooks/useEmojiSearch"; import { useBlossomUpload } from "@/hooks/useBlossomUpload"; import { useAccount } from "@/hooks/useAccount"; -import { Loader2, Paperclip } from "lucide-react"; +import { Loader2, Paperclip, CheckCircle2 } from "lucide-react"; import { toast } from "sonner"; import { hub } from "@/services/hub"; import { NoteBlueprint } from "applesauce-common/blueprints"; @@ -22,11 +16,6 @@ import type { ActionContext } from "applesauce-actions"; import { useEventStore } from "applesauce-react/hooks"; import { addressLoader, profileLoader } from "@/services/loaders"; -interface PostDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - // Action builder for creating a short text note function CreateNoteAction(content: SerializedContent) { return async ({ factory, sign, publish }: ActionContext) => { @@ -52,17 +41,18 @@ function CreateNoteAction(content: SerializedContent) { }; } -export default function PostDialog({ open, onOpenChange }: PostDialogProps) { +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); - // Load contacts and their profiles when dialog opens + // Load contacts and their profiles useEffect(() => { - if (!open || !pubkey) return; + if (!pubkey) return; // Load contacts list (kind 3) const contactsSubscription = addressLoader({ @@ -101,7 +91,7 @@ export default function PostDialog({ open, onOpenChange }: PostDialogProps) { contactsSubscription.unsubscribe(); storeSubscription.unsubscribe(); }; - }, [open, pubkey, eventStore, profileService]); + }, [pubkey, eventStore, profileService]); // Blossom upload for attachments const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({ @@ -150,10 +140,10 @@ export default function PostDialog({ open, onOpenChange }: PostDialogProps) { await lastValueFrom(hub.exec(CreateNoteAction, content)); toast.success("Post published!"); + setIsPublished(true); editorRef.current?.clear(); - onOpenChange(false); } catch (error) { - console.error("[PostDialog] Failed to publish:", error); + console.error("[PostViewer] Failed to publish:", error); toast.error( error instanceof Error ? error.message : "Failed to publish post", ); @@ -161,7 +151,7 @@ export default function PostDialog({ open, onOpenChange }: PostDialogProps) { setIsPublishing(false); } }, - [canSign, pubkey, onOpenChange], + [canSign, pubkey], ); // Handle submit button click @@ -172,58 +162,82 @@ export default function PostDialog({ open, onOpenChange }: PostDialogProps) { } }, [handlePublish]); + // Reset published state when user starts typing again + const handleChange = useCallback(() => { + if (isPublished) { + setIsPublished(false); + } + }, [isPublished]); + + if (!canSign) { + return ( +
+
+

Sign in to post

+

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

+
+
+ ); + } + return ( - <> - - - - New Post - +
+
+ +
-
- +
+ -
- - - -
-
- -
+
+ {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/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 62937f2..122f50c 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -43,7 +43,6 @@ import { RelayLink } from "./RelayLink"; import SettingsDialog from "@/components/SettingsDialog"; import LoginDialog from "./LoginDialog"; import ConnectWalletDialog from "@/components/ConnectWalletDialog"; -import PostDialog from "@/components/PostDialog"; import { useState } from "react"; import { useTheme } from "@/lib/themes"; import { toast } from "sonner"; @@ -95,7 +94,6 @@ export default function UserMenu() { const [showLogin, setShowLogin] = useState(false); const [showConnectWallet, setShowConnectWallet] = useState(false); const [showWalletInfo, setShowWalletInfo] = useState(false); - const [showPost, setShowPost] = useState(false); const { themeId, setTheme, availableThemes } = useTheme(); // Calculate monthly donations reactively from DB (last 30 days) @@ -148,6 +146,10 @@ export default function UserMenu() { ); } + function openPost() { + addWindow("post", {}, "New Post"); + } + function openWallet() { addWindow("wallet", {}, "Wallet"); } @@ -221,7 +223,6 @@ export default function UserMenu() { onOpenChange={setShowConnectWallet} onConnected={openWallet} /> - {/* Wallet Info Dialog */} {nwcConnection && ( @@ -385,7 +386,7 @@ export default function UserMenu() { setShowPost(true)} + onClick={openPost} > New Post 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: {}, + }, }; From 39667dea6870080aae412447bf31a02089410a07 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 14:55:30 +0000 Subject: [PATCH 05/13] fix: use refs for suggestion search functions and reduce PostViewer spacing NostrEditor: - Use refs for suggestion configs instead of capturing at mount time - This ensures search functions are always fresh when TipTap calls them - Fixes autocomplete filtering not updating as user types PostViewer: - Reduce padding from p-4 to p-3 - Reduce minLines from 10 to 6 - Reduce button bar spacing from pt-4 mt-4 to pt-2 mt-2 --- src/components/PostViewer.tsx | 6 +- src/components/editor/NostrEditor.tsx | 120 +++++++++++--------------- 2 files changed, 55 insertions(+), 71 deletions(-) diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index 6d2d166..9bb8370 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -184,7 +184,7 @@ export function PostViewer() { } return ( -
+
-
+
diff --git a/src/components/editor/NostrEditor.tsx b/src/components/editor/NostrEditor.tsx index b02c7a6..7d8262f 100644 --- a/src/components/editor/NostrEditor.tsx +++ b/src/components/editor/NostrEditor.tsx @@ -325,8 +325,17 @@ function createSuggestionConfig( items: async ({ query }) => { // Always use the current config from ref to get fresh search function const config = configRef.current; - if (!config) return []; - return await config.search(query); + if (!config) { + console.warn( + `[NostrEditor] Suggestion config for '${triggerChar}' is undefined`, + ); + return []; + } + const results = await config.search(query); + console.log( + `[NostrEditor] Search '${triggerChar}' query="${query}" results=${results.length}`, + ); + return results; }, render: () => { let component: ReactRenderer; @@ -441,6 +450,16 @@ export const NostrEditor = forwardRef( emojiConfigRef.current = suggestions.find((s) => s.char === ":"); slashConfigRef.current = suggestions.find((s) => s.char === "/"); + // Debug: log suggestion config status + if (process.env.NODE_ENV === "development") { + console.log("[NostrEditor] Suggestions updated:", { + mention: !!mentionConfigRef.current, + emoji: !!emojiConfigRef.current, + slash: !!slashConfigRef.current, + suggestionsCount: suggestions.length, + }); + } + // Helper function to serialize editor content const serializeContent = useCallback( (editorInstance: { From cc7b0448caf8de28f3d572ed610596c8ceae0e28 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 16:15:59 +0000 Subject: [PATCH 07/13] fix: refactor autocomplete refs pattern and add full JSON draft persistence - Refactor NostrEditor to use a single refs object (SuggestionRefs) for all autocomplete functions instead of multiple individual refs - Create dedicated suggestion config functions for mention, emoji, and slash commands that read from refs at call time - Save drafts as full TipTap JSON to preserve emojis, mentions, and blob data - Add resize-y CSS for full editor variant - Add getJSON/setContent methods to NostrEditorHandle for draft persistence --- src/components/PostViewer.tsx | 53 ++-- src/components/editor/NostrEditor.tsx | 346 ++++++++++++++++++++------ src/components/editor/types.ts | 4 + 3 files changed, 295 insertions(+), 108 deletions(-) diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index 1a5c97a..4bda0f7 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -56,8 +56,8 @@ export function PostViewer() { // Use pubkey as draft key - one draft per account, persists across reloads const draftKey = pubkey ? `${DRAFT_STORAGE_PREFIX}${pubkey}` : null; - // Load draft from localStorage on mount - const [initialContent, setInitialContent] = useState( + // Load draft from localStorage on mount (stores full TipTap JSON for rich content) + const [initialContent, setInitialContent] = useState( undefined, ); const draftLoadedRef = useRef(false); @@ -69,30 +69,30 @@ export function PostViewer() { try { const savedDraft = localStorage.getItem(draftKey); if (savedDraft) { - setInitialContent(savedDraft); + const parsed = JSON.parse(savedDraft); + setInitialContent(parsed); } } catch (error) { console.warn("[PostViewer] Failed to load draft:", error); } }, [draftKey]); - // Save draft to localStorage when content changes - const saveDraft = useCallback( - (content: SerializedContent) => { - if (!draftKey) return; - try { - if (content.text.trim()) { - localStorage.setItem(draftKey, content.text); - } else { - localStorage.removeItem(draftKey); - } - } catch (error) { - // localStorage might be full or disabled - console.warn("[PostViewer] Failed to save draft:", error); + // 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); } - }, - [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(() => { @@ -218,15 +218,12 @@ export function PostViewer() { }, [handlePublish]); // Handle content change - save draft and reset published state - const handleChange = useCallback( - (content: SerializedContent) => { - if (isPublished) { - setIsPublished(false); - } - saveDraft(content); - }, - [isPublished, saveDraft], - ); + const handleChange = useCallback(() => { + if (isPublished) { + setIsPublished(false); + } + saveDraft(); + }, [isPublished, saveDraft]); if (!canSign) { return ( diff --git a/src/components/editor/NostrEditor.tsx b/src/components/editor/NostrEditor.tsx index 7d8262f..3854191 100644 --- a/src/components/editor/NostrEditor.tsx +++ b/src/components/editor/NostrEditor.tsx @@ -34,6 +34,32 @@ import type { SuggestionListHandle, } from "./types"; +/** + * Refs container for suggestion functions + * Allows updating search/select functions without recreating extensions + */ +interface SuggestionRefs { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mentionSearch: (query: string) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mentionOnSelect: (item: any) => { + type: string; + attrs: Record; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + emojiSearch: (query: string) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + emojiOnSelect: (item: any) => { + type: string; + attrs: Record; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + slashSearch: (query: string) => Promise; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + slashOnExecute?: (item: any) => Promise; + handleSubmit: (editor: unknown) => void; +} + // Re-export handle type for consumers export type { NostrEditorHandle }; @@ -41,7 +67,7 @@ export interface NostrEditorProps { /** Placeholder text when editor is empty */ placeholder?: string; /** Initial content (plain text) */ - initialContent?: string; + initialContent?: string | object; /** Called when content is submitted */ onSubmit?: (content: SerializedContent) => void; /** Called when content changes */ @@ -311,31 +337,20 @@ function createBlobAttachmentNode(previewStyle: BlobPreviewStyle) { /** * Create a TipTap suggestion configuration from our SuggestionConfig - * Uses a ref to always get the latest search function (since TipTap captures at mount) + * Uses a refs object to access the latest functions without recreating extensions */ -function createSuggestionConfig( - configRef: React.MutableRefObject | undefined>, - triggerChar: string, - handleSubmitRef: React.MutableRefObject<(editor: unknown) => void>, -): Omit, "editor"> { +function createMentionSuggestionConfig( + config: SuggestionConfig, + refsObj: React.MutableRefObject, +): Omit, "editor"> { return { - char: triggerChar, - allowSpaces: configRef.current?.allowSpaces ?? false, - allow: configRef.current?.allow, + char: config.char, + allowSpaces: config.allowSpaces ?? false, + allow: config.allow, items: async ({ query }) => { - // Always use the current config from ref to get fresh search function - const config = configRef.current; - if (!config) { - console.warn( - `[NostrEditor] Suggestion config for '${triggerChar}' is undefined`, - ); - return []; - } - const results = await config.search(query); - console.log( - `[NostrEditor] Search '${triggerChar}' query="${query}" results=${results.length}`, - ); - return results; + // Always use the current search function from refs + const searchFn = refsObj.current.mentionSearch; + return await searchFn(query); }, render: () => { let component: ReactRenderer; @@ -344,9 +359,6 @@ function createSuggestionConfig( return { onStart: (props) => { - const config = configRef.current; - if (!config) return; - editorRef = props.editor; component = new ReactRenderer(config.component as never, { props: { @@ -367,7 +379,6 @@ function createSuggestionConfig( interactive: true, trigger: "manual", placement: config.placement ?? "bottom-start", - // Ensure popup appears above dialogs (which use z-50) zIndex: 100, }); }, @@ -391,13 +402,167 @@ function createSuggestionConfig( return true; } - // Ctrl/Cmd+Enter always submits if ( props.event.key === "Enter" && (props.event.ctrlKey || props.event.metaKey) ) { popup[0]?.hide(); - handleSubmitRef.current(editorRef); + refsObj.current.handleSubmit(editorRef); + return true; + } + + return component.ref?.onKeyDown(props.event) ?? false; + }, + + onExit() { + popup[0]?.destroy(); + component.destroy(); + }, + }; + }, + }; +} + +function createEmojiSuggestionConfig( + config: SuggestionConfig, + refsObj: React.MutableRefObject, +): Omit, "editor"> { + return { + char: config.char, + allowSpaces: config.allowSpaces ?? false, + allow: config.allow, + items: async ({ query }) => { + const searchFn = refsObj.current.emojiSearch; + return await searchFn(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; + let editorRef: unknown; + + return { + onStart: (props) => { + editorRef = props.editor; + component = new ReactRenderer(config.component as never, { + 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: config.placement ?? "bottom-start", + zIndex: 100, + }); + }, + + 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; + } + + if ( + props.event.key === "Enter" && + (props.event.ctrlKey || props.event.metaKey) + ) { + popup[0]?.hide(); + refsObj.current.handleSubmit(editorRef); + return true; + } + + return component.ref?.onKeyDown(props.event) ?? false; + }, + + onExit() { + popup[0]?.destroy(); + component.destroy(); + }, + }; + }, + }; +} + +function createSlashSuggestionConfig( + config: SuggestionConfig, + refsObj: React.MutableRefObject, +): Omit, "editor"> { + return { + char: config.char, + allowSpaces: config.allowSpaces ?? false, + allow: config.allow, + items: async ({ query }) => { + const searchFn = refsObj.current.slashSearch; + return await searchFn(query); + }, + render: () => { + let component: ReactRenderer; + let popup: TippyInstance[]; + + return { + onStart: (props) => { + component = new ReactRenderer(config.component as never, { + 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: config.placement ?? "top-start", + zIndex: 100, + }); + }, + + 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; } @@ -431,33 +596,35 @@ export const NostrEditor = forwardRef( }, ref, ) => { - const handleSubmitRef = useRef<(editor: unknown) => void>(() => {}); + // Find suggestion configs + const mentionConfig = suggestions.find((s) => s.char === "@"); + const emojiConfig = suggestions.find((s) => s.char === ":"); + const slashConfig = suggestions.find((s) => s.char === "/"); - // Refs for suggestion configs - TipTap captures these at mount, so we use refs - // to always get the latest search functions - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mentionConfigRef = useRef | undefined>( - undefined, - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const emojiConfigRef = useRef | undefined>(undefined); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const slashConfigRef = useRef | undefined>(undefined); + // Single refs object for all suggestion functions - updated on every render + const suggestionRefsObj = useRef({ + mentionSearch: async () => [], + mentionOnSelect: () => ({ type: "mention", attrs: {} }), + emojiSearch: async () => [], + emojiOnSelect: () => ({ type: "emoji", attrs: {} }), + slashSearch: async () => [], + slashOnExecute: undefined, + handleSubmit: () => {}, + }); - // Initialize refs immediately (for first render) and update when suggestions change - // This ensures the refs have values before extensions are created - mentionConfigRef.current = suggestions.find((s) => s.char === "@"); - emojiConfigRef.current = suggestions.find((s) => s.char === ":"); - slashConfigRef.current = suggestions.find((s) => s.char === "/"); - - // Debug: log suggestion config status - if (process.env.NODE_ENV === "development") { - console.log("[NostrEditor] Suggestions updated:", { - mention: !!mentionConfigRef.current, - emoji: !!emojiConfigRef.current, - slash: !!slashConfigRef.current, - suggestionsCount: suggestions.length, - }); + // Update refs object with current search functions on every render + // This ensures TipTap extensions always call the latest functions + if (mentionConfig) { + suggestionRefsObj.current.mentionSearch = mentionConfig.search; + suggestionRefsObj.current.mentionOnSelect = mentionConfig.onSelect; + } + if (emojiConfig) { + suggestionRefsObj.current.emojiSearch = emojiConfig.search; + suggestionRefsObj.current.emojiOnSelect = emojiConfig.onSelect; + } + if (slashConfig) { + suggestionRefsObj.current.slashSearch = slashConfig.search; + suggestionRefsObj.current.slashOnExecute = slashConfig.onExecute; } // Helper function to serialize editor content @@ -572,7 +739,8 @@ export const NostrEditor = forwardRef( [onSubmit, serializeContent], ); - handleSubmitRef.current = handleSubmit; + // Update handleSubmit in refs object + suggestionRefsObj.current.handleSubmit = handleSubmit; // Build extensions array const extensions = useMemo(() => { @@ -584,7 +752,7 @@ export const NostrEditor = forwardRef( addKeyboardShortcuts() { return { "Mod-Enter": ({ editor }) => { - handleSubmitRef.current(editor); + suggestionRefsObj.current.handleSubmit(editor); return true; }, Enter: ({ editor }) => { @@ -599,7 +767,7 @@ export const NostrEditor = forwardRef( if (isMobile) { return editor.commands.setHardBreak(); } else { - handleSubmitRef.current(editor); + suggestionRefsObj.current.handleSubmit(editor); return true; } } @@ -617,13 +785,16 @@ export const NostrEditor = forwardRef( createBlobAttachmentNode(blobPreview), ]; - // Add mention extension for @ mentions (uses ref for latest config) - if (mentionConfigRef.current) { + // Add mention extension for @ mentions + if (mentionConfig) { exts.push( Mention.configure({ HTMLAttributes: { class: "mention" }, suggestion: { - ...createSuggestionConfig(mentionConfigRef, "@", handleSubmitRef), + ...createMentionSuggestionConfig( + mentionConfig, + suggestionRefsObj, + ), command: ({ editor, range, @@ -633,9 +804,10 @@ export const NostrEditor = forwardRef( range: unknown; props: unknown; }) => { - const config = mentionConfigRef.current; - if (!config) return; - const result = config.onSelect(props as never); + // Use refs to get latest onSelect function + const result = suggestionRefsObj.current.mentionOnSelect( + props as never, + ); const ed = editor as { chain: () => { focus: () => { @@ -662,13 +834,13 @@ export const NostrEditor = forwardRef( ); } - // Add emoji extension (uses ref for latest config) - if (emojiConfigRef.current) { + // Add emoji extension + if (emojiConfig) { exts.push( EmojiMention.configure({ HTMLAttributes: { class: "emoji" }, suggestion: { - ...createSuggestionConfig(emojiConfigRef, ":", handleSubmitRef), + ...createEmojiSuggestionConfig(emojiConfig, suggestionRefsObj), command: ({ editor, range, @@ -678,9 +850,10 @@ export const NostrEditor = forwardRef( range: unknown; props: unknown; }) => { - const config = emojiConfigRef.current; - if (!config) return; - const result = config.onSelect(props as never); + // Use refs to get latest onSelect function + const result = suggestionRefsObj.current.emojiOnSelect( + props as never, + ); const ed = editor as { chain: () => { focus: () => { @@ -704,14 +877,14 @@ export const NostrEditor = forwardRef( ); } - // Add slash command extension (uses ref for latest config) - if (slashConfigRef.current) { + // Add slash command extension + if (slashConfig) { const SlashCommand = Mention.extend({ name: "slashCommand" }); exts.push( SlashCommand.configure({ HTMLAttributes: { class: "slash-command" }, suggestion: { - ...createSuggestionConfig(slashConfigRef, "/", handleSubmitRef), + ...createSlashSuggestionConfig(slashConfig, suggestionRefsObj), command: ({ editor, props, @@ -719,14 +892,14 @@ export const NostrEditor = forwardRef( editor: unknown; props: unknown; }) => { - const config = slashConfigRef.current; - if (!config) return; const ed = editor as { commands: { clearContent: () => void } }; - if (config.clearOnSelect !== false) { + if (slashConfig.clearOnSelect !== false) { ed.commands.clearContent(); } - if (config.onExecute) { - config.onExecute(props as never).catch((error) => { + // Use refs to get latest onExecute function + const onExecute = suggestionRefsObj.current.slashOnExecute; + if (onExecute) { + onExecute(props as never).catch((error) => { console.error( "[NostrEditor] Command execution failed:", error, @@ -743,9 +916,15 @@ export const NostrEditor = forwardRef( } return exts; - // Note: We don't depend on suggestions here - we use refs that are updated - // synchronously when suggestions change, ensuring the search functions are always fresh - }, [submitBehavior, placeholder, blobPreview]); + // Depend on whether configs exist (for extension creation) but not search functions (refs handle updates) + }, [ + submitBehavior, + placeholder, + blobPreview, + !!mentionConfig, + !!emojiConfig, + !!slashConfig, + ]); const editor = useEditor({ extensions, @@ -774,6 +953,12 @@ export const NostrEditor = forwardRef( 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) { @@ -848,6 +1033,7 @@ export const NostrEditor = forwardRef( "rounded border bg-background transition-colors focus-within:border-primary px-2", variant === "inline" && "h-7 flex items-center overflow-hidden", variant !== "inline" && "py-2 overflow-y-auto", + variant === "full" && "resize-y min-h-[100px]", className, )} style={getInlineStyles()} diff --git a/src/components/editor/types.ts b/src/components/editor/types.ts index 2a22871..253da8f 100644 --- a/src/components/editor/types.ts +++ b/src/components/editor/types.ts @@ -113,6 +113,10 @@ export interface NostrEditorHandle { 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 */ From d540f4a91e21e62b31e833e8d5fdf0e40d7f2f39 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 16:27:18 +0000 Subject: [PATCH 08/13] fix: improve draft loading and add autocomplete debug logging - Load drafts using setContent after editor is ready instead of initialContent prop - Add onReady callback to NostrEditor to notify when editor is mounted - Add console.log debugging to trace autocomplete update flow --- src/components/PostViewer.tsx | 27 +++++++++++++++++++-------- src/components/editor/NostrEditor.tsx | 21 +++++++++++++++++++-- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index 4bda0f7..f8032ef 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -56,26 +56,37 @@ export function PostViewer() { // Use pubkey as draft key - one draft per account, persists across reloads const draftKey = pubkey ? `${DRAFT_STORAGE_PREFIX}${pubkey}` : null; - // Load draft from localStorage on mount (stores full TipTap JSON for rich content) - const [initialContent, setInitialContent] = useState( - undefined, - ); + // 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) return; + if ( + draftLoadedRef.current || + !draftKey || + !editorReady || + !editorRef.current + ) + return; draftLoadedRef.current = true; try { const savedDraft = localStorage.getItem(draftKey); if (savedDraft) { const parsed = JSON.parse(savedDraft); - setInitialContent(parsed); + // Use setContent to load draft after editor is mounted + editorRef.current.setContent(parsed); } } catch (error) { console.warn("[PostViewer] Failed to load draft:", error); } - }, [draftKey]); + }, [draftKey, editorReady]); // Save draft to localStorage when content changes (uses full TipTap JSON) const saveDraft = useCallback(() => { @@ -250,7 +261,7 @@ export function PostViewer() { minLines={6} suggestions={suggestions} onChange={handleChange} - initialContent={initialContent} + onReady={handleEditorReady} autoFocus /> diff --git a/src/components/editor/NostrEditor.tsx b/src/components/editor/NostrEditor.tsx index 3854191..1c115c3 100644 --- a/src/components/editor/NostrEditor.tsx +++ b/src/components/editor/NostrEditor.tsx @@ -66,12 +66,14 @@ export type { NostrEditorHandle }; export interface NostrEditorProps { /** Placeholder text when editor is empty */ placeholder?: string; - /** Initial content (plain text) */ + /** 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) */ @@ -350,7 +352,11 @@ function createMentionSuggestionConfig( items: async ({ query }) => { // Always use the current search function from refs const searchFn = refsObj.current.mentionSearch; - return await searchFn(query); + const results = await searchFn(query); + console.log( + `[NostrEditor] Mention items() query="${query}" results=${results.length}`, + ); + return results; }, render: () => { let component: ReactRenderer; @@ -359,6 +365,9 @@ function createMentionSuggestionConfig( return { onStart: (props) => { + console.log( + `[NostrEditor] Mention onStart items=${props.items?.length}`, + ); editorRef = props.editor; component = new ReactRenderer(config.component as never, { props: { @@ -384,6 +393,9 @@ function createMentionSuggestionConfig( }, onUpdate(props) { + console.log( + `[NostrEditor] Mention onUpdate items=${props.items?.length}`, + ); component.updateProps({ items: props.items, command: props.command, @@ -585,6 +597,7 @@ export const NostrEditor = forwardRef( initialContent, onSubmit, onChange, + onReady, submitBehavior = "enter", variant = "inline", minLines = 1, @@ -935,6 +948,10 @@ export const NostrEditor = forwardRef( }, }, autofocus: autoFocus, + onCreate: () => { + // Notify parent that editor is ready for operations like loading drafts + onReady?.(); + }, onUpdate: ({ editor }) => { if (onChange) { onChange(serializeContent(editor)); From 28aceb6e071d14274d6d0d92cfe8361470cf8232 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 16:33:13 +0000 Subject: [PATCH 09/13] fix: remove debug logging from autocomplete --- src/components/editor/NostrEditor.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/components/editor/NostrEditor.tsx b/src/components/editor/NostrEditor.tsx index 1c115c3..b50c777 100644 --- a/src/components/editor/NostrEditor.tsx +++ b/src/components/editor/NostrEditor.tsx @@ -352,11 +352,7 @@ function createMentionSuggestionConfig( items: async ({ query }) => { // Always use the current search function from refs const searchFn = refsObj.current.mentionSearch; - const results = await searchFn(query); - console.log( - `[NostrEditor] Mention items() query="${query}" results=${results.length}`, - ); - return results; + return await searchFn(query); }, render: () => { let component: ReactRenderer; @@ -365,9 +361,6 @@ function createMentionSuggestionConfig( return { onStart: (props) => { - console.log( - `[NostrEditor] Mention onStart items=${props.items?.length}`, - ); editorRef = props.editor; component = new ReactRenderer(config.component as never, { props: { @@ -393,9 +386,6 @@ function createMentionSuggestionConfig( }, onUpdate(props) { - console.log( - `[NostrEditor] Mention onUpdate items=${props.items?.length}`, - ); component.updateProps({ items: props.items, command: props.command, From b2b5346abeae62b9704082a618f5181d094983cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 16:41:25 +0000 Subject: [PATCH 10/13] fix: simplify autocomplete suggestion config to fix filtering not updating UI The previous refs-based pattern for suggestion configs was causing the autocomplete UI to not update when search results changed. Reverted to a simpler generic createSuggestionConfig function that captures the config directly in the closure, which matches the original working implementation. --- src/components/editor/NostrEditor.tsx | 272 +++----------------------- 1 file changed, 26 insertions(+), 246 deletions(-) diff --git a/src/components/editor/NostrEditor.tsx b/src/components/editor/NostrEditor.tsx index b50c777..1aa3d94 100644 --- a/src/components/editor/NostrEditor.tsx +++ b/src/components/editor/NostrEditor.tsx @@ -34,32 +34,6 @@ import type { SuggestionListHandle, } from "./types"; -/** - * Refs container for suggestion functions - * Allows updating search/select functions without recreating extensions - */ -interface SuggestionRefs { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mentionSearch: (query: string) => Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mentionOnSelect: (item: any) => { - type: string; - attrs: Record; - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - emojiSearch: (query: string) => Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - emojiOnSelect: (item: any) => { - type: string; - attrs: Record; - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - slashSearch: (query: string) => Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - slashOnExecute?: (item: any) => Promise; - handleSubmit: (editor: unknown) => void; -} - // Re-export handle type for consumers export type { NostrEditorHandle }; @@ -339,20 +313,17 @@ function createBlobAttachmentNode(previewStyle: BlobPreviewStyle) { /** * Create a TipTap suggestion configuration from our SuggestionConfig - * Uses a refs object to access the latest functions without recreating extensions */ -function createMentionSuggestionConfig( - config: SuggestionConfig, - refsObj: React.MutableRefObject, -): Omit, "editor"> { +function createSuggestionConfig( + config: SuggestionConfig, + handleSubmitRef: React.MutableRefObject<(editor: unknown) => void>, +): Omit, "editor"> { return { char: config.char, allowSpaces: config.allowSpaces ?? false, allow: config.allow, items: async ({ query }) => { - // Always use the current search function from refs - const searchFn = refsObj.current.mentionSearch; - return await searchFn(query); + return await config.search(query); }, render: () => { let component: ReactRenderer; @@ -404,167 +375,13 @@ function createMentionSuggestionConfig( return true; } + // Ctrl/Cmd+Enter always submits if ( props.event.key === "Enter" && (props.event.ctrlKey || props.event.metaKey) ) { popup[0]?.hide(); - refsObj.current.handleSubmit(editorRef); - return true; - } - - return component.ref?.onKeyDown(props.event) ?? false; - }, - - onExit() { - popup[0]?.destroy(); - component.destroy(); - }, - }; - }, - }; -} - -function createEmojiSuggestionConfig( - config: SuggestionConfig, - refsObj: React.MutableRefObject, -): Omit, "editor"> { - return { - char: config.char, - allowSpaces: config.allowSpaces ?? false, - allow: config.allow, - items: async ({ query }) => { - const searchFn = refsObj.current.emojiSearch; - return await searchFn(query); - }, - render: () => { - let component: ReactRenderer; - let popup: TippyInstance[]; - let editorRef: unknown; - - return { - onStart: (props) => { - editorRef = props.editor; - component = new ReactRenderer(config.component as never, { - 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: config.placement ?? "bottom-start", - zIndex: 100, - }); - }, - - 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; - } - - if ( - props.event.key === "Enter" && - (props.event.ctrlKey || props.event.metaKey) - ) { - popup[0]?.hide(); - refsObj.current.handleSubmit(editorRef); - return true; - } - - return component.ref?.onKeyDown(props.event) ?? false; - }, - - onExit() { - popup[0]?.destroy(); - component.destroy(); - }, - }; - }, - }; -} - -function createSlashSuggestionConfig( - config: SuggestionConfig, - refsObj: React.MutableRefObject, -): Omit, "editor"> { - return { - char: config.char, - allowSpaces: config.allowSpaces ?? false, - allow: config.allow, - items: async ({ query }) => { - const searchFn = refsObj.current.slashSearch; - return await searchFn(query); - }, - render: () => { - let component: ReactRenderer; - let popup: TippyInstance[]; - - return { - onStart: (props) => { - component = new ReactRenderer(config.component as never, { - 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: config.placement ?? "top-start", - zIndex: 100, - }); - }, - - 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(); + handleSubmitRef.current(editorRef); return true; } @@ -599,36 +416,7 @@ export const NostrEditor = forwardRef( }, ref, ) => { - // Find suggestion configs - const mentionConfig = suggestions.find((s) => s.char === "@"); - const emojiConfig = suggestions.find((s) => s.char === ":"); - const slashConfig = suggestions.find((s) => s.char === "/"); - - // Single refs object for all suggestion functions - updated on every render - const suggestionRefsObj = useRef({ - mentionSearch: async () => [], - mentionOnSelect: () => ({ type: "mention", attrs: {} }), - emojiSearch: async () => [], - emojiOnSelect: () => ({ type: "emoji", attrs: {} }), - slashSearch: async () => [], - slashOnExecute: undefined, - handleSubmit: () => {}, - }); - - // Update refs object with current search functions on every render - // This ensures TipTap extensions always call the latest functions - if (mentionConfig) { - suggestionRefsObj.current.mentionSearch = mentionConfig.search; - suggestionRefsObj.current.mentionOnSelect = mentionConfig.onSelect; - } - if (emojiConfig) { - suggestionRefsObj.current.emojiSearch = emojiConfig.search; - suggestionRefsObj.current.emojiOnSelect = emojiConfig.onSelect; - } - if (slashConfig) { - suggestionRefsObj.current.slashSearch = slashConfig.search; - suggestionRefsObj.current.slashOnExecute = slashConfig.onExecute; - } + const handleSubmitRef = useRef<(editor: unknown) => void>(() => {}); // Helper function to serialize editor content const serializeContent = useCallback( @@ -742,8 +530,12 @@ export const NostrEditor = forwardRef( [onSubmit, serializeContent], ); - // Update handleSubmit in refs object - suggestionRefsObj.current.handleSubmit = handleSubmit; + 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(() => { @@ -755,7 +547,7 @@ export const NostrEditor = forwardRef( addKeyboardShortcuts() { return { "Mod-Enter": ({ editor }) => { - suggestionRefsObj.current.handleSubmit(editor); + handleSubmitRef.current(editor); return true; }, Enter: ({ editor }) => { @@ -770,7 +562,7 @@ export const NostrEditor = forwardRef( if (isMobile) { return editor.commands.setHardBreak(); } else { - suggestionRefsObj.current.handleSubmit(editor); + handleSubmitRef.current(editor); return true; } } @@ -794,10 +586,7 @@ export const NostrEditor = forwardRef( Mention.configure({ HTMLAttributes: { class: "mention" }, suggestion: { - ...createMentionSuggestionConfig( - mentionConfig, - suggestionRefsObj, - ), + ...createSuggestionConfig(mentionConfig, handleSubmitRef), command: ({ editor, range, @@ -807,10 +596,7 @@ export const NostrEditor = forwardRef( range: unknown; props: unknown; }) => { - // Use refs to get latest onSelect function - const result = suggestionRefsObj.current.mentionOnSelect( - props as never, - ); + const result = mentionConfig.onSelect(props as never); const ed = editor as { chain: () => { focus: () => { @@ -843,7 +629,7 @@ export const NostrEditor = forwardRef( EmojiMention.configure({ HTMLAttributes: { class: "emoji" }, suggestion: { - ...createEmojiSuggestionConfig(emojiConfig, suggestionRefsObj), + ...createSuggestionConfig(emojiConfig, handleSubmitRef), command: ({ editor, range, @@ -853,10 +639,7 @@ export const NostrEditor = forwardRef( range: unknown; props: unknown; }) => { - // Use refs to get latest onSelect function - const result = suggestionRefsObj.current.emojiOnSelect( - props as never, - ); + const result = emojiConfig.onSelect(props as never); const ed = editor as { chain: () => { focus: () => { @@ -887,7 +670,7 @@ export const NostrEditor = forwardRef( SlashCommand.configure({ HTMLAttributes: { class: "slash-command" }, suggestion: { - ...createSlashSuggestionConfig(slashConfig, suggestionRefsObj), + ...createSuggestionConfig(slashConfig, handleSubmitRef), command: ({ editor, props, @@ -899,10 +682,8 @@ export const NostrEditor = forwardRef( if (slashConfig.clearOnSelect !== false) { ed.commands.clearContent(); } - // Use refs to get latest onExecute function - const onExecute = suggestionRefsObj.current.slashOnExecute; - if (onExecute) { - onExecute(props as never).catch((error) => { + if (slashConfig.onExecute) { + slashConfig.onExecute(props as never).catch((error) => { console.error( "[NostrEditor] Command execution failed:", error, @@ -919,14 +700,13 @@ export const NostrEditor = forwardRef( } return exts; - // Depend on whether configs exist (for extension creation) but not search functions (refs handle updates) }, [ submitBehavior, placeholder, blobPreview, - !!mentionConfig, - !!emojiConfig, - !!slashConfig, + mentionConfig, + emojiConfig, + slashConfig, ]); const editor = useEditor({ From 19add5dce6bcbcff5e77d83fb1796d59d2b5dda3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 16:58:30 +0000 Subject: [PATCH 11/13] chore: add debug logging to autocomplete suggestion system Added session tracking and logging to help diagnose why autocomplete filtering stops working after the first use. Logs show: - When items() is called with query and result count - When onStart/onUpdate/onExit are called - Session IDs to track suggestion lifecycle --- src/components/editor/NostrEditor.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/components/editor/NostrEditor.tsx b/src/components/editor/NostrEditor.tsx index 1aa3d94..475b22c 100644 --- a/src/components/editor/NostrEditor.tsx +++ b/src/components/editor/NostrEditor.tsx @@ -318,20 +318,31 @@ function createSuggestionConfig( config: SuggestionConfig, handleSubmitRef: React.MutableRefObject<(editor: unknown) => void>, ): Omit, "editor"> { + // Track session for debugging + let sessionId = 0; + return { char: config.char, allowSpaces: config.allowSpaces ?? false, allow: config.allow, items: async ({ query }) => { - return await config.search(query); + const results = await config.search(query); + console.log( + `[Suggestion ${config.char}] items called: query="${query}" results=${results.length} session=${sessionId}`, + ); + return results; }, render: () => { let component: ReactRenderer; let popup: TippyInstance[]; let editorRef: unknown; + const currentSession = ++sessionId; return { onStart: (props) => { + console.log( + `[Suggestion ${config.char}] onStart: session=${currentSession} items=${props.items?.length}`, + ); editorRef = props.editor; component = new ReactRenderer(config.component as never, { props: { @@ -357,6 +368,9 @@ function createSuggestionConfig( }, onUpdate(props) { + console.log( + `[Suggestion ${config.char}] onUpdate: session=${currentSession} items=${props.items?.length}`, + ); component.updateProps({ items: props.items, command: props.command, @@ -389,6 +403,9 @@ function createSuggestionConfig( }, onExit() { + console.log( + `[Suggestion ${config.char}] onExit: session=${currentSession}`, + ); popup[0]?.destroy(); component.destroy(); }, From 36e1d3dde37b32c9b09d05880b1abf7e5878216d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 17:17:04 +0000 Subject: [PATCH 12/13] fix: work around TipTap suggestion async race condition TipTap's suggestion plugin has a race condition where onUpdate receives stale/wrong items when using async items functions. The logs showed: items called: query="fi" results=4 onUpdate: items=0 <- Wrong! Should be 4 Fixed by: 1. Storing componentRef and currentCommand in outer scope 2. Directly updating component in items() after getting results 3. Using queryCounter to discard stale results from previous queries 4. onUpdate now only updates command reference and popup position This bypasses TipTap's buggy async result handling entirely. --- src/components/editor/NostrEditor.tsx | 65 +++++++++++++++++---------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/src/components/editor/NostrEditor.tsx b/src/components/editor/NostrEditor.tsx index 475b22c..1314ec1 100644 --- a/src/components/editor/NostrEditor.tsx +++ b/src/components/editor/NostrEditor.tsx @@ -313,38 +313,58 @@ function createBlobAttachmentNode(previewStyle: BlobPreviewStyle) { /** * Create a TipTap suggestion configuration from our SuggestionConfig + * + * Note: TipTap's suggestion plugin has a race condition with async items functions + * where onUpdate receives stale/wrong results. We work around this by: + * 1. Storing a ref to the component and current command + * 2. Manually updating the component in the items function after getting results + * 3. Using a query counter to discard stale results */ function createSuggestionConfig( config: SuggestionConfig, handleSubmitRef: React.MutableRefObject<(editor: unknown) => void>, ): Omit, "editor"> { - // Track session for debugging - let sessionId = 0; + // Shared state between items() and render callbacks + // This allows items() to directly update the component, bypassing TipTap's buggy async handling + let componentRef: ReactRenderer | null = null; + let currentCommand: ((item: T) => void) | null = null; + let queryCounter = 0; return { char: config.char, allowSpaces: config.allowSpaces ?? false, allow: config.allow, items: async ({ query }) => { + // Increment counter to track this query + const thisQuery = ++queryCounter; const results = await config.search(query); - console.log( - `[Suggestion ${config.char}] items called: query="${query}" results=${results.length} session=${sessionId}`, - ); + + // If a newer query was started, discard these results + if (thisQuery !== queryCounter) { + return results; // Return anyway for TipTap, but don't update component + } + + // Directly update the component with fresh results + // This bypasses TipTap's buggy async result handling + if (componentRef && currentCommand) { + componentRef.updateProps({ + items: results, + command: currentCommand, + }); + } + return results; }, render: () => { - let component: ReactRenderer; let popup: TippyInstance[]; let editorRef: unknown; - const currentSession = ++sessionId; return { onStart: (props) => { - console.log( - `[Suggestion ${config.char}] onStart: session=${currentSession} items=${props.items?.length}`, - ); editorRef = props.editor; - component = new ReactRenderer(config.component as never, { + currentCommand = props.command as (item: T) => void; + + componentRef = new ReactRenderer(config.component as never, { props: { items: props.items, command: props.command, @@ -358,7 +378,7 @@ function createSuggestionConfig( popup = tippy("body", { getReferenceClientRect: props.clientRect as () => DOMRect, appendTo: () => document.body, - content: component.element, + content: componentRef.element, showOnCreate: true, interactive: true, trigger: "manual", @@ -368,13 +388,11 @@ function createSuggestionConfig( }, onUpdate(props) { - console.log( - `[Suggestion ${config.char}] onUpdate: session=${currentSession} items=${props.items?.length}`, - ); - component.updateProps({ - items: props.items, - command: props.command, - }); + // Update command reference (TipTap may change it) + currentCommand = props.command as (item: T) => void; + + // Note: We don't rely on props.items here because TipTap's async handling + // often provides stale/wrong results. The items() function updates directly. if (!props.clientRect) return; @@ -399,15 +417,14 @@ function createSuggestionConfig( return true; } - return component.ref?.onKeyDown(props.event) ?? false; + return componentRef?.ref?.onKeyDown(props.event) ?? false; }, onExit() { - console.log( - `[Suggestion ${config.char}] onExit: session=${currentSession}`, - ); popup[0]?.destroy(); - component.destroy(); + componentRef?.destroy(); + componentRef = null; + currentCommand = null; }, }; }, From 9a1b06649151764482bca48c771a672f35dc6c52 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 17:21:24 +0000 Subject: [PATCH 13/13] fix: cleaner solution for TipTap suggestion async bug Root cause: TipTap's items() function has buggy async handling where results arrive out of order or get lost entirely. Solution: Bypass items() entirely for async work: - items() returns empty array (sync, no async) - All search logic lives in render callbacks - onStart triggers initial search - onUpdate detects query changes and triggers new searches - doSearch() updates component directly with race protection This is simpler and more robust than the previous workaround because: 1. All search logic is in one place (render callbacks) 2. We don't fight TipTap's broken async handling 3. Clear data flow: query change -> search -> update component --- src/components/editor/NostrEditor.tsx | 113 ++++++++++++++------------ 1 file changed, 59 insertions(+), 54 deletions(-) diff --git a/src/components/editor/NostrEditor.tsx b/src/components/editor/NostrEditor.tsx index 1314ec1..c7ff575 100644 --- a/src/components/editor/NostrEditor.tsx +++ b/src/components/editor/NostrEditor.tsx @@ -314,61 +314,53 @@ function createBlobAttachmentNode(previewStyle: BlobPreviewStyle) { /** * Create a TipTap suggestion configuration from our SuggestionConfig * - * Note: TipTap's suggestion plugin has a race condition with async items functions - * where onUpdate receives stale/wrong results. We work around this by: - * 1. Storing a ref to the component and current command - * 2. Manually updating the component in the items function after getting results - * 3. Using a query counter to discard stale results + * 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"> { - // Shared state between items() and render callbacks - // This allows items() to directly update the component, bypassing TipTap's buggy async handling - let componentRef: ReactRenderer | null = null; - let currentCommand: ((item: T) => void) | null = null; - let queryCounter = 0; - return { char: config.char, allowSpaces: config.allowSpaces ?? false, allow: config.allow, - items: async ({ query }) => { - // Increment counter to track this query - const thisQuery = ++queryCounter; - const results = await config.search(query); - - // If a newer query was started, discard these results - if (thisQuery !== queryCounter) { - return results; // Return anyway for TipTap, but don't update component - } - - // Directly update the component with fresh results - // This bypasses TipTap's buggy async result handling - if (componentRef && currentCommand) { - componentRef.updateProps({ - items: results, - command: currentCommand, - }); - } - - return results; - }, + // Don't use items() for async work - TipTap's async handling is buggy + // We do our own search in onUpdate instead + items: () => [], render: () => { - let popup: TippyInstance[]; - let editorRef: unknown; + 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; - currentCommand = props.command as (item: T) => void; + currentQuery = (props as { query?: string }).query ?? ""; - componentRef = new ReactRenderer(config.component as never, { + component = new ReactRenderer(config.component as never, { props: { - items: props.items, + items: [], command: props.command, - onClose: () => popup[0]?.hide(), + onClose: () => popup?.[0]?.hide(), }, editor: props.editor, }); @@ -378,32 +370,43 @@ function createSuggestionConfig( popup = tippy("body", { getReferenceClientRect: props.clientRect as () => DOMRect, appendTo: () => document.body, - content: componentRef.element, + content: component.element, showOnCreate: true, interactive: true, trigger: "manual", placement: config.placement ?? "bottom-start", zIndex: 100, }); + + // Trigger initial search + doSearch(currentQuery); }, onUpdate(props) { - // Update command reference (TipTap may change it) - currentCommand = props.command as (item: T) => void; + const newQuery = (props as { query?: string }).query ?? ""; - // Note: We don't rely on props.items here because TipTap's async handling - // often provides stale/wrong results. The items() function updates directly. + // Search when query changes + if (newQuery !== currentQuery) { + currentQuery = newQuery; + doSearch(newQuery); + } - if (!props.clientRect) return; + // Update command (TipTap regenerates it) + if (component) { + component.updateProps({ command: props.command }); + } - popup[0]?.setProps({ - getReferenceClientRect: props.clientRect as () => DOMRect, - }); + // 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(); + popup?.[0]?.hide(); return true; } @@ -412,19 +415,21 @@ function createSuggestionConfig( props.event.key === "Enter" && (props.event.ctrlKey || props.event.metaKey) ) { - popup[0]?.hide(); + popup?.[0]?.hide(); handleSubmitRef.current(editorRef); return true; } - return componentRef?.ref?.onKeyDown(props.event) ?? false; + return component?.ref?.onKeyDown(props.event) ?? false; }, onExit() { - popup[0]?.destroy(); - componentRef?.destroy(); - componentRef = null; - currentCommand = null; + popup?.[0]?.destroy(); + component?.destroy(); + component = null; + popup = null; + currentQuery = ""; + searchCounter = 0; }, }; },