refactor(editor): replace DOM manipulation with React node views and floating-ui (#253)

* refactor(editor): replace DOM manipulation with React node views and floating-ui

- Convert all inline node views (emoji, blob attachment, event preview) from
  imperative document.createElement() to React components via ReactNodeViewRenderer
- Replace tippy.js with @floating-ui/react-dom for suggestion popup positioning
- Create useSuggestionRenderer hook bridging Tiptap suggestion callbacks to React state
- Extract shared EmojiMention, SubmitShortcut, and inline node extensions to separate files
- Extract types (EmojiTag, BlobAttachment, SerializedContent) to editor/types.ts
- Extract serialization logic to editor/utils/serialize.ts
- Remove redundant DOM keydown listener from RichEditor (handled by SubmitShortcut extension)
- Remove tippy.js dependency (-1045 lines net, RichEditor 632→297, MentionEditor 1038→354)

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* fix(editor): fix suggestion popover positioning, scrolling, and profile click behavior

- Replace UserName component in ProfileSuggestionList with plain text display
  so clicking a suggestion autocompletes instead of opening their profile
  (UserName has an onClick that calls addWindow and stopPropagation)
- Add react-virtuoso to ProfileSuggestionList for efficient lazy rendering
  of up to 20 search results with fixed item height scrolling
- Add profile avatars with lazy loading and initial-letter fallback
- Fix SuggestionPopover positioning with autoUpdate for scroll/resize tracking
- Add size middleware to constrain popover max-height to available viewport space

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* refactor(editor): convert emoji suggestion from grid to scrollable list with Virtuoso

Replace the 8-column grid layout with a vertical list matching the profile
suggestion style — each row shows the emoji preview alongside its :shortcode:
name. Uses react-virtuoso with fixedItemHeight for lazy rendering and smooth
keyboard-driven scrolling through large emoji sets.

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* fix(editor): set mentionSuggestionChar to ':' for emoji nodes

When backspacing over a mention-based node, Tiptap inserts the node's
mentionSuggestionChar attribute as undo text. The EmojiMention extension
inherits Mention's default of '@', so deleting an emoji left '@' instead
of ':'. Fix by explicitly setting mentionSuggestionChar: ':' in the emoji
command's attrs for both RichEditor and MentionEditor.

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* test(editor): add comprehensive test suite for custom TipTap extensions

Tests all 8 custom extensions using headless TipTap Editor instances in
jsdom environment (TipTap has no official testing package):

- EmojiMention: schema, renderText (unicode vs custom), mentionSuggestionChar
  attribute handling, backspace behavior regression test
- BlobAttachmentRichNode/InlineNode: schema (block vs inline), attributes,
  renderText URL serialization, parseHTML selectors
- NostrEventPreviewRichNode/InlineNode: schema, renderText encoding for
  note/nevent/naddr back to nostr: URIs
- SubmitShortcut: Mod-Enter always submits, Enter behavior with
  enterSubmits flag
- FilePasteHandler: media type filtering (image/video/audio), non-media
  rejection, mixed paste filtering, edge cases (no files, no callback)
- NostrPasteHandler: bech32 regex matching (npub/note/nevent/naddr/nprofile),
  nostr: prefix handling, URL exclusion, node creation (mention vs preview),
  surrounding text preservation, multiple entities
- Serialization: formatBlobSize, serializeRichContent (emoji tag extraction,
  blob dedup, address refs), serializeInlineContent (mention→nostr: URI,
  emoji→shortcode, blob→URL, event preview encoding)

90 new tests total.

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* fix(editor): paste handler and serialization bugs found via adversarial testing

NostrPasteHandler fixes:
- Punctuation after bech32 now matches (npub1..., npub1...! etc.)
  Changed trailing lookahead from (?=$|\s) to (?=$|\s|[.,!?;:)\]}>])
- Fixed double-space between entities — unconditional " " after every
  entity caused doubled spaces. Now only adds trailing space when entity
  is at the very end of pasted text (for cursor positioning).
- Tightened regex character class from [\w] to [a-z0-9] to match actual
  bech32 charset (rejects uppercase, underscore)
- Wrapped dispatch in try/catch to handle block-node-at-inline-position
  errors gracefully (falls back to default paste)

Serialization fix:
- serializeRichContent now guards blob collection with `url && sha256`
  matching the defensive checks already in serializeInlineContent.
  Previously null sha256 would corrupt the dedup Set and null url would
  produce invalid BlobAttachment entries.

