diff --git a/package-lock.json b/package-lock.json index d918160..24aa938 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ ], "dependencies": { "@fiatjaf/git-natural-api": "npm:@jsr/fiatjaf__git-natural-api@^0.2.3", + "@floating-ui/react-dom": "^2.1.7", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", @@ -78,8 +79,7 @@ "shell-quote": "^1.8.3", "shiki": "^3.20.0", "sonner": "^2.0.7", - "tailwind-merge": "^2.5.5", - "tippy.js": "^6.3.7" + "tailwind-merge": "^2.5.5" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -1506,31 +1506,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", + "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", @@ -2328,16 +2328,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -12258,15 +12248,6 @@ "node": ">=14.0.0" } }, - "node_modules/tippy.js": { - "version": "6.3.7", - "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", - "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", - "license": "MIT", - "dependencies": { - "@popperjs/core": "^2.9.0" - } - }, "node_modules/tldts": { "version": "7.0.19", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", diff --git a/package.json b/package.json index 5228fa3..e518951 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@fiatjaf/git-natural-api": "npm:@jsr/fiatjaf__git-natural-api@^0.2.3", + "@floating-ui/react-dom": "^2.1.7", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", @@ -87,8 +88,7 @@ "shell-quote": "^1.8.3", "shiki": "^3.20.0", "sonner": "^2.0.7", - "tailwind-merge": "^2.5.5", - "tippy.js": "^6.3.7" + "tailwind-merge": "^2.5.5" }, "devDependencies": { "@eslint/js": "^9.17.0", diff --git a/src/components/PostViewer.tsx b/src/components/PostViewer.tsx index 2482fea..e06d297 100644 --- a/src/components/PostViewer.tsx +++ b/src/components/PostViewer.tsx @@ -28,8 +28,12 @@ import { useEmojiSearch } from "@/hooks/useEmojiSearch"; import { useBlossomUpload } from "@/hooks/useBlossomUpload"; import { useRelayState } from "@/hooks/useRelayState"; import { useSettings } from "@/hooks/useSettings"; -import { RichEditor, type RichEditorHandle } from "./editor/RichEditor"; -import type { BlobAttachment, EmojiTag } from "./editor/MentionEditor"; +import { + RichEditor, + type RichEditorHandle, + type BlobAttachment, + type EmojiTag, +} from "./editor/RichEditor"; import { RelayLink } from "./nostr/RelayLink"; import { Kind1Renderer } from "./nostr/kinds"; import pool from "@/services/relay-pool"; diff --git a/src/components/chat/EmojiPickerDialog.tsx b/src/components/chat/EmojiPickerDialog.tsx index 0ae36a8..fcd0c68 100644 --- a/src/components/chat/EmojiPickerDialog.tsx +++ b/src/components/chat/EmojiPickerDialog.tsx @@ -1,11 +1,11 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Search } from "lucide-react"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import type { EmojiSearchResult } from "@/services/emoji-search"; import type { EmojiTag } from "@/lib/emoji-helpers"; import { useEmojiSearch } from "@/hooks/useEmojiSearch"; -import { CustomEmoji } from "../nostr/CustomEmoji"; interface EmojiPickerDialogProps { open: boolean; @@ -18,6 +18,9 @@ interface EmojiPickerDialogProps { // Frequently used emojis stored in localStorage const STORAGE_KEY = "grimoire:reaction-history"; +const ITEM_HEIGHT = 40; +const MAX_VISIBLE = 8; + function getReactionHistory(): Record { try { const stored = localStorage.getItem(STORAGE_KEY); @@ -44,10 +47,10 @@ function updateReactionHistory(emoji: string): void { * EmojiPickerDialog - Searchable emoji picker for reactions * * Features: - * - Real-time search using FlexSearch + * - Real-time search using FlexSearch with scrollable virtualized results * - Frequently used emoji at top when no search query - * - Quick reaction bar for common emojis * - Supports both unicode and NIP-30 custom emoji + * - Keyboard navigation (arrow keys, enter, escape) * - Tracks usage in localStorage */ export function EmojiPickerDialog({ @@ -58,6 +61,8 @@ export function EmojiPickerDialog({ }: EmojiPickerDialogProps) { const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const virtuosoRef = useRef(null); // Use the same emoji search hook as chat autocomplete const { service } = useEmojiSearch(); @@ -74,126 +79,192 @@ export function EmojiPickerDialog({ // Perform search when query changes useEffect(() => { const performSearch = async () => { - // Always fetch 8 emoji (1 row of 8) for consistent height - const results = await service.search(searchQuery, { limit: 8 }); + const results = await service.search(searchQuery, { limit: 200 }); setSearchResults(results); + setSelectedIndex(0); }; performSearch(); }, [searchQuery, service]); + // Reset state when dialog opens/closes + useEffect(() => { + if (!open) { + setSearchQuery(""); + setSelectedIndex(0); + } + }, [open]); + // Get frequently used emojis from history const frequentlyUsed = useMemo(() => { const history = getReactionHistory(); return Object.entries(history) - .sort((a, b) => b[1] - a[1]) // Sort by count descending - .slice(0, 8) // Max 1 row + .sort((a, b) => b[1] - a[1]) .map(([emoji]) => emoji); }, []); - // Combine recently used with search results for display - // When no search query: show recently used first, then fill with other emoji - // When searching: show search results + // When no search query: show recently used first, then fill with search results + // When searching: show search results only const displayEmojis = useMemo(() => { if (searchQuery.trim()) { - // Show search results return searchResults; } - // No search query: prioritize recently used, then fill with other emoji if (frequentlyUsed.length > 0) { const recentSet = new Set(frequentlyUsed); - // Get additional emoji to fill to 8, excluding recently used - const additional = searchResults - .filter((r) => { - const key = r.source === "unicode" ? r.url : `:${r.shortcode}:`; - return !recentSet.has(key); - }) - .slice(0, 8 - frequentlyUsed.length); + const additional = searchResults.filter((r) => { + const key = r.source === "unicode" ? r.url : `:${r.shortcode}:`; + return !recentSet.has(key); + }); - // Combine: recently used get priority, but displayed as regular emoji const recentResults: EmojiSearchResult[] = []; for (const emojiStr of frequentlyUsed) { if (emojiStr.startsWith(":") && emojiStr.endsWith(":")) { const shortcode = emojiStr.slice(1, -1); const customEmoji = service.getByShortcode(shortcode); - if (customEmoji) { - recentResults.push(customEmoji); - } + if (customEmoji) recentResults.push(customEmoji); } else { - // Unicode emoji - find it in search results const found = searchResults.find((r) => r.url === emojiStr); if (found) recentResults.push(found); } } - return [...recentResults, ...additional].slice(0, 8); + return [...recentResults, ...additional]; } - // No history: just show top 8 emoji return searchResults; }, [searchQuery, searchResults, frequentlyUsed, service]); - const handleEmojiClick = (result: EmojiSearchResult) => { - if (result.source === "unicode") { - // For unicode emoji, the "url" field contains the emoji character - onEmojiSelect(result.url); - updateReactionHistory(result.url); - } else { - // For custom emoji, pass the shortcode as content and emoji tag info - onEmojiSelect(`:${result.shortcode}:`, { - shortcode: result.shortcode, - url: result.url, - address: result.address, - }); - updateReactionHistory(`:${result.shortcode}:`); - } - onOpenChange(false); - setSearchQuery(""); // Reset search on close - }; + const handleEmojiClick = useCallback( + (result: EmojiSearchResult) => { + if (result.source === "unicode") { + onEmojiSelect(result.url); + updateReactionHistory(result.url); + } else { + onEmojiSelect(`:${result.shortcode}:`, { + shortcode: result.shortcode, + url: result.url, + address: result.address, + }); + updateReactionHistory(`:${result.shortcode}:`); + } + onOpenChange(false); + }, + [onEmojiSelect, onOpenChange], + ); + + // Keyboard navigation in the search input + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => + prev < displayEmojis.length - 1 ? prev + 1 : 0, + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => + prev > 0 ? prev - 1 : displayEmojis.length - 1, + ); + } else if (e.key === "Enter") { + e.preventDefault(); + if (displayEmojis[selectedIndex]) { + handleEmojiClick(displayEmojis[selectedIndex]); + } + } + }, + [displayEmojis, selectedIndex, handleEmojiClick], + ); + + // Scroll selected item into view + useEffect(() => { + virtuosoRef.current?.scrollIntoView({ + index: selectedIndex, + behavior: "auto", + }); + }, [selectedIndex]); + + const listHeight = Math.min(displayEmojis.length, MAX_VISIBLE) * ITEM_HEIGHT; + + const renderItem = useCallback( + (index: number) => { + const item = displayEmojis[index]; + return ( + + ); + }, + [displayEmojis, selectedIndex, handleEmojiClick], + ); return ( - + {/* Search input */} -
+
setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} className="pl-9" autoFocus />
- {/* Fixed 1-row emoji grid (8 emoji) with consistent height */} -
- {displayEmojis.length > 0 ? ( - displayEmojis.map((result) => ( - - )) - ) : ( -
- No emojis found -
- )} -
+ {/* Scrollable emoji list */} + {displayEmojis.length > 0 ? ( +
+ +
+ ) : ( +
+ No emojis found +
+ )}
); diff --git a/src/components/editor/EmojiSuggestionList.tsx b/src/components/editor/EmojiSuggestionList.tsx index 8d1a36f..b240b9a 100644 --- a/src/components/editor/EmojiSuggestionList.tsx +++ b/src/components/editor/EmojiSuggestionList.tsx @@ -4,9 +4,10 @@ import { useImperativeHandle, useRef, useState, + useCallback, } from "react"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import type { EmojiSearchResult } from "@/services/emoji-search"; -import { cn } from "@/lib/utils"; export interface EmojiSuggestionListProps { items: EmojiSearchResult[]; @@ -18,43 +19,26 @@ export interface EmojiSuggestionListHandle { onKeyDown: (event: KeyboardEvent) => boolean; } -const GRID_COLS = 8; +const ITEM_HEIGHT = 40; +const MAX_VISIBLE = 8; export const EmojiSuggestionList = forwardRef< EmojiSuggestionListHandle, EmojiSuggestionListProps >(({ items, command, onClose }, ref) => { const [selectedIndex, setSelectedIndex] = useState(0); - const listRef = useRef(null); + const virtuosoRef = useRef(null); - // Keyboard navigation with grid support + // Keyboard navigation (linear list) useImperativeHandle(ref, () => ({ onKeyDown: (event: KeyboardEvent) => { if (event.key === "ArrowUp") { - setSelectedIndex((prev) => { - const newIndex = prev - GRID_COLS; - return newIndex < 0 ? Math.max(0, items.length + newIndex) : newIndex; - }); + setSelectedIndex((prev) => (prev + items.length - 1) % items.length); return true; } if (event.key === "ArrowDown") { - setSelectedIndex((prev) => { - const newIndex = prev + GRID_COLS; - return newIndex >= items.length - ? Math.min(items.length - 1, newIndex % GRID_COLS) - : newIndex; - }); - return true; - } - - if (event.key === "ArrowLeft") { - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1)); - return true; - } - - if (event.key === "ArrowRight") { - setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0)); + setSelectedIndex((prev) => (prev + 1) % items.length); return true; } @@ -74,16 +58,12 @@ export const EmojiSuggestionList = forwardRef< }, })); - // Scroll selected item into view + // Scroll selected item into view via Virtuoso useEffect(() => { - const selectedElement = listRef.current?.querySelector( - `[data-index="${selectedIndex}"]`, - ); - if (selectedElement) { - selectedElement.scrollIntoView({ - block: "nearest", - }); - } + virtuosoRef.current?.scrollIntoView({ + index: selectedIndex, + behavior: "auto", + }); }, [selectedIndex]); // Reset selected index when items change @@ -91,62 +71,72 @@ export const EmojiSuggestionList = forwardRef< setSelectedIndex(0); }, [items]); + const renderItem = useCallback( + (index: number) => { + const item = items[index]; + return ( + + ); + }, + [items, selectedIndex, command], + ); + if (items.length === 0) { return ( -
+
No emoji found
); } + const listHeight = Math.min(items.length, MAX_VISIBLE) * ITEM_HEIGHT; + return (
-
- {items.map((item, index) => ( - - ))} -
- {/* Show selected emoji shortcode */} - {items[selectedIndex] && ( -
- :{items[selectedIndex].shortcode}: -
- )} +
); }); diff --git a/src/components/editor/MentionEditor.tsx b/src/components/editor/MentionEditor.tsx index 304bc33..df29201 100644 --- a/src/components/editor/MentionEditor.tsx +++ b/src/components/editor/MentionEditor.tsx @@ -6,75 +6,29 @@ import { 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 { useEditor, EditorContent } from "@tiptap/react"; import Mention from "@tiptap/extension-mention"; +import StarterKit from "@tiptap/starter-kit"; 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"; +import { ProfileSuggestionList } from "./ProfileSuggestionList"; +import { EmojiSuggestionList } from "./EmojiSuggestionList"; +import { SlashCommandSuggestionList } from "./SlashCommandSuggestionList"; import type { ProfileSearchResult } from "@/services/profile-search"; import type { EmojiSearchResult } from "@/services/emoji-search"; import type { ChatAction } from "@/types/chat-actions"; -import { nip19 } from "nostr-tools"; import { NostrPasteHandler } from "./extensions/nostr-paste-handler"; import { FilePasteHandler } from "./extensions/file-paste-handler"; +import { EmojiMention } from "./extensions/emoji"; +import { BlobAttachmentInlineNode } from "./extensions/blob-attachment-inline"; +import { NostrEventPreviewInlineNode } from "./extensions/nostr-event-preview-inline"; +import { SubmitShortcut } from "./extensions/submit-shortcut"; +import { serializeInlineContent } from "./utils/serialize"; +import { useSuggestionRenderer } from "./hooks/useSuggestionRenderer"; +import type { EmojiTag, BlobAttachment, SerializedContent } from "./types"; -/** - * Represents an emoji tag for NIP-30 - */ -export interface EmojiTag { - shortcode: string; - url: string; - /** NIP-30 optional 4th tag: "30030:pubkey:identifier" address of the emoji set */ - address?: 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 - * Note: mentions, event quotes, and hashtags are extracted automatically by applesauce - * from the text content (nostr: URIs and #hashtags), so we don't need to extract them here. - */ -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[]; - /** Referenced addresses for a tags (from naddr - not yet handled by applesauce) */ - addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>; -} +// Re-export types for backward compatibility +export type { EmojiTag, BlobAttachment, SerializedContent } from "./types"; export interface MentionEditorProps { placeholder?: string; @@ -105,277 +59,6 @@ export interface MentionEditorHandle { 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 }; - }, - }, - address: { - default: null, - parseHTML: (element) => element.getAttribute("data-address"), - renderHTML: (attributes) => { - if (!attributes.address) return {}; - return { "data-address": attributes.address }; - }, - }, - }; - }, - - // 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`; -} - -// Create nostr event preview node for nevent/naddr/note/npub/nprofile -const NostrEventPreview = Node.create({ - name: "nostrEventPreview", - group: "inline", - inline: true, - atom: true, - - addAttributes() { - return { - type: { default: null }, // 'note' | 'nevent' | 'naddr' - data: { default: null }, // Decoded bech32 data (varies by type) - }; - }, - - parseHTML() { - return [ - { - tag: 'span[data-nostr-preview="true"]', - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - "span", - mergeAttributes(HTMLAttributes, { "data-nostr-preview": "true" }), - ]; - }, - - renderText({ node }) { - // Serialize back to nostr: URI for plain text export - const { type, data } = node.attrs; - try { - if (type === "note") { - return `nostr:${nip19.noteEncode(data)}`; - } else if (type === "nevent") { - return `nostr:${nip19.neventEncode(data)}`; - } else if (type === "naddr") { - return `nostr:${nip19.naddrEncode(data)}`; - } - } catch (err) { - console.error("[NostrEventPreview] Failed to encode:", err); - } - return ""; - }, - - addNodeView() { - return ({ node }) => { - const { type, data } = node.attrs; - - // Create wrapper span - const dom = document.createElement("span"); - dom.className = - "nostr-event-preview inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/30 text-xs align-middle"; - dom.contentEditable = "false"; - - // Type label - const typeLabel = document.createElement("span"); - typeLabel.className = "text-primary font-medium"; - - // Content label - const contentLabel = document.createElement("span"); - contentLabel.className = "text-muted-foreground truncate max-w-[140px]"; - - if (type === "note" || type === "nevent") { - // event + short ID - typeLabel.textContent = "event"; - contentLabel.textContent = - type === "note" ? data.slice(0, 8) : data.id.slice(0, 8); - } else if (type === "naddr") { - // address + d identifier (or short pubkey if no identifier) - typeLabel.textContent = "address"; - contentLabel.textContent = data.identifier || data.pubkey.slice(0, 8); - } - - dom.appendChild(typeLabel); - dom.appendChild(contentLabel); - - return { dom }; - }; - }, -}); - export const MentionEditor = forwardRef< MentionEditorHandle, MentionEditorProps @@ -394,186 +77,66 @@ 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 handleSubmit = useCallback( + (editorInstance: any) => { + if (!editorInstance || !onSubmit) return; + + const { text, emojiTags, blobAttachments } = + serializeInlineContent(editorInstance); + if (text) { + onSubmit(text, emojiTags, blobAttachments); + editorInstance.commands.clearContent(); + } + }, + [onSubmit], + ); + + handleSubmitRef.current = handleSubmit; + + // React-based suggestion renderers (replace tippy.js + ReactRenderer) + const { render: renderMentionSuggestion, portal: mentionPortal } = + useSuggestionRenderer(ProfileSuggestionList as any, { + onModEnter: () => { + // Submit via the ref when Ctrl/Cmd+Enter is pressed in suggestion + }, + }); + + const { render: renderEmojiSuggestion, portal: emojiPortal } = + useSuggestionRenderer(EmojiSuggestionList as any); + + const { render: renderSlashSuggestion, portal: slashPortal } = + useSuggestionRenderer(SlashCommandSuggestionList as any, { + placement: "top-start", + }); + + // Mention suggestion config 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(); - }, - }; - }, + items: async ({ query }) => searchProfiles(query), + render: renderMentionSuggestion, }), - [searchProfiles], + [searchProfiles, renderMentionSuggestion], ); - // Create emoji suggestion configuration for : emoji + // Emoji suggestion config const emojiSuggestion: Omit | null = 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(); - }, - }; - }, + items: async ({ query }) => searchEmojis(query), + render: renderEmojiSuggestion, } : null, - [searchEmojis], + [searchEmojis, renderEmojiSuggestion], ); - // Create slash command suggestion configuration for / commands - // Only triggers when / is at the very beginning of the input + // Slash command suggestion config const slashCommandSuggestion: Omit | null = useMemo( () => @@ -581,286 +144,40 @@ export const MentionEditor = forwardRef< ? { char: "/", allowSpaces: false, - // Only allow slash commands at the start of input (position 1 in TipTap = first char) + // Only allow slash commands at the start of input 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(); - }, - }; - }, + items: async ({ query }) => searchCommands(query), + render: renderSlashSuggestion, } : null, - [searchCommands], + [searchCommands, renderSlashSuggestion], ); - // 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 addressRefs: Array<{ - kind: number; - pubkey: string; - identifier: string; - }> = []; - const seenEmojis = new Set(); - const seenBlobs = new Set(); - const seenAddrs = new Set(); - const json = editorInstance.getJSON(); + // Detect mobile devices (touch support) + const isMobile = "ontouchstart" in window || navigator.maxTouchPoints > 0; - 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; - const address = child.attrs?.address; - - 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, - address: address ?? undefined, - }); - } - } - } 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, - }); - } - } - } else if (child.type === "nostrEventPreview") { - // Nostr event preview - serialize back to nostr: URI - const { type, data } = child.attrs; - try { - if (type === "note") { - text += `nostr:${nip19.noteEncode(data)}`; - } else if (type === "nevent") { - text += `nostr:${nip19.neventEncode(data)}`; - } else if (type === "naddr") { - text += `nostr:${nip19.naddrEncode(data)}`; - // Extract addressRefs for manual a tags (applesauce doesn't handle naddr yet) - const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`; - if (!seenAddrs.has(addrKey)) { - seenAddrs.add(addrKey); - addressRefs.push({ - kind: data.kind, - pubkey: data.pubkey, - identifier: data.identifier || "", - }); - } - } - } catch (err) { - console.error( - "[MentionEditor] Failed to serialize nostr preview:", - err, - ); - } - } - }); - text += "\n"; - } - }); - - return { - text: text.trim(), - emojiTags, - blobAttachments, - addressRefs, - }; - }, - [], - ); - - // 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(); - } - }, - [onSubmit, serializeContent], - ); - - // Keep ref updated with latest handleSubmit - handleSubmitRef.current = handleSubmit; - - // Build extensions array + // Build extensions 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, + SubmitShortcut.configure({ + submitRef: handleSubmitRef, + enterSubmits: !isMobile, + }), StarterKit.configure({ - // Shift+Enter inserts hard break (newline) - hardBreak: { - keepMarks: false, - }, + hardBreak: { keepMarks: false }, }), Mention.configure({ - HTMLAttributes: { - class: "mention", - }, + 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, - }, + attrs: { id: props.pubkey, label: props.displayName }, }, { type: "text", text: " " }, ]) @@ -871,32 +188,21 @@ export const MentionEditor = forwardRef< return `@${node.attrs.label}`; }, }), - Placeholder.configure({ - placeholder, - }), - // Add blob attachment extension for media previews - BlobAttachmentNode, - // Add nostr event preview extension for bech32 links - NostrEventPreview, - // Add paste handler to transform bech32 strings into previews + Placeholder.configure({ placeholder }), + BlobAttachmentInlineNode, + NostrEventPreviewInlineNode, NostrPasteHandler, - // Add file paste handler for clipboard file uploads - FilePasteHandler.configure({ - onFilePaste, - }), + FilePasteHandler.configure({ onFilePaste }), ]; // Add emoji extension if search is provided if (emojiSuggestion) { exts.push( EmojiMention.configure({ - HTMLAttributes: { - class: "emoji", - }, + HTMLAttributes: { class: "emoji" }, suggestion: { ...emojiSuggestion, command: ({ editor, range, props }: any) => { - // props is the EmojiSearchResult editor .chain() .focus() @@ -908,7 +214,7 @@ export const MentionEditor = forwardRef< label: props.shortcode, url: props.url, source: props.source, - address: props.address ?? null, + mentionSuggestionChar: ":", }, }, { type: "text", text: " " }, @@ -916,30 +222,21 @@ export const MentionEditor = forwardRef< .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", - }); - + const SlashCommand = Mention.extend({ name: "slashCommand" }); exts.push( SlashCommand.configure({ - HTMLAttributes: { - class: "slash-command", - }, + 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:", @@ -964,6 +261,7 @@ export const MentionEditor = forwardRef< onCommandExecute, onFilePaste, placeholder, + isMobile, ]); const editor = useEditor({ @@ -976,7 +274,6 @@ export const MentionEditor = forwardRef< autofocus: autoFocus, }); - // Expose editor methods useImperativeHandle( ref, () => ({ @@ -991,18 +288,14 @@ export const MentionEditor = forwardRef< blobAttachments: [], addressRefs: [], }; - return serializeContent(editor); + return serializeInlineContent(editor); }, isEmpty: () => editor?.isEmpty ?? true, submit: () => { - if (editor) { - handleSubmit(editor); - } + if (editor) handleSubmit(editor); }, insertText: (text: string) => { - if (editor) { - editor.chain().focus().insertContent(text).run(); - } + if (editor) editor.chain().focus().insertContent(text).run(); }, insertBlob: (blob: BlobAttachment) => { if (editor) { @@ -1026,7 +319,7 @@ export const MentionEditor = forwardRef< } }, }), - [editor, serializeContent, handleSubmit], + [editor, handleSubmit], ); // Cleanup on unmount @@ -1036,15 +329,16 @@ export const MentionEditor = forwardRef< }; }, [editor]); - if (!editor) { - return null; - } + if (!editor) return null; return (
+ {mentionPortal} + {emojiPortal} + {slashPortal}
); }, diff --git a/src/components/editor/ProfileSuggestionList.tsx b/src/components/editor/ProfileSuggestionList.tsx index 0be7b74..fbc2ef4 100644 --- a/src/components/editor/ProfileSuggestionList.tsx +++ b/src/components/editor/ProfileSuggestionList.tsx @@ -4,7 +4,9 @@ import { useImperativeHandle, useRef, useState, + useCallback, } from "react"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import type { ProfileSearchResult } from "@/services/profile-search"; import { UserName } from "../nostr/UserName"; @@ -18,12 +20,15 @@ export interface ProfileSuggestionListHandle { onKeyDown: (event: KeyboardEvent) => boolean; } +const ITEM_HEIGHT = 48; +const MAX_VISIBLE = 6; + export const ProfileSuggestionList = forwardRef< ProfileSuggestionListHandle, ProfileSuggestionListProps >(({ items, command, onClose }, ref) => { const [selectedIndex, setSelectedIndex] = useState(0); - const listRef = useRef(null); + const virtuosoRef = useRef(null); // Keyboard navigation useImperativeHandle(ref, () => ({ @@ -54,14 +59,12 @@ export const ProfileSuggestionList = forwardRef< }, })); - // Scroll selected item into view + // Scroll selected item into view via Virtuoso useEffect(() => { - const selectedElement = listRef.current?.children[selectedIndex]; - if (selectedElement) { - selectedElement.scrollIntoView({ - block: "nearest", - }); - } + virtuosoRef.current?.scrollIntoView({ + index: selectedIndex, + behavior: "auto", + }); }, [selectedIndex]); // Reset selected index when items change @@ -69,31 +72,35 @@ export const ProfileSuggestionList = forwardRef< setSelectedIndex(0); }, [items]); - if (items.length === 0) { - return ( -
- No profiles found -
- ); - } - - return ( -
- {items.map((item, index) => ( + const renderItem = useCallback( + (index: number) => { + const item = items[index]; + return ( - ))} + ); + }, + [items, selectedIndex, command], + ); + + if (items.length === 0) { + return ( +
+ No profiles found +
+ ); + } + + const listHeight = Math.min(items.length, MAX_VISIBLE) * ITEM_HEIGHT; + + return ( +
+
); }); diff --git a/src/components/editor/RichEditor.tsx b/src/components/editor/RichEditor.tsx index 2ef9fbb..a2ccce6 100644 --- a/src/components/editor/RichEditor.tsx +++ b/src/components/editor/RichEditor.tsx @@ -1,28 +1,17 @@ import { forwardRef, - useEffect, useImperativeHandle, useMemo, useCallback, useRef, } from "react"; -import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react"; -import { Extension } from "@tiptap/core"; +import { useEditor, EditorContent } from "@tiptap/react"; 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 { ProfileSuggestionList } from "./ProfileSuggestionList"; +import { EmojiSuggestionList } from "./EmojiSuggestionList"; import type { ProfileSearchResult } from "@/services/profile-search"; import type { EmojiSearchResult } from "@/services/emoji-search"; import { nip19 } from "nostr-tools"; @@ -30,17 +19,19 @@ import { NostrPasteHandler } from "./extensions/nostr-paste-handler"; import { FilePasteHandler } from "./extensions/file-paste-handler"; import { BlobAttachmentRichNode } from "./extensions/blob-attachment-rich"; import { NostrEventPreviewRichNode } from "./extensions/nostr-event-preview-rich"; -import type { - EmojiTag, - BlobAttachment, - SerializedContent, -} from "./MentionEditor"; +import { EmojiMention } from "./extensions/emoji"; +import { SubmitShortcut } from "./extensions/submit-shortcut"; +import { serializeRichContent } from "./utils/serialize"; +import { useSuggestionRenderer } from "./hooks/useSuggestionRenderer"; +import type { BlobAttachment, SerializedContent } from "./types"; + +export type { EmojiTag, BlobAttachment, SerializedContent } from "./types"; export interface RichEditorProps { placeholder?: string; onSubmit?: ( content: string, - emojiTags: EmojiTag[], + emojiTags: Array<{ shortcode: string; url: string }>, blobAttachments: BlobAttachment[], addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>, ) => void; @@ -73,159 +64,6 @@ export interface RichEditorHandle { setContent: (json: any) => void; } -// Create emoji extension by extending Mention with a different name and custom node view -const EmojiMention = Mention.extend({ - name: "emoji", - - // Add custom attributes for emoji (url and source) - addAttributes() { - return { - ...this.parent?.(), - url: { - default: null, - parseHTML: (element) => element.getAttribute("data-url"), - renderHTML: (attributes) => { - if (!attributes.url) return {}; - return { "data-url": attributes.url }; - }, - }, - source: { - default: null, - parseHTML: (element) => element.getAttribute("data-source"), - renderHTML: (attributes) => { - if (!attributes.source) return {}; - return { "data-source": attributes.source }; - }, - }, - address: { - default: null, - parseHTML: (element) => element.getAttribute("data-address"), - renderHTML: (attributes) => { - if (!attributes.address) return {}; - return { "data-address": attributes.address }; - }, - }, - }; - }, - - // Override renderText to return empty string (nodeView handles display) - renderText({ node }) { - // Return the emoji character for unicode, or empty for custom - // This is what gets copied to clipboard - if (node.attrs.source === "unicode") { - return node.attrs.url || ""; - } - return `:${node.attrs.id}:`; - }, - - addNodeView() { - return ({ node }) => { - const { url, source, id } = node.attrs; - const isUnicode = source === "unicode"; - - // Create wrapper span - const dom = document.createElement("span"); - dom.className = "emoji-node"; - dom.setAttribute("data-emoji", id || ""); - - if (isUnicode && url) { - // Unicode emoji - render as text span - const span = document.createElement("span"); - span.className = "emoji-unicode"; - span.textContent = url; - span.title = `:${id}:`; - dom.appendChild(span); - } else if (url) { - // Custom emoji - render as image - const img = document.createElement("img"); - img.src = url; - img.alt = `:${id}:`; - img.title = `:${id}:`; - img.className = "emoji-image"; - img.draggable = false; - img.onerror = () => { - // Fallback to shortcode if image fails to load - dom.textContent = `:${id}:`; - }; - dom.appendChild(img); - } else { - // Fallback if no url - show shortcode - dom.textContent = `:${id}:`; - } - - return { - dom, - }; - }; - }, -}); - -/** - * Serialize editor content to plain text with nostr: URIs - * Note: hashtags, mentions, and event quotes are extracted automatically by applesauce's - * NoteBlueprint from the text content, so we only need to extract what it doesn't handle: - * - Custom emojis (for emoji tags) - * - Blob attachments (for imeta tags) - * - Address references (naddr - not yet supported by applesauce) - */ -function serializeContent(editor: any): SerializedContent { - const emojiTags: EmojiTag[] = []; - const blobAttachments: BlobAttachment[] = []; - const addressRefs: Array<{ - kind: number; - pubkey: string; - identifier: string; - }> = []; - const seenEmojis = new Set(); - const seenBlobs = new Set(); - const seenAddrs = new Set(); - - // Get plain text representation with single newline between blocks - // (TipTap's default is double newline which adds extra blank lines) - const text = editor.getText({ blockSeparator: "\n" }); - - // Walk the document to collect emoji, blob, and address reference data - editor.state.doc.descendants((node: any) => { - if (node.type.name === "emoji") { - const { id, url, source, address } = node.attrs; - // Only add custom emojis (not unicode) and avoid duplicates - if (source !== "unicode" && !seenEmojis.has(id)) { - seenEmojis.add(id); - emojiTags.push({ shortcode: id, url, address: address ?? undefined }); - } - } else if (node.type.name === "blobAttachment") { - const { url, sha256, mimeType, size, server } = node.attrs; - // Avoid duplicates - if (!seenBlobs.has(sha256)) { - seenBlobs.add(sha256); - blobAttachments.push({ url, sha256, mimeType, size, server }); - } - } else if (node.type.name === "nostrEventPreview") { - // Extract address references (naddr) for manual a tags - // Note: applesauce handles note/nevent automatically from nostr: URIs - const { type, data } = node.attrs; - if (type === "naddr" && data) { - const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`; - if (!seenAddrs.has(addrKey)) { - seenAddrs.add(addrKey); - addressRefs.push({ - kind: data.kind, - pubkey: data.pubkey, - identifier: data.identifier || "", - }); - } - } - } - }); - - return { - text, - emojiTags, - blobAttachments, - addressRefs, - }; -} - export const RichEditor = forwardRef( ( { @@ -242,200 +80,67 @@ export const RichEditor = forwardRef( }, ref, ) => { - // Ref to access handleSubmit from keyboard shortcuts const handleSubmitRef = useRef<(editor: any) => void>(() => {}); - // Create mention suggestion configuration for @ mentions - const mentionSuggestion: Omit = useMemo( - () => ({ - char: "@", - allowSpaces: false, - items: async ({ query }) => { - return await searchProfiles(query); - }, - render: () => { - let component: ReactRenderer; - let popup: TippyInstance[]; - - return { - onStart: (props) => { - component = new ReactRenderer(ProfileSuggestionList, { - props: { items: [], command: props.command }, - editor: props.editor, - }); - - if (!props.clientRect) { - return; - } - - popup = tippy("body", { - getReferenceClientRect: props.clientRect as () => DOMRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - theme: "mention", - }); - }, - - onUpdate(props) { - component.updateProps({ - items: props.items, - command: props.command, - }); - - if (!props.clientRect) { - return; - } - - popup[0].setProps({ - getReferenceClientRect: props.clientRect as () => DOMRect, - }); - }, - - onKeyDown(props) { - if (props.event.key === "Escape") { - popup[0].hide(); - return true; - } - return component.ref?.onKeyDown(props.event) || false; - }, - - onExit() { - popup[0].destroy(); - component.destroy(); - }, - }; - }, - }), - [searchProfiles], - ); - - // Create emoji suggestion configuration for : emojis - const emojiSuggestion: Omit | undefined = - useMemo(() => { - if (!searchEmojis) return undefined; - - return { - char: ":", - allowSpaces: false, - items: async ({ query }) => { - return await searchEmojis(query); - }, - render: () => { - let component: ReactRenderer; - let popup: TippyInstance[]; - - return { - onStart: (props) => { - component = new ReactRenderer(EmojiSuggestionList, { - props: { items: [], command: props.command }, - editor: props.editor, - }); - - if (!props.clientRect) { - return; - } - - popup = tippy("body", { - getReferenceClientRect: props.clientRect as () => DOMRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - theme: "mention", - }); - }, - - onUpdate(props) { - component.updateProps({ - items: props.items, - command: props.command, - }); - - if (!props.clientRect) { - return; - } - - popup[0].setProps({ - getReferenceClientRect: props.clientRect as () => DOMRect, - }); - }, - - onKeyDown(props) { - if (props.event.key === "Escape") { - popup[0].hide(); - return true; - } - return component.ref?.onKeyDown(props.event) || false; - }, - - onExit() { - popup[0].destroy(); - component.destroy(); - }, - }; - }, - }; - }, [searchEmojis]); - - // Handle submit const handleSubmit = useCallback( (editorInstance: any) => { - if (editorInstance.isEmpty) { - return; - } + if (editorInstance.isEmpty) return; - const serialized = serializeContent(editorInstance); - - if (onSubmit) { - onSubmit( - serialized.text, - serialized.emojiTags, - serialized.blobAttachments, - serialized.addressRefs, - ); - // Don't clear content here - let the parent component decide when to clear - } + const serialized = serializeRichContent(editorInstance); + onSubmit?.( + serialized.text, + serialized.emojiTags, + serialized.blobAttachments, + serialized.addressRefs, + ); }, [onSubmit], ); - // Keep ref updated with latest handleSubmit handleSubmitRef.current = handleSubmit; - // Build extensions array - const extensions = useMemo(() => { - // Custom extension for keyboard shortcuts - const SubmitShortcut = Extension.create({ - name: "submitShortcut", - addKeyboardShortcuts() { - return { - // Ctrl/Cmd+Enter submits - "Mod-Enter": ({ editor }) => { - handleSubmitRef.current(editor); - return true; - }, - // Plain Enter creates a new line (default behavior) - }; - }, - }); + // React-based suggestion renderers (replace tippy.js) + const { render: renderMentionSuggestion, portal: mentionPortal } = + useSuggestionRenderer(ProfileSuggestionList as any); + const { render: renderEmojiSuggestion, portal: emojiPortal } = + useSuggestionRenderer(EmojiSuggestionList as any); + + // Mention suggestion config + const mentionSuggestion: Omit = useMemo( + () => ({ + char: "@", + allowSpaces: false, + items: async ({ query }) => searchProfiles(query), + render: renderMentionSuggestion, + }), + [searchProfiles, renderMentionSuggestion], + ); + + // Emoji suggestion config + const emojiSuggestion: Omit | undefined = + useMemo(() => { + if (!searchEmojis) return undefined; + return { + char: ":", + allowSpaces: false, + items: async ({ query }) => searchEmojis(query), + render: renderEmojiSuggestion, + }; + }, [searchEmojis, renderEmojiSuggestion]); + + // Build extensions + const extensions = useMemo(() => { const exts = [ - SubmitShortcut, + SubmitShortcut.configure({ + submitRef: handleSubmitRef, + enterSubmits: false, + }), StarterKit.configure({ - // Enable paragraph, hardBreak, etc. for multi-line - hardBreak: { - keepMarks: false, - }, + hardBreak: { keepMarks: false }, }), Mention.extend({ renderText({ node }) { - // Serialize to nostr: URI for plain text export try { return `nostr:${nip19.npubEncode(node.attrs.id)}`; } catch (err) { @@ -444,23 +149,17 @@ export const RichEditor = forwardRef( } }, }).configure({ - HTMLAttributes: { - class: "mention", - }, + 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, - }, + attrs: { id: props.pubkey, label: props.displayName }, }, { type: "text", text: " " }, ]) @@ -471,32 +170,20 @@ export const RichEditor = forwardRef( return `@${node.attrs.label}`; }, }), - Placeholder.configure({ - placeholder, - }), - // Add blob attachment extension for full-size media previews + Placeholder.configure({ placeholder }), BlobAttachmentRichNode, - // Add nostr event preview extension for full event rendering NostrEventPreviewRichNode, - // Add paste handler to transform bech32 strings into previews NostrPasteHandler, - // Add file paste handler for clipboard file uploads - FilePasteHandler.configure({ - onFilePaste, - }), + FilePasteHandler.configure({ onFilePaste }), ]; - // Add emoji extension if search is provided if (emojiSuggestion) { exts.push( EmojiMention.configure({ - HTMLAttributes: { - class: "emoji", - }, + HTMLAttributes: { class: "emoji" }, suggestion: { ...emojiSuggestion, command: ({ editor, range, props }: any) => { - // props is the EmojiSearchResult editor .chain() .focus() @@ -508,7 +195,7 @@ export const RichEditor = forwardRef( label: props.shortcode, url: props.url, source: props.source, - address: props.address ?? null, + mentionSuggestionChar: ":", }, }, { type: "text", text: " " }, @@ -532,29 +219,21 @@ export const RichEditor = forwardRef( }, }, autofocus: autoFocus, - onUpdate: () => { - onChange?.(); - }, + onUpdate: () => onChange?.(), }); - // Helper to check if editor view is ready (prevents "view not available" errors) const isEditorReady = useCallback(() => { return editor && editor.view && editor.view.dom; }, [editor]); - // Expose editor methods useImperativeHandle( ref, () => ({ focus: () => { - if (isEditorReady()) { - editor?.commands.focus(); - } + if (isEditorReady()) editor?.commands.focus(); }, clear: () => { - if (isEditorReady()) { - editor?.commands.clearContent(); - } + if (isEditorReady()) editor?.commands.clearContent(); }, getContent: () => { if (!isEditorReady()) return ""; @@ -568,21 +247,17 @@ export const RichEditor = forwardRef( blobAttachments: [], addressRefs: [], }; - return serializeContent(editor); + return serializeRichContent(editor); }, isEmpty: () => { if (!isEditorReady()) return true; return editor?.isEmpty ?? true; }, submit: () => { - if (isEditorReady() && editor) { - handleSubmit(editor); - } + if (isEditorReady() && editor) handleSubmit(editor); }, insertText: (text: string) => { - if (isEditorReady()) { - editor?.commands.insertContent(text); - } + if (isEditorReady()) editor?.commands.insertContent(text); }, insertBlob: (blob: BlobAttachment) => { if (isEditorReady()) { @@ -597,41 +272,19 @@ export const RichEditor = forwardRef( return editor?.getJSON() || null; }, setContent: (json: any) => { - // Check editor and view are ready before setting content - if (isEditorReady() && json) { - editor?.commands.setContent(json); - } + if (isEditorReady() && json) editor?.commands.setContent(json); }, }), [editor, handleSubmit, isEditorReady], ); - // Handle submit on Ctrl/Cmd+Enter - useEffect(() => { - // Check both editor and editor.view exist (view may not be ready immediately) - if (!editor?.view?.dom) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.ctrlKey || event.metaKey) && event.key === "Enter") { - event.preventDefault(); - handleSubmit(editor); - } - }; - - editor.view.dom.addEventListener("keydown", handleKeyDown); - return () => { - // Also check view.dom exists in cleanup (editor might be destroyed) - editor.view?.dom?.removeEventListener("keydown", handleKeyDown); - }; - }, [editor, handleSubmit]); - - if (!editor) { - return null; - } + if (!editor) return null; return (
+ {mentionPortal} + {emojiPortal}
); }, diff --git a/src/components/editor/SuggestionPopover.tsx b/src/components/editor/SuggestionPopover.tsx new file mode 100644 index 0000000..0a68dd2 --- /dev/null +++ b/src/components/editor/SuggestionPopover.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef, type ReactNode } from "react"; +import { + useFloating, + offset, + flip, + shift, + autoUpdate, + size, +} from "@floating-ui/react-dom"; +import { createPortal } from "react-dom"; + +interface SuggestionPopoverProps { + /** Function that returns the cursor bounding rect (from Tiptap suggestion) */ + clientRect: (() => DOMRect | null) | null; + /** Popover content (suggestion list component) */ + children: ReactNode; + /** Floating-ui placement */ + placement?: "bottom-start" | "top-start"; +} + +/** + * Generic floating popover for suggestion dropdowns + * + * Uses @floating-ui/react-dom with a virtual reference element (cursor position) + * to position suggestion lists. Rendered via React portal. + * + * Uses autoUpdate to keep position correct during scroll/resize, and a + * size middleware to constrain max-height to available viewport space. + */ +export function SuggestionPopover({ + clientRect, + children, + placement = "bottom-start", +}: SuggestionPopoverProps) { + const cleanupRef = useRef<(() => void) | null>(null); + + const { refs, floatingStyles, update } = useFloating({ + placement, + middleware: [ + offset(8), + flip({ padding: 8 }), + shift({ padding: 8 }), + size({ + padding: 8, + apply({ availableHeight, elements }) { + elements.floating.style.maxHeight = `${Math.max(100, availableHeight)}px`; + }, + }), + ], + }); + + // Set up virtual reference and auto-update when clientRect changes + useEffect(() => { + // Clean up previous auto-update + cleanupRef.current?.(); + cleanupRef.current = null; + + if (!clientRect) return; + + const virtualEl = { + getBoundingClientRect: () => clientRect() || new DOMRect(), + }; + + refs.setReference(virtualEl); + + // Start auto-update for scroll/resize tracking + const floating = refs.floating.current; + if (floating) { + cleanupRef.current = autoUpdate(virtualEl, floating, update, { + ancestorScroll: true, + ancestorResize: true, + elementResize: true, + animationFrame: false, + }); + } + + return () => { + cleanupRef.current?.(); + cleanupRef.current = null; + }; + }, [clientRect, refs, update]); + + return createPortal( +
+ {children} +
, + document.body, + ); +} diff --git a/src/components/editor/extensions/blob-attachment-inline.ts b/src/components/editor/extensions/blob-attachment-inline.ts new file mode 100644 index 0000000..2692179 --- /dev/null +++ b/src/components/editor/extensions/blob-attachment-inline.ts @@ -0,0 +1,48 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { BlobAttachmentInline } from "../node-views/BlobAttachmentInline"; + +/** + * Inline blob attachment node for MentionEditor (chat-style) + * + * Shows a compact badge with media type and size. + * Uses ReactNodeViewRenderer for React-based rendering. + */ +export const BlobAttachmentInlineNode = 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" }), + ]; + }, + + renderText({ node }) { + return node.attrs.url || ""; + }, + + addNodeView() { + return ReactNodeViewRenderer(BlobAttachmentInline, { + as: "span", + className: "", + }); + }, +}); diff --git a/src/components/editor/extensions/blob-attachment.test.ts b/src/components/editor/extensions/blob-attachment.test.ts new file mode 100644 index 0000000..2f3f928 --- /dev/null +++ b/src/components/editor/extensions/blob-attachment.test.ts @@ -0,0 +1,190 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; + +// Mock React node views (not needed for headless tests) +vi.mock("@tiptap/react", () => ({ + ReactNodeViewRenderer: () => () => null, +})); +vi.mock("../node-views/BlobAttachmentRich", () => ({ + BlobAttachmentRich: {}, +})); +vi.mock("../node-views/BlobAttachmentInline", () => ({ + BlobAttachmentInline: {}, +})); + +import { BlobAttachmentRichNode } from "./blob-attachment-rich"; +import { BlobAttachmentInlineNode } from "./blob-attachment-inline"; + +beforeAll(() => { + const rect = { + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => ({}), + }; + HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getClientRects = (() => []) as any; + document.elementFromPoint = (() => null) as any; +}); + +const SAMPLE_BLOB = { + url: "https://cdn.example.com/image.png", + sha256: "abc123def456", + mimeType: "image/png", + size: 1024, + server: "https://blossom.example.com", +}; + +describe("BlobAttachmentRichNode (block-level)", () => { + let editor: Editor; + + afterEach(() => { + editor?.destroy(); + }); + + function createRichEditor(content?: string) { + return new Editor({ + extensions: [StarterKit, BlobAttachmentRichNode], + content, + }); + } + + describe("schema", () => { + it("should register blobAttachment node type", () => { + editor = createRichEditor(); + expect(editor.schema.nodes.blobAttachment).toBeDefined(); + }); + + it("should be a block-level node", () => { + editor = createRichEditor(); + const spec = editor.schema.nodes.blobAttachment.spec; + expect(spec.group).toBe("block"); + expect(spec.inline).toBe(false); + }); + + it("should be an atom node", () => { + editor = createRichEditor(); + expect(editor.schema.nodes.blobAttachment.spec.atom).toBe(true); + }); + + it("should have correct attributes", () => { + editor = createRichEditor(); + const attrs = editor.schema.nodes.blobAttachment.spec.attrs!; + expect(attrs).toHaveProperty("url"); + expect(attrs).toHaveProperty("sha256"); + expect(attrs).toHaveProperty("mimeType"); + expect(attrs).toHaveProperty("size"); + expect(attrs).toHaveProperty("server"); + }); + }); + + describe("renderText", () => { + it("should return URL as text", () => { + editor = createRichEditor(); + editor.commands.insertContent({ + type: "blobAttachment", + attrs: SAMPLE_BLOB, + }); + expect(editor.getText()).toContain(SAMPLE_BLOB.url); + }); + + it("should return empty string when url is null", () => { + editor = createRichEditor(); + editor.commands.insertContent({ + type: "blobAttachment", + attrs: { ...SAMPLE_BLOB, url: null }, + }); + expect(editor.getText().trim()).toBe(""); + }); + }); + + describe("node attributes", () => { + it("should store all attributes on inserted node", () => { + editor = createRichEditor(); + editor.commands.insertContent({ + type: "blobAttachment", + attrs: SAMPLE_BLOB, + }); + + let blobNode: any = null; + editor.state.doc.descendants((node) => { + if (node.type.name === "blobAttachment") { + blobNode = node; + return false; + } + }); + + expect(blobNode).not.toBeNull(); + expect(blobNode.attrs.url).toBe(SAMPLE_BLOB.url); + expect(blobNode.attrs.sha256).toBe(SAMPLE_BLOB.sha256); + expect(blobNode.attrs.mimeType).toBe(SAMPLE_BLOB.mimeType); + expect(blobNode.attrs.size).toBe(SAMPLE_BLOB.size); + expect(blobNode.attrs.server).toBe(SAMPLE_BLOB.server); + }); + }); + + describe("parseHTML", () => { + it("should parse div with data-blob-attachment attribute", () => { + editor = createRichEditor(); + const parseRules = editor.schema.nodes.blobAttachment.spec.parseDOM; + expect(parseRules).toBeDefined(); + expect(parseRules![0].tag).toBe('div[data-blob-attachment="true"]'); + }); + }); +}); + +describe("BlobAttachmentInlineNode", () => { + let editor: Editor; + + afterEach(() => { + editor?.destroy(); + }); + + function createInlineEditor(content?: string) { + return new Editor({ + extensions: [StarterKit, BlobAttachmentInlineNode], + content, + }); + } + + describe("schema", () => { + it("should be an inline node", () => { + editor = createInlineEditor(); + const spec = editor.schema.nodes.blobAttachment.spec; + expect(spec.group).toBe("inline"); + expect(spec.inline).toBe(true); + }); + + it("should be an atom node", () => { + editor = createInlineEditor(); + expect(editor.schema.nodes.blobAttachment.spec.atom).toBe(true); + }); + }); + + describe("renderText", () => { + it("should return URL as text", () => { + editor = createInlineEditor(); + editor.commands.insertContent({ + type: "blobAttachment", + attrs: SAMPLE_BLOB, + }); + expect(editor.getText()).toContain(SAMPLE_BLOB.url); + }); + }); + + describe("parseHTML", () => { + it("should parse span with data-blob-attachment attribute", () => { + editor = createInlineEditor(); + const parseRules = editor.schema.nodes.blobAttachment.spec.parseDOM; + expect(parseRules![0].tag).toBe('span[data-blob-attachment="true"]'); + }); + }); +}); diff --git a/src/components/editor/extensions/emoji.test.ts b/src/components/editor/extensions/emoji.test.ts new file mode 100644 index 0000000..26b5dd2 --- /dev/null +++ b/src/components/editor/extensions/emoji.test.ts @@ -0,0 +1,260 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; + +// Mock React node views (not needed for headless tests) +vi.mock("@tiptap/react", () => ({ + ReactNodeViewRenderer: () => () => null, +})); +vi.mock("../node-views/EmojiNodeView", () => ({ EmojiNodeView: {} })); + +import { EmojiMention } from "./emoji"; + +// ProseMirror requires layout APIs that jsdom lacks +beforeAll(() => { + const rect = { + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => ({}), + }; + HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getClientRects = (() => []) as any; + document.elementFromPoint = (() => null) as any; +}); + +function createEditor(content?: string) { + return new Editor({ + extensions: [ + StarterKit, + EmojiMention.configure({ + suggestion: { char: ":" }, + }), + ], + content, + }); +} + +describe("EmojiMention", () => { + let editor: Editor; + + afterEach(() => { + editor?.destroy(); + }); + + describe("schema", () => { + it("should register emoji node type", () => { + editor = createEditor(); + expect(editor.schema.nodes.emoji).toBeDefined(); + }); + + it("should have url attribute", () => { + editor = createEditor(); + expect(editor.schema.nodes.emoji.spec.attrs).toHaveProperty("url"); + }); + + it("should have source attribute", () => { + editor = createEditor(); + expect(editor.schema.nodes.emoji.spec.attrs).toHaveProperty("source"); + }); + + it("should inherit mention attributes (id, label)", () => { + editor = createEditor(); + const attrs = editor.schema.nodes.emoji.spec.attrs!; + expect(attrs).toHaveProperty("id"); + expect(attrs).toHaveProperty("label"); + }); + }); + + describe("renderText", () => { + it("should return emoji character for unicode source", () => { + editor = createEditor(); + editor.commands.insertContent({ + type: "emoji", + attrs: { + id: "fire", + label: "fire", + url: "🔥", + source: "unicode", + mentionSuggestionChar: ":", + }, + }); + expect(editor.getText()).toContain("🔥"); + }); + + it("should return :shortcode: for custom emoji", () => { + editor = createEditor(); + editor.commands.insertContent({ + type: "emoji", + attrs: { + id: "pepe", + label: "pepe", + url: "https://cdn.example.com/pepe.png", + source: "custom", + mentionSuggestionChar: ":", + }, + }); + expect(editor.getText()).toContain(":pepe:"); + }); + + it("should return empty string for unicode with no url", () => { + editor = createEditor(); + editor.commands.insertContent({ + type: "emoji", + attrs: { + id: "unknown", + label: "unknown", + url: null, + source: "unicode", + mentionSuggestionChar: ":", + }, + }); + expect(editor.getText()).not.toContain(":unknown:"); + expect(editor.getText().trim()).toBe(""); + }); + + it("should handle multiple emoji in sequence", () => { + editor = createEditor(); + editor + .chain() + .insertContent([ + { + type: "emoji", + attrs: { + id: "fire", + url: "🔥", + source: "unicode", + mentionSuggestionChar: ":", + }, + }, + { type: "text", text: " " }, + { + type: "emoji", + attrs: { + id: "pepe", + url: "https://cdn.example.com/pepe.png", + source: "custom", + mentionSuggestionChar: ":", + }, + }, + ]) + .run(); + + const text = editor.getText(); + expect(text).toContain("🔥"); + expect(text).toContain(":pepe:"); + }); + }); + + describe("mentionSuggestionChar", () => { + it("should preserve mentionSuggestionChar when explicitly set to ':'", () => { + editor = createEditor(); + editor.commands.insertContent({ + type: "emoji", + attrs: { + id: "fire", + label: "fire", + url: "🔥", + source: "unicode", + mentionSuggestionChar: ":", + }, + }); + + let emojiNode: any = null; + editor.state.doc.descendants((node) => { + if (node.type.name === "emoji") { + emojiNode = node; + return false; + } + }); + + expect(emojiNode).not.toBeNull(); + expect(emojiNode.attrs.mentionSuggestionChar).toBe(":"); + }); + + it("should default mentionSuggestionChar to '@' if not explicitly set", () => { + editor = createEditor(); + editor.commands.insertContent({ + type: "emoji", + attrs: { + id: "fire", + label: "fire", + url: "🔥", + source: "unicode", + // NOT setting mentionSuggestionChar - tests the regression + }, + }); + + let emojiNode: any = null; + editor.state.doc.descendants((node) => { + if (node.type.name === "emoji") { + emojiNode = node; + return false; + } + }); + + // Mention extension defaults to "@" - this is why we must always + // set mentionSuggestionChar: ":" when inserting emoji nodes + expect(emojiNode?.attrs.mentionSuggestionChar).toBe("@"); + }); + }); + + describe("backspace behavior", () => { + it("should replace emoji with ':' when backspacing (mentionSuggestionChar set)", () => { + editor = createEditor("

