mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-16 18:37:14 +02:00
refactor: extract NostrEditor as generic post composer
Refactor the chat composer into a reusable NostrEditor component with configurable behavior for different UI contexts (chat, posts, long-form). Key changes: - Create NostrEditor with configurable props: - submitBehavior: 'enter' | 'ctrl-enter' | 'button-only' - variant: 'inline' | 'multiline' | 'full' - blobPreview: 'compact' | 'card' | 'gallery' - Extract suggestion system into pluggable architecture (SuggestionConfig) - Add helper functions to create standard Nostr suggestions - Update search hooks (useProfileSearch, useEmojiSearch) with injectable sources for custom profile/emoji sets - Convert MentionEditor to backward-compatible wrapper around NostrEditor - Update ChatViewer to use new NostrEditor component This enables building WYSIWYG editors with the same autocomplete features as chat (profile mentions, emoji, uploads) but with different behaviors suitable for long-form posts or notes.
This commit is contained in:
@@ -44,12 +44,9 @@ import { ChatMessageContextMenu } from "./chat/ChatMessageContextMenu";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "./ui/button";
|
||||
import LoginDialog from "./nostr/LoginDialog";
|
||||
import {
|
||||
MentionEditor,
|
||||
type MentionEditorHandle,
|
||||
type EmojiTag,
|
||||
type BlobAttachment,
|
||||
} from "./editor/MentionEditor";
|
||||
import { NostrEditor, type NostrEditorHandle } from "./editor/NostrEditor";
|
||||
import type { EmojiTag, BlobAttachment } from "./editor/types";
|
||||
import { createNostrSuggestions } from "./editor/suggestions";
|
||||
import { useProfileSearch } from "@/hooks/useProfileSearch";
|
||||
import { useEmojiSearch } from "@/hooks/useEmojiSearch";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
@@ -459,8 +456,8 @@ export function ChatViewer({
|
||||
// Copy chat identifier to clipboard
|
||||
const { copy: copyChatId, copied: chatIdCopied } = useCopy();
|
||||
|
||||
// Ref to MentionEditor for programmatic submission
|
||||
const editorRef = useRef<MentionEditorHandle>(null);
|
||||
// Ref to NostrEditor for programmatic submission
|
||||
const editorRef = useRef<NostrEditorHandle>(null);
|
||||
|
||||
// Blossom upload hook for file attachments
|
||||
const { open: openUpload, dialog: uploadDialog } = useBlossomUpload({
|
||||
@@ -688,6 +685,18 @@ export function ChatViewer({
|
||||
[conversation, canSign, isSending, adapter, pubkey, signer],
|
||||
);
|
||||
|
||||
// Create suggestions configuration for NostrEditor
|
||||
const suggestions = useMemo(
|
||||
() =>
|
||||
createNostrSuggestions({
|
||||
searchProfiles,
|
||||
searchEmojis,
|
||||
searchCommands,
|
||||
onCommandExecute: handleCommandExecute,
|
||||
}),
|
||||
[searchProfiles, searchEmojis, searchCommands, handleCommandExecute],
|
||||
);
|
||||
|
||||
// Handle reply button click
|
||||
const handleReply = useCallback((messageId: string) => {
|
||||
setReplyTo(messageId);
|
||||
@@ -1089,16 +1098,21 @@ export function ChatViewer({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<MentionEditor
|
||||
<NostrEditor
|
||||
ref={editorRef}
|
||||
placeholder="Type a message..."
|
||||
searchProfiles={searchProfiles}
|
||||
searchEmojis={searchEmojis}
|
||||
searchCommands={searchCommands}
|
||||
onCommandExecute={handleCommandExecute}
|
||||
onSubmit={(content, emojiTags, blobAttachments) => {
|
||||
if (content.trim()) {
|
||||
handleSend(content, replyTo, emojiTags, blobAttachments);
|
||||
suggestions={suggestions}
|
||||
submitBehavior="enter"
|
||||
variant="inline"
|
||||
blobPreview="compact"
|
||||
onSubmit={(content) => {
|
||||
if (content.text.trim()) {
|
||||
handleSend(
|
||||
content.text,
|
||||
replyTo,
|
||||
content.emojiTags,
|
||||
content.blobAttachments,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-w-0"
|
||||
|
||||
@@ -1,79 +1,36 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
|
||||
import { Extension, Node, mergeAttributes } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import type { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
import type { Instance as TippyInstance } from "tippy.js";
|
||||
import "tippy.js/dist/tippy.css";
|
||||
import {
|
||||
ProfileSuggestionList,
|
||||
type ProfileSuggestionListHandle,
|
||||
} from "./ProfileSuggestionList";
|
||||
import {
|
||||
EmojiSuggestionList,
|
||||
type EmojiSuggestionListHandle,
|
||||
} from "./EmojiSuggestionList";
|
||||
import {
|
||||
SlashCommandSuggestionList,
|
||||
type SlashCommandSuggestionListHandle,
|
||||
} from "./SlashCommandSuggestionList";
|
||||
/**
|
||||
* MentionEditor - Backward compatibility wrapper around NostrEditor
|
||||
*
|
||||
* This file provides the legacy MentionEditor API while using NostrEditor internally.
|
||||
* New code should import from NostrEditor and use the new API directly.
|
||||
*
|
||||
* @deprecated Use NostrEditor from "./NostrEditor" instead
|
||||
*/
|
||||
|
||||
import { forwardRef, useMemo, useCallback } from "react";
|
||||
import { NostrEditor, type NostrEditorProps } from "./NostrEditor";
|
||||
import { createNostrSuggestions } from "./suggestions";
|
||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import type { ChatAction } from "@/types/chat-actions";
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
// Re-export types from the new location for backward compatibility
|
||||
export type {
|
||||
EmojiTag,
|
||||
BlobAttachment,
|
||||
SerializedContent,
|
||||
NostrEditorHandle as MentionEditorHandle,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Represents an emoji tag for NIP-30
|
||||
* @deprecated Use NostrEditorProps instead
|
||||
*/
|
||||
export interface EmojiTag {
|
||||
shortcode: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a blob attachment for imeta tags (NIP-92)
|
||||
*/
|
||||
export interface BlobAttachment {
|
||||
/** The URL of the blob */
|
||||
url: string;
|
||||
/** SHA256 hash of the blob content */
|
||||
sha256: string;
|
||||
/** MIME type of the blob */
|
||||
mimeType?: string;
|
||||
/** Size in bytes */
|
||||
size?: number;
|
||||
/** Blossom server URL */
|
||||
server?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of serializing editor content
|
||||
*/
|
||||
export interface SerializedContent {
|
||||
/** The text content with mentions as nostr: URIs and emoji as :shortcode: */
|
||||
text: string;
|
||||
/** Emoji tags to include in the event (NIP-30) */
|
||||
emojiTags: EmojiTag[];
|
||||
/** Blob attachments for imeta tags (NIP-92) */
|
||||
blobAttachments: BlobAttachment[];
|
||||
}
|
||||
|
||||
export interface MentionEditorProps {
|
||||
placeholder?: string;
|
||||
onSubmit?: (
|
||||
content: string,
|
||||
emojiTags: EmojiTag[],
|
||||
blobAttachments: BlobAttachment[],
|
||||
emojiTags: import("./types").EmojiTag[],
|
||||
blobAttachments: import("./types").BlobAttachment[],
|
||||
) => void;
|
||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
||||
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
||||
@@ -83,201 +40,32 @@ export interface MentionEditorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface MentionEditorHandle {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
getContent: () => string;
|
||||
getSerializedContent: () => SerializedContent;
|
||||
isEmpty: () => boolean;
|
||||
submit: () => void;
|
||||
/** Insert text at the current cursor position */
|
||||
insertText: (text: string) => void;
|
||||
/** Insert a blob attachment with rich preview */
|
||||
insertBlob: (blob: BlobAttachment) => void;
|
||||
}
|
||||
|
||||
// Create emoji extension by extending Mention with a different name and custom node view
|
||||
const EmojiMention = Mention.extend({
|
||||
name: "emoji",
|
||||
|
||||
// Add custom attributes for emoji (url and source)
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
url: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-url"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.url) return {};
|
||||
return { "data-url": attributes.url };
|
||||
},
|
||||
},
|
||||
source: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-source"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.source) return {};
|
||||
return { "data-source": attributes.source };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// Override renderText to return empty string (nodeView handles display)
|
||||
renderText({ node }) {
|
||||
// Return the emoji character for unicode, or empty for custom
|
||||
// This is what gets copied to clipboard
|
||||
if (node.attrs.source === "unicode") {
|
||||
return node.attrs.url || "";
|
||||
}
|
||||
return `:${node.attrs.id}:`;
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const { url, source, id } = node.attrs;
|
||||
const isUnicode = source === "unicode";
|
||||
|
||||
// Create wrapper span
|
||||
const dom = document.createElement("span");
|
||||
dom.className = "emoji-node";
|
||||
dom.setAttribute("data-emoji", id || "");
|
||||
|
||||
if (isUnicode && url) {
|
||||
// Unicode emoji - render as text span
|
||||
const span = document.createElement("span");
|
||||
span.className = "emoji-unicode";
|
||||
span.textContent = url;
|
||||
span.title = `:${id}:`;
|
||||
dom.appendChild(span);
|
||||
} else if (url) {
|
||||
// Custom emoji - render as image
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.alt = `:${id}:`;
|
||||
img.title = `:${id}:`;
|
||||
img.className = "emoji-image";
|
||||
img.draggable = false;
|
||||
img.onerror = () => {
|
||||
// Fallback to shortcode if image fails to load
|
||||
dom.textContent = `:${id}:`;
|
||||
};
|
||||
dom.appendChild(img);
|
||||
} else {
|
||||
// Fallback if no url - show shortcode
|
||||
dom.textContent = `:${id}:`;
|
||||
}
|
||||
|
||||
return {
|
||||
dom,
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Create blob attachment extension for media previews
|
||||
const BlobAttachmentNode = Node.create({
|
||||
name: "blobAttachment",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
url: { default: null },
|
||||
sha256: { default: null },
|
||||
mimeType: { default: null },
|
||||
size: { default: null },
|
||||
server: { default: null },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-blob-attachment="true"]',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(HTMLAttributes, { "data-blob-attachment": "true" }),
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const { url, mimeType, size } = node.attrs;
|
||||
|
||||
// Create wrapper span
|
||||
const dom = document.createElement("span");
|
||||
dom.className =
|
||||
"blob-attachment inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 border border-border text-xs align-middle";
|
||||
dom.contentEditable = "false";
|
||||
|
||||
const isImage = mimeType?.startsWith("image/");
|
||||
const isVideo = mimeType?.startsWith("video/");
|
||||
const isAudio = mimeType?.startsWith("audio/");
|
||||
|
||||
if (isImage && url) {
|
||||
// Show image thumbnail
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.alt = "attachment";
|
||||
img.className = "h-4 w-4 object-cover rounded";
|
||||
img.draggable = false;
|
||||
dom.appendChild(img);
|
||||
} else {
|
||||
// Show icon based on type
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "text-muted-foreground";
|
||||
if (isVideo) {
|
||||
icon.textContent = "🎬";
|
||||
} else if (isAudio) {
|
||||
icon.textContent = "🎵";
|
||||
} else {
|
||||
icon.textContent = "📎";
|
||||
}
|
||||
dom.appendChild(icon);
|
||||
}
|
||||
|
||||
// Add type label
|
||||
const label = document.createElement("span");
|
||||
label.className = "text-muted-foreground truncate max-w-[80px]";
|
||||
if (isImage) {
|
||||
label.textContent = "image";
|
||||
} else if (isVideo) {
|
||||
label.textContent = "video";
|
||||
} else if (isAudio) {
|
||||
label.textContent = "audio";
|
||||
} else {
|
||||
label.textContent = "file";
|
||||
}
|
||||
dom.appendChild(label);
|
||||
|
||||
// Add size if available
|
||||
if (size) {
|
||||
const sizeEl = document.createElement("span");
|
||||
sizeEl.className = "text-muted-foreground/70";
|
||||
sizeEl.textContent = formatBlobSize(size);
|
||||
dom.appendChild(sizeEl);
|
||||
}
|
||||
|
||||
return { dom };
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function formatBlobSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* MentionEditor - Legacy chat composer component
|
||||
*
|
||||
* @deprecated Use NostrEditor instead with the new API:
|
||||
*
|
||||
* ```tsx
|
||||
* import { NostrEditor } from "./editor/NostrEditor";
|
||||
* import { createNostrSuggestions } from "./editor/suggestions";
|
||||
*
|
||||
* const suggestions = createNostrSuggestions({
|
||||
* searchProfiles,
|
||||
* searchEmojis,
|
||||
* searchCommands,
|
||||
* onCommandExecute,
|
||||
* });
|
||||
*
|
||||
* <NostrEditor
|
||||
* suggestions={suggestions}
|
||||
* submitBehavior="enter"
|
||||
* variant="inline"
|
||||
* onSubmit={(content) => handleSend(content.text, content.emojiTags, content.blobAttachments)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const MentionEditor = forwardRef<
|
||||
MentionEditorHandle,
|
||||
import("./types").NostrEditorHandle,
|
||||
MentionEditorProps
|
||||
>(
|
||||
(
|
||||
@@ -293,603 +81,40 @@ export const MentionEditor = forwardRef<
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
// Ref to access handleSubmit from suggestion plugins (defined early so useMemo can access it)
|
||||
const handleSubmitRef = useRef<(editor: any) => void>(() => {});
|
||||
|
||||
// Create mention suggestion configuration for @ mentions
|
||||
const mentionSuggestion: Omit<SuggestionOptions, "editor"> = useMemo(
|
||||
() => ({
|
||||
char: "@",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchProfiles(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<ProfileSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
let editorRef: any;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
editorRef = props.editor;
|
||||
component = new ReactRenderer(ProfileSuggestionList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
onClose: () => {
|
||||
popup[0]?.hide();
|
||||
},
|
||||
},
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Enter submits the message
|
||||
if (
|
||||
props.event.key === "Enter" &&
|
||||
(props.event.ctrlKey || props.event.metaKey)
|
||||
) {
|
||||
popup[0]?.hide();
|
||||
handleSubmitRef.current(editorRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0]?.destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
[searchProfiles],
|
||||
);
|
||||
|
||||
// Create emoji suggestion configuration for : emoji
|
||||
const emojiSuggestion: Omit<SuggestionOptions, "editor"> | null = useMemo(
|
||||
// Create suggestions configuration
|
||||
const suggestions = useMemo(
|
||||
() =>
|
||||
searchEmojis
|
||||
? {
|
||||
char: ":",
|
||||
allowSpaces: false,
|
||||
items: async ({ query }) => {
|
||||
return await searchEmojis(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<EmojiSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
let editorRef: any;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
editorRef = props.editor;
|
||||
component = new ReactRenderer(EmojiSuggestionList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
onClose: () => {
|
||||
popup[0]?.hide();
|
||||
},
|
||||
},
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Enter submits the message
|
||||
if (
|
||||
props.event.key === "Enter" &&
|
||||
(props.event.ctrlKey || props.event.metaKey)
|
||||
) {
|
||||
popup[0]?.hide();
|
||||
handleSubmitRef.current(editorRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0]?.destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
: null,
|
||||
[searchEmojis],
|
||||
createNostrSuggestions({
|
||||
searchProfiles,
|
||||
searchEmojis,
|
||||
searchCommands,
|
||||
onCommandExecute,
|
||||
}),
|
||||
[searchProfiles, searchEmojis, searchCommands, onCommandExecute],
|
||||
);
|
||||
|
||||
// Create slash command suggestion configuration for / commands
|
||||
// Only triggers when / is at the very beginning of the input
|
||||
const slashCommandSuggestion: Omit<SuggestionOptions, "editor"> | null =
|
||||
useMemo(
|
||||
() =>
|
||||
searchCommands
|
||||
? {
|
||||
char: "/",
|
||||
allowSpaces: false,
|
||||
// Only allow slash commands at the start of input (position 1 in TipTap = first char)
|
||||
allow: ({ range }) => range.from === 1,
|
||||
items: async ({ query }) => {
|
||||
return await searchCommands(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<SlashCommandSuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
let editorRef: any;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
editorRef = props.editor;
|
||||
component = new ReactRenderer(
|
||||
SlashCommandSuggestionList,
|
||||
{
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
onClose: () => {
|
||||
popup[0]?.hide();
|
||||
},
|
||||
},
|
||||
editor: props.editor,
|
||||
},
|
||||
);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect:
|
||||
props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "top-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0]?.setProps({
|
||||
getReferenceClientRect:
|
||||
props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Enter submits the message
|
||||
if (
|
||||
props.event.key === "Enter" &&
|
||||
(props.event.ctrlKey || props.event.metaKey)
|
||||
) {
|
||||
popup[0]?.hide();
|
||||
handleSubmitRef.current(editorRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0]?.destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
: null,
|
||||
[searchCommands],
|
||||
);
|
||||
|
||||
// Helper function to serialize editor content with mentions, emojis, and blobs
|
||||
const serializeContent = useCallback(
|
||||
(editorInstance: any): SerializedContent => {
|
||||
let text = "";
|
||||
const emojiTags: EmojiTag[] = [];
|
||||
const blobAttachments: BlobAttachment[] = [];
|
||||
const seenEmojis = new Set<string>();
|
||||
const seenBlobs = new Set<string>();
|
||||
const json = editorInstance.getJSON();
|
||||
|
||||
json.content?.forEach((node: any) => {
|
||||
if (node.type === "paragraph") {
|
||||
node.content?.forEach((child: any) => {
|
||||
if (child.type === "text") {
|
||||
text += child.text;
|
||||
} else if (child.type === "hardBreak") {
|
||||
// Preserve newlines from Shift+Enter
|
||||
text += "\n";
|
||||
} else if (child.type === "mention") {
|
||||
const pubkey = child.attrs?.id;
|
||||
if (pubkey) {
|
||||
try {
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
text += `nostr:${npub}`;
|
||||
} catch {
|
||||
// Fallback to display name if encoding fails
|
||||
text += `@${child.attrs?.label || "unknown"}`;
|
||||
}
|
||||
}
|
||||
} else if (child.type === "emoji") {
|
||||
const shortcode = child.attrs?.id;
|
||||
const url = child.attrs?.url;
|
||||
const source = child.attrs?.source;
|
||||
|
||||
if (source === "unicode" && url) {
|
||||
// Unicode emoji - output the actual character
|
||||
text += url;
|
||||
} else if (shortcode) {
|
||||
// Custom emoji - output :shortcode: and add tag
|
||||
text += `:${shortcode}:`;
|
||||
|
||||
if (url && !seenEmojis.has(shortcode)) {
|
||||
seenEmojis.add(shortcode);
|
||||
emojiTags.push({ shortcode, url });
|
||||
}
|
||||
}
|
||||
} else if (child.type === "blobAttachment") {
|
||||
// Blob attachment - output URL and track for imeta tag
|
||||
const { url, sha256, mimeType, size, server } = child.attrs;
|
||||
if (url) {
|
||||
text += url;
|
||||
// Add to blob attachments for imeta tags (dedupe by sha256)
|
||||
if (sha256 && !seenBlobs.has(sha256)) {
|
||||
seenBlobs.add(sha256);
|
||||
blobAttachments.push({
|
||||
url,
|
||||
sha256,
|
||||
mimeType: mimeType || undefined,
|
||||
size: size || undefined,
|
||||
server: server || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
text += "\n";
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
text: text.trim(),
|
||||
emojiTags,
|
||||
blobAttachments,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Helper function to handle submission
|
||||
const handleSubmit = useCallback(
|
||||
(editorInstance: any) => {
|
||||
if (!editorInstance || !onSubmit) return;
|
||||
|
||||
const { text, emojiTags, blobAttachments } =
|
||||
serializeContent(editorInstance);
|
||||
if (text) {
|
||||
onSubmit(text, emojiTags, blobAttachments);
|
||||
editorInstance.commands.clearContent();
|
||||
// Adapt the old onSubmit signature to the new one
|
||||
const handleSubmit = useCallback<NonNullable<NostrEditorProps["onSubmit"]>>(
|
||||
(content) => {
|
||||
if (onSubmit) {
|
||||
onSubmit(content.text, content.emojiTags, content.blobAttachments);
|
||||
}
|
||||
},
|
||||
[onSubmit, serializeContent],
|
||||
[onSubmit],
|
||||
);
|
||||
|
||||
// Keep ref updated with latest handleSubmit
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
|
||||
// Build extensions array
|
||||
const extensions = useMemo(() => {
|
||||
// Detect mobile devices (touch support)
|
||||
const isMobile = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
// Custom extension for keyboard shortcuts (runs before suggestion plugins)
|
||||
const SubmitShortcut = Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// Ctrl/Cmd+Enter always submits
|
||||
"Mod-Enter": ({ editor }) => {
|
||||
handleSubmitRef.current(editor);
|
||||
return true;
|
||||
},
|
||||
// Plain Enter behavior depends on device
|
||||
Enter: ({ editor }) => {
|
||||
if (isMobile) {
|
||||
// On mobile, Enter inserts a newline (hardBreak)
|
||||
return editor.commands.setHardBreak();
|
||||
} else {
|
||||
// On desktop, Enter submits the message
|
||||
handleSubmitRef.current(editor);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const exts = [
|
||||
SubmitShortcut,
|
||||
StarterKit.configure({
|
||||
// Shift+Enter inserts hard break (newline)
|
||||
hardBreak: {
|
||||
keepMarks: false,
|
||||
},
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
suggestion: {
|
||||
...mentionSuggestion,
|
||||
command: ({ editor, range, props }: any) => {
|
||||
// props is the ProfileSearchResult
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
id: props.pubkey,
|
||||
label: props.displayName,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
},
|
||||
},
|
||||
renderLabel({ node }) {
|
||||
return `@${node.attrs.label}`;
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
// Add blob attachment extension for media previews
|
||||
BlobAttachmentNode,
|
||||
];
|
||||
|
||||
// Add emoji extension if search is provided
|
||||
if (emojiSuggestion) {
|
||||
exts.push(
|
||||
EmojiMention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "emoji",
|
||||
},
|
||||
suggestion: {
|
||||
...emojiSuggestion,
|
||||
command: ({ editor, range, props }: any) => {
|
||||
// props is the EmojiSearchResult
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: props.shortcode,
|
||||
label: props.shortcode,
|
||||
url: props.url,
|
||||
source: props.source,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
},
|
||||
},
|
||||
// Note: renderLabel is not used when nodeView is defined
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Add slash command extension if search is provided
|
||||
if (slashCommandSuggestion) {
|
||||
const SlashCommand = Mention.extend({
|
||||
name: "slashCommand",
|
||||
});
|
||||
|
||||
exts.push(
|
||||
SlashCommand.configure({
|
||||
HTMLAttributes: {
|
||||
class: "slash-command",
|
||||
},
|
||||
suggestion: {
|
||||
...slashCommandSuggestion,
|
||||
command: ({ editor, props }: any) => {
|
||||
// props is the ChatAction
|
||||
// Execute the command immediately and clear the editor
|
||||
editor.commands.clearContent();
|
||||
if (onCommandExecute) {
|
||||
// Execute action asynchronously
|
||||
onCommandExecute(props).catch((error) => {
|
||||
console.error(
|
||||
"[MentionEditor] Command execution failed:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
renderLabel({ node }) {
|
||||
return `/${node.attrs.label}`;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [
|
||||
mentionSuggestion,
|
||||
emojiSuggestion,
|
||||
slashCommandSuggestion,
|
||||
onCommandExecute,
|
||||
placeholder,
|
||||
]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose prose-sm max-w-none focus:outline-none text-sm",
|
||||
},
|
||||
},
|
||||
autofocus: autoFocus,
|
||||
});
|
||||
|
||||
// Expose editor methods
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => editor?.commands.focus(),
|
||||
clear: () => editor?.commands.clearContent(),
|
||||
getContent: () => editor?.getText() || "",
|
||||
getSerializedContent: () => {
|
||||
if (!editor) return { text: "", emojiTags: [], blobAttachments: [] };
|
||||
return serializeContent(editor);
|
||||
},
|
||||
isEmpty: () => editor?.isEmpty ?? true,
|
||||
submit: () => {
|
||||
if (editor) {
|
||||
handleSubmit(editor);
|
||||
}
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
if (editor) {
|
||||
editor.chain().focus().insertContent(text).run();
|
||||
}
|
||||
},
|
||||
insertBlob: (blob: BlobAttachment) => {
|
||||
if (editor) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent([
|
||||
{
|
||||
type: "blobAttachment",
|
||||
attrs: {
|
||||
url: blob.url,
|
||||
sha256: blob.sha256,
|
||||
mimeType: blob.mimeType,
|
||||
size: blob.size,
|
||||
server: blob.server,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[editor, serializeContent, handleSubmit],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
editor?.destroy();
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded border bg-background transition-colors focus-within:border-primary h-7 flex items-center overflow-hidden px-2 ${className}`}
|
||||
>
|
||||
<EditorContent editor={editor} className="flex-1 min-w-0" />
|
||||
</div>
|
||||
<NostrEditor
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
suggestions={suggestions}
|
||||
submitBehavior="enter"
|
||||
variant="inline"
|
||||
blobPreview="compact"
|
||||
onSubmit={handleSubmit}
|
||||
autoFocus={autoFocus}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
859
src/components/editor/NostrEditor.tsx
Normal file
859
src/components/editor/NostrEditor.tsx
Normal file
@@ -0,0 +1,859 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent, ReactRenderer } from "@tiptap/react";
|
||||
import {
|
||||
Extension,
|
||||
Node,
|
||||
mergeAttributes,
|
||||
type AnyExtension,
|
||||
} from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import type { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
import type { Instance as TippyInstance } from "tippy.js";
|
||||
import "tippy.js/dist/tippy.css";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
NostrEditorHandle,
|
||||
SerializedContent,
|
||||
BlobAttachment,
|
||||
EmojiTag,
|
||||
SuggestionConfig,
|
||||
SubmitBehavior,
|
||||
EditorVariant,
|
||||
BlobPreviewStyle,
|
||||
SuggestionListHandle,
|
||||
} from "./types";
|
||||
|
||||
// Re-export handle type for consumers
|
||||
export type { NostrEditorHandle };
|
||||
|
||||
export interface NostrEditorProps {
|
||||
/** Placeholder text when editor is empty */
|
||||
placeholder?: string;
|
||||
/** Initial content (plain text) */
|
||||
initialContent?: string;
|
||||
/** Called when content is submitted */
|
||||
onSubmit?: (content: SerializedContent) => void;
|
||||
/** Called when content changes */
|
||||
onChange?: (content: SerializedContent) => void;
|
||||
/** Submit behavior: 'enter' (chat), 'ctrl-enter' (post), 'button-only' (external button) */
|
||||
submitBehavior?: SubmitBehavior;
|
||||
/** Layout variant: 'inline' (chat), 'multiline' (auto-expand), 'full' (fixed height) */
|
||||
variant?: EditorVariant;
|
||||
/** Minimum lines for multiline/full variants */
|
||||
minLines?: number;
|
||||
/** Maximum lines for multiline variant (auto-expand limit) */
|
||||
maxLines?: number;
|
||||
/** Blob preview style: 'compact' (pill), 'card' (thumbnail), 'gallery' (full-width) */
|
||||
blobPreview?: BlobPreviewStyle;
|
||||
/** Suggestion configurations */
|
||||
suggestions?: SuggestionConfig[];
|
||||
/** Auto-focus on mount */
|
||||
autoFocus?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Create emoji extension by extending Mention with a different name and custom node view
|
||||
const EmojiMention = Mention.extend({
|
||||
name: "emoji",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
url: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-url"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.url) return {};
|
||||
return { "data-url": attributes.url };
|
||||
},
|
||||
},
|
||||
source: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-source"),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.source) return {};
|
||||
return { "data-source": attributes.source };
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
if (node.attrs.source === "unicode") {
|
||||
return node.attrs.url || "";
|
||||
}
|
||||
return `:${node.attrs.id}:`;
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const { url, source, id } = node.attrs;
|
||||
const isUnicode = source === "unicode";
|
||||
|
||||
const dom = document.createElement("span");
|
||||
dom.className = "emoji-node";
|
||||
dom.setAttribute("data-emoji", id || "");
|
||||
|
||||
if (isUnicode && url) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "emoji-unicode";
|
||||
span.textContent = url;
|
||||
span.title = `:${id}:`;
|
||||
dom.appendChild(span);
|
||||
} else if (url) {
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.alt = `:${id}:`;
|
||||
img.title = `:${id}:`;
|
||||
img.className = "emoji-image";
|
||||
img.draggable = false;
|
||||
img.onerror = () => {
|
||||
dom.textContent = `:${id}:`;
|
||||
};
|
||||
dom.appendChild(img);
|
||||
} else {
|
||||
dom.textContent = `:${id}:`;
|
||||
}
|
||||
|
||||
return { dom };
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function formatBlobSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create blob attachment node with configurable preview style
|
||||
*/
|
||||
function createBlobAttachmentNode(previewStyle: BlobPreviewStyle) {
|
||||
return Node.create({
|
||||
name: "blobAttachment",
|
||||
group: previewStyle === "compact" ? "inline" : "block",
|
||||
inline: previewStyle === "compact",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
url: { default: null },
|
||||
sha256: { default: null },
|
||||
mimeType: { default: null },
|
||||
size: { default: null },
|
||||
server: { default: null },
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-blob-attachment="true"]' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(HTMLAttributes, { "data-blob-attachment": "true" }),
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node }) => {
|
||||
const { url, mimeType, size } = node.attrs;
|
||||
const isImage = mimeType?.startsWith("image/");
|
||||
const isVideo = mimeType?.startsWith("video/");
|
||||
const isAudio = mimeType?.startsWith("audio/");
|
||||
|
||||
const dom = document.createElement(
|
||||
previewStyle === "compact" ? "span" : "div",
|
||||
);
|
||||
dom.contentEditable = "false";
|
||||
|
||||
if (previewStyle === "compact") {
|
||||
// Compact: small inline pill
|
||||
dom.className =
|
||||
"blob-attachment inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50 border border-border text-xs align-middle";
|
||||
|
||||
if (isImage && url) {
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.alt = "attachment";
|
||||
img.className = "h-4 w-4 object-cover rounded";
|
||||
img.draggable = false;
|
||||
dom.appendChild(img);
|
||||
} else {
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "text-muted-foreground";
|
||||
icon.textContent = isVideo ? "🎬" : isAudio ? "🎵" : "📎";
|
||||
dom.appendChild(icon);
|
||||
}
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "text-muted-foreground truncate max-w-[80px]";
|
||||
label.textContent = isImage
|
||||
? "image"
|
||||
: isVideo
|
||||
? "video"
|
||||
: isAudio
|
||||
? "audio"
|
||||
: "file";
|
||||
dom.appendChild(label);
|
||||
|
||||
if (size) {
|
||||
const sizeEl = document.createElement("span");
|
||||
sizeEl.className = "text-muted-foreground/70";
|
||||
sizeEl.textContent = formatBlobSize(size);
|
||||
dom.appendChild(sizeEl);
|
||||
}
|
||||
} else if (previewStyle === "card") {
|
||||
// Card: medium thumbnail card
|
||||
dom.className =
|
||||
"blob-attachment-card my-2 inline-flex items-center gap-3 p-2 rounded-lg bg-muted/30 border border-border max-w-xs";
|
||||
|
||||
if (isImage && url) {
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.alt = "attachment";
|
||||
img.className = "h-16 w-16 object-cover rounded";
|
||||
img.draggable = false;
|
||||
dom.appendChild(img);
|
||||
} else {
|
||||
const iconWrapper = document.createElement("div");
|
||||
iconWrapper.className =
|
||||
"h-16 w-16 flex items-center justify-center bg-muted rounded";
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "text-2xl";
|
||||
icon.textContent = isVideo ? "🎬" : isAudio ? "🎵" : "📎";
|
||||
iconWrapper.appendChild(icon);
|
||||
dom.appendChild(iconWrapper);
|
||||
}
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "flex flex-col gap-0.5 min-w-0";
|
||||
|
||||
const typeLabel = document.createElement("span");
|
||||
typeLabel.className = "text-sm font-medium capitalize";
|
||||
typeLabel.textContent = isImage
|
||||
? "Image"
|
||||
: isVideo
|
||||
? "Video"
|
||||
: isAudio
|
||||
? "Audio"
|
||||
: "File";
|
||||
info.appendChild(typeLabel);
|
||||
|
||||
if (size) {
|
||||
const sizeEl = document.createElement("span");
|
||||
sizeEl.className = "text-xs text-muted-foreground";
|
||||
sizeEl.textContent = formatBlobSize(size);
|
||||
info.appendChild(sizeEl);
|
||||
}
|
||||
|
||||
dom.appendChild(info);
|
||||
} else {
|
||||
// Gallery: full-width preview
|
||||
dom.className = "blob-attachment-gallery my-2 w-full";
|
||||
|
||||
if (isImage && url) {
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.alt = "attachment";
|
||||
img.className = "max-w-full max-h-64 rounded-lg object-contain";
|
||||
img.draggable = false;
|
||||
dom.appendChild(img);
|
||||
} else if (isVideo && url) {
|
||||
const video = document.createElement("video");
|
||||
video.src = url;
|
||||
video.className = "max-w-full max-h-64 rounded-lg";
|
||||
video.controls = true;
|
||||
dom.appendChild(video);
|
||||
} else if (isAudio && url) {
|
||||
const audio = document.createElement("audio");
|
||||
audio.src = url;
|
||||
audio.className = "w-full";
|
||||
audio.controls = true;
|
||||
dom.appendChild(audio);
|
||||
} else {
|
||||
const fileCard = document.createElement("div");
|
||||
fileCard.className =
|
||||
"inline-flex items-center gap-2 p-3 rounded-lg bg-muted/30 border border-border";
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "text-xl";
|
||||
icon.textContent = "📎";
|
||||
fileCard.appendChild(icon);
|
||||
const label = document.createElement("span");
|
||||
label.className = "text-sm";
|
||||
label.textContent = size
|
||||
? `File (${formatBlobSize(size)})`
|
||||
: "File";
|
||||
fileCard.appendChild(label);
|
||||
dom.appendChild(fileCard);
|
||||
}
|
||||
}
|
||||
|
||||
return { dom };
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TipTap suggestion configuration from our SuggestionConfig
|
||||
*/
|
||||
function createSuggestionConfig<T>(
|
||||
config: SuggestionConfig<T>,
|
||||
handleSubmitRef: React.MutableRefObject<(editor: unknown) => void>,
|
||||
): Omit<SuggestionOptions<T>, "editor"> {
|
||||
return {
|
||||
char: config.char,
|
||||
allowSpaces: config.allowSpaces ?? false,
|
||||
allow: config.allow,
|
||||
items: async ({ query }) => {
|
||||
return await config.search(query);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<SuggestionListHandle>;
|
||||
let popup: TippyInstance[];
|
||||
let editorRef: unknown;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
editorRef = props.editor;
|
||||
component = new ReactRenderer(config.component as never, {
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
onClose: () => popup[0]?.hide(),
|
||||
},
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) return;
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: config.placement ?? "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (!props.clientRect) return;
|
||||
|
||||
popup[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect as () => DOMRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+Enter always submits
|
||||
if (
|
||||
props.event.key === "Enter" &&
|
||||
(props.event.ctrlKey || props.event.metaKey)
|
||||
) {
|
||||
popup[0]?.hide();
|
||||
handleSubmitRef.current(editorRef);
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props.event) ?? false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0]?.destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const NostrEditor = forwardRef<NostrEditorHandle, NostrEditorProps>(
|
||||
(
|
||||
{
|
||||
placeholder = "Type a message...",
|
||||
initialContent,
|
||||
onSubmit,
|
||||
onChange,
|
||||
submitBehavior = "enter",
|
||||
variant = "inline",
|
||||
minLines = 1,
|
||||
maxLines = 10,
|
||||
blobPreview = "compact",
|
||||
suggestions = [],
|
||||
autoFocus = false,
|
||||
className = "",
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const handleSubmitRef = useRef<(editor: unknown) => void>(() => {});
|
||||
|
||||
// Helper function to serialize editor content
|
||||
const serializeContent = useCallback(
|
||||
(editorInstance: {
|
||||
getJSON: () => { content?: unknown[] };
|
||||
getText: () => string;
|
||||
}): SerializedContent => {
|
||||
let text = "";
|
||||
const emojiTags: EmojiTag[] = [];
|
||||
const blobAttachments: BlobAttachment[] = [];
|
||||
const seenEmojis = new Set<string>();
|
||||
const seenBlobs = new Set<string>();
|
||||
const json = editorInstance.getJSON();
|
||||
|
||||
const processNode = (node: Record<string, unknown>) => {
|
||||
if (node.type === "text") {
|
||||
text += node.text as string;
|
||||
} else if (node.type === "hardBreak") {
|
||||
text += "\n";
|
||||
} else if (node.type === "mention") {
|
||||
const attrs = node.attrs as Record<string, unknown>;
|
||||
const pubkey = attrs?.id as string;
|
||||
if (pubkey) {
|
||||
try {
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
text += `nostr:${npub}`;
|
||||
} catch {
|
||||
text += `@${(attrs?.label as string) || "unknown"}`;
|
||||
}
|
||||
}
|
||||
} else if (node.type === "emoji") {
|
||||
const attrs = node.attrs as Record<string, unknown>;
|
||||
const shortcode = attrs?.id as string;
|
||||
const url = attrs?.url as string;
|
||||
const source = attrs?.source as string;
|
||||
|
||||
if (source === "unicode" && url) {
|
||||
text += url;
|
||||
} else if (shortcode) {
|
||||
text += `:${shortcode}:`;
|
||||
if (url && !seenEmojis.has(shortcode)) {
|
||||
seenEmojis.add(shortcode);
|
||||
emojiTags.push({ shortcode, url });
|
||||
}
|
||||
}
|
||||
} else if (node.type === "blobAttachment") {
|
||||
const attrs = node.attrs as Record<string, unknown>;
|
||||
const url = attrs.url as string;
|
||||
const sha256 = attrs.sha256 as string;
|
||||
if (url) {
|
||||
text += url;
|
||||
if (sha256 && !seenBlobs.has(sha256)) {
|
||||
seenBlobs.add(sha256);
|
||||
blobAttachments.push({
|
||||
url,
|
||||
sha256,
|
||||
mimeType: (attrs.mimeType as string) || undefined,
|
||||
size: (attrs.size as number) || undefined,
|
||||
server: (attrs.server as string) || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processContent = (content: unknown[]) => {
|
||||
for (const node of content) {
|
||||
const n = node as Record<string, unknown>;
|
||||
if (n.type === "paragraph" || n.type === "doc") {
|
||||
if (n.content) {
|
||||
processContent(n.content as unknown[]);
|
||||
}
|
||||
if (n.type === "paragraph") {
|
||||
text += "\n";
|
||||
}
|
||||
} else {
|
||||
processNode(n);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (json.content) {
|
||||
processContent(json.content);
|
||||
}
|
||||
|
||||
return {
|
||||
text: text.trim(),
|
||||
emojiTags,
|
||||
blobAttachments,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Helper function to handle submission
|
||||
const handleSubmit = useCallback(
|
||||
(editorInstance: unknown) => {
|
||||
if (!editorInstance || !onSubmit) return;
|
||||
const editor = editorInstance as {
|
||||
getJSON: () => { content?: unknown[] };
|
||||
getText: () => string;
|
||||
commands: { clearContent: () => void };
|
||||
};
|
||||
|
||||
const content = serializeContent(editor);
|
||||
if (content.text) {
|
||||
onSubmit(content);
|
||||
editor.commands.clearContent();
|
||||
}
|
||||
},
|
||||
[onSubmit, serializeContent],
|
||||
);
|
||||
|
||||
handleSubmitRef.current = handleSubmit;
|
||||
|
||||
// Build extensions array
|
||||
const extensions = useMemo(() => {
|
||||
const isMobile = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
// Custom extension for keyboard shortcuts
|
||||
const SubmitShortcut = Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Enter": ({ editor }) => {
|
||||
handleSubmitRef.current(editor);
|
||||
return true;
|
||||
},
|
||||
Enter: ({ editor }) => {
|
||||
if (submitBehavior === "button-only") {
|
||||
// Never submit on Enter, always newline
|
||||
return editor.commands.setHardBreak();
|
||||
} else if (submitBehavior === "ctrl-enter") {
|
||||
// Enter always inserts newline
|
||||
return editor.commands.setHardBreak();
|
||||
} else {
|
||||
// submitBehavior === 'enter'
|
||||
if (isMobile) {
|
||||
return editor.commands.setHardBreak();
|
||||
} else {
|
||||
handleSubmitRef.current(editor);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const exts: AnyExtension[] = [
|
||||
SubmitShortcut,
|
||||
StarterKit.configure({
|
||||
hardBreak: { keepMarks: false },
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
createBlobAttachmentNode(blobPreview),
|
||||
];
|
||||
|
||||
// Add mention extension for @ mentions (find it in suggestions)
|
||||
const mentionConfig = suggestions.find((s) => s.char === "@");
|
||||
if (mentionConfig) {
|
||||
exts.push(
|
||||
Mention.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
suggestion: {
|
||||
...createSuggestionConfig(mentionConfig, handleSubmitRef),
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: {
|
||||
editor: unknown;
|
||||
range: unknown;
|
||||
props: unknown;
|
||||
}) => {
|
||||
const result = mentionConfig.onSelect(props as never);
|
||||
const ed = editor as {
|
||||
chain: () => {
|
||||
focus: () => {
|
||||
insertContentAt: (
|
||||
range: unknown,
|
||||
content: unknown[],
|
||||
) => { run: () => void };
|
||||
};
|
||||
};
|
||||
};
|
||||
ed.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{ type: result.type, attrs: result.attrs },
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
},
|
||||
},
|
||||
renderLabel({ node }) {
|
||||
return `@${node.attrs.label}`;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Add emoji extension (find it in suggestions)
|
||||
const emojiConfig = suggestions.find((s) => s.char === ":");
|
||||
if (emojiConfig) {
|
||||
exts.push(
|
||||
EmojiMention.configure({
|
||||
HTMLAttributes: { class: "emoji" },
|
||||
suggestion: {
|
||||
...createSuggestionConfig(emojiConfig, handleSubmitRef),
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: {
|
||||
editor: unknown;
|
||||
range: unknown;
|
||||
props: unknown;
|
||||
}) => {
|
||||
const result = emojiConfig.onSelect(props as never);
|
||||
const ed = editor as {
|
||||
chain: () => {
|
||||
focus: () => {
|
||||
insertContentAt: (
|
||||
range: unknown,
|
||||
content: unknown[],
|
||||
) => { run: () => void };
|
||||
};
|
||||
};
|
||||
};
|
||||
ed.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{ type: "emoji", attrs: result.attrs },
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Add slash command extension (find it in suggestions)
|
||||
const slashConfig = suggestions.find((s) => s.char === "/");
|
||||
if (slashConfig) {
|
||||
const SlashCommand = Mention.extend({ name: "slashCommand" });
|
||||
exts.push(
|
||||
SlashCommand.configure({
|
||||
HTMLAttributes: { class: "slash-command" },
|
||||
suggestion: {
|
||||
...createSuggestionConfig(slashConfig, handleSubmitRef),
|
||||
command: ({
|
||||
editor,
|
||||
props,
|
||||
}: {
|
||||
editor: unknown;
|
||||
props: unknown;
|
||||
}) => {
|
||||
const ed = editor as { commands: { clearContent: () => void } };
|
||||
if (slashConfig.clearOnSelect !== false) {
|
||||
ed.commands.clearContent();
|
||||
}
|
||||
if (slashConfig.onExecute) {
|
||||
slashConfig.onExecute(props as never).catch((error) => {
|
||||
console.error(
|
||||
"[NostrEditor] Command execution failed:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
renderLabel({ node }) {
|
||||
return `/${node.attrs.label}`;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Add any additional custom suggestions (not @, :, or /)
|
||||
const customSuggestions = suggestions.filter(
|
||||
(s) => !["@", ":", "/"].includes(s.char),
|
||||
);
|
||||
for (const config of customSuggestions) {
|
||||
const CustomMention = Mention.extend({
|
||||
name: `suggestion-${config.char}`,
|
||||
});
|
||||
exts.push(
|
||||
CustomMention.configure({
|
||||
HTMLAttributes: { class: `suggestion-${config.char}` },
|
||||
suggestion: {
|
||||
...createSuggestionConfig(config, handleSubmitRef),
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: {
|
||||
editor: unknown;
|
||||
range: unknown;
|
||||
props: unknown;
|
||||
}) => {
|
||||
const result = config.onSelect(props as never);
|
||||
const ed = editor as {
|
||||
chain: () => {
|
||||
focus: () => {
|
||||
insertContentAt: (
|
||||
range: unknown,
|
||||
content: unknown[],
|
||||
) => { run: () => void };
|
||||
};
|
||||
};
|
||||
};
|
||||
ed.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{ type: result.type, attrs: result.attrs },
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [suggestions, submitBehavior, placeholder, blobPreview]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content: initialContent,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose prose-sm max-w-none focus:outline-none text-sm",
|
||||
},
|
||||
},
|
||||
autofocus: autoFocus,
|
||||
onUpdate: ({ editor }) => {
|
||||
if (onChange) {
|
||||
onChange(serializeContent(editor));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Expose editor methods
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => editor?.commands.focus(),
|
||||
clear: () => editor?.commands.clearContent(),
|
||||
getContent: () => editor?.getText() || "",
|
||||
getSerializedContent: () => {
|
||||
if (!editor) return { text: "", emojiTags: [], blobAttachments: [] };
|
||||
return serializeContent(editor);
|
||||
},
|
||||
isEmpty: () => editor?.isEmpty ?? true,
|
||||
submit: () => {
|
||||
if (editor) {
|
||||
handleSubmit(editor);
|
||||
}
|
||||
},
|
||||
insertText: (text: string) => {
|
||||
if (editor) {
|
||||
editor.chain().focus().insertContent(text).run();
|
||||
}
|
||||
},
|
||||
insertBlob: (blob: BlobAttachment) => {
|
||||
if (editor) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent([
|
||||
{
|
||||
type: "blobAttachment",
|
||||
attrs: {
|
||||
url: blob.url,
|
||||
sha256: blob.sha256,
|
||||
mimeType: blob.mimeType,
|
||||
size: blob.size,
|
||||
server: blob.server,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: " " },
|
||||
])
|
||||
.run();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[editor, serializeContent, handleSubmit],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
editor?.destroy();
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Inline styles for dynamic height (Tailwind can't do dynamic values)
|
||||
const getInlineStyles = (): React.CSSProperties => {
|
||||
const lineHeight = 24;
|
||||
|
||||
switch (variant) {
|
||||
case "inline":
|
||||
return {};
|
||||
case "multiline":
|
||||
return {
|
||||
minHeight: `${Math.max(minLines, 2) * lineHeight}px`,
|
||||
maxHeight: `${maxLines * lineHeight}px`,
|
||||
};
|
||||
case "full":
|
||||
return {
|
||||
height: `${Math.max(minLines, 5) * lineHeight}px`,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border bg-background transition-colors focus-within:border-primary px-2",
|
||||
variant === "inline" && "h-7 flex items-center overflow-hidden",
|
||||
variant !== "inline" && "py-2 overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
style={getInlineStyles()}
|
||||
>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className={cn("flex-1 min-w-0", variant !== "inline" && "h-full")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
NostrEditor.displayName = "NostrEditor";
|
||||
131
src/components/editor/suggestions.tsx
Normal file
131
src/components/editor/suggestions.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Default suggestion configurations for NostrEditor
|
||||
*
|
||||
* These provide ready-to-use configurations for common Nostr autocomplete features:
|
||||
* - Profile mentions (@)
|
||||
* - Emoji autocomplete (:)
|
||||
* - Slash commands (/)
|
||||
*/
|
||||
|
||||
import type { SuggestionConfig } from "./types";
|
||||
import type { ProfileSearchResult } from "@/services/profile-search";
|
||||
import type { EmojiSearchResult } from "@/services/emoji-search";
|
||||
import type { ChatAction } from "@/types/chat-actions";
|
||||
import {
|
||||
ProfileSuggestionList,
|
||||
type ProfileSuggestionListProps,
|
||||
} from "./ProfileSuggestionList";
|
||||
import {
|
||||
EmojiSuggestionList,
|
||||
type EmojiSuggestionListProps,
|
||||
} from "./EmojiSuggestionList";
|
||||
import {
|
||||
SlashCommandSuggestionList,
|
||||
type SlashCommandSuggestionListProps,
|
||||
} from "./SlashCommandSuggestionList";
|
||||
|
||||
/**
|
||||
* Create a profile mention suggestion config (@mentions)
|
||||
*/
|
||||
export function createProfileSuggestion(
|
||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>,
|
||||
): SuggestionConfig<ProfileSearchResult> {
|
||||
return {
|
||||
char: "@",
|
||||
allowSpaces: false,
|
||||
search: searchProfiles,
|
||||
component:
|
||||
ProfileSuggestionList as React.ComponentType<ProfileSuggestionListProps>,
|
||||
onSelect: (profile) => ({
|
||||
type: "mention",
|
||||
attrs: {
|
||||
id: profile.pubkey,
|
||||
label: profile.displayName,
|
||||
},
|
||||
}),
|
||||
placement: "bottom-start",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an emoji suggestion config (:emoji:)
|
||||
*/
|
||||
export function createEmojiSuggestion(
|
||||
searchEmojis: (query: string) => Promise<EmojiSearchResult[]>,
|
||||
): SuggestionConfig<EmojiSearchResult> {
|
||||
return {
|
||||
char: ":",
|
||||
allowSpaces: false,
|
||||
search: searchEmojis,
|
||||
component:
|
||||
EmojiSuggestionList as React.ComponentType<EmojiSuggestionListProps>,
|
||||
onSelect: (emoji) => ({
|
||||
type: "emoji",
|
||||
attrs: {
|
||||
id: emoji.shortcode,
|
||||
label: emoji.shortcode,
|
||||
url: emoji.url,
|
||||
source: emoji.source,
|
||||
},
|
||||
}),
|
||||
placement: "bottom-start",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a slash command suggestion config (/commands)
|
||||
*/
|
||||
export function createSlashCommandSuggestion(
|
||||
searchCommands: (query: string) => Promise<ChatAction[]>,
|
||||
onExecute: (action: ChatAction) => Promise<void>,
|
||||
): SuggestionConfig<ChatAction> {
|
||||
return {
|
||||
char: "/",
|
||||
allowSpaces: false,
|
||||
// Only allow at the start of input
|
||||
allow: ({ range }) => range.from === 1,
|
||||
search: searchCommands,
|
||||
component:
|
||||
SlashCommandSuggestionList as React.ComponentType<SlashCommandSuggestionListProps>,
|
||||
onSelect: (action) => ({
|
||||
type: "slashCommand",
|
||||
attrs: {
|
||||
id: action.name,
|
||||
label: action.name,
|
||||
},
|
||||
}),
|
||||
onExecute,
|
||||
clearOnSelect: true,
|
||||
placement: "top-start",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create all standard Nostr editor suggestions
|
||||
*/
|
||||
export function createNostrSuggestions(options: {
|
||||
searchProfiles: (query: string) => Promise<ProfileSearchResult[]>;
|
||||
searchEmojis?: (query: string) => Promise<EmojiSearchResult[]>;
|
||||
searchCommands?: (query: string) => Promise<ChatAction[]>;
|
||||
onCommandExecute?: (action: ChatAction) => Promise<void>;
|
||||
}): SuggestionConfig<unknown>[] {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const suggestions: SuggestionConfig<any>[] = [
|
||||
createProfileSuggestion(options.searchProfiles),
|
||||
];
|
||||
|
||||
if (options.searchEmojis) {
|
||||
suggestions.push(createEmojiSuggestion(options.searchEmojis));
|
||||
}
|
||||
|
||||
if (options.searchCommands && options.onCommandExecute) {
|
||||
suggestions.push(
|
||||
createSlashCommandSuggestion(
|
||||
options.searchCommands,
|
||||
options.onCommandExecute,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
122
src/components/editor/types.ts
Normal file
122
src/components/editor/types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
/**
|
||||
* Represents an emoji tag for NIP-30
|
||||
*/
|
||||
export interface EmojiTag {
|
||||
shortcode: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a blob attachment for imeta tags (NIP-92)
|
||||
*/
|
||||
export interface BlobAttachment {
|
||||
/** The URL of the blob */
|
||||
url: string;
|
||||
/** SHA256 hash of the blob content */
|
||||
sha256: string;
|
||||
/** MIME type of the blob */
|
||||
mimeType?: string;
|
||||
/** Size in bytes */
|
||||
size?: number;
|
||||
/** Blossom server URL */
|
||||
server?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of serializing editor content
|
||||
*/
|
||||
export interface SerializedContent {
|
||||
/** The text content with mentions as nostr: URIs and emoji as :shortcode: */
|
||||
text: string;
|
||||
/** Emoji tags to include in the event (NIP-30) */
|
||||
emojiTags: EmojiTag[];
|
||||
/** Blob attachments for imeta tags (NIP-92) */
|
||||
blobAttachments: BlobAttachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for suggestion list components
|
||||
*/
|
||||
export interface SuggestionListProps<T> {
|
||||
items: T[];
|
||||
command: (item: T) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle for suggestion list components (keyboard navigation)
|
||||
*/
|
||||
export interface SuggestionListHandle {
|
||||
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a suggestion type
|
||||
*/
|
||||
export interface SuggestionConfig<T = unknown> {
|
||||
/** Trigger character (e.g., "@", ":", "/") */
|
||||
char: string;
|
||||
/** Search function to find suggestions */
|
||||
search: (query: string) => Promise<T[]>;
|
||||
/** Component to render the suggestion list */
|
||||
component: ComponentType<
|
||||
SuggestionListProps<T> & { ref?: React.Ref<SuggestionListHandle> }
|
||||
>;
|
||||
/** Command to execute when item is selected - transforms item to TipTap node attrs */
|
||||
onSelect: (item: T) => {
|
||||
type: string;
|
||||
attrs: Record<string, unknown>;
|
||||
};
|
||||
/** Whether to allow spaces in the query */
|
||||
allowSpaces?: boolean;
|
||||
/** Custom allow function (e.g., only at start of input) */
|
||||
allow?: (props: { range: { from: number; to: number } }) => boolean;
|
||||
/** Popup placement */
|
||||
placement?: "top-start" | "bottom-start";
|
||||
/** Optional callback when command is executed (e.g., for slash commands) */
|
||||
onExecute?: (item: T) => Promise<void>;
|
||||
/** Whether selection should clear the trigger text (for slash commands) */
|
||||
clearOnSelect?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit behavior configuration
|
||||
*/
|
||||
export type SubmitBehavior =
|
||||
| "enter" // Enter submits (desktop chat default), Shift+Enter for newline
|
||||
| "ctrl-enter" // Only Ctrl/Cmd+Enter submits, Enter inserts newline
|
||||
| "button-only"; // No keyboard submit, rely on external button
|
||||
|
||||
/**
|
||||
* Layout variant for the editor
|
||||
*/
|
||||
export type EditorVariant =
|
||||
| "inline" // Single-line chat input (current chat behavior)
|
||||
| "multiline" // Auto-expanding textarea
|
||||
| "full"; // Full editor with fixed height and scroll
|
||||
|
||||
/**
|
||||
* Blob preview style
|
||||
*/
|
||||
export type BlobPreviewStyle =
|
||||
| "compact" // Small inline pill (current chat behavior)
|
||||
| "card" // Medium card with thumbnail
|
||||
| "gallery"; // Full-width image gallery
|
||||
|
||||
/**
|
||||
* Handle exposed by NostrEditor for imperative control
|
||||
*/
|
||||
export interface NostrEditorHandle {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
getContent: () => string;
|
||||
getSerializedContent: () => SerializedContent;
|
||||
isEmpty: () => boolean;
|
||||
submit: () => void;
|
||||
/** Insert text at the current cursor position */
|
||||
insertText: (text: string) => void;
|
||||
/** Insert a blob attachment with rich preview */
|
||||
insertBlob: (blob: BlobAttachment) => void;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Observable } from "rxjs";
|
||||
import {
|
||||
EmojiSearchService,
|
||||
type EmojiSearchResult,
|
||||
@@ -8,23 +9,107 @@ import eventStore from "@/services/event-store";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
import { useAccount } from "./useAccount";
|
||||
|
||||
export interface UseEmojiSearchOptions {
|
||||
/** Event to extract context emojis from (e.g., current conversation) */
|
||||
contextEvent?: NostrEvent;
|
||||
/** Custom emoji events to index (kind 10030 or 30030) */
|
||||
customEmojiEvents?: NostrEvent[];
|
||||
/** Custom observable source for emoji events */
|
||||
emojiSource$?: Observable<NostrEvent[]>;
|
||||
/** Whether to include Unicode emojis (default: true) */
|
||||
includeUnicode?: boolean;
|
||||
/** Whether to include user's emoji list from EventStore (default: true) */
|
||||
includeUserEmojis?: boolean;
|
||||
/** Maximum results to return (default: 24) */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to provide emoji search functionality with automatic indexing
|
||||
* of Unicode emojis and user's custom emojis from the event store
|
||||
* of Unicode emojis and user's custom emojis from the event store.
|
||||
*
|
||||
* Supports injectable sources for custom emoji sets.
|
||||
*
|
||||
* @example
|
||||
* // Default: Unicode + user's custom emojis
|
||||
* const { searchEmojis } = useEmojiSearch();
|
||||
*
|
||||
* @example
|
||||
* // With context event (extracts emoji tags from event)
|
||||
* const { searchEmojis } = useEmojiSearch({ contextEvent: event });
|
||||
*
|
||||
* @example
|
||||
* // Custom emoji source only
|
||||
* const { searchEmojis } = useEmojiSearch({
|
||||
* emojiSource$: customEmojis$,
|
||||
* includeUnicode: false,
|
||||
* includeUserEmojis: false,
|
||||
* });
|
||||
*/
|
||||
export function useEmojiSearch(contextEvent?: NostrEvent) {
|
||||
export function useEmojiSearch(options: UseEmojiSearchOptions = {}) {
|
||||
const {
|
||||
contextEvent,
|
||||
customEmojiEvents,
|
||||
emojiSource$,
|
||||
includeUnicode = true,
|
||||
includeUserEmojis = true,
|
||||
limit = 24,
|
||||
} = options;
|
||||
|
||||
const serviceRef = useRef<EmojiSearchService | null>(null);
|
||||
const { pubkey } = useAccount();
|
||||
|
||||
// Create service instance (singleton per component mount)
|
||||
if (!serviceRef.current) {
|
||||
serviceRef.current = new EmojiSearchService();
|
||||
// Load Unicode emojis immediately
|
||||
serviceRef.current.addUnicodeEmojis(UNICODE_EMOJIS);
|
||||
}
|
||||
|
||||
const service = serviceRef.current;
|
||||
|
||||
// Load Unicode emojis if enabled
|
||||
useEffect(() => {
|
||||
if (includeUnicode) {
|
||||
service.addUnicodeEmojis(UNICODE_EMOJIS);
|
||||
}
|
||||
}, [includeUnicode, service]);
|
||||
|
||||
// Add custom emoji events if provided
|
||||
useEffect(() => {
|
||||
if (customEmojiEvents && customEmojiEvents.length > 0) {
|
||||
for (const event of customEmojiEvents) {
|
||||
if (event.kind === 10030) {
|
||||
service.addUserEmojiList(event);
|
||||
} else if (event.kind === 30030) {
|
||||
service.addEmojiSet(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [customEmojiEvents, service]);
|
||||
|
||||
// Subscribe to custom emoji source if provided
|
||||
useEffect(() => {
|
||||
if (!emojiSource$) return;
|
||||
|
||||
const subscription = emojiSource$.subscribe({
|
||||
next: (events) => {
|
||||
for (const event of events) {
|
||||
if (event.kind === 10030) {
|
||||
service.addUserEmojiList(event);
|
||||
} else if (event.kind === 30030) {
|
||||
service.addEmojiSet(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("Failed to load emojis from custom source:", error);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [emojiSource$, service]);
|
||||
|
||||
// Add context emojis when context event changes
|
||||
useEffect(() => {
|
||||
if (contextEvent) {
|
||||
@@ -34,7 +119,7 @@ export function useEmojiSearch(contextEvent?: NostrEvent) {
|
||||
|
||||
// Subscribe to user's emoji list (kind 10030) and emoji sets (kind 30030)
|
||||
useEffect(() => {
|
||||
if (!pubkey) {
|
||||
if (!includeUserEmojis || !pubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,15 +181,15 @@ export function useEmojiSearch(contextEvent?: NostrEvent) {
|
||||
// Clear custom emojis but keep unicode
|
||||
service.clearCustom();
|
||||
};
|
||||
}, [pubkey, service]);
|
||||
}, [pubkey, service, includeUserEmojis]);
|
||||
|
||||
// Memoize search function
|
||||
const searchEmojis = useMemo(
|
||||
() =>
|
||||
async (query: string): Promise<EmojiSearchResult[]> => {
|
||||
return await service.search(query, { limit: 24 });
|
||||
return await service.search(query, { limit });
|
||||
},
|
||||
[service],
|
||||
[service, limit],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,26 +1,89 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Observable } from "rxjs";
|
||||
import {
|
||||
ProfileSearchService,
|
||||
type ProfileSearchResult,
|
||||
} from "@/services/profile-search";
|
||||
import eventStore from "@/services/event-store";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
export interface UseProfileSearchOptions {
|
||||
/** Initial profiles to index immediately */
|
||||
initialProfiles?: NostrEvent[];
|
||||
/** Custom observable source for profiles (replaces default EventStore subscription) */
|
||||
profileSource$?: Observable<NostrEvent[]>;
|
||||
/** Whether to also include profiles from global EventStore (default: true) */
|
||||
includeGlobal?: boolean;
|
||||
/** Maximum results to return (default: 20) */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to provide profile search functionality with automatic indexing
|
||||
* of profiles from the event store
|
||||
* of profiles from the event store.
|
||||
*
|
||||
* Supports injectable sources for custom profile sets (e.g., group members only).
|
||||
*
|
||||
* @example
|
||||
* // Default: index all profiles from global EventStore
|
||||
* const { searchProfiles } = useProfileSearch();
|
||||
*
|
||||
* @example
|
||||
* // Custom source: only group members
|
||||
* const { searchProfiles } = useProfileSearch({
|
||||
* profileSource$: groupMemberProfiles$,
|
||||
* includeGlobal: false,
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // Pre-populate with known profiles
|
||||
* const { searchProfiles } = useProfileSearch({
|
||||
* initialProfiles: knownProfiles,
|
||||
* });
|
||||
*/
|
||||
export function useProfileSearch() {
|
||||
export function useProfileSearch(options: UseProfileSearchOptions = {}) {
|
||||
const {
|
||||
initialProfiles,
|
||||
profileSource$,
|
||||
includeGlobal = true,
|
||||
limit = 20,
|
||||
} = options;
|
||||
|
||||
const serviceRef = useRef<ProfileSearchService | null>(null);
|
||||
|
||||
// Create service instance (singleton per component mount)
|
||||
if (!serviceRef.current) {
|
||||
serviceRef.current = new ProfileSearchService();
|
||||
// Index initial profiles immediately if provided
|
||||
if (initialProfiles && initialProfiles.length > 0) {
|
||||
serviceRef.current.addProfiles(initialProfiles);
|
||||
}
|
||||
}
|
||||
|
||||
const service = serviceRef.current;
|
||||
|
||||
// Subscribe to profile events from the event store
|
||||
// Subscribe to custom profile source if provided
|
||||
useEffect(() => {
|
||||
if (!profileSource$) return;
|
||||
|
||||
const subscription = profileSource$.subscribe({
|
||||
next: (events) => {
|
||||
service.addProfiles(events);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("Failed to load profiles from custom source:", error);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [profileSource$, service]);
|
||||
|
||||
// Subscribe to global profile events from the event store
|
||||
useEffect(() => {
|
||||
if (!includeGlobal) return;
|
||||
|
||||
const subscription = eventStore
|
||||
.timeline([{ kinds: [0], limit: 1000 }])
|
||||
.subscribe({
|
||||
@@ -36,15 +99,15 @@ export function useProfileSearch() {
|
||||
subscription.unsubscribe();
|
||||
service.clear(); // Clean up indexed profiles
|
||||
};
|
||||
}, [service]);
|
||||
}, [service, includeGlobal]);
|
||||
|
||||
// Memoize search function
|
||||
const searchProfiles = useMemo(
|
||||
() =>
|
||||
async (query: string): Promise<ProfileSearchResult[]> => {
|
||||
return await service.search(query, { limit: 20 });
|
||||
return await service.search(query, { limit });
|
||||
},
|
||||
[service],
|
||||
[service, limit],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user