Added 22 new edge case tests:
- Paste handler: punctuation boundaries, double-space regression,
  malformed bech32 fallback, uppercase rejection, error resilience
- Serialization: empty editor, null sha256/url blobs, invalid pubkey
  fallback, missing mention attrs, inline dedup, multi-paragraph

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* fix(editor): raise suggestion search limits for profiles and emojis

Both suggestion dropdowns use Virtuoso for virtualized rendering, so
they can handle large result sets without performance issues. The
previous limits (20 profiles, 24 emojis) were too restrictive — users
with many custom emojis sharing a substring or large contact lists
couldn't scroll to find the right match.

Raised both limits to 200 to allow thorough browsing while still
bounding the result set.

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* refactor(chat): rework emoji picker to scrollable list with search

Replace the fixed 1-row grid (8 emojis) with a scrollable virtualized
list matching the editor's EmojiSuggestionList look & feel:

- Search box at top with magnifying glass icon
- Virtuoso-backed scrollable list (8 visible items, unlimited results)
- Each row shows emoji icon + :shortcode: label
- Keyboard navigation: arrow keys to select, Enter to confirm
- Mouse hover highlights, click selects
- Frequently used emojis still shown first when no search query
- Narrower dialog (max-w-xs) for a compact picker feel

https://claude.ai/code/session_01CzeTFzSETs9wSPH2feDE6u

* fix: add address field to EmojiTag in editor types, fix GroupMessageOptions

- Add optional `address` field to EmojiTag in editor/types.ts to match
  NIP-30 changes from main (30030 emoji set address)
- Extend GroupMessageOptions with MetaTagOptions to fix type error in
  GroupMessageBlueprint's setMetaTags call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(editor): restore address attr, fix serialization, UserName, no-scroll

- Restore `address` attribute in shared EmojiMention extension (emoji.ts)
  that was dropped during refactor — required for NIP-30 emoji set tracking
- Extract `address` from emoji nodes in both serializeRichContent and
  serializeInlineContent so it makes it into published events
- Fix MentionEditorProps.onSubmit signature: use EmojiTag[] (not the narrower
  inline type) so address field flows through to callers
- Restore UserName component in ProfileSuggestionList for proper display
  with Grimoire member badges and supporter flame
- Remove scrollbar when all items fit: set overflow:hidden on Virtuoso when
  items.length <= MAX_VISIBLE (profile list, emoji list, emoji picker dialog)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-03-03 21:50:32 +01:00
committed by GitHub
parent cffb981ad1
commit 8c9ecd574c
30 changed files with 3488 additions and 1445 deletions

View File

@@ -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";

View File