hello

"); + // Move to end + editor.commands.focus("end"); + // Insert emoji with correct mentionSuggestionChar + editor.commands.insertContent({ + type: "emoji", + attrs: { + id: "fire", + label: "fire", + url: "🔥", + source: "unicode", + mentionSuggestionChar: ":", + }, + }); + + // Verify emoji is present + expect(editor.getText()).toContain("🔥"); + + // Simulate backspace - Mention extension handles this + editor.commands.keyboardShortcut("Backspace"); + + // After backspace, emoji should be replaced by ":" + const text = editor.getText(); + expect(text).not.toContain("🔥"); + expect(text).toContain(":"); + expect(text).not.toContain("@"); + }); + + it("should replace emoji with '@' when mentionSuggestionChar was not set (regression)", () => { + editor = createEditor("

hello

"); + editor.commands.focus("end"); + editor.commands.insertContent({ + type: "emoji", + attrs: { + id: "fire", + label: "fire", + url: "🔥", + source: "unicode", + // NOT setting mentionSuggestionChar - demonstrates the regression + }, + }); + + editor.commands.keyboardShortcut("Backspace"); + + // Without the fix, this would be "@" instead of ":" + const text = editor.getText(); + expect(text).toContain("@"); + }); + }); +}); diff --git a/src/components/editor/extensions/emoji.ts b/src/components/editor/extensions/emoji.ts new file mode 100644 index 0000000..d8a672f --- /dev/null +++ b/src/components/editor/extensions/emoji.ts @@ -0,0 +1,60 @@ +import Mention from "@tiptap/extension-mention"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { EmojiNodeView } from "../node-views/EmojiNodeView"; + +/** + * Shared emoji extension for both RichEditor and MentionEditor + * + * Extends the Mention extension with emoji-specific attributes (url, source) + * and uses a React node view for rendering. + */ +export 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 }; + }, + }, + address: { + default: null, + parseHTML: (element) => element.getAttribute("data-address"), + renderHTML: (attributes) => { + if (!attributes.address) return {}; + return { "data-address": attributes.address }; + }, + }, + }; + }, + + renderText({ node }) { + // Return the emoji character for unicode, or shortcode for custom + // This is what gets copied to clipboard + if (node.attrs.source === "unicode") { + return node.attrs.url || ""; + } + return `:${node.attrs.id}:`; + }, + + addNodeView() { + return ReactNodeViewRenderer(EmojiNodeView, { + // Render as inline span, not a block-level wrapper + as: "span", + className: "", + }); + }, +}); diff --git a/src/components/editor/extensions/file-paste-handler.test.ts b/src/components/editor/extensions/file-paste-handler.test.ts new file mode 100644 index 0000000..b51aa14 --- /dev/null +++ b/src/components/editor/extensions/file-paste-handler.test.ts @@ -0,0 +1,229 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { FilePasteHandler } from "./file-paste-handler"; + +beforeAll(() => { + const rect = { + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => ({}), + }; + HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getClientRects = (() => []) as any; + document.elementFromPoint = (() => null) as any; +}); + +/** + * Find the filePasteHandler plugin and extract its handlePaste function. + * ProseMirror's PluginKey auto-increments keys (filePasteHandler$, filePasteHandler$1, etc.) + * so we match by prefix. Cast to remove `this` context requirement. + */ +function getPasteHandler(editor: Editor) { + const plugin = editor.state.plugins.find((p) => + (p as any).key?.startsWith("filePasteHandler$"), + ); + return plugin?.props?.handlePaste as + | ((view: any, event: any, slice: any) => boolean | void) + | undefined; +} + +/** Create a mock ClipboardEvent with files */ +function mockPasteEvent( + files: Array<{ name: string; type: string }>, +): ClipboardEvent { + const fileList = files.map( + (f) => new File(["content"], f.name, { type: f.type }), + ); + + return { + clipboardData: { + files: fileList, + getData: () => "", + }, + preventDefault: vi.fn(), + } as unknown as ClipboardEvent; +} + +describe("FilePasteHandler", () => { + let editor: Editor; + + afterEach(() => { + editor?.destroy(); + }); + + describe("image files", () => { + it("should call onFilePaste with image files", () => { + const onFilePaste = vi.fn(); + editor = new Editor({ + extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })], + }); + + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent([{ name: "photo.png", type: "image/png" }]); + + const handled = handlePaste!(editor.view, event, null as any); + + expect(handled).toBe(true); + expect(onFilePaste).toHaveBeenCalledTimes(1); + expect(onFilePaste.mock.calls[0][0]).toHaveLength(1); + expect(onFilePaste.mock.calls[0][0][0].name).toBe("photo.png"); + }); + + it("should handle multiple image types", () => { + const onFilePaste = vi.fn(); + editor = new Editor({ + extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })], + }); + + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent([ + { name: "photo.jpg", type: "image/jpeg" }, + { name: "graphic.webp", type: "image/webp" }, + { name: "icon.gif", type: "image/gif" }, + ]); + + handlePaste!(editor.view, event, null as any); + + expect(onFilePaste).toHaveBeenCalledTimes(1); + expect(onFilePaste.mock.calls[0][0]).toHaveLength(3); + }); + }); + + describe("video files", () => { + it("should call onFilePaste with video files", () => { + const onFilePaste = vi.fn(); + editor = new Editor({ + extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })], + }); + + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent([{ name: "clip.mp4", type: "video/mp4" }]); + + const handled = handlePaste!(editor.view, event, null as any); + + expect(handled).toBe(true); + expect(onFilePaste).toHaveBeenCalledTimes(1); + expect(onFilePaste.mock.calls[0][0][0].type).toBe("video/mp4"); + }); + }); + + describe("audio files", () => { + it("should call onFilePaste with audio files", () => { + const onFilePaste = vi.fn(); + editor = new Editor({ + extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })], + }); + + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent([{ name: "song.mp3", type: "audio/mpeg" }]); + + const handled = handlePaste!(editor.view, event, null as any); + + expect(handled).toBe(true); + expect(onFilePaste).toHaveBeenCalledTimes(1); + }); + }); + + describe("non-media files", () => { + it("should ignore non-media files", () => { + const onFilePaste = vi.fn(); + editor = new Editor({ + extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })], + }); + + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent([ + { name: "document.pdf", type: "application/pdf" }, + { name: "data.json", type: "application/json" }, + ]); + + const handled = handlePaste!(editor.view, event, null as any); + + expect(handled).toBe(false); + expect(onFilePaste).not.toHaveBeenCalled(); + }); + + it("should filter out non-media files from mixed paste", () => { + const onFilePaste = vi.fn(); + editor = new Editor({ + extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })], + }); + + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent([ + { name: "photo.png", type: "image/png" }, + { name: "readme.txt", type: "text/plain" }, + { name: "clip.mp4", type: "video/mp4" }, + ]); + + handlePaste!(editor.view, event, null as any); + + expect(onFilePaste).toHaveBeenCalledTimes(1); + // Should only receive the image and video, not the text file + const files = onFilePaste.mock.calls[0][0]; + expect(files).toHaveLength(2); + expect(files[0].name).toBe("photo.png"); + expect(files[1].name).toBe("clip.mp4"); + }); + }); + + describe("no files", () => { + it("should return false when no files in clipboard", () => { + const onFilePaste = vi.fn(); + editor = new Editor({ + extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })], + }); + + const handlePaste = getPasteHandler(editor); + const event = { + clipboardData: { files: [], getData: () => "" }, + preventDefault: vi.fn(), + } as unknown as ClipboardEvent; + + const handled = handlePaste!(editor.view, event, null as any); + + expect(handled).toBe(false); + expect(onFilePaste).not.toHaveBeenCalled(); + }); + + it("should return false when clipboardData is null", () => { + const onFilePaste = vi.fn(); + editor = new Editor({ + extensions: [StarterKit, FilePasteHandler.configure({ onFilePaste })], + }); + + const handlePaste = getPasteHandler(editor); + const event = { + clipboardData: null, + preventDefault: vi.fn(), + } as unknown as ClipboardEvent; + + const handled = handlePaste!(editor.view, event, null as any); + + expect(handled).toBe(false); + }); + }); + + describe("no callback", () => { + it("should return false when onFilePaste is not configured", () => { + editor = new Editor({ + extensions: [StarterKit, FilePasteHandler.configure({})], + }); + + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent([{ name: "photo.png", type: "image/png" }]); + + const handled = handlePaste!(editor.view, event, null as any); + expect(handled).toBe(false); + }); + }); +}); diff --git a/src/components/editor/extensions/nostr-event-preview-inline.ts b/src/components/editor/extensions/nostr-event-preview-inline.ts new file mode 100644 index 0000000..3b6d989 --- /dev/null +++ b/src/components/editor/extensions/nostr-event-preview-inline.ts @@ -0,0 +1,54 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { nip19 } from "nostr-tools"; +import { NostrEventPreviewInline } from "../node-views/NostrEventPreviewInline"; + +/** + * Inline Nostr event preview node for MentionEditor (chat-style) + * + * Shows a compact badge with event type and truncated ID. + * Uses ReactNodeViewRenderer for React-based rendering. + */ +export const NostrEventPreviewInlineNode = Node.create({ + name: "nostrEventPreview", + group: "inline", + inline: true, + atom: true, + + addAttributes() { + return { + type: { default: null }, // 'note' | 'nevent' | 'naddr' + data: { default: null }, // Decoded bech32 data + }; + }, + + parseHTML() { + return [{ tag: 'span[data-nostr-preview="true"]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes(HTMLAttributes, { "data-nostr-preview": "true" }), + ]; + }, + + renderText({ node }) { + const { type, data } = node.attrs; + try { + if (type === "note") return `nostr:${nip19.noteEncode(data)}`; + if (type === "nevent") return `nostr:${nip19.neventEncode(data)}`; + if (type === "naddr") return `nostr:${nip19.naddrEncode(data)}`; + } catch (err) { + console.error("[NostrEventPreviewInline] Failed to encode:", err); + } + return ""; + }, + + addNodeView() { + return ReactNodeViewRenderer(NostrEventPreviewInline, { + as: "span", + className: "", + }); + }, +}); diff --git a/src/components/editor/extensions/nostr-event-preview.test.ts b/src/components/editor/extensions/nostr-event-preview.test.ts new file mode 100644 index 0000000..4538d08 --- /dev/null +++ b/src/components/editor/extensions/nostr-event-preview.test.ts @@ -0,0 +1,216 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { nip19 } from "nostr-tools"; + +// Mock React node views +vi.mock("@tiptap/react", () => ({ + ReactNodeViewRenderer: () => () => null, +})); +vi.mock("../node-views/NostrEventPreviewRich", () => ({ + NostrEventPreviewRich: {}, +})); +vi.mock("../node-views/NostrEventPreviewInline", () => ({ + NostrEventPreviewInline: {}, +})); + +import { NostrEventPreviewRichNode } from "./nostr-event-preview-rich"; +import { NostrEventPreviewInlineNode } from "./nostr-event-preview-inline"; + +beforeAll(() => { + const rect = { + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => ({}), + }; + HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getClientRects = (() => []) as any; + document.elementFromPoint = (() => null) as any; +}); + +// Test data +const TEST_EVENT_ID = + "d7a9c9f8e7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0"; +const TEST_PUBKEY = + "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; + +describe("NostrEventPreviewRichNode (block-level)", () => { + let editor: Editor; + + afterEach(() => { + editor?.destroy(); + }); + + function createRichEditor() { + return new Editor({ + extensions: [StarterKit, NostrEventPreviewRichNode], + }); + } + + describe("schema", () => { + it("should register nostrEventPreview node type", () => { + editor = createRichEditor(); + expect(editor.schema.nodes.nostrEventPreview).toBeDefined(); + }); + + it("should be a block-level atom node", () => { + editor = createRichEditor(); + const spec = editor.schema.nodes.nostrEventPreview.spec; + expect(spec.group).toBe("block"); + expect(spec.inline).toBe(false); + expect(spec.atom).toBe(true); + }); + + it("should have type and data attributes", () => { + editor = createRichEditor(); + const attrs = editor.schema.nodes.nostrEventPreview.spec.attrs!; + expect(attrs).toHaveProperty("type"); + expect(attrs).toHaveProperty("data"); + }); + }); + + describe("renderText", () => { + it("should encode note type back to nostr: URI", () => { + editor = createRichEditor(); + editor.commands.insertContent({ + type: "nostrEventPreview", + attrs: { type: "note", data: TEST_EVENT_ID }, + }); + + const text = editor.getText(); + const expectedNote = nip19.noteEncode(TEST_EVENT_ID); + expect(text).toContain(`nostr:${expectedNote}`); + }); + + it("should encode nevent type back to nostr: URI", () => { + editor = createRichEditor(); + const eventPointer = { + id: TEST_EVENT_ID, + relays: ["wss://relay.example.com"], + }; + editor.commands.insertContent({ + type: "nostrEventPreview", + attrs: { type: "nevent", data: eventPointer }, + }); + + const text = editor.getText(); + const expectedNevent = nip19.neventEncode(eventPointer); + expect(text).toContain(`nostr:${expectedNevent}`); + }); + + it("should encode naddr type back to nostr: URI", () => { + editor = createRichEditor(); + const addrPointer = { + kind: 30023, + pubkey: TEST_PUBKEY, + identifier: "my-article", + }; + editor.commands.insertContent({ + type: "nostrEventPreview", + attrs: { type: "naddr", data: addrPointer }, + }); + + const text = editor.getText(); + const expectedNaddr = nip19.naddrEncode(addrPointer); + expect(text).toContain(`nostr:${expectedNaddr}`); + }); + + it("should return empty string for unknown type", () => { + editor = createRichEditor(); + editor.commands.insertContent({ + type: "nostrEventPreview", + attrs: { type: "unknown", data: null }, + }); + expect(editor.getText().trim()).toBe(""); + }); + }); + + describe("parseHTML", () => { + it("should parse div with data-nostr-preview attribute", () => { + editor = createRichEditor(); + const parseRules = editor.schema.nodes.nostrEventPreview.spec.parseDOM; + expect(parseRules![0].tag).toBe('div[data-nostr-preview="true"]'); + }); + }); +}); + +describe("NostrEventPreviewInlineNode", () => { + let editor: Editor; + + afterEach(() => { + editor?.destroy(); + }); + + function createInlineEditor() { + return new Editor({ + extensions: [StarterKit, NostrEventPreviewInlineNode], + }); + } + + describe("schema", () => { + it("should be an inline atom node", () => { + editor = createInlineEditor(); + const spec = editor.schema.nodes.nostrEventPreview.spec; + expect(spec.group).toBe("inline"); + expect(spec.inline).toBe(true); + expect(spec.atom).toBe(true); + }); + }); + + describe("renderText", () => { + it("should encode note back to nostr: URI", () => { + editor = createInlineEditor(); + editor.commands.insertContent({ + type: "nostrEventPreview", + attrs: { type: "note", data: TEST_EVENT_ID }, + }); + + const expectedNote = nip19.noteEncode(TEST_EVENT_ID); + expect(editor.getText()).toContain(`nostr:${expectedNote}`); + }); + + it("should encode nevent back to nostr: URI", () => { + editor = createInlineEditor(); + const eventPointer = { id: TEST_EVENT_ID }; + editor.commands.insertContent({ + type: "nostrEventPreview", + attrs: { type: "nevent", data: eventPointer }, + }); + + const expectedNevent = nip19.neventEncode(eventPointer); + expect(editor.getText()).toContain(`nostr:${expectedNevent}`); + }); + + it("should encode naddr back to nostr: URI", () => { + editor = createInlineEditor(); + const addrPointer = { + kind: 30023, + pubkey: TEST_PUBKEY, + identifier: "test", + }; + editor.commands.insertContent({ + type: "nostrEventPreview", + attrs: { type: "naddr", data: addrPointer }, + }); + + const expectedNaddr = nip19.naddrEncode(addrPointer); + expect(editor.getText()).toContain(`nostr:${expectedNaddr}`); + }); + }); + + describe("parseHTML", () => { + it("should parse span with data-nostr-preview attribute", () => { + editor = createInlineEditor(); + const parseRules = editor.schema.nodes.nostrEventPreview.spec.parseDOM; + expect(parseRules![0].tag).toBe('span[data-nostr-preview="true"]'); + }); + }); +}); diff --git a/src/components/editor/extensions/nostr-paste-handler.test.ts b/src/components/editor/extensions/nostr-paste-handler.test.ts new file mode 100644 index 0000000..e139700 --- /dev/null +++ b/src/components/editor/extensions/nostr-paste-handler.test.ts @@ -0,0 +1,509 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import Mention from "@tiptap/extension-mention"; +import { nip19 } from "nostr-tools"; + +// Mock React node views +vi.mock("@tiptap/react", () => ({ + ReactNodeViewRenderer: () => () => null, +})); +vi.mock("../node-views/NostrEventPreviewRich", () => ({ + NostrEventPreviewRich: {}, +})); + +// Mock profile search service +vi.mock("@/services/profile-search", () => ({ + default: { + getByPubkey: () => null, + }, +})); + +import { NostrPasteHandler } from "./nostr-paste-handler"; +import { NostrEventPreviewRichNode } from "./nostr-event-preview-rich"; + +beforeAll(() => { + const rect = { + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => ({}), + }; + HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getClientRects = (() => []) as any; + document.elementFromPoint = (() => null) as any; +}); + +// Test data +const TEST_PUBKEY = + "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; +const TEST_EVENT_ID = + "d7a9c9f8e7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0"; +const TEST_NPUB = nip19.npubEncode(TEST_PUBKEY); +const TEST_NOTE = nip19.noteEncode(TEST_EVENT_ID); +const TEST_NPROFILE = nip19.nprofileEncode({ pubkey: TEST_PUBKEY }); +const TEST_NEVENT = nip19.neventEncode({ id: TEST_EVENT_ID }); +const TEST_NADDR = nip19.naddrEncode({ + kind: 30023, + pubkey: TEST_PUBKEY, + identifier: "test-article", +}); + +function createEditor() { + return new Editor({ + extensions: [ + StarterKit, + Mention.configure({ suggestion: { char: "@" } }), + NostrEventPreviewRichNode, + NostrPasteHandler, + ], + }); +} + +/** + * Find the nostrPasteHandler plugin and extract its handlePaste function. + * ProseMirror's PluginKey auto-increments keys (nostrPasteHandler$, nostrPasteHandler$1, etc.) + * so we match by prefix. Cast to remove `this` context requirement. + */ +function getPasteHandler(editor: Editor) { + const plugin = editor.state.plugins.find((p) => + (p as any).key?.startsWith("nostrPasteHandler$"), + ); + return plugin?.props?.handlePaste as + | ((view: any, event: any, slice: any) => boolean | void) + | undefined; +} + +/** Create a mock ClipboardEvent with text */ +function mockPasteEvent(text: string): ClipboardEvent { + return { + clipboardData: { + getData: (type: string) => (type === "text/plain" ? text : ""), + files: [], + }, + preventDefault: vi.fn(), + } as unknown as ClipboardEvent; +} + +describe("NostrPasteHandler", () => { + let editor: Editor; + + afterEach(() => { + editor?.destroy(); + }); + + describe("bech32 regex matching", () => { + it("should detect npub with nostr: prefix", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent(`nostr:${TEST_NPUB}`); + + const handled = handlePaste!(editor.view, event, null as any); + expect(handled).toBe(true); + }); + + it("should detect bare npub without nostr: prefix", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent(TEST_NPUB); + + const handled = handlePaste!(editor.view, event, null as any); + expect(handled).toBe(true); + }); + + it("should detect note", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent(TEST_NOTE); + + const handled = handlePaste!(editor.view, event, null as any); + expect(handled).toBe(true); + }); + + it("should detect nprofile", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent(TEST_NPROFILE); + + const handled = handlePaste!(editor.view, event, null as any); + expect(handled).toBe(true); + }); + + it("should detect nevent", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent(TEST_NEVENT); + + const handled = handlePaste!(editor.view, event, null as any); + expect(handled).toBe(true); + }); + + it("should detect naddr", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent(TEST_NADDR); + + const handled = handlePaste!(editor.view, event, null as any); + expect(handled).toBe(true); + }); + + it("should NOT match bech32 inside URLs", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + // bech32 inside a URL should not be matched (no whitespace boundary) + const event = mockPasteEvent(`https://njump.me/${TEST_NPUB}`); + + const handled = handlePaste!(editor.view, event, null as any); + expect(handled).toBe(false); + }); + + it("should pass through plain text without bech32", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent("just some regular text"); + + const handled = handlePaste!(editor.view, event, null as any); + expect(handled).toBe(false); + }); + + it("should pass through empty clipboard", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const event = mockPasteEvent(""); + + const handled = handlePaste!(editor.view, event, null as any); + expect(handled).toBe(false); + }); + }); + + describe("node creation", () => { + it("should create mention node for npub", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + handlePaste!(editor.view, mockPasteEvent(TEST_NPUB), null as any); + + let mentionNode: any = null; + editor.state.doc.descendants((node) => { + if (node.type.name === "mention") { + mentionNode = node; + return false; + } + }); + + expect(mentionNode).not.toBeNull(); + expect(mentionNode.attrs.id).toBe(TEST_PUBKEY); + }); + + it("should create mention node for nprofile", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + handlePaste!(editor.view, mockPasteEvent(TEST_NPROFILE), null as any); + + let mentionNode: any = null; + editor.state.doc.descendants((node) => { + if (node.type.name === "mention") { + mentionNode = node; + return false; + } + }); + + expect(mentionNode).not.toBeNull(); + expect(mentionNode.attrs.id).toBe(TEST_PUBKEY); + }); + + it("should create nostrEventPreview node for note", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + handlePaste!(editor.view, mockPasteEvent(TEST_NOTE), null as any); + + let previewNode: any = null; + editor.state.doc.descendants((node) => { + if (node.type.name === "nostrEventPreview") { + previewNode = node; + return false; + } + }); + + expect(previewNode).not.toBeNull(); + expect(previewNode.attrs.type).toBe("note"); + expect(previewNode.attrs.data).toBe(TEST_EVENT_ID); + }); + + it("should create nostrEventPreview node for nevent", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + handlePaste!(editor.view, mockPasteEvent(TEST_NEVENT), null as any); + + let previewNode: any = null; + editor.state.doc.descendants((node) => { + if (node.type.name === "nostrEventPreview") { + previewNode = node; + return false; + } + }); + + expect(previewNode).not.toBeNull(); + expect(previewNode.attrs.type).toBe("nevent"); + expect(previewNode.attrs.data).toHaveProperty("id", TEST_EVENT_ID); + }); + + it("should create nostrEventPreview node for naddr", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + handlePaste!(editor.view, mockPasteEvent(TEST_NADDR), null as any); + + let previewNode: any = null; + editor.state.doc.descendants((node) => { + if (node.type.name === "nostrEventPreview") { + previewNode = node; + return false; + } + }); + + expect(previewNode).not.toBeNull(); + expect(previewNode.attrs.type).toBe("naddr"); + expect(previewNode.attrs.data).toHaveProperty("kind", 30023); + expect(previewNode.attrs.data).toHaveProperty("pubkey", TEST_PUBKEY); + expect(previewNode.attrs.data).toHaveProperty( + "identifier", + "test-article", + ); + }); + }); + + describe("surrounding text preservation", () => { + it("should preserve text before bech32", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + handlePaste!( + editor.view, + mockPasteEvent(`check this out ${TEST_NOTE}`), + null as any, + ); + + // The editor should contain both text and the preview node + let hasText = false; + let hasPreview = false; + editor.state.doc.descendants((node) => { + if (node.isText && node.text?.includes("check this out")) { + hasText = true; + } + if (node.type.name === "nostrEventPreview") { + hasPreview = true; + } + }); + + expect(hasText).toBe(true); + expect(hasPreview).toBe(true); + }); + + it("should preserve text after bech32", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + handlePaste!( + editor.view, + mockPasteEvent(`${TEST_NOTE} is really cool`), + null as any, + ); + + let hasText = false; + let hasPreview = false; + editor.state.doc.descendants((node) => { + if (node.isText && node.text?.includes("is really cool")) { + hasText = true; + } + if (node.type.name === "nostrEventPreview") { + hasPreview = true; + } + }); + + expect(hasText).toBe(true); + expect(hasPreview).toBe(true); + }); + + it("should handle multiple bech32 entities in one paste", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + handlePaste!( + editor.view, + mockPasteEvent(`${TEST_NPUB} shared ${TEST_NOTE}`), + null as any, + ); + + let mentionCount = 0; + let previewCount = 0; + editor.state.doc.descendants((node) => { + if (node.type.name === "mention") mentionCount++; + if (node.type.name === "nostrEventPreview") previewCount++; + }); + + expect(mentionCount).toBe(1); + expect(previewCount).toBe(1); + }); + + it("should not introduce double spaces between entity and text", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + handlePaste!( + editor.view, + mockPasteEvent(`${TEST_NPUB} said hello`), + null as any, + ); + + // Collect all text content from the doc + const textNodes: string[] = []; + editor.state.doc.descendants((node) => { + if (node.isText) textNodes.push(node.text!); + }); + const fullText = textNodes.join(""); + + // Should not have double spaces + expect(fullText).not.toContain(" "); + expect(fullText).toContain("said hello"); + }); + + it("should not add trailing space when entity is followed by more text", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + handlePaste!( + editor.view, + mockPasteEvent(`${TEST_NPUB} is cool`), + null as any, + ); + + const textNodes: string[] = []; + editor.state.doc.descendants((node) => { + if (node.isText) textNodes.push(node.text!); + }); + const fullText = textNodes.join(""); + + // Should have exactly one space before "is cool" + expect(fullText).toMatch(/\sis cool$/); + expect(fullText).not.toMatch(/\s\sis cool$/); + }); + }); + + describe("punctuation handling", () => { + it("should match bech32 followed by comma", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const handled = handlePaste!( + editor.view, + mockPasteEvent(`${TEST_NPUB}, check this out`), + null as any, + ); + + expect(handled).toBe(true); + + let mentionCount = 0; + let hasComma = false; + editor.state.doc.descendants((node) => { + if (node.type.name === "mention") mentionCount++; + if (node.isText && node.text?.includes(",")) hasComma = true; + }); + expect(mentionCount).toBe(1); + expect(hasComma).toBe(true); + }); + + it("should match bech32 followed by period", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const handled = handlePaste!( + editor.view, + mockPasteEvent(`See ${TEST_NPUB}.`), + null as any, + ); + + expect(handled).toBe(true); + }); + + it("should match bech32 followed by exclamation mark", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const handled = handlePaste!( + editor.view, + mockPasteEvent(`Look at ${TEST_NPUB}!`), + null as any, + ); + + expect(handled).toBe(true); + }); + + it("should match bech32 followed by closing parenthesis", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const handled = handlePaste!( + editor.view, + mockPasteEvent(`(by ${TEST_NPUB})`), + null as any, + ); + + expect(handled).toBe(true); + }); + }); + + describe("malformed bech32", () => { + it("should fall back to plain text for invalid bech32", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + // npub1 followed by lowercase chars (matches regex) but invalid checksum + const fakeBech32 = + "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; + const handled = handlePaste!( + editor.view, + mockPasteEvent(fakeBech32), + null as any, + ); + + // Should match regex but fail decode — falls back to plain text insert + // or returns false if the catch path just inserts text + if (handled) { + // The invalid bech32 was inserted as plain text + let hasPlainText = false; + editor.state.doc.descendants((node) => { + if (node.isText && node.text?.includes("npub1")) { + hasPlainText = true; + } + }); + expect(hasPlainText).toBe(true); + } + // Either way, no mention node should be created + let mentionCount = 0; + editor.state.doc.descendants((node) => { + if (node.type.name === "mention") mentionCount++; + }); + expect(mentionCount).toBe(0); + }); + + it("should not match uppercase bech32", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + const handled = handlePaste!( + editor.view, + mockPasteEvent(TEST_NPUB.toUpperCase()), + null as any, + ); + + expect(handled).toBe(false); + }); + }); + + describe("error resilience", () => { + it("should not crash the editor on dispatch failure", () => { + editor = createEditor(); + const handlePaste = getPasteHandler(editor); + + // This should not throw, even if internal dispatch has issues + expect(() => { + handlePaste!(editor.view, mockPasteEvent(TEST_NPUB), null as any); + }).not.toThrow(); + }); + }); +}); diff --git a/src/components/editor/extensions/nostr-paste-handler.ts b/src/components/editor/extensions/nostr-paste-handler.ts index 34617b8..0d770e9 100644 --- a/src/components/editor/extensions/nostr-paste-handler.ts +++ b/src/components/editor/extensions/nostr-paste-handler.ts @@ -39,11 +39,12 @@ export const NostrPasteHandler = Extension.create({ if (!text) return false; // Regex to detect nostr bech32 strings (with or without nostr: prefix) - // Only match entities surrounded by whitespace or at string boundaries + // Only match entities surrounded by whitespace/punctuation or at string boundaries // to avoid matching entities within URLs (e.g., https://njump.me/npub1...) // Note: Using (^|\s) capture group instead of lookbehind for Safari compatibility + // Trailing lookahead allows common punctuation so "npub1..., cool" works const bech32Regex = - /(^|\s)(?:nostr:)?(npub1[\w]{58,}|note1[\w]{58,}|nevent1[\w]+|naddr1[\w]+|nprofile1[\w]+)(?=$|\s)/g; + /(^|\s)(?:nostr:)?(npub1[a-z0-9]{58,}|note1[a-z0-9]{58,}|nevent1[a-z0-9]+|naddr1[a-z0-9]+|nprofile1[a-z0-9]+)(?=$|\s|[.,!?;:)\]}>])/g; const matches = Array.from(text.matchAll(bech32Regex)); if (matches.length === 0) return false; // No bech32 found, use default paste @@ -113,8 +114,15 @@ export const NostrPasteHandler = Extension.create({ ); } - // Add space after preview node - nodes.push(view.state.schema.text(" ")); + // Add trailing space only when entity is at the very end of the paste + // (for cursor positioning). Don't add if there's more text coming, + // since the boundary whitespace handling already preserves spacing. + const isLastMatch = match === matches[matches.length - 1]; + const hasTrailingText = + matchIndex + fullMatch.length < text.length; + if (isLastMatch && !hasTrailingText) { + nodes.push(view.state.schema.text(" ")); + } } catch (err) { // Invalid bech32, insert as plain text (entity portion without boundary) console.warn( @@ -139,21 +147,31 @@ export const NostrPasteHandler = Extension.create({ // Insert all nodes at cursor position if (nodes.length > 0) { - const { tr } = view.state; - const { from } = view.state.selection; + try { + const { tr } = view.state; + const { from } = view.state.selection; - // Insert content and track position - let insertPos = from; - nodes.forEach((node) => { - tr.insert(insertPos, node); - insertPos += node.nodeSize; - }); + // Insert content and track position + let insertPos = from; + nodes.forEach((node) => { + tr.insert(insertPos, node); + insertPos += node.nodeSize; + }); - // Move cursor to end of inserted content - tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos))); + // Move cursor to end of inserted content + tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos))); - view.dispatch(tr); - return true; // Prevent default paste + view.dispatch(tr); + return true; // Prevent default paste + } catch (err) { + // If insertion fails (e.g., block node at inline position), + // fall through to default paste behavior + console.warn( + "[NostrPasteHandler] Failed to insert nodes:", + err, + ); + return false; + } } return false; diff --git a/src/components/editor/extensions/submit-shortcut.test.ts b/src/components/editor/extensions/submit-shortcut.test.ts new file mode 100644 index 0000000..584d9b5 --- /dev/null +++ b/src/components/editor/extensions/submit-shortcut.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import { SubmitShortcut } from "./submit-shortcut"; + +beforeAll(() => { + const rect = { + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => ({}), + }; + HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getClientRects = (() => []) as any; + document.elementFromPoint = (() => null) as any; +}); + +describe("SubmitShortcut", () => { + let editor: Editor; + + afterEach(() => { + editor?.destroy(); + }); + + describe("Mod-Enter (always submits)", () => { + it("should call submit handler on Mod-Enter", () => { + const submitFn = vi.fn(); + const submitRef = { current: submitFn }; + + editor = new Editor({ + extensions: [ + StarterKit, + SubmitShortcut.configure({ + submitRef, + enterSubmits: false, + }), + ], + content: "

Hello

", + }); + + editor.commands.keyboardShortcut("Mod-Enter"); + expect(submitFn).toHaveBeenCalledTimes(1); + expect(submitFn).toHaveBeenCalledWith(editor); + }); + + it("should call submit handler on Mod-Enter even when enterSubmits is true", () => { + const submitFn = vi.fn(); + const submitRef = { current: submitFn }; + + editor = new Editor({ + extensions: [ + StarterKit, + SubmitShortcut.configure({ + submitRef, + enterSubmits: true, + }), + ], + content: "

Hello

", + }); + + editor.commands.keyboardShortcut("Mod-Enter"); + expect(submitFn).toHaveBeenCalledTimes(1); + }); + }); + + describe("Enter behavior with enterSubmits: true", () => { + it("should call submit handler on Enter", () => { + const submitFn = vi.fn(); + const submitRef = { current: submitFn }; + + editor = new Editor({ + extensions: [ + StarterKit, + SubmitShortcut.configure({ + submitRef, + enterSubmits: true, + }), + ], + content: "

Hello

", + }); + + editor.commands.keyboardShortcut("Enter"); + expect(submitFn).toHaveBeenCalledTimes(1); + expect(submitFn).toHaveBeenCalledWith(editor); + }); + }); + + describe("Enter behavior with enterSubmits: false", () => { + it("should NOT call submit handler on Enter", () => { + const submitFn = vi.fn(); + const submitRef = { current: submitFn }; + + editor = new Editor({ + extensions: [ + StarterKit, + SubmitShortcut.configure({ + submitRef, + enterSubmits: false, + }), + ], + content: "

Hello

", + }); + + editor.commands.keyboardShortcut("Enter"); + expect(submitFn).not.toHaveBeenCalled(); + }); + }); + + describe("ref update", () => { + it("should use the ref value that was current at configure time", () => { + const submitFn = vi.fn(); + const submitRef = { current: submitFn }; + + editor = new Editor({ + extensions: [ + StarterKit, + SubmitShortcut.configure({ + submitRef, + enterSubmits: false, + }), + ], + content: "

Hello

", + }); + + // Trigger shortcut - should call the function from configure time + editor.commands.keyboardShortcut("Mod-Enter"); + expect(submitFn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/editor/extensions/submit-shortcut.ts b/src/components/editor/extensions/submit-shortcut.ts new file mode 100644 index 0000000..19356e3 --- /dev/null +++ b/src/components/editor/extensions/submit-shortcut.ts @@ -0,0 +1,49 @@ +import { Extension } from "@tiptap/core"; +import type { MutableRefObject } from "react"; +import type { Editor } from "@tiptap/core"; + +interface SubmitShortcutOptions { + /** Ref to the submit handler (uses ref to avoid stale closures) */ + submitRef: MutableRefObject<(editor: Editor) => void>; + /** If true, plain Enter submits (desktop chat). If false, Enter creates newline (rich editor / mobile). */ + enterSubmits: boolean; +} + +/** + * Keyboard shortcut extension for editor submission + * + * - Ctrl/Cmd+Enter always submits + * - Plain Enter behavior depends on `enterSubmits` option: + * - true (desktop chat): Enter submits, Shift+Enter inserts newline + * - false (rich editor / mobile): Enter creates newline normally + */ +export const SubmitShortcut = Extension.create({ + name: "submitShortcut", + + addOptions() { + return { + submitRef: { current: () => {} } as MutableRefObject< + (editor: Editor) => void + >, + enterSubmits: false, + }; + }, + + addKeyboardShortcuts() { + const shortcuts: Record boolean> = { + "Mod-Enter": () => { + this.options.submitRef.current(this.editor); + return true; + }, + }; + + if (this.options.enterSubmits) { + shortcuts["Enter"] = () => { + this.options.submitRef.current(this.editor); + return true; + }; + } + + return shortcuts; + }, +}); diff --git a/src/components/editor/hooks/useSuggestionRenderer.tsx b/src/components/editor/hooks/useSuggestionRenderer.tsx new file mode 100644 index 0000000..c0a9ab0 --- /dev/null +++ b/src/components/editor/hooks/useSuggestionRenderer.tsx @@ -0,0 +1,123 @@ +import { + useState, + useCallback, + useRef, + type ReactElement, + type ComponentType, + type RefObject, +} from "react"; +import type { + SuggestionOptions, + SuggestionKeyDownProps, +} from "@tiptap/suggestion"; +import { SuggestionPopover } from "../SuggestionPopover"; + +/** Handle interface that suggestion list components must expose via forwardRef */ +export interface SuggestionListHandle { + onKeyDown: (event: KeyboardEvent) => boolean; +} + +/** Props that suggestion list components receive */ +export interface SuggestionListProps { + items: T[]; + command: (item: T) => void; + onClose?: () => void; +} + +interface SuggestionState { + items: T[]; + command: (item: T) => void; + clientRect: (() => DOMRect | null) | null; +} + +interface UseSuggestionRendererOptions { + /** Floating-ui placement for the popover */ + placement?: "bottom-start" | "top-start"; + /** Called when Ctrl/Cmd+Enter is pressed while suggestion is open */ + onModEnter?: () => void; +} + +/** + * Hook that bridges Tiptap's suggestion render callbacks to React state + * + * Returns: + * - `render`: A stable function compatible with Tiptap's suggestion.render option + * - `portal`: A ReactElement to include in the component tree (renders via portal) + * + * The render function is stable (never changes reference) so it's safe to use + * as a useMemo dependency for extension configuration. + */ +export function useSuggestionRenderer( + Component: ComponentType< + SuggestionListProps & { ref?: RefObject } + >, + options?: UseSuggestionRendererOptions, +): { + render: () => ReturnType>; + portal: ReactElement | null; +} { + const [state, setState] = useState | null>(null); + const componentRef = useRef(null); + const onModEnterRef = useRef(options?.onModEnter); + onModEnterRef.current = options?.onModEnter; + + // Stable render factory — uses setState which is guaranteed stable by React + const render = useCallback( + (): ReturnType> => ({ + onStart: (props) => { + setState({ + items: props.items as T[], + command: props.command as (item: T) => void, + clientRect: props.clientRect as (() => DOMRect | null) | null, + }); + }, + + onUpdate: (props) => { + setState({ + items: props.items as T[], + command: props.command as (item: T) => void, + clientRect: props.clientRect as (() => DOMRect | null) | null, + }); + }, + + onKeyDown: (props: SuggestionKeyDownProps) => { + if (props.event.key === "Escape") { + setState(null); + return true; + } + + // Ctrl/Cmd+Enter submits the message even when suggestion is open + if ( + props.event.key === "Enter" && + (props.event.ctrlKey || props.event.metaKey) + ) { + setState(null); + onModEnterRef.current?.(); + return true; + } + + return componentRef.current?.onKeyDown(props.event) ?? false; + }, + + onExit: () => { + setState(null); + }, + }), + [], + ); + + const placement = options?.placement ?? "bottom-start"; + + const portal = state ? ( + + setState(null)} + /> + + ) : null; + + return { render, portal }; +} diff --git a/src/components/editor/node-views/BlobAttachmentInline.tsx b/src/components/editor/node-views/BlobAttachmentInline.tsx new file mode 100644 index 0000000..6d5ab26 --- /dev/null +++ b/src/components/editor/node-views/BlobAttachmentInline.tsx @@ -0,0 +1,57 @@ +import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react"; +import { formatBlobSize } from "../utils/serialize"; + +/** + * Inline badge-style node view for blob attachments (used in MentionEditor) + * + * Shows a compact badge with media type icon, label, and size. + * Replaces direct DOM manipulation with a React component. + */ +export function BlobAttachmentInline({ node }: ReactNodeViewProps) { + const { url, mimeType, size } = node.attrs as { + url: string; + sha256: string; + mimeType: string | null; + size: number | null; + server: string | null; + }; + + const isImage = mimeType?.startsWith("image/"); + const isVideo = mimeType?.startsWith("video/"); + const isAudio = mimeType?.startsWith("audio/"); + + const typeLabel = isImage + ? "image" + : isVideo + ? "video" + : isAudio + ? "audio" + : "file"; + + return ( + + {isImage && url ? ( + attachment + ) : ( + + {isVideo ? "\uD83C\uDFAC" : isAudio ? "\uD83C\uDFB5" : "\uD83D\uDCCE"} + + )} + + {typeLabel} + + {size != null && ( + {formatBlobSize(size)} + )} + + ); +} diff --git a/src/components/editor/node-views/EmojiNodeView.tsx b/src/components/editor/node-views/EmojiNodeView.tsx new file mode 100644 index 0000000..cb78527 --- /dev/null +++ b/src/components/editor/node-views/EmojiNodeView.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react"; + +/** + * React node view for emoji (both unicode and custom NIP-30 emoji) + * + * Replaces direct DOM manipulation with a React component. + * Renders unicode emoji as text spans and custom emoji as images with error fallback. + */ +export function EmojiNodeView({ node }: ReactNodeViewProps) { + const { url, source, id } = node.attrs as { + url: string | null; + source: string | null; + id: string; + }; + const isUnicode = source === "unicode"; + const [imgError, setImgError] = useState(false); + + // Fallback to shortcode text + if (imgError || (!isUnicode && !url)) { + return ( + + {`:${id}:`} + + ); + } + + if (isUnicode && url) { + return ( + + + {url} + + + ); + } + + // Custom emoji with image + return ( + + {`:${id}:`} setImgError(true)} + /> + + ); +} diff --git a/src/components/editor/node-views/NostrEventPreviewInline.tsx b/src/components/editor/node-views/NostrEventPreviewInline.tsx new file mode 100644 index 0000000..ddd504c --- /dev/null +++ b/src/components/editor/node-views/NostrEventPreviewInline.tsx @@ -0,0 +1,42 @@ +import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react"; + +/** + * Inline badge-style node view for Nostr event previews (used in MentionEditor) + * + * Shows a compact badge with event type and truncated identifier. + * Replaces direct DOM manipulation with a React component. + */ +export function NostrEventPreviewInline({ node }: ReactNodeViewProps) { + const { type, data } = node.attrs as { + type: "note" | "nevent" | "naddr"; + data: any; + }; + + let typeLabel: string; + let contentLabel: string; + + if (type === "note" || type === "nevent") { + typeLabel = "event"; + contentLabel = + type === "note" ? data?.slice(0, 8) : data?.id?.slice(0, 8) || ""; + } else if (type === "naddr") { + typeLabel = "address"; + contentLabel = data?.identifier || data?.pubkey?.slice(0, 8) || ""; + } else { + typeLabel = "ref"; + contentLabel = ""; + } + + return ( + + {typeLabel} + + {contentLabel} + + + ); +} diff --git a/src/components/editor/types.ts b/src/components/editor/types.ts new file mode 100644 index 0000000..422c190 --- /dev/null +++ b/src/components/editor/types.ts @@ -0,0 +1,45 @@ +/** + * Shared types for editor components + */ + +/** + * Represents an emoji tag for NIP-30 + */ +export interface EmojiTag { + shortcode: string; + url: string; + /** NIP-30 optional 4th tag: "30030:pubkey:identifier" address of the emoji set */ + address?: 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 + * Note: mentions, event quotes, and hashtags are extracted automatically by applesauce + * from the text content (nostr: URIs and #hashtags), so we don't need to extract them here. + */ +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[]; + /** Referenced addresses for a tags (from naddr - not yet handled by applesauce) */ + addressRefs: Array<{ kind: number; pubkey: string; identifier: string }>; +} diff --git a/src/components/editor/utils/serialize.test.ts b/src/components/editor/utils/serialize.test.ts new file mode 100644 index 0000000..9781d34 --- /dev/null +++ b/src/components/editor/utils/serialize.test.ts @@ -0,0 +1,663 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { Editor } from "@tiptap/core"; +import StarterKit from "@tiptap/starter-kit"; +import Mention from "@tiptap/extension-mention"; +import { nip19 } from "nostr-tools"; + +// Mock React node views +vi.mock("@tiptap/react", () => ({ + ReactNodeViewRenderer: () => () => null, +})); +vi.mock("../node-views/EmojiNodeView", () => ({ EmojiNodeView: {} })); +vi.mock("../node-views/BlobAttachmentRich", () => ({ + BlobAttachmentRich: {}, +})); +vi.mock("../node-views/BlobAttachmentInline", () => ({ + BlobAttachmentInline: {}, +})); +vi.mock("../node-views/NostrEventPreviewRich", () => ({ + NostrEventPreviewRich: {}, +})); +vi.mock("../node-views/NostrEventPreviewInline", () => ({ + NostrEventPreviewInline: {}, +})); + +import { EmojiMention } from "../extensions/emoji"; +import { BlobAttachmentRichNode } from "../extensions/blob-attachment-rich"; +import { BlobAttachmentInlineNode } from "../extensions/blob-attachment-inline"; +import { NostrEventPreviewRichNode } from "../extensions/nostr-event-preview-rich"; +import { NostrEventPreviewInlineNode } from "../extensions/nostr-event-preview-inline"; +import { + serializeRichContent, + serializeInlineContent, + formatBlobSize, +} from "./serialize"; + +beforeAll(() => { + const rect = { + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => ({}), + }; + HTMLElement.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getBoundingClientRect = () => rect as DOMRect; + Range.prototype.getClientRects = (() => []) as any; + document.elementFromPoint = (() => null) as any; +}); + +// Test data +const TEST_PUBKEY = + "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; +const TEST_EVENT_ID = + "d7a9c9f8e7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0e9d8c7b6a5d4c3b2a1f0"; + +/** Create a rich editor (block-level attachments, NostrEventPreview, emoji) */ +function createRichEditor(content?: string) { + return new Editor({ + extensions: [ + StarterKit, + Mention.configure({ suggestion: { char: "@" } }), + EmojiMention.configure({ suggestion: { char: ":" } }), + BlobAttachmentRichNode, + NostrEventPreviewRichNode, + ], + content, + }); +} + +/** Create an inline editor (inline attachments, NostrEventPreview, emoji) */ +function createInlineEditor(content?: string) { + return new Editor({ + extensions: [ + StarterKit, + Mention.configure({ suggestion: { char: "@" } }), + EmojiMention.configure({ suggestion: { char: ":" } }), + BlobAttachmentInlineNode, + NostrEventPreviewInlineNode, + ], + content, + }); +} + +describe("formatBlobSize", () => { + it("should format bytes", () => { + expect(formatBlobSize(100)).toBe("100B"); + expect(formatBlobSize(0)).toBe("0B"); + expect(formatBlobSize(1023)).toBe("1023B"); + }); + + it("should format kilobytes", () => { + expect(formatBlobSize(1024)).toBe("1KB"); + expect(formatBlobSize(2048)).toBe("2KB"); + expect(formatBlobSize(512 * 1024)).toBe("512KB"); + }); + + it("should format megabytes", () => { + expect(formatBlobSize(1024 * 1024)).toBe("1.0MB"); + expect(formatBlobSize(1.5 * 1024 * 1024)).toBe("1.5MB"); + expect(formatBlobSize(10 * 1024 * 1024)).toBe("10.0MB"); + }); + + it("should handle boundary values", () => { + // Just under 1KB + expect(formatBlobSize(1023)).toBe("1023B"); + // Exactly 1KB + expect(formatBlobSize(1024)).toBe("1KB"); + // Just under 1MB + expect(formatBlobSize(1024 * 1024 - 1)).toBe("1024KB"); + // Exactly 1MB + expect(formatBlobSize(1024 * 1024)).toBe("1.0MB"); + }); + + it("should handle zero", () => { + expect(formatBlobSize(0)).toBe("0B"); + }); + + it("should handle very large values (no GB formatting)", () => { + // 1 GB displayed as MB + expect(formatBlobSize(1024 * 1024 * 1024)).toBe("1024.0MB"); + }); +}); + +describe("serializeRichContent", () => { + let editor: Editor; + + afterEach(() => { + editor?.destroy(); + }); + + describe("text extraction", () => { + it("should extract plain text", () => { + editor = createRichEditor("

Hello world

"); + const result = serializeRichContent(editor); + expect(result.text).toBe("Hello world"); + }); + + it("should use single newline between blocks", () => { + editor = createRichEditor("

Line 1

Line 2

"); + const result = serializeRichContent(editor); + expect(result.text).toBe("Line 1\nLine 2"); + }); + }); + + describe("emoji extraction", () => { + it("should collect custom emoji tags", () => { + editor = createRichEditor(); + editor.commands.insertContent([ + { type: "text", text: "Hello " }, + { + type: "emoji", + attrs: { + id: "pepe", + url: "https://cdn.example.com/pepe.png", + source: "custom", + mentionSuggestionChar: ":", + }, + }, + ]); + + const result = serializeRichContent(editor); + expect(result.emojiTags).toHaveLength(1); + expect(result.emojiTags[0]).toEqual({ + shortcode: "pepe", + url: "https://cdn.example.com/pepe.png", + }); + }); + + it("should NOT collect unicode emoji tags", () => { + editor = createRichEditor(); + editor.commands.insertContent({ + type: "emoji", + attrs: { + id: "fire", + url: "🔥", + source: "unicode", + mentionSuggestionChar: ":", + }, + }); + + const result = serializeRichContent(editor); + expect(result.emojiTags).toHaveLength(0); + }); + + it("should deduplicate emoji tags", () => { + editor = createRichEditor(); + editor.commands.insertContent([ + { + type: "emoji", + attrs: { + id: "pepe", + url: "https://cdn.example.com/pepe.png", + source: "custom", + mentionSuggestionChar: ":", + }, + }, + { type: "text", text: " " }, + { + type: "emoji", + attrs: { + id: "pepe", + url: "https://cdn.example.com/pepe.png", + source: "custom", + mentionSuggestionChar: ":", + }, + }, + ]); + + const result = serializeRichContent(editor); + expect(result.emojiTags).toHaveLength(1); + }); + }); + + describe("blob attachment extraction", () => { + it("should collect blob attachments", () => { + editor = createRichEditor(); + editor.commands.insertContent({ + type: "blobAttachment", + attrs: { + url: "https://cdn.example.com/image.png", + sha256: "abc123", + mimeType: "image/png", + size: 1024, + server: "https://blossom.example.com", + }, + }); + + const result = serializeRichContent(editor); + expect(result.blobAttachments).toHaveLength(1); + expect(result.blobAttachments[0]).toEqual({ + url: "https://cdn.example.com/image.png", + sha256: "abc123", + mimeType: "image/png", + size: 1024, + server: "https://blossom.example.com", + }); + }); + + it("should deduplicate blob attachments by sha256", () => { + editor = createRichEditor(); + editor.commands.insertContent([ + { + type: "paragraph", + content: [{ type: "text", text: "First" }], + }, + { + type: "blobAttachment", + attrs: { + url: "https://cdn.example.com/image.png", + sha256: "abc123", + mimeType: "image/png", + size: 1024, + }, + }, + { + type: "paragraph", + content: [{ type: "text", text: "Second" }], + }, + { + type: "blobAttachment", + attrs: { + url: "https://cdn.example.com/image.png", + sha256: "abc123", + mimeType: "image/png", + size: 1024, + }, + }, + ]); + + const result = serializeRichContent(editor); + expect(result.blobAttachments).toHaveLength(1); + }); + }); + + describe("address reference extraction", () => { + it("should collect naddr references", () => { + editor = createRichEditor(); + editor.commands.insertContent({ + type: "nostrEventPreview", + attrs: { + type: "naddr", + data: { + kind: 30023, + pubkey: TEST_PUBKEY, + identifier: "my-article", + }, + }, + }); + + const result = serializeRichContent(editor); + expect(result.addressRefs).toHaveLength(1); + expect(result.addressRefs[0]).toEqual({ + kind: 30023, + pubkey: TEST_PUBKEY, + identifier: "my-article", + }); + }); + + it("should NOT collect note or nevent as address refs", () => { + editor = createRichEditor(); + editor.commands.insertContent([ + { + type: "paragraph", + content: [{ type: "text", text: "Before" }], + }, + { + type: "nostrEventPreview", + attrs: { type: "note", data: TEST_EVENT_ID }, + }, + { + type: "paragraph", + content: [{ type: "text", text: "After" }], + }, + { + type: "nostrEventPreview", + attrs: { + type: "nevent", + data: { id: TEST_EVENT_ID }, + }, + }, + ]); + + const result = serializeRichContent(editor); + expect(result.addressRefs).toHaveLength(0); + }); + }); + + describe("edge cases", () => { + it("should return empty result for empty editor", () => { + editor = createRichEditor(); + const result = serializeRichContent(editor); + expect(result.text).toBe(""); + expect(result.emojiTags).toHaveLength(0); + expect(result.blobAttachments).toHaveLength(0); + expect(result.addressRefs).toHaveLength(0); + }); + + it("should NOT collect blob attachments with null sha256", () => { + editor = createRichEditor(); + editor.commands.insertContent({ + type: "blobAttachment", + attrs: { + url: "https://cdn.example.com/image.png", + sha256: null, + mimeType: "image/png", + size: 1024, + }, + }); + + const result = serializeRichContent(editor); + expect(result.blobAttachments).toHaveLength(0); + }); + + it("should NOT collect blob attachments with null url", () => { + editor = createRichEditor(); + editor.commands.insertContent({ + type: "blobAttachment", + attrs: { + url: null, + sha256: "abc123", + mimeType: "image/png", + size: 1024, + }, + }); + + const result = serializeRichContent(editor); + expect(result.blobAttachments).toHaveLength(0); + }); + }); +}); + +describe("serializeInlineContent", () => { + let editor: Editor; + + afterEach(() => { + editor?.destroy(); + }); + + describe("text extraction", () => { + it("should extract plain text", () => { + editor = createInlineEditor("

Hello world

"); + const result = serializeInlineContent(editor); + expect(result.text).toBe("Hello world"); + }); + }); + + describe("mention serialization", () => { + it("should serialize mentions as nostr: URIs", () => { + editor = createInlineEditor(); + editor.commands.insertContent([ + { type: "text", text: "Hello " }, + { + type: "mention", + attrs: { id: TEST_PUBKEY, label: "alice" }, + }, + ]); + + const result = serializeInlineContent(editor); + const expectedNpub = nip19.npubEncode(TEST_PUBKEY); + expect(result.text).toContain(`nostr:${expectedNpub}`); + }); + }); + + describe("emoji serialization", () => { + it("should serialize unicode emoji as character", () => { + editor = createInlineEditor(); + editor.commands.insertContent([ + { type: "text", text: "Hello " }, + { + type: "emoji", + attrs: { + id: "fire", + url: "🔥", + source: "unicode", + mentionSuggestionChar: ":", + }, + }, + ]); + + const result = serializeInlineContent(editor); + expect(result.text).toContain("🔥"); + expect(result.emojiTags).toHaveLength(0); + }); + + it("should serialize custom emoji as :shortcode: and collect tags", () => { + editor = createInlineEditor(); + editor.commands.insertContent([ + { type: "text", text: "Hello " }, + { + type: "emoji", + attrs: { + id: "pepe", + url: "https://cdn.example.com/pepe.png", + source: "custom", + mentionSuggestionChar: ":", + }, + }, + ]); + + const result = serializeInlineContent(editor); + expect(result.text).toContain(":pepe:"); + expect(result.emojiTags).toHaveLength(1); + expect(result.emojiTags[0]).toEqual({ + shortcode: "pepe", + url: "https://cdn.example.com/pepe.png", + }); + }); + }); + + describe("blob attachment serialization", () => { + it("should serialize blob attachments as URLs", () => { + editor = createInlineEditor(); + editor.commands.insertContent({ + type: "blobAttachment", + attrs: { + url: "https://cdn.example.com/image.png", + sha256: "abc123", + mimeType: "image/png", + size: 1024, + }, + }); + + const result = serializeInlineContent(editor); + expect(result.text).toContain("https://cdn.example.com/image.png"); + expect(result.blobAttachments).toHaveLength(1); + expect(result.blobAttachments[0].sha256).toBe("abc123"); + }); + }); + + describe("nostr event preview serialization", () => { + it("should serialize note as nostr: URI", () => { + editor = createInlineEditor(); + editor.commands.insertContent({ + type: "nostrEventPreview", + attrs: { type: "note", data: TEST_EVENT_ID }, + }); + + const result = serializeInlineContent(editor); + const expectedNote = nip19.noteEncode(TEST_EVENT_ID); + expect(result.text).toContain(`nostr:${expectedNote}`); + }); + + it("should serialize nevent as nostr: URI", () => { + editor = createInlineEditor(); + editor.commands.insertContent({ + type: "nostrEventPreview", + attrs: { type: "nevent", data: { id: TEST_EVENT_ID } }, + }); + + const result = serializeInlineContent(editor); + const expectedNevent = nip19.neventEncode({ id: TEST_EVENT_ID }); + expect(result.text).toContain(`nostr:${expectedNevent}`); + }); + + it("should serialize naddr as nostr: URI and collect address ref", () => { + editor = createInlineEditor(); + editor.commands.insertContent({ + type: "nostrEventPreview", + attrs: { + type: "naddr", + data: { + kind: 30023, + pubkey: TEST_PUBKEY, + identifier: "my-article", + }, + }, + }); + + const result = serializeInlineContent(editor); + const expectedNaddr = nip19.naddrEncode({ + kind: 30023, + pubkey: TEST_PUBKEY, + identifier: "my-article", + }); + expect(result.text).toContain(`nostr:${expectedNaddr}`); + expect(result.addressRefs).toHaveLength(1); + }); + }); + + describe("edge cases", () => { + it("should return empty result for empty editor", () => { + editor = createInlineEditor(); + const result = serializeInlineContent(editor); + expect(result.text).toBe(""); + expect(result.emojiTags).toHaveLength(0); + expect(result.blobAttachments).toHaveLength(0); + expect(result.addressRefs).toHaveLength(0); + }); + + it("should fall back to @label for invalid pubkey in mention", () => { + editor = createInlineEditor(); + editor.commands.insertContent([ + { type: "text", text: "Hello " }, + { + type: "mention", + attrs: { id: "not-a-valid-hex-pubkey", label: "broken" }, + }, + ]); + + const result = serializeInlineContent(editor); + expect(result.text).toContain("@broken"); + }); + + it("should handle mention with missing pubkey", () => { + editor = createInlineEditor(); + editor.commands.insertContent([ + { type: "text", text: "Hello " }, + { + type: "mention", + attrs: { id: null, label: "ghost" }, + }, + ]); + + const result = serializeInlineContent(editor); + // Mention with null id should be silently dropped + expect(result.text).not.toContain("nostr:"); + expect(result.text).not.toContain("@ghost"); + }); + + it("should handle blob attachment without sha256 (emit URL but not collect)", () => { + editor = createInlineEditor(); + editor.commands.insertContent({ + type: "blobAttachment", + attrs: { + url: "https://cdn.example.com/image.png", + sha256: null, + mimeType: "image/png", + }, + }); + + const result = serializeInlineContent(editor); + expect(result.text).toContain("https://cdn.example.com/image.png"); + expect(result.blobAttachments).toHaveLength(0); + }); + + it("should handle blob attachment without url (skip entirely)", () => { + editor = createInlineEditor(); + editor.commands.insertContent({ + type: "blobAttachment", + attrs: { url: null, sha256: "abc123" }, + }); + + const result = serializeInlineContent(editor); + expect(result.text.trim()).toBe(""); + expect(result.blobAttachments).toHaveLength(0); + }); + + it("should deduplicate inline emoji tags", () => { + editor = createInlineEditor(); + editor.commands.insertContent([ + { + type: "emoji", + attrs: { + id: "pepe", + url: "https://cdn.example.com/pepe.png", + source: "custom", + mentionSuggestionChar: ":", + }, + }, + { type: "text", text: " " }, + { + type: "emoji", + attrs: { + id: "pepe", + url: "https://cdn.example.com/pepe.png", + source: "custom", + mentionSuggestionChar: ":", + }, + }, + ]); + + const result = serializeInlineContent(editor); + expect(result.emojiTags).toHaveLength(1); + }); + + it("should handle multiple paragraphs", () => { + editor = createInlineEditor("

Line 1

Line 2

Line 3

"); + const result = serializeInlineContent(editor); + expect(result.text).toBe("Line 1\nLine 2\nLine 3"); + }); + + it("should handle empty paragraphs", () => { + editor = createInlineEditor("

Before

After

"); + const result = serializeInlineContent(editor); + expect(result.text).toContain("Before"); + expect(result.text).toContain("After"); + }); + }); + + describe("combined content", () => { + it("should handle mixed content correctly", () => { + editor = createInlineEditor(); + editor.commands.insertContent([ + { type: "text", text: "Hello " }, + { + type: "mention", + attrs: { id: TEST_PUBKEY, label: "alice" }, + }, + { type: "text", text: " check this " }, + { + type: "emoji", + attrs: { + id: "fire", + url: "🔥", + source: "unicode", + mentionSuggestionChar: ":", + }, + }, + ]); + + const result = serializeInlineContent(editor); + expect(result.text).toContain("Hello"); + expect(result.text).toContain(`nostr:${nip19.npubEncode(TEST_PUBKEY)}`); + expect(result.text).toContain("check this"); + expect(result.text).toContain("🔥"); + }); + }); +}); diff --git a/src/components/editor/utils/serialize.ts b/src/components/editor/utils/serialize.ts new file mode 100644 index 0000000..d0cfbf4 --- /dev/null +++ b/src/components/editor/utils/serialize.ts @@ -0,0 +1,173 @@ +import { nip19 } from "nostr-tools"; +import type { Editor } from "@tiptap/core"; +import type { EmojiTag, BlobAttachment, SerializedContent } from "../types"; + +/** + * Serialize RichEditor content to plain text with nostr: URIs + * + * Walks the ProseMirror document tree to extract emoji tags, blob attachments, + * and address references. Mentions, event quotes, and hashtags are extracted + * automatically by applesauce from the text content. + */ +export function serializeRichContent(editor: Editor): SerializedContent { + const emojiTags: EmojiTag[] = []; + const blobAttachments: BlobAttachment[] = []; + const addressRefs: Array<{ + kind: number; + pubkey: string; + identifier: string; + }> = []; + const seenEmojis = new Set(); + const seenBlobs = new Set(); + const seenAddrs = new Set(); + + // Get plain text representation with single newline between blocks + // (TipTap's default is double newline which adds extra blank lines) + const text = editor.getText({ blockSeparator: "\n" }); + + // Walk the document to collect emoji, blob, and address reference data + editor.state.doc.descendants((node) => { + if (node.type.name === "emoji") { + const { id, url, source, address } = node.attrs; + // Only add custom emojis (not unicode) and avoid duplicates + if (source !== "unicode" && !seenEmojis.has(id)) { + seenEmojis.add(id); + emojiTags.push({ shortcode: id, url, address: address ?? undefined }); + } + } else if (node.type.name === "blobAttachment") { + const { url, sha256, mimeType, size, server } = node.attrs; + if (url && sha256 && !seenBlobs.has(sha256)) { + seenBlobs.add(sha256); + blobAttachments.push({ url, sha256, mimeType, size, server }); + } + } else if (node.type.name === "nostrEventPreview") { + const { type, data } = node.attrs; + if (type === "naddr" && data) { + const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`; + if (!seenAddrs.has(addrKey)) { + seenAddrs.add(addrKey); + addressRefs.push({ + kind: data.kind, + pubkey: data.pubkey, + identifier: data.identifier || "", + }); + } + } + } + }); + + return { text, emojiTags, blobAttachments, addressRefs }; +} + +/** + * Serialize MentionEditor content by walking the JSON structure + * + * MentionEditor uses inline nodes (not block-level), so we walk the JSON + * to reconstruct text with nostr: URIs for mentions and :shortcode: for custom emoji. + */ +export function serializeInlineContent(editor: Editor): SerializedContent { + let text = ""; + const emojiTags: EmojiTag[] = []; + const blobAttachments: BlobAttachment[] = []; + const addressRefs: Array<{ + kind: number; + pubkey: string; + identifier: string; + }> = []; + const seenEmojis = new Set(); + const seenBlobs = new Set(); + const seenAddrs = new Set(); + const json = editor.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") { + text += "\n"; + } else if (child.type === "mention") { + const pubkey = child.attrs?.id; + if (pubkey) { + try { + const npub = nip19.npubEncode(pubkey); + text += `nostr:${npub}`; + } catch { + 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; + const address = child.attrs?.address; + + if (source === "unicode" && url) { + text += url; + } else if (shortcode) { + text += `:${shortcode}:`; + if (url && !seenEmojis.has(shortcode)) { + seenEmojis.add(shortcode); + emojiTags.push({ shortcode, url, address: address ?? undefined }); + } + } + } else if (child.type === "blobAttachment") { + const { url, sha256, mimeType, size, server } = child.attrs; + if (url) { + text += url; + if (sha256 && !seenBlobs.has(sha256)) { + seenBlobs.add(sha256); + blobAttachments.push({ + url, + sha256, + mimeType: mimeType || undefined, + size: size || undefined, + server: server || undefined, + }); + } + } + } else if (child.type === "nostrEventPreview") { + const { type, data } = child.attrs; + try { + if (type === "note") { + text += `nostr:${nip19.noteEncode(data)}`; + } else if (type === "nevent") { + text += `nostr:${nip19.neventEncode(data)}`; + } else if (type === "naddr") { + text += `nostr:${nip19.naddrEncode(data)}`; + const addrKey = `${data.kind}:${data.pubkey}:${data.identifier || ""}`; + if (!seenAddrs.has(addrKey)) { + seenAddrs.add(addrKey); + addressRefs.push({ + kind: data.kind, + pubkey: data.pubkey, + identifier: data.identifier || "", + }); + } + } + } catch (err) { + console.error( + "[serializeInlineContent] Failed to serialize nostr preview:", + err, + ); + } + } + }); + text += "\n"; + } + }); + + return { + text: text.trim(), + emojiTags, + blobAttachments, + addressRefs, + }; +} + +/** Format byte size to human-readable string */ +export 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`; +} diff --git a/src/hooks/useEmojiSearch.ts b/src/hooks/useEmojiSearch.ts index 5481671..e5edfee 100644 --- a/src/hooks/useEmojiSearch.ts +++ b/src/hooks/useEmojiSearch.ts @@ -102,7 +102,7 @@ export function useEmojiSearch(contextEvent?: NostrEvent) { const searchEmojis = useMemo( () => async (query: string): Promise => { - return await service.search(query, { limit: 24 }); + return await service.search(query, { limit: 200 }); }, [service], ); diff --git a/src/hooks/useProfileSearch.ts b/src/hooks/useProfileSearch.ts index 48a5b1b..1c1814d 100644 --- a/src/hooks/useProfileSearch.ts +++ b/src/hooks/useProfileSearch.ts @@ -13,7 +13,7 @@ export function useProfileSearch() { const searchProfiles = useMemo( () => async (query: string): Promise => { - return await profileSearch.search(query, { limit: 20 }); + return await profileSearch.search(query, { limit: 200 }); }, [], ); diff --git a/src/lib/blueprints.ts b/src/lib/blueprints.ts index c0db785..ef51c98 100644 --- a/src/lib/blueprints.ts +++ b/src/lib/blueprints.ts @@ -140,9 +140,10 @@ export function NoteReplyBlueprint( // GroupMessageBlueprint // --------------------------------------------------------------------------- -export type GroupMessageOptions = TextContentOptionsWithAddress & { - previous?: NostrEvent[]; -}; +export type GroupMessageOptions = TextContentOptionsWithAddress & + MetaTagOptions & { + previous?: NostrEvent[]; + }; export function GroupMessageBlueprint( group: GroupPointer,