@@ -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<string, number> {
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<EmojiSearchResult[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const virtuosoRef = useRef<VirtuosoHandle>(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 (
<button
role="option"
aria-selected={index === selectedIndex}
onClick={(e) => {
e.preventDefault();
handleEmojiClick(item);
}}
onMouseEnter={() => setSelectedIndex(index)}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors ${
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
}`}
>
<span className="flex size-7 items-center justify-center flex-shrink-0">
{item.source === "unicode" ? (
<span className="text-lg leading-none">{item.url}</span>
) : (
<img
src={item.url}
alt={`:${item.shortcode}:`}
className="size-6 object-contain"
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
)}
</span>
<span className="truncate text-sm text-popover-foreground/80">
:{item.shortcode}:
</span>
</button>
);
},
[displayEmojis, selectedIndex, handleEmojiClick],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogContent className="max-w-xs p-4 gap-2">
{/* Search input */}
<div className="relative mt-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 size-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search emojis..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="pl-9"
autoFocus
/>
</div>
{/* Fixed 1-row emoji grid (8 emoji) with consistent height */}
<div className="grid grid-cols-8 items-center gap-3 h-[1.5rem]">
{displayEmojis.length > 0 ? (
displayEmojis.map((result) => (
<button
key={`${result.source}:${result.shortcode}`}
onClick={() => handleEmojiClick(result)}
className="hover:bg-muted rounded p-2 transition-colors flex items-center justify-center aspect-square"
title={`:${result.shortcode}:`}
>
{result.source === "unicode" ? (
<span className="text-xl leading-none">{result.url}</span>
) : (
<CustomEmoji
size="md"
shortcode={result.shortcode}
url={result.url}
/>
)}
</button>
))
) : (
<div className="col-span-8 flex items-center justify-center text-sm text-muted-foreground">
No emojis found
</div>
)}
</div>
{/* Scrollable emoji list */}
{displayEmojis.length > 0 ? (
<div
role="listbox"
className="rounded-md border border-border/50 bg-popover text-popover-foreground overflow-hidden"
>
<Virtuoso
ref={virtuosoRef}
totalCount={displayEmojis.length}
fixedItemHeight={ITEM_HEIGHT}
style={{
height: listHeight,
overflow:
displayEmojis.length <= MAX_VISIBLE ? "hidden" : "auto",
}}
itemContent={renderItem}
/>
</div>
) : (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
No emojis found
</div>
)}
</DialogContent>
</Dialog>
);

View File

@@ -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<HTMLDivElement>(null);
const virtuosoRef = useRef<VirtuosoHandle>(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 (
<button
role="option"
aria-selected={index === selectedIndex}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
command(item);
}}
onMouseEnter={() => setSelectedIndex(index)}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors ${
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
}`}
>
<span className="flex size-7 items-center justify-center flex-shrink-0">
{item.source === "unicode" ? (
<span className="text-lg leading-none">{item.url}</span>
) : (
<img
src={item.url}
alt={`:${item.shortcode}:`}
className="size-6 object-contain"
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
)}
</span>
<span className="truncate text-sm text-popover-foreground/80">
:{item.shortcode}:
</span>
</button>
);
},
[items, selectedIndex, command],
);
if (items.length === 0) {
return (
<div className="border border-border/50 bg-popover p-4 text-sm text-popover-foreground/60 shadow-md">
<div className="rounded-md border border-border/50 bg-popover p-4 text-sm text-popover-foreground/60 shadow-md">
No emoji found
</div>
);
}
const listHeight = Math.min(items.length, MAX_VISIBLE) * ITEM_HEIGHT;
return (
<div
ref={listRef}
role="listbox"
className="max-h-[280px] w-full max-w-[296px] overflow-y-auto border border-border/50 bg-popover p-2 text-popover-foreground shadow-md"
className="w-[260px] rounded-md border border-border/50 bg-popover text-popover-foreground shadow-md overflow-hidden"
>
<div className="grid grid-cols-6 md:grid-cols-8 gap-1 md:gap-0.5">
{items.map((item, index) => (
<button
key={`${item.shortcode}-${item.source}`}
data-index={index}
role="option"
aria-selected={index === selectedIndex}
onClick={() => command(item)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
"flex size-10 md:size-8 items-center justify-center rounded transition-colors",
index === selectedIndex ? "bg-muted" : "hover:bg-muted/60",
)}
title={`:${item.shortcode}:`}
>
{item.source === "unicode" ? (
// Unicode emoji - render as text
<span className="text-xl md:text-lg leading-none">
{item.url}
</span>
) : (
// Custom emoji - render as image
<img
src={item.url}
alt={`:${item.shortcode}:`}
className="size-7 md:size-6 object-contain"
loading="lazy"
onError={(e) => {
// Replace with fallback on error
e.currentTarget.style.display = "none";
}}
/>
)}
</button>
))}
</div>
{/* Show selected emoji shortcode */}
{items[selectedIndex] && (
<div className="mt-2 border-t border-border/50 pt-2 text-center text-xs text-popover-foreground/60">
:{items[selectedIndex].shortcode}:
</div>
)}
<Virtuoso
ref={virtuosoRef}
totalCount={items.length}
fixedItemHeight={ITEM_HEIGHT}
style={{
height: listHeight,
overflow: items.length <= MAX_VISIBLE ? "hidden" : "auto",
}}
itemContent={renderItem}
/>
</div>
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -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<HTMLDivElement>(null);
const virtuosoRef = useRef<VirtuosoHandle>(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 (
<div className="border border-border/50 bg-popover p-4 text-sm text-popover-foreground/60 shadow-md">
No profiles found
</div>
);
}
return (
<div
ref={listRef}
role="listbox"
className="max-h-[300px] w-full max-w-[320px] overflow-y-auto border border-border/50 bg-popover text-popover-foreground shadow-md"
>
{items.map((item, index) => (
const renderItem = useCallback(
(index: number) => {
const item = items[index];
return (
<button
key={item.pubkey}
role="option"
aria-selected={index === selectedIndex}
onClick={() => command(item)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
command(item);
}}
onMouseEnter={() => setSelectedIndex(index)}
className={`flex w-full items-center gap-3 px-3 py-3 md:py-2 min-h-[44px] text-left transition-colors ${
className={`flex w-full items-center gap-3 px-3 py-2.5 min-h-[44px] text-left transition-colors ${
index === selectedIndex ? "bg-muted/60" : "hover:bg-muted/60"
}`}
>
{item.picture ? (
<img
src={item.picture}
alt=""
className="size-8 rounded-full object-cover flex-shrink-0"
loading="lazy"
/>
) : (
<div className="size-8 rounded-full bg-muted flex-shrink-0 flex items-center justify-center text-xs text-muted-foreground">
{item.displayName?.charAt(0)?.toUpperCase() || "?"}
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
<UserName pubkey={item.pubkey} />
@@ -105,7 +112,36 @@ export const ProfileSuggestionList = forwardRef<
)}
</div>
</button>
))}
);
},
[items, selectedIndex, command],
);
if (items.length === 0) {
return (
<div className="rounded-md border border-border/50 bg-popover p-4 text-sm text-popover-foreground/60 shadow-md">
No profiles found
</div>
);
}
const listHeight = Math.min(items.length, MAX_VISIBLE) * ITEM_HEIGHT;
return (
<div
role="listbox"
className="w-[320px] rounded-md border border-border/50 bg-popover text-popover-foreground shadow-md overflow-hidden"
>
<Virtuoso
ref={virtuosoRef}
totalCount={items.length}
fixedItemHeight={ITEM_HEIGHT}
style={{
height: listHeight,
overflow: items.length <= MAX_VISIBLE ? "hidden" : "auto",
}}
itemContent={renderItem}
/>
</div>
);
});

View File

@@ -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<string>();
const seenBlobs = new Set<string>();
const seenAddrs = new Set<string>();
// 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<RichEditorHandle, RichEditorProps>(
(
{
@@ -242,200 +80,67 @@ export const RichEditor = forwardRef<RichEditorHandle, RichEditorProps>(
},
ref,
) => {
// Ref to access handleSubmit from keyboard shortcuts
const handleSubmitRef = useRef<(editor: any) => void>(() => {});
// Create mention suggestion configuration for @ mentions
const mentionSuggestion: Omit<SuggestionOptions, "editor"> = useMemo(
() => ({
char: "@",
allowSpaces: false,
items: async ({ query }) => {
return await searchProfiles(query);
},
render: () => {
let component: ReactRenderer<ProfileSuggestionListHandle>;
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<SuggestionOptions, "editor"> | undefined =
useMemo(() => {
if (!searchEmojis) return undefined;
return {
char: ":",
allowSpaces: false,
items: async ({ query }) => {
return await searchEmojis(query);
},
render: () => {
let component: ReactRenderer<EmojiSuggestionListHandle>;
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<ProfileSearchResult>(ProfileSuggestionList as any);
const { render: renderEmojiSuggestion, portal: emojiPortal } =
useSuggestionRenderer<EmojiSearchResult>(EmojiSuggestionList as any);
// Mention suggestion config
const mentionSuggestion: Omit<SuggestionOptions, "editor"> = useMemo(
() => ({
char: "@",
allowSpaces: false,
items: async ({ query }) => searchProfiles(query),
render: renderMentionSuggestion,
}),
[searchProfiles, renderMentionSuggestion],
);
// Emoji suggestion config
const emojiSuggestion: Omit<SuggestionOptions, "editor"> | 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<RichEditorHandle, RichEditorProps>(
}
},
}).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<RichEditorHandle, RichEditorProps>(
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<RichEditorHandle, RichEditorProps>(
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<RichEditorHandle, RichEditorProps>(
},
},
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<RichEditorHandle, RichEditorProps>(
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<RichEditorHandle, RichEditorProps>(
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 (
<div className={`rich-editor ${className}`}>
<EditorContent editor={editor} />
{mentionPortal}
{emojiPortal}
</div>
);
},

View File

@@ -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(
<div ref={refs.setFloating} style={{ ...floatingStyles, zIndex: 50 }}>
{children}
</div>,
document.body,
);
}

View File

@@ -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: "",
});
},
});

View File

@@ -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"]');
});
});
});

View File

@@ -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("<p>hello </p>");
// 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("<p>hello </p>");
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("@");
});
});
});

View File

@@ -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: "",
});
},
});

View File

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

View File

@@ -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: "",
});
},
});

View File

@@ -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"]');
});
});
});

View File

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

View File

@@ -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;

View File

@@ -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: "<p>Hello</p>",
});
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: "<p>Hello</p>",
});
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: "<p>Hello</p>",
});
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: "<p>Hello</p>",
});
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: "<p>Hello</p>",
});
// Trigger shortcut - should call the function from configure time
editor.commands.keyboardShortcut("Mod-Enter");
expect(submitFn).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -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<SubmitShortcutOptions>({
name: "submitShortcut",
addOptions() {
return {
submitRef: { current: () => {} } as MutableRefObject<
(editor: Editor) => void
>,
enterSubmits: false,
};
},
addKeyboardShortcuts() {
const shortcuts: Record<string, () => 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;
},
});

View File

@@ -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<T> {
items: T[];
command: (item: T) => void;
onClose?: () => void;
}
interface SuggestionState<T> {
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<T>(
Component: ComponentType<
SuggestionListProps<T> & { ref?: RefObject<SuggestionListHandle | null> }
>,
options?: UseSuggestionRendererOptions,
): {
render: () => ReturnType<NonNullable<SuggestionOptions["render"]>>;
portal: ReactElement | null;
} {
const [state, setState] = useState<SuggestionState<T> | null>(null);
const componentRef = useRef<SuggestionListHandle>(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<NonNullable<SuggestionOptions["render"]>> => ({
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 ? (
<SuggestionPopover clientRect={state.clientRect} placement={placement}>
<Component
ref={componentRef}
items={state.items}
command={state.command}
onClose={() => setState(null)}
/>
</SuggestionPopover>
) : null;
return { render, portal };
}

View File

@@ -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 (
<NodeViewWrapper
as="span"
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"
contentEditable={false}
>
{isImage && url ? (
<img
src={url}
alt="attachment"
className="h-4 w-4 object-cover rounded"
draggable={false}
/>
) : (
<span className="text-muted-foreground">
{isVideo ? "\uD83C\uDFAC" : isAudio ? "\uD83C\uDFB5" : "\uD83D\uDCCE"}
</span>
)}
<span className="text-muted-foreground truncate max-w-[80px]">
{typeLabel}
</span>
{size != null && (
<span className="text-muted-foreground/70">{formatBlobSize(size)}</span>
)}
</NodeViewWrapper>
);
}

View File

@@ -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 (
<NodeViewWrapper as="span" className="emoji-node" data-emoji={id || ""}>
{`:${id}:`}
</NodeViewWrapper>
);
}
if (isUnicode && url) {
return (
<NodeViewWrapper as="span" className="emoji-node" data-emoji={id || ""}>
<span className="emoji-unicode" title={`:${id}:`}>
{url}
</span>
</NodeViewWrapper>
);
}
// Custom emoji with image
return (
<NodeViewWrapper as="span" className="emoji-node" data-emoji={id || ""}>
<img
src={url!}
alt={`:${id}:`}
title={`:${id}:`}
className="emoji-image"
draggable={false}
onError={() => setImgError(true)}
/>
</NodeViewWrapper>
);
}

View File

@@ -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 (
<NodeViewWrapper
as="span"
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"
contentEditable={false}
>
<span className="text-primary font-medium">{typeLabel}</span>
<span className="text-muted-foreground truncate max-w-[140px]">
{contentLabel}
</span>
</NodeViewWrapper>
);
}

View File

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

View File

@@ -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("<p>Hello world</p>");
const result = serializeRichContent(editor);
expect(result.text).toBe("Hello world");
});
it("should use single newline between blocks", () => {
editor = createRichEditor("<p>Line 1</p><p>Line 2</p>");
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("<p>Hello world</p>");
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("<p>Line 1</p><p>Line 2</p><p>Line 3</p>");
const result = serializeInlineContent(editor);
expect(result.text).toBe("Line 1\nLine 2\nLine 3");
});
it("should handle empty paragraphs", () => {
editor = createInlineEditor("<p>Before</p><p></p><p>After</p>");
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("🔥");
});
});
});

View File

@@ -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<string>();
const seenBlobs = new Set<string>();
const seenAddrs = new Set<string>();
// 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<string>();
const seenBlobs = new Set<string>();
const seenAddrs = new Set<string>();
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`;